pgkiln 0.45.0 → 0.47.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 (93) hide show
  1. package/db/migrations/106_form_designer.sql +102 -0
  2. package/db/migrations/107_app_version.sql +9 -0
  3. package/db/migrations/108_sql_history.sql +25 -0
  4. package/docs/apex-feature-parity.md +33 -4
  5. package/docs/guide/03-builder.md +123 -28
  6. package/docs/guide/04-pages-and-regions.md +74 -2
  7. package/docs/guide/05-items.md +14 -2
  8. package/docs/guide/08-security.md +1 -1
  9. package/docs/guide/09-reference.md +1 -1
  10. package/docs/guide/10-tutorial.md +1 -1
  11. package/docs/guide/11-from-apex.md +2 -1
  12. package/docs/guide/12-development.md +26 -3
  13. package/docs/guide/16-files.md +2 -2
  14. package/examples/hr/hr_61_form_designer.sql +80 -0
  15. package/package.json +1 -1
  16. package/public/app.css +62 -2
  17. package/public/app.js +244 -35
  18. package/public/builder.css +21 -1
  19. package/public/builder.js +67 -0
  20. package/public/sw.js +2 -1
  21. package/public/worksheet.css +244 -0
  22. package/public/worksheet.js +665 -0
  23. package/src/app.ts +9 -1
  24. package/src/builder/ai-builder.ts +3 -3
  25. package/src/builder/appwizard.ts +22 -6
  26. package/src/builder/arrange.ts +33 -12
  27. package/src/builder/code-editor.ts +1 -1
  28. package/src/builder/components.ts +1 -1
  29. package/src/builder/designer.ts +44 -17
  30. package/src/builder/errors.ts +65 -0
  31. package/src/builder/form-designer.ts +229 -0
  32. package/src/builder/locks.ts +6 -0
  33. package/src/builder/newapp.ts +2 -1
  34. package/src/builder/object-actions.ts +166 -0
  35. package/src/builder/object-browser.ts +111 -33
  36. package/src/builder/querybuilder.ts +1 -1
  37. package/src/builder/region-help.ts +2 -0
  38. package/src/builder/region-settings.ts +2 -0
  39. package/src/builder/reporter.ts +1 -1
  40. package/src/builder/routes.ts +7 -5
  41. package/src/builder/sampledata.ts +2 -2
  42. package/src/builder/scripts.ts +3 -3
  43. package/src/builder/search.ts +3 -3
  44. package/src/builder/shared.ts +2 -2
  45. package/src/builder/sql.ts +15 -73
  46. package/src/builder/table-editor.ts +219 -0
  47. package/src/builder/ui.ts +5 -3
  48. package/src/builder/unload.ts +8 -4
  49. package/src/builder/worksheet.ts +569 -0
  50. package/src/builder/workspaces.ts +1 -1
  51. package/src/cli/replace.ts +10 -0
  52. package/src/i18n/ar.ts +62 -0
  53. package/src/i18n/cs.ts +62 -0
  54. package/src/i18n/da.ts +62 -0
  55. package/src/i18n/de.ts +62 -0
  56. package/src/i18n/el.ts +62 -0
  57. package/src/i18n/es.ts +62 -0
  58. package/src/i18n/fi.ts +62 -0
  59. package/src/i18n/fr.ts +62 -0
  60. package/src/i18n/he.ts +62 -0
  61. package/src/i18n/it.ts +62 -0
  62. package/src/i18n/ja.ts +62 -0
  63. package/src/i18n/ko.ts +62 -0
  64. package/src/i18n/nb.ts +62 -0
  65. package/src/i18n/pl.ts +62 -0
  66. package/src/i18n/pt.ts +62 -0
  67. package/src/i18n/ru.ts +62 -0
  68. package/src/i18n/sv.ts +62 -0
  69. package/src/i18n/tr.ts +62 -0
  70. package/src/i18n/uk.ts +62 -0
  71. package/src/i18n/zh.ts +62 -0
  72. package/src/i18n.ts +124 -0
  73. package/src/metadata.ts +4 -2
  74. package/src/runtime/devbar.ts +1 -1
  75. package/src/runtime/facet-state.ts +10 -5
  76. package/src/runtime/facets.ts +29 -5
  77. package/src/runtime/form-designer.ts +676 -0
  78. package/src/runtime/grid.ts +15 -8
  79. package/src/runtime/items.ts +33 -21
  80. package/src/runtime/lists.ts +1 -1
  81. package/src/runtime/pdf.ts +2 -1
  82. package/src/runtime/regions.ts +4 -0
  83. package/src/runtime/render.ts +7 -3
  84. package/src/runtime/report.ts +2 -2
  85. package/src/runtime/routes.ts +3 -1
  86. package/src/runtime/smart-filters.ts +2 -1
  87. package/src/runtime/tasks.ts +6 -3
  88. package/src/sqlformat.ts +208 -0
  89. package/src/sqlplan.ts +133 -0
  90. package/src/sqlscript.ts +6 -2
  91. package/src/sqlworksheet.ts +278 -0
  92. package/src/tabledef.ts +226 -0
  93. package/src/unload.ts +14 -3
@@ -9,7 +9,8 @@ import { checksumValid, urlChecksum } from '../security.ts';
9
9
  import { isAuthorized, pageAllowed } from './authz.ts';
10
10
  import { publicError, toState, type PageContext } from './context.ts';
11
11
  import { arrange, cleanLayout, currentLayout, defaultLayout, layoutJson, layoutParam, MAX_FROZEN, MAX_WIDTH, MIN_WIDTH, parseLayout, type GridLayout, type Placed } from './grid-layout.ts';
12
- import { lovContains, lovOptions, type LovOption } from './items.ts';
12
+ import { appDateMask, dateField, lovContains, lovOptions, type LovOption } from './items.ts';
13
+ import { parseDateMask } from './format.ts';
13
14
  import { resolveRestRegion, restAllows, restGridCall, restWritable } from './rest-sources.ts';
14
15
  import { fillItems, linkAttrs } from './links.ts';
15
16
  import { detailsOf, masterColumnOf, masterItemOf, selectHref, selectRowOf } from './master-detail.ts';
@@ -102,7 +103,7 @@ const rowToken = (ctx: PageContext, r: Region, pk: string) => urlChecksum(ctx.ap
102
103
 
103
104
  // ---------------------------------------------------------------- render
104
105
 
105
- function control(col: GridColumn, name: string, value: string, label: string) {
106
+ function control(ctx: PageContext, col: GridColumn, name: string, value: string, label: string) {
106
107
  const aria = raw(` aria-label="${label.replace(/"/g, '"')}"`);
107
108
  if (col.options)
108
109
  return html`<select name="${name}"${aria}><option value=""></option>${col.options.map(
@@ -111,7 +112,8 @@ function control(col: GridColumn, name: string, value: string, label: string) {
111
112
  if (col.typeOid === 16)
112
113
  return html`<input type="checkbox" name="${name}" value="true"${value === 'true' ? raw(' checked') : ''}${aria}>`;
113
114
  if (NUMERIC.has(col.typeOid)) return html`<input type="number" step="any" inputmode="decimal" name="${name}" value="${value}"${aria}>`;
114
- if (col.typeOid === 1082) return html`<input type="date" name="${name}" value="${value}"${aria}>`;
115
+ // (sprint 84) a date in the application's date format, with pgkiln's date picker
116
+ if (col.typeOid === 1082) return dateField(ctx, { id: `f_${name}`, iso: value, mask: appDateMask(ctx), attrs: html` name="${name}"${aria}` });
115
117
  if (col.typeOid === 1114 || col.typeOid === 1184)
116
118
  return html`<input type="datetime-local" name="${name}" value="${value.slice(0, 16).replace(' ', 'T')}"${aria}>`;
117
119
  return html`<input type="text" name="${name}" value="${value}"${aria}>`;
@@ -329,7 +331,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
329
331
  if (!col.editable) return html`<td class="${colCls(p, pIdx, NUMERIC.has(col.typeOid) ? 'num' : null)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}>${cell(row[col.idx], col.typeOid, fmtOf(col.name))}</td>`;
330
332
  const name = `${g}_${i}_c${col.ci}`;
331
333
  const value = pi !== undefined ? (col.typeOid === 16 ? (pv(`${g}_${pi}_c${col.ci}`) === 'true' ? 'true' : 'false') : (pv(`${g}_${pi}_c${col.ci}`) ?? dbValue)) : dbValue;
332
- return html`<td class="${colCls(p, pIdx)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}>${cellText(col, value, value === dbValue ? cell(row[col.idx], col.typeOid, fmtOf(col.name)) : value)}${control(col, name, value, `${lbl}, row ${i + 1}`)}</td>`;
334
+ return html`<td class="${colCls(p, pIdx)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}>${cellText(col, value, value === dbValue ? cell(row[col.idx], col.typeOid, fmtOf(col.name)) : value)}${control(ctx, col, name, value, `${lbl}, row ${i + 1}`)}</td>`;
333
335
  });
334
336
  const deleted = pi !== undefined && pv(`${g}_${pi}_del`) === 'true';
335
337
  const selValue = selIdx >= 0 ? toState(row[selIdx]) ?? '' : null;
@@ -366,7 +368,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
366
368
  const lbl = label(col.name);
367
369
  if (masterCol && col.name === masterCol) return html`<td class="${colCls(p, pIdx)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}>${masterValue}</td>`;
368
370
  return writable.has(col.name)
369
- ? html`<td class="${colCls(p, pIdx)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}>${control({ ...col, editable: true }, `${g}_n${j}_c${col.ci}`, values[col.ci] ?? '', `${lbl}, new row`)}</td>`
371
+ ? html`<td class="${colCls(p, pIdx)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}>${control(ctx, { ...col, editable: true }, `${g}_n${j}_c${col.ci}`, values[col.ci] ?? '', `${lbl}, new row`)}</td>`
370
372
  : html`<td class="${colCls(p, pIdx)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}></td>`;
371
373
  })}
372
374
  </tr>`;
@@ -417,7 +419,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
417
419
  orig[col.ci] = dbValue;
418
420
  if (!col.editable) return field(p, cell(row[col.idx], col.typeOid, fmtOf(col.name)), false);
419
421
  const value = pi !== undefined ? (col.typeOid === 16 ? (pv(`${g}_${pi}_c${col.ci}`) === 'true' ? 'true' : 'false') : (pv(`${g}_${pi}_c${col.ci}`) ?? dbValue)) : dbValue;
420
- return field(p, control(col, `${g}_0_c${col.ci}`, value, label(col.name)), true);
422
+ return field(p, control(ctx, col, `${g}_0_c${col.ci}`, value, label(col.name)), true);
421
423
  });
422
424
  const deleted = pi !== undefined && pv(`${g}_${pi}_del`) === 'true';
423
425
  const links = [
@@ -438,7 +440,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
438
440
  const col = p.col;
439
441
  if (masterCol && col.name === masterCol) return [field(p, masterValue, false)];
440
442
  if (!writable.has(col.name)) return [];
441
- return [field(p, control({ ...col, editable: true }, `${g}_n0_c${col.ci}`, pv(`${g}_n0_c${col.ci}`) ?? '', label(col.name)), true)];
443
+ return [field(p, control(ctx, { ...col, editable: true }, `${g}_n0_c${col.ci}`, pv(`${g}_n0_c${col.ci}`) ?? '', label(col.name)), true)];
442
444
  });
443
445
  return html`<div class="grid-record grid-new" data-new-row="0"><div class="form-grid">${fieldsHtml}</div></div>
444
446
  ${recordNav()}`;
@@ -718,7 +720,12 @@ export async function gridDml(ctx: PageContext, p: Process): Promise<string | nu
718
720
  const lov = r.config.columns?.[name]?.lov;
719
721
  return !lov || v === '' || (await lovContains(ctx, lov, [v]));
720
722
  };
721
- const norm = (ci: number, v: string | undefined) => (cols[ci].dataTypeID === 16 ? (v === 'true' ? 'true' : 'false') : v ?? '');
723
+ // (sprint 84) a date typed in the date format (or YYYY-MM-DD) → YYYY-MM-DD; other text goes to the database as it is (which refuses it)
724
+ const dateMask = appDateMask(ctx);
725
+ const norm = (ci: number, v: string | undefined) =>
726
+ cols[ci].dataTypeID === 16 ? (v === 'true' ? 'true' : 'false')
727
+ : cols[ci].dataTypeID === 1082 && v?.trim() ? (parseDateMask(v, dateMask, ctx.locale.lang) ?? v)
728
+ : v ?? '';
722
729
 
723
730
  const errors: string[] = [];
724
731
  let inserted = 0, updated = 0, deleted = 0;
@@ -14,7 +14,7 @@ import { markdownHtml, sanitizeHtml } from '../richtext.ts';
14
14
  import { qrSvg, type Ecc } from '../qrcode.ts';
15
15
  import { formatNumber, isPlainNumber, maskError } from '../numformat.ts';
16
16
  import { addressControl, imageControl } from './data-items.ts';
17
- import { applyMask, formatMaskError, monthNames } from './format.ts';
17
+ import { applyMask, formatMaskError, monthNames, parseDateMask } from './format.ts';
18
18
 
19
19
  const TRUTHY = new Set(['true', 't', 'on', '1', 'yes', 'y']);
20
20
  export const isTruthy = (v: string | null | undefined) => !!v && TRUTHY.has(v.toLowerCase());
@@ -184,15 +184,36 @@ export const splitRange = (v: string | null | undefined): [string, string] => {
184
184
  return [from, to];
185
185
  };
186
186
 
187
+ /** (sprint 84) The application's date format, else ISO: every date field is text in it, with pgkiln's date picker. */
188
+ export const appDateMask = (ctx: PageContext) => ctx.locale.dateMask?.trim() || 'YYYY-MM-DD';
189
+ /** (sprint 84) A date typed in the application's date format (or YYYY-MM-DD) as YYYY-MM-DD; null when it isn't one. */
190
+ export const appDateReader = (ctx: PageContext) => (text: string) => parseDateMask(text, appDateMask(ctx), ctx.locale.lang);
191
+
187
192
  /**
188
- * (sprint 76) The date mask a date item shows its value in: its own format mask (a date mask), else the
189
- * application's date format; null: the browser's date field (in the browser's notation).
193
+ * (sprint 76) The date mask a date or date range item shows its value in: its own format mask (a date mask),
194
+ * else the application's date format, else YYYY-MM-DD; null for other items.
190
195
  */
191
196
  export function itemDateMask(ctx: PageContext, item: Item): string | null {
192
- if (item.type !== 'date') return null;
197
+ if (item.type !== 'date' && item.type !== 'daterange') return null;
193
198
  const own = typeof item.config?.format_mask === 'string' ? item.config.format_mask.trim() : '';
194
199
  if (own && /(YYYY|YY|MM|DD|MON)/i.test(own) && !formatMaskError(own)) return own;
195
- return ctx.locale.dateMask?.trim() || null;
200
+ return appDateMask(ctx);
201
+ }
202
+
203
+ /**
204
+ * (sprint 84; APEX: the date picker) A date as text in a date mask, with a calendar button that opens pgkiln's
205
+ * date picker (app.js). Without JavaScript the text is typed (in the mask, or as YYYY-MM-DD) and read back on
206
+ * submit. `attrs`: extra attributes of the text field (name, form, min/max, aria).
207
+ */
208
+ export function dateField(ctx: PageContext, o: { id: string; iso: string; mask: string; attrs?: Raw | string; editable?: boolean; typed?: string }) {
209
+ const iso = /^\d{4}-\d{2}-\d{2}/.test(o.iso) ? o.iso.slice(0, 10) : '';
210
+ const shown = iso ? applyMask(iso, o.mask, ctx.locale.lang) : (o.typed ?? o.iso);
211
+ const editable = o.editable !== false;
212
+ const t = ctx.locale.t;
213
+ // a number pad on phones, unless the mask has month or day names
214
+ const numeric = /MON|DAY|DY/i.test(o.mask.replace(/"[^"]*"/g, '')) ? '' : raw(' inputmode="numeric"');
215
+ return html`<span class="input-with-button date-field"><input type="text" id="${o.id}"${o.attrs ?? ''} value="${shown}" placeholder="${o.mask}" autocomplete="off"${numeric} data-date-mask="${o.mask}" data-months="${JSON.stringify(monthNames(ctx.locale.lang))}"${editable ? '' : raw(' readonly')}>${
216
+ editable ? html`<button type="button" class="btn" data-date-pick="${o.id}" aria-label="${t('item.choose_date')}" title="${t('item.choose_date')}" aria-haspopup="dialog">${icon('calendar')}</button>` : ''}</span>`;
196
217
  }
197
218
 
198
219
  /** A number or display item's number format mask ({"format_mask": "999G990D00"}), when it is a valid one. */
@@ -384,9 +405,11 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
384
405
  useLegend = true;
385
406
  const [from, to] = splitRange(value);
386
407
  const t = ctx.locale.t;
408
+ // (sprint 84) each part in the date format with the date picker; the picker keeps the from on or before the to
409
+ const mask = itemDateMask(ctx, item)!;
387
410
  control = html`<div class="date-range" data-range="${id}">
388
- <span class="range-part"><label class="sub" for="${id}">${t('item.range_from')}</label><input type="date" id="${id}" name="${id}" value="${from}"${to ? html` max="${to}"` : ''}${aria}></span>
389
- <span class="range-part"><label class="sub" for="${id}_TO">${t('item.range_to')}</label><input type="date" id="${id}_TO" name="${id}" value="${to}"${from ? html` min="${from}"` : ''}${aria}></span>
411
+ <span class="range-part"><label class="sub" for="${id}">${t('item.range_from')}</label>${dateField(ctx, { id, iso: from, mask, attrs: html` name="${id}"${aria}` })}</span>
412
+ <span class="range-part"><label class="sub" for="${id}_TO">${t('item.range_to')}</label>${dateField(ctx, { id: `${id}_TO`, iso: to, mask, attrs: html` name="${id}"${aria}` })}</span>
390
413
  </div>`;
391
414
  break;
392
415
  }
@@ -398,21 +421,10 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
398
421
  control = html`<div class="input-with-button"><input type="text" id="${id}" name="${id}" value="${value}" inputmode="decimal" placeholder="52.01160,4.35710"${aria}>
399
422
  <button type="button" class="btn" data-locate="${id}">${icon('map')} ${ctx.locale.t('item.locate')}</button></div>`;
400
423
  break;
401
- case 'date': {
402
- // (sprint 76) in the application's date format: a text field and a calendar button (app.js), read back on submit
403
- const mask = itemDateMask(ctx, item);
404
- if (!mask) {
405
- control = html`<input type="date" id="${id}" name="${id}" value="${value}"${aria}${editable ? '' : raw(' readonly')}>`;
406
- break;
407
- }
408
- const iso = /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : '';
409
- const shown = iso ? applyMask(iso, mask, ctx.locale.lang) : value;
410
- const months = monthNames(ctx.locale.lang);
411
- control = html`<div class="input-with-button date-field"><input type="text" id="${id}" name="${id}" value="${shown}" placeholder="${mask}" autocomplete="off" data-date-mask="${mask}" data-months="${JSON.stringify(months)}"${aria}${editable ? '' : raw(' readonly')}>
412
- ${editable ? html`<input type="date" class="date-native" value="${iso}" tabindex="-1" aria-hidden="true" data-date-for="${id}">
413
- <button type="button" class="btn" data-date-pick="${id}" aria-label="${ctx.locale.t('item.choose_date')}" title="${ctx.locale.t('item.choose_date')}">${icon('calendar')}</button>` : ''}</div>`;
424
+ case 'date':
425
+ // (sprint 76, 84) in the date format: a text field and pgkiln's date picker (app.js), read back on submit
426
+ control = dateField(ctx, { id, iso: value, mask: itemDateMask(ctx, item)!, attrs: html` name="${id}"${aria}`, editable });
414
427
  break;
415
- }
416
428
  default: {
417
429
  const typed: Record<string, string> = { number: 'number', date: 'date', password: 'password', email: 'email', tel: 'tel', url: 'url' };
418
430
  // a number with a format mask is text in the language's notation (1.234,50), read back on submit
@@ -225,7 +225,7 @@ export async function renderListRegion(ctx: PageContext, r: Region): Promise<Raw
225
225
  case 'badges':
226
226
  return html`<ul class="list-badges">${flat(nodes).map((n) => html`<li><a ${linkAttrs(n, 'list-badge')}><span class="list-badge-value">${n.badge ?? icon(n.icon ?? 'chevron')}</span><span class="list-badge-label">${n.label}</span></a></li>`)}</ul>`;
227
227
  case 'cards':
228
- return html`<ul class="cards list-cards">${flat(nodes).map((n) => html`<li><a ${linkAttrs(n, 'card list-card')}><span class="list-card-head">${icon(n.icon ?? 'chevron')}<h3>${n.label}</h3>${badge(n)}</span>${n.description ? html`<p class="muted">${n.description}</p>` : ''}</a></li>`)}</ul>`;
228
+ return html`<ul class="cards list-cards">${flat(nodes).map((n) => html`<li><a ${linkAttrs(n, 'card list-card')}><span class="list-card-head"><span class="list-card-icon">${icon(n.icon ?? 'chevron')}</span><h3>${n.label}</h3>${badge(n)}</span>${n.description ? html`<p class="muted">${n.description}</p>` : ''}</a></li>`)}</ul>`;
229
229
  case 'tabs':
230
230
  return html`<nav class="list-tabs" aria-label="${label}"><ul class="rds-list">${nodes.filter((n) => n.href).map((n) => html`<li><a ${linkAttrs(n, `rds-tab${n.inTrail ? ' is-current' : ''}`)}>${icon(n.icon)}<span>${n.label}</span>${badge(n)}</a></li>`)}</ul></nav>`;
231
231
  case 'wizard_progress': {
@@ -1,3 +1,4 @@
1
+ import { appDateReader } from './items.ts';
1
2
  import { existsSync } from 'node:fs';
2
3
  import PDFDocument from 'pdfkit';
3
4
  import type pg from 'pg';
@@ -192,7 +193,7 @@ export async function reportPdf(ctx: PageContext, r: Region): Promise<Buffer> {
192
193
  ...st.filters.map((f) => `${headingOf(r, f.column, ctx.locale.tr)} ${OPERATORS[f.op]?.label ?? f.op} ${OPERATORS[f.op]?.noValue ? '' : f.value}`.trim()),
193
194
  ...(() => {
194
195
  const defs = reportFacetDefs(ctx.page.regions, r.id, ctx.vis?.regions);
195
- return facetFilters(ctx.params, r.id, defs).map((f) => describeFacetFilter(f, defs.get(f.column)?.label ?? headingOf(r, f.column, ctx.locale.tr), t, defs.get(f.column)));
196
+ return facetFilters(ctx.params, r.id, defs, undefined, appDateReader(ctx)).map((f) => describeFacetFilter(f, defs.get(f.column)?.label ?? headingOf(r, f.column, ctx.locale.tr), t, defs.get(f.column)));
196
197
  })(),
197
198
  ...(st.search ? [`${t('report.search')}: "${st.search}"`] : []),
198
199
  ].map((s) => printable(oneLine(s)))
@@ -21,6 +21,7 @@ import { renderGrid } from './grid.ts';
21
21
  import { masterItemOf, mastersOf } from './master-detail.ts';
22
22
  import { renderTasks } from './tasks.ts';
23
23
  import { renderDataReporter } from './data-reporter.ts';
24
+ import { renderFormDesigner } from './form-designer.ts';
24
25
  import { renderAssistant } from './assistant.ts';
25
26
  import { renderAiFilter } from './ai-filter.ts';
26
27
  import { renderWorkflows } from './workflows.ts';
@@ -378,6 +379,9 @@ async function renderBody(ctx: PageContext, r: Region, hidden: Set<string>): Pro
378
379
  case 'data_reporter':
379
380
  body = await renderDataReporter(ctx, r);
380
381
  break;
382
+ case 'form_designer':
383
+ body = await renderFormDesigner(ctx, r);
384
+ break;
381
385
  case 'ai_assistant':
382
386
  body = await renderAssistant(ctx, r);
383
387
  break;
@@ -268,10 +268,11 @@ export async function chrome(ctx: PageContext, main: Raw, title: string) {
268
268
  const navbar = navbarNodes ? navbarMarkup(navbarNodes, t('list.navbar')) : '';
269
269
  // (sprint 72) the account's display name in the user menu
270
270
  const shown = signedIn ? ((await displayName(ctx.user)) ?? ctx.user) : ctx.user;
271
+ // (sprint 88, GEN-4; APEX: the page footer) under the content: the application's version and "Back to top" (to the header, #top)
271
272
  return documentShell(
272
273
  `${title} · ${ctx.app.name}`,
273
274
  html`<a class="skip-link" href="#main">${t('common.skip')}</a>
274
- <header class="t-header">
275
+ <header class="t-header" id="top">
275
276
  <a href="#t-nav" class="t-nav-toggle icon-button" role="button" aria-label="${t('common.toggle_nav')}" aria-controls="t-nav">${icon('menu')}</a>
276
277
  <a class="t-logo" href="${ctx.base}/${ctx.app.home_page}">${ctx.app.name}</a>
277
278
  <span class="t-spacer"></span>
@@ -306,7 +307,8 @@ export async function chrome(ctx: PageContext, main: Raw, title: string) {
306
307
  <div class="t-body">
307
308
  <nav id="t-nav" class="t-nav" aria-label="Main"><ul>${nav}</ul></nav>
308
309
  <a href="#" class="t-nav-backdrop" tabindex="-1" aria-hidden="true"></a>
309
- <main class="t-main" id="main">${main}</main>
310
+ <main class="t-main" id="main">${main}
311
+ <footer class="t-footer">${ctx.app.version ? html`<span class="t-version">${ctx.locale.tr(ctx.app.version)}</span>` : ''}<a class="t-top" href="#top">${icon('arrow-up')} ${t('common.back_to_top')}</a></footer></main>
310
312
  </div>
311
313
  ${devbarHtml(ctx)}`,
312
314
  `t-app${topNav ? ' nav-top' : ''}`,
@@ -338,7 +340,9 @@ const CLIENT_TEXTS = ['pwa.offline_banner', 'pwa.queued', 'pwa.queue_waiting', '
338
340
  'push.turn_on', 'push.turn_off', 'push.on', 'push.off', 'push.blocked', 'push.unsupported', 'push.failed', 'page.unsaved_changes', 'page.link_copied',
339
341
  'session.warning', 'session.stay', 'session.max', 'session.ended', 'login.sign_in_link',
340
342
  // (sprint 76) a report column heading's menu
341
- 'report.sort_asc', 'report.sort_desc', 'report.hide', 'report.break', 'report.filter_values', 'report.no_values'] as const;
343
+ 'report.sort_asc', 'report.sort_desc', 'report.hide', 'report.break', 'report.filter_values', 'report.no_values',
344
+ // (sprint 84) the date picker
345
+ 'item.choose_date', 'calendar.previous', 'calendar.next', 'calendar.today', 'facets.clear_short'] as const;
342
346
  export const clientTexts = (ctx: PageContext) => Object.fromEntries(CLIENT_TEXTS.map((k) => [k, ctx.locale.t(k)]));
343
347
 
344
348
  /**
@@ -8,7 +8,7 @@ import { esc, html, raw, type Raw } from '../html.ts';
8
8
  import type { Region } from '../metadata.ts';
9
9
  import { isAuthorized, pageAllowed } from './authz.ts';
10
10
  import { bindValues, publicError, stripSemicolon, type PageContext } from './context.ts';
11
- import { heading, splitValues } from './items.ts';
11
+ import { appDateReader, heading, splitValues } from './items.ts';
12
12
  import { linkAttrs } from './links.ts';
13
13
  import type { Translate } from '../i18n.ts';
14
14
  import type { Formatter } from './format.ts';
@@ -421,7 +421,7 @@ export async function filtered(ctx: PageContext, r: Region, st: ReportState) {
421
421
  // the search term, facet values and range bounds are query parameters
422
422
  const p = new SqlParams();
423
423
  const hides = hidesColumns(r);
424
- const facets = facetFilters(ctx.params, r.id, reportFacetDefs(ctx.page.regions, r.id, ctx.vis?.regions));
424
+ const facets = facetFilters(ctx.params, r.id, reportFacetDefs(ctx.page.regions, r.id, ctx.vis?.regions), undefined, appDateReader(ctx));
425
425
  const needCols = st.filters.length || facets.length || st.breakCol || st.aggregates.length || st.highlights.length || st.view !== 'report' || st.area || st.near || keysetColumns(r) || (hides && (st.search || st.sort)) || (st.search && st.searchCol);
426
426
  // column name → type oid
427
427
  const fields = needCols ? await fieldsOf(ctx, src) : [];
@@ -360,7 +360,9 @@ function applyPostedItems(ctx: PageContext, body: Body, only?: string[]) {
360
360
  }
361
361
  if (item.type === 'daterange') {
362
362
  // two date inputs with the item's name (or "from:to" from a dynamic action); format checked in validate()
363
- const parts = (Array.isArray(raw) ? raw : raw ? String(raw).split(':') : []).map((v) => String(v).trim());
363
+ // (sprint 84) each part typed in the date format (or YYYY-MM-DD) → YYYY-MM-DD; other text is kept, and validate() reports it
364
+ const mask = itemDateMask(ctx, item)!;
365
+ const parts = (Array.isArray(raw) ? raw : raw ? String(raw).split(':') : []).map((v) => String(v).trim()).map((v) => (v ? (parseDateMask(v, mask, ctx.locale.lang) ?? v) : v));
364
366
  ctx.session.state[item.name] = parts.some(Boolean) ? (parts.length === 1 ? `${parts[0]}:` : parts.join(':')) : null;
365
367
  continue;
366
368
  }
@@ -1,3 +1,4 @@
1
+ import { appDateReader } from './items.ts';
1
2
  import { html, type Raw } from '../html.ts';
2
3
  import { icon } from '../icons.ts';
3
4
  import type { Region } from '../metadata.ts';
@@ -34,7 +35,7 @@ export async function renderSmartFilters(ctx: PageContext, r: Region): Promise<R
34
35
  const formId = `sf${r.id}`;
35
36
  const defs = facetDefs(r.config).filter((f) => fs.cols.has(f.column));
36
37
  const per = Number.isInteger(r.config.suggestions) ? Math.max(0, Math.min(MAX_SUGGESTIONS, r.config.suggestions)) : 3;
37
- const filters = facetFilters(ctx.params, report.id, fs.all);
38
+ const filters = facetFilters(ctx.params, report.id, fs.all, undefined, appDateReader(ctx));
38
39
  const own = new Map<string, FacetFilter>(filters.filter((f) => defs.some((d) => d.column === f.column)).map((f) => [f.column, f]));
39
40
 
40
41
  // ---- chips: the search term and each filter of this region's facets, each with a remove link
@@ -6,6 +6,7 @@ import type { Region } from '../metadata.ts';
6
6
  import { logActivity, saveState } from '../session.ts';
7
7
  import { pageAllowed } from './authz.ts';
8
8
  import { publicError, stripSemicolon, type PageContext } from './context.ts';
9
+ import { appDateMask, appDateReader, dateField } from './items.ts';
9
10
  import { linkAttrs } from './links.ts';
10
11
  import { cell } from './report.ts';
11
12
  import { loadContext, safeNext, txContext, type Req } from './routes.ts';
@@ -158,8 +159,8 @@ async function vacationPanel(ctx: PageContext, r: Region): Promise<Raw | ''> {
158
159
  : html`<p class="muted">${t('tasks.vacation_help')}</p>
159
160
  <div class="vacation-fields">
160
161
  <label>${t('tasks.vacation_to')} <input name="to" form="${f}" maxlength="100" required autocomplete="off"></label>
161
- <label>${t('tasks.vacation_from')} <input type="date" name="from" form="${f}" required></label>
162
- <label>${t('tasks.vacation_until')} <input type="date" name="until" form="${f}" required></label>
162
+ <label>${t('tasks.vacation_from')} ${dateField(ctx, { id: `${f}_from`, iso: '', mask: appDateMask(ctx), attrs: html` name="from" form="${f}" required` })}</label>
163
+ <label>${t('tasks.vacation_until')} ${dateField(ctx, { id: `${f}_until`, iso: '', mask: appDateMask(ctx), attrs: html` name="until" form="${f}" required` })}</label>
163
164
  <button class="btn" form="${f}" name="action" value="set">${t('tasks.vacation_save')}</button></div>`}
164
165
  </details>`;
165
166
  }
@@ -173,7 +174,9 @@ export async function taskRoutes(app: FastifyInstance) {
173
174
  const next = safeNext(ctx.app, b.next);
174
175
  if (b.__csrf !== ctx.session.csrf_token || !['set', 'clear'].includes(String(b.action))) return reply.redirect(next, 303);
175
176
  const t = ctx.locale.t;
176
- const date = (v: unknown) => (typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) ? v : null);
177
+ // (sprint 84) typed in the application's date format (or YYYY-MM-DD)
178
+ const readDate = appDateReader(ctx);
179
+ const date = (v: unknown) => (typeof v === 'string' && v.length <= 100 ? readDate(v) : null);
177
180
  try {
178
181
  await appTx(txContext(ctx), async (c) => {
179
182
  if (b.action === 'clear') await c.query('select meta.clear_vacation_rule()');
@@ -0,0 +1,208 @@
1
+ import { skipQuoted } from './binds.ts';
2
+
3
+ // (sprint 93, Database Actions DA-9) Format SQL for the worksheet (Ctrl+F7): keywords in one case, each clause
4
+ // of a query on its own line (SELECT, FROM, WHERE, GROUP BY, …), joins indented, AND / OR of a condition and the
5
+ // columns of a select list one per line, subqueries indented. Strings, quoted identifiers, dollar-quoted bodies
6
+ // and comments are kept as they are; everything else is re-spaced. It is a layout aid, not a parser: SQL it
7
+ // doesn't understand keeps its words and order.
8
+
9
+ const KEYWORDS = new Set(`
10
+ select from where and or not in is null like ilike between exists as on join inner left right full outer cross natural
11
+ using group by order having limit offset fetch first next rows row only union all intersect except distinct case when
12
+ then else end insert into values update set delete returning with recursive create table view materialized index
13
+ replace drop alter add column constraint primary key foreign references unique check default cascade restrict
14
+ truncate grant revoke to begin commit rollback do language function procedure returns return true false asc desc
15
+ nulls last over partition window filter lateral any some array cast interval if conflict nothing merge matched
16
+ explain analyze schema sequence trigger policy for each execute`.trim().split(/\s+/));
17
+
18
+ /** Clauses that start a line in a query; two-word ones are matched as their first word plus the next. */
19
+ const CLAUSES = new Set(['select', 'from', 'where', 'having', 'limit', 'offset', 'fetch', 'union', 'intersect', 'except', 'window', 'returning', 'values', 'set', 'with', 'insert', 'update', 'delete', 'on conflict']);
20
+ const TWO_WORD = new Set(['group by', 'order by', 'partition by', 'on conflict', 'union all']);
21
+ const JOIN_START = new Set(['join', 'inner', 'left', 'right', 'full', 'cross', 'natural']);
22
+
23
+ type Tok = { t: 'ws' | 'word' | 'quoted' | 'comment' | 'punct' | 'num'; v: string };
24
+
25
+ function tokens(sql: string): Tok[] {
26
+ const out: Tok[] = [];
27
+ let i = 0;
28
+ while (i < sql.length) {
29
+ const c = sql[i];
30
+ if (/\s/.test(c)) {
31
+ let j = i + 1;
32
+ while (j < sql.length && /\s/.test(sql[j])) j++;
33
+ out.push({ t: 'ws', v: sql.slice(i, j) });
34
+ i = j;
35
+ continue;
36
+ }
37
+ const prev = i > 0 ? sql[i - 1] : '';
38
+ if (!(c === '$' && /[A-Za-z0-9_]/.test(prev))) {
39
+ const end = skipQuoted(sql, i);
40
+ if (end !== null && end > i) {
41
+ out.push({ t: c === '-' || c === '/' ? 'comment' : 'quoted', v: sql.slice(i, end) });
42
+ i = end;
43
+ continue;
44
+ }
45
+ }
46
+ if (/[A-Za-z_\u0080-￿]/.test(c)) {
47
+ let j = i + 1;
48
+ while (j < sql.length && /[A-Za-z0-9_$\u0080-￿]/.test(sql[j])) j++;
49
+ // E'…' strings: the E belongs to the string
50
+ if ((sql[j] === "'") && j === i + 1 && /[eE]/.test(c)) {
51
+ const end = skipQuoted(sql, j)!;
52
+ out.push({ t: 'quoted', v: sql.slice(i, end) });
53
+ i = end;
54
+ continue;
55
+ }
56
+ out.push({ t: 'word', v: sql.slice(i, j) });
57
+ i = j;
58
+ continue;
59
+ }
60
+ if (/[0-9]/.test(c)) {
61
+ let j = i + 1;
62
+ while (j < sql.length && /[0-9.eE_]/.test(sql[j])) j++;
63
+ out.push({ t: 'num', v: sql.slice(i, j) });
64
+ i = j;
65
+ continue;
66
+ }
67
+ // $1 parameters
68
+ const param = c === '$' ? /^\$\d+/.exec(sql.slice(i, i + 12)) : null;
69
+ if (param) {
70
+ out.push({ t: 'num', v: param[0] });
71
+ i += param[0].length;
72
+ continue;
73
+ }
74
+ // operators of several characters stay together (::, <=, <>, ->>, ||, :=)
75
+ const op = /^(::|->>|->|#>>|#>|<=|>=|<>|!=|\|\||:=|=>)/.exec(sql.slice(i, i + 3));
76
+ out.push({ t: 'punct', v: op ? op[0] : c });
77
+ i += op ? op[0].length : 1;
78
+ }
79
+ return out;
80
+ }
81
+
82
+ export function formatSql(sql: string, keywordCase: 'upper' | 'lower' = 'lower'): string {
83
+ const toks = tokens(sql).filter((t) => t.t !== 'ws');
84
+ const kw = (w: string) => (keywordCase === 'upper' ? w.toUpperCase() : w.toLowerCase());
85
+ interface Frame { query: boolean; base: number; clause: string; between: boolean }
86
+ const frames: Frame[] = [{ query: true, base: 0, clause: '', between: false }];
87
+ let out = '';
88
+ let line = ''; // the current line
89
+ let glue = false; // the next token sticks to the last one (a unary minus)
90
+ const flush = () => {
91
+ out += `${line.replace(/\s+$/, '')}\n`;
92
+ line = '';
93
+ };
94
+ const newline = (indent: number) => {
95
+ if (line.trim()) flush();
96
+ line = ' '.repeat(Math.max(0, indent));
97
+ };
98
+ const put = (s: string, space = true) => {
99
+ if (glue) {
100
+ glue = false;
101
+ line += s;
102
+ return;
103
+ }
104
+ if (space && line.trim() && !/[\s(]$/.test(line) && !/^[,).;:[\]]/.test(s) && !line.endsWith('::') && !line.endsWith('.')) line += ' ';
105
+ else if (!line.trim() && line.length === 0) line = ' '.repeat(frames[frames.length - 1].base);
106
+ line += s;
107
+ };
108
+ for (let k = 0; k < toks.length; k++) {
109
+ const t = toks[k];
110
+ const f = frames[frames.length - 1];
111
+ const lower = t.v.toLowerCase();
112
+ if (t.t === 'comment') {
113
+ put(t.v);
114
+ if (t.v.startsWith('--')) newline(f.base);
115
+ continue;
116
+ }
117
+ if (t.t === 'word' && KEYWORDS.has(lower)) {
118
+ const nextWord = toks.slice(k + 1).find((x) => x.t !== 'comment');
119
+ const two = nextWord && nextWord.t === 'word' ? `${lower} ${nextWord.v.toLowerCase()}` : '';
120
+ if (f.query && TWO_WORD.has(two) && two !== 'partition by') {
121
+ newline(f.base);
122
+ put(`${kw(lower)} ${kw(nextWord!.v.toLowerCase())}`);
123
+ f.clause = two;
124
+ k = toks.indexOf(nextWord!);
125
+ continue;
126
+ }
127
+ if (f.query && CLAUSES.has(lower) && !(lower === 'set' && f.clause !== 'update') && !(lower === 'values' && f.clause === 'values')) {
128
+ if ((out || line.trim()) && !line.endsWith('(')) newline(f.base);
129
+ put(kw(lower));
130
+ f.clause = lower;
131
+ f.between = false;
132
+ continue;
133
+ }
134
+ if (f.query && JOIN_START.has(lower)) {
135
+ const prevWord = [...toks.slice(0, k)].reverse().find((x) => x.t !== 'comment');
136
+ const afterJoinWord = prevWord?.t === 'word' && /^(inner|left|right|full|cross|natural|outer)$/i.test(prevWord.v);
137
+ // left( and right( are functions; JOIN right after INNER / LEFT … stays on its line
138
+ const starts = lower === 'join' ? !afterJoinWord : !!nextWord && /^(join|outer|inner)$/i.test(nextWord.v);
139
+ if (starts && !afterJoinWord) {
140
+ newline(f.base + 2);
141
+ put(kw(lower));
142
+ f.clause = 'from';
143
+ continue;
144
+ }
145
+ }
146
+ if (f.query && (lower === 'and' || lower === 'or') && ['where', 'having', 'from'].includes(f.clause)) {
147
+ if (lower === 'and' && f.between) {
148
+ f.between = false;
149
+ put(kw(lower));
150
+ continue;
151
+ }
152
+ newline(f.base + (f.clause === 'from' ? 4 : 2) + (lower === 'and' ? 1 : 2));
153
+ put(kw(lower));
154
+ continue;
155
+ }
156
+ if (lower === 'between') f.between = true;
157
+ put(kw(lower));
158
+ continue;
159
+ }
160
+ if (t.t === 'punct') {
161
+ if (t.v === '(') {
162
+ const next = toks.slice(k + 1).find((x) => x.t !== 'comment');
163
+ const sub = !!next && next.t === 'word' && /^(select|with|values)$/i.test(next.v);
164
+ const prevTok = toks[k - 1];
165
+ const before = toks[k - 2];
166
+ const named = before?.t === 'word' && /^(into|table|view|index|on|using|references|function|procedure|type)$/i.test(before.v);
167
+ const fn = !!prevTok && (prevTok.t === 'word' || prevTok.t === 'quoted') && !named
168
+ && (!KEYWORDS.has(prevTok.v.toLowerCase()) || /^(left|right|replace|cast|any|some)$/i.test(prevTok.v));
169
+ put('(', !fn);
170
+ frames.push({ query: sub, base: sub ? line.length : f.base, clause: '', between: false });
171
+ continue;
172
+ }
173
+ if (t.v === ')') {
174
+ if (frames.length > 1) frames.pop();
175
+ put(')', false);
176
+ continue;
177
+ }
178
+ if (t.v === ',') {
179
+ put(',', false);
180
+ if (f.query && f.clause === 'select') newline(f.base + 7);
181
+ else if (f.query && (f.clause === 'group by' || f.clause === 'order by')) newline(f.base + 9);
182
+ continue;
183
+ }
184
+ if (t.v === ';') {
185
+ put(';', false);
186
+ flush();
187
+ out += '\n';
188
+ frames.splice(1);
189
+ frames[0].clause = '';
190
+ continue;
191
+ }
192
+ if (t.v === '.' || t.v === '::') {
193
+ line += t.v;
194
+ continue;
195
+ }
196
+ const prevTok = toks[k - 1];
197
+ const unary = (t.v === '-' || t.v === '+') && (!prevTok || prevTok.t === 'punct' && prevTok.v !== ')' || (prevTok.t === 'word' && KEYWORDS.has(prevTok.v.toLowerCase())));
198
+ put(t.v);
199
+ if (unary) glue = true;
200
+ continue;
201
+ }
202
+ // a word after "." or "::" sticks to it
203
+ if (line.endsWith('.') || line.endsWith('::')) line += t.v;
204
+ else put(t.v);
205
+ }
206
+ if (line.trim()) flush();
207
+ return out.replace(/\n{3,}/g, '\n\n').replace(/\s+$/, '') + (sql.trimEnd().endsWith(';') ? '\n' : '');
208
+ }