pgkiln 0.41.0 → 0.43.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 (68) hide show
  1. package/db/migrations/097_generated_app_quality.sql +911 -0
  2. package/db/migrations/098_app_activity.sql +37 -0
  3. package/db/migrations/099_layout_positions.sql +29 -0
  4. package/docs/apex-feature-parity.md +38 -11
  5. package/docs/guide/03-builder.md +133 -31
  6. package/docs/guide/04-pages-and-regions.md +46 -3
  7. package/docs/guide/05-items.md +1 -1
  8. package/docs/guide/09-reference.md +10 -3
  9. package/docs/guide/11-from-apex.md +1 -1
  10. package/docs/guide/12-development.md +3 -2
  11. package/docs/guide/14-globalization.md +7 -3
  12. package/docs/guide/17-mobile.md +1 -1
  13. package/package.json +1 -1
  14. package/public/app.css +233 -33
  15. package/public/app.js +263 -0
  16. package/public/builder.css +142 -2
  17. package/public/builder.js +311 -3
  18. package/src/builder/appfromfile.ts +5 -4
  19. package/src/builder/appsheets.ts +23 -9
  20. package/src/builder/appwizard.ts +215 -70
  21. package/src/builder/arrange.ts +133 -17
  22. package/src/builder/blueprints.ts +2 -1
  23. package/src/builder/components.ts +29 -23
  24. package/src/builder/designer.ts +67 -18
  25. package/src/builder/forms.ts +4 -1
  26. package/src/builder/home.ts +2 -32
  27. package/src/builder/newapp.ts +182 -5
  28. package/src/builder/object-browser.ts +342 -0
  29. package/src/builder/pwa.ts +3 -3
  30. package/src/builder/routes.ts +86 -51
  31. package/src/builder/shared.ts +103 -1
  32. package/src/builder/sql.ts +13 -65
  33. package/src/builder/ui.ts +21 -6
  34. package/src/i18n/ar.ts +17 -0
  35. package/src/i18n/cs.ts +17 -0
  36. package/src/i18n/da.ts +17 -0
  37. package/src/i18n/de.ts +17 -0
  38. package/src/i18n/el.ts +17 -0
  39. package/src/i18n/es.ts +17 -0
  40. package/src/i18n/fi.ts +17 -0
  41. package/src/i18n/fr.ts +17 -0
  42. package/src/i18n/he.ts +17 -0
  43. package/src/i18n/it.ts +17 -0
  44. package/src/i18n/ja.ts +17 -0
  45. package/src/i18n/ko.ts +17 -0
  46. package/src/i18n/nb.ts +17 -0
  47. package/src/i18n/pl.ts +17 -0
  48. package/src/i18n/pt.ts +17 -0
  49. package/src/i18n/ru.ts +17 -0
  50. package/src/i18n/sv.ts +17 -0
  51. package/src/i18n/tr.ts +17 -0
  52. package/src/i18n/uk.ts +17 -0
  53. package/src/i18n/zh.ts +17 -0
  54. package/src/i18n.ts +34 -0
  55. package/src/icons.ts +2 -1
  56. package/src/layout.ts +2 -2
  57. package/src/metadata.ts +5 -1
  58. package/src/runtime/charts.ts +8 -3
  59. package/src/runtime/devbar.ts +35 -14
  60. package/src/runtime/engine.ts +13 -4
  61. package/src/runtime/format.ts +52 -0
  62. package/src/runtime/items.ts +27 -0
  63. package/src/runtime/locale.ts +5 -2
  64. package/src/runtime/regions.ts +61 -4
  65. package/src/runtime/render.ts +38 -8
  66. package/src/runtime/report.ts +149 -88
  67. package/src/runtime/routes.ts +34 -3
  68. package/src/runtime/styles.ts +5 -2
package/src/i18n/tr.ts CHANGED
@@ -111,6 +111,21 @@ export const tr: Record<MessageKey, string> = {
111
111
  'color.blue': "Mavi",
112
112
  'color.gray': "Gri",
113
113
  'report.saved_reports': "Kayıtlı raporlar",
114
+ 'report.first': "İlk sayfa",
115
+ 'report.last': "Son sayfa",
116
+ 'report.hidden_column': "Gizli",
117
+ 'report.show_column': "Sütunu yeniden göster",
118
+ 'report.search_column': "Şurada ara",
119
+ 'report.all_columns': "Tüm sütunlar",
120
+ 'report.columns': "Sütunlar",
121
+ 'report.hide_columns': "Gizlenecek sütunlar",
122
+ 'report.data': "Veri",
123
+ 'report.format': "Biçim",
124
+ 'report.report': "Rapor",
125
+ 'report.download_menu': "İndir",
126
+ 'report.hide': "Sütunu gizle",
127
+ 'report.filter_values': "Değerleri filtrele",
128
+ 'report.no_values': "Değer yok",
114
129
  'report.save_as': "Farklı kaydet…",
115
130
  'report.name': "Ad",
116
131
  'report.save': "Kaydet",
@@ -148,11 +163,13 @@ export const tr: Record<MessageKey, string> = {
148
163
  'pwa.queue_waiting': "Gönderilmeyi bekleyen {n} form",
149
164
  'pwa.send_now': "Şimdi gönder",
150
165
  'pwa.discard': "At",
166
+ 'pwa.install': "Uygulamayı yükle",
151
167
  'pwa.status.waiting': "bekliyor",
152
168
  'pwa.status.signin': "göndermek için oturum açın",
153
169
  'pwa.status.invalid': "reddedildi: sayfayı açıp düzeltin",
154
170
  'pwa.status.error': "gönderilmedi",
155
171
  'item.locate': "Konumumu kullan",
172
+ 'item.choose_date': "Bir tarih seçin",
156
173
  'item.show_map': "harita",
157
174
  'item.scan': "Tara",
158
175
  'item.locate_error': "Konum kullanılamıyor.",
package/src/i18n/uk.ts CHANGED
@@ -111,6 +111,21 @@ export const uk: Record<MessageKey, string> = {
111
111
  'color.blue': "Синій",
112
112
  'color.gray': "Сірий",
113
113
  'report.saved_reports': "Збережені звіти",
114
+ 'report.first': "Перша сторінка",
115
+ 'report.last': "Остання сторінка",
116
+ 'report.hidden_column': "Прихований",
117
+ 'report.show_column': "Знову показати стовпець",
118
+ 'report.search_column': "Шукати в",
119
+ 'report.all_columns': "Усі стовпці",
120
+ 'report.columns': "Стовпці",
121
+ 'report.hide_columns': "Приховати стовпці",
122
+ 'report.data': "Дані",
123
+ 'report.format': "Формат",
124
+ 'report.report': "Звіт",
125
+ 'report.download_menu': "Завантажити",
126
+ 'report.hide': "Приховати стовпець",
127
+ 'report.filter_values': "Фільтр значень",
128
+ 'report.no_values': "Немає значень",
114
129
  'report.save_as': "Зберегти як…",
115
130
  'report.name': "Назва",
116
131
  'report.save': "Зберегти",
@@ -148,11 +163,13 @@ export const uk: Record<MessageKey, string> = {
148
163
  'pwa.queue_waiting': "Форм очікують надсилання: {n}",
149
164
  'pwa.send_now': "Надіслати зараз",
150
165
  'pwa.discard': "Відкинути",
166
+ 'pwa.install': "Встановити застосунок",
151
167
  'pwa.status.waiting': "очікує",
152
168
  'pwa.status.signin': "увійдіть, щоб надіслати",
153
169
  'pwa.status.invalid': "відхилено: відкрийте сторінку й виправте",
154
170
  'pwa.status.error': "не надіслано",
155
171
  'item.locate': "Моє розташування",
172
+ 'item.choose_date': "Вибрати дату",
156
173
  'item.show_map': "карта",
157
174
  'item.scan': "Сканувати",
158
175
  'item.locate_error': "Розташування недоступне.",
package/src/i18n/zh.ts CHANGED
@@ -111,6 +111,21 @@ export const zh: Record<MessageKey, string> = {
111
111
  'color.blue': "蓝色",
112
112
  'color.gray': "灰色",
113
113
  'report.saved_reports': "已保存的报表",
114
+ 'report.first': "第一页",
115
+ 'report.last': "最后一页",
116
+ 'report.hidden_column': "已隐藏",
117
+ 'report.show_column': "重新显示该列",
118
+ 'report.search_column': "搜索范围",
119
+ 'report.all_columns': "所有列",
120
+ 'report.columns': "列",
121
+ 'report.hide_columns': "要隐藏的列",
122
+ 'report.data': "数据",
123
+ 'report.format': "格式",
124
+ 'report.report': "报表",
125
+ 'report.download_menu': "下载",
126
+ 'report.hide': "隐藏列",
127
+ 'report.filter_values': "筛选值",
128
+ 'report.no_values': "没有值",
114
129
  'report.save_as': "另存为…",
115
130
  'report.name': "名称",
116
131
  'report.save': "保存",
@@ -148,11 +163,13 @@ export const zh: Record<MessageKey, string> = {
148
163
  'pwa.queue_waiting': "{n} 个表单等待发送",
149
164
  'pwa.send_now': "立即发送",
150
165
  'pwa.discard': "放弃",
166
+ 'pwa.install': "安装应用",
151
167
  'pwa.status.waiting': "等待中",
152
168
  'pwa.status.signin': "登录后发送",
153
169
  'pwa.status.invalid': "被拒绝:请打开页面并更正",
154
170
  'pwa.status.error': "未发送",
155
171
  'item.locate': "使用我的位置",
172
+ 'item.choose_date': "选择日期",
156
173
  'item.show_map': "地图",
157
174
  'item.scan': "扫描",
158
175
  'item.locate_error': "无法获取位置。",
package/src/i18n.ts CHANGED
@@ -138,6 +138,21 @@ const en = {
138
138
  'color.blue': 'Blue',
139
139
  'color.gray': 'Gray',
140
140
  'report.saved_reports': 'Saved reports',
141
+ 'report.first': "First page",
142
+ 'report.last': "Last page",
143
+ 'report.hidden_column': "Hidden",
144
+ 'report.show_column': "Show the column again",
145
+ 'report.search_column': "Search in",
146
+ 'report.all_columns': "All columns",
147
+ 'report.columns': "Columns",
148
+ 'report.hide_columns': "Columns to hide",
149
+ 'report.data': "Data",
150
+ 'report.format': "Format",
151
+ 'report.report': "Report",
152
+ 'report.download_menu': "Download",
153
+ 'report.hide': "Hide column",
154
+ 'report.filter_values': "Filter the values",
155
+ 'report.no_values': "No values",
141
156
  'report.save_as': 'Save as…',
142
157
  'report.name': 'Name',
143
158
  'report.save': 'Save',
@@ -175,11 +190,13 @@ const en = {
175
190
  'pwa.queue_waiting': '{n} form(s) waiting to be sent',
176
191
  'pwa.send_now': 'Send now',
177
192
  'pwa.discard': 'Discard',
193
+ 'pwa.install': 'Install app',
178
194
  'pwa.status.waiting': 'waiting',
179
195
  'pwa.status.signin': 'sign in to send',
180
196
  'pwa.status.invalid': 'rejected: open the page and correct it',
181
197
  'pwa.status.error': 'not sent',
182
198
  'item.locate': 'Use my location',
199
+ 'item.choose_date': "Choose a date",
183
200
  'item.show_map': 'map',
184
201
  'item.scan': 'Scan',
185
202
  'item.locate_error': 'The location is not available.',
@@ -826,6 +843,21 @@ const nl: Record<MessageKey, string> = {
826
843
  'color.blue': 'Blauw',
827
844
  'color.gray': 'Grijs',
828
845
  'report.saved_reports': 'Opgeslagen rapporten',
846
+ 'report.first': "Eerste pagina",
847
+ 'report.last': "Laatste pagina",
848
+ 'report.hidden_column': "Verborgen",
849
+ 'report.show_column': "Kolom weer tonen",
850
+ 'report.search_column': "Zoeken in",
851
+ 'report.all_columns': "Alle kolommen",
852
+ 'report.columns': "Kolommen",
853
+ 'report.hide_columns': "Kolommen verbergen",
854
+ 'report.data': "Gegevens",
855
+ 'report.format': "Opmaak",
856
+ 'report.report': "Rapport",
857
+ 'report.download_menu': "Downloaden",
858
+ 'report.hide': "Kolom verbergen",
859
+ 'report.filter_values': "Waarden filteren",
860
+ 'report.no_values': "Geen waarden",
829
861
  'report.save_as': 'Opslaan als…',
830
862
  'report.name': 'Naam',
831
863
  'report.save': 'Opslaan',
@@ -863,11 +895,13 @@ const nl: Record<MessageKey, string> = {
863
895
  'pwa.queue_waiting': '{n} formulier(en) wachten op verzending',
864
896
  'pwa.send_now': 'Nu versturen',
865
897
  'pwa.discard': 'Weggooien',
898
+ 'pwa.install': 'App installeren',
866
899
  'pwa.status.waiting': 'wacht',
867
900
  'pwa.status.signin': 'meld je aan om te versturen',
868
901
  'pwa.status.invalid': 'geweigerd: open de pagina en verbeter het',
869
902
  'pwa.status.error': 'niet verzonden',
870
903
  'item.locate': 'Mijn locatie',
904
+ 'item.choose_date': "Kies een datum",
871
905
  'item.show_map': 'kaart',
872
906
  'item.scan': 'Scannen',
873
907
  'item.locate_error': 'De locatie is niet beschikbaar.',
package/src/icons.ts CHANGED
@@ -63,7 +63,8 @@ export const ICON_MODIFIERS = Object.keys(MODIFIERS);
63
63
  export function iconParts(value: string | null | undefined): { name: string; set: 'pgkiln' | 'lucide'; classes: string[] } | null {
64
64
  if (!value) return null;
65
65
  const tokens = String(value).trim().toLowerCase().split(/\s+/).slice(0, 8).map((t) => (t.startsWith('fa-') ? t.slice(3) : t)).filter((t) => t && t !== 'fa');
66
- const name = tokens.find((t) => !(t in MODIFIERS));
66
+ // (sprint 72) "info" is a modifier and an icon: alone, it is the icon
67
+ const name = tokens.find((t) => !(t in MODIFIERS)) ?? (tokens.length === 1 && (ICONS as readonly string[]).includes(tokens[0]) ? tokens[0] : undefined);
67
68
  if (!name) return null;
68
69
  const set = (ICONS as readonly string[]).includes(name) ? 'pgkiln' : lucideNames().has(name) ? 'lucide' : null;
69
70
  if (!set) return null;
package/src/layout.ts CHANGED
@@ -7,13 +7,13 @@ export interface RootAttrs {
7
7
  dir?: 'ltr' | 'rtl';
8
8
  theme?: 'auto' | 'light' | 'dark';
9
9
  /** a base style other than Standard (src/runtime/styles.ts BASE_STYLES) */
10
- style?: 'iris';
10
+ style?: 'iris' | 'lavender';
11
11
  }
12
12
 
13
13
  export function documentShell(title: string, body: Raw, bodyClass = '', data: Record<string, string> = {}, head: Raw | '' = '', root: RootAttrs = {}) {
14
14
  const attrs = Object.entries(data).map(([k, v]) => html` ${k}="${v}"`);
15
15
  return html`<!doctype html>
16
- <html lang="${root.lang ?? 'en'}"${root.dir === 'rtl' ? html` dir="rtl"` : ''}${root.theme && root.theme !== 'auto' ? html` data-theme="${root.theme}"` : ''}${root.style === 'iris' ? html` data-style="iris"` : ''}>
16
+ <html lang="${root.lang ?? 'en'}"${root.dir === 'rtl' ? html` dir="rtl"` : ''}${root.theme && root.theme !== 'auto' ? html` data-theme="${root.theme}"` : ''}${root.style === 'iris' || root.style === 'lavender' ? html` data-style="${root.style}"` : ''}>
17
17
  <head>
18
18
  <meta charset="utf-8">
19
19
  <meta name="viewport" content="width=device-width, initial-scale=1">
package/src/metadata.ts CHANGED
@@ -154,6 +154,8 @@ export interface Region {
154
154
  rest_source?: string | null;
155
155
  /** (053) template options: CSS classes from REGION_OPTIONS (others are ignored) */
156
156
  template_options?: string[];
157
+ /** (099) where the region shows: banner, breadcrumb, left, body (null), right or footer */
158
+ position?: 'banner' | 'breadcrumb' | 'left' | 'body' | 'right' | 'footer' | null;
157
159
  }
158
160
 
159
161
  export type ItemType =
@@ -205,6 +207,8 @@ export interface Button {
205
207
  badge_query?: string | null;
206
208
  /** (053) template options: CSS classes from BUTTON_OPTIONS (others are ignored) */
207
209
  template_options?: string[];
210
+ /** (099) a region button's position: create, edit, change, copy, help, up, previous, close, delete, next or region (null) */
211
+ slot?: string | null;
208
212
  }
209
213
 
210
214
  export interface MenuEntry {
@@ -328,7 +332,7 @@ export interface Page extends PageSummary {
328
332
  /** (077) ask before leaving the page with unsaved changes */
329
333
  warn_unsaved: boolean;
330
334
  /** (087) 'minimal': no header and navigation */
331
- page_template: 'standard' | 'minimal';
335
+ page_template: 'standard' | 'minimal' | 'left_side_column' | 'right_side_column';
332
336
  }
333
337
 
334
338
  /** The static files a list of names refers to, in that order, with their version. */
@@ -173,18 +173,23 @@ const tip = (label: string, series: Series[], i: number) =>
173
173
  // ---------------------------------------------------------------- bar (horizontal)
174
174
  function bar(labels: string[], series: Series[]) {
175
175
  const all = series.flatMap((s) => s.values);
176
- const max = Math.max(...all.map(Math.abs), 1);
176
+ // (097) a value axis with gridlines: bars are measured against a nice scale from zero
177
+ const scale = niceScale(0, Math.max(...all.map(Math.abs), 1));
178
+ const max = scale.hi || 1;
179
+ const ticks = scale.ticks.filter((t) => t >= 0 && t <= max);
180
+ const at = (t: number) => css.cls(`left:${pct((t / max) * 100)}`);
181
+ const grid = html`<span class="bar-grid" aria-hidden="true">${ticks.map((t) => html`<span class="bar-gridline${t === 0 ? ' baseline' : ''} ${at(t)}"></span>`)}</span>`;
177
182
  const multi = series.length > 1;
178
183
  return html`<div class="chart-bar${multi ? ' multi' : ''}">${labels.map((l, i) => {
179
184
  const inner = html`<span class="bar-label" title="${l}">${l}</span>
180
- <span class="bar-stack">${series.map((s, si) => {
185
+ <span class="bar-stack">${grid}${series.map((s, si) => {
181
186
  const b = html`<span class="bar s${si + 1} ${css.cls(`width:${pct((Math.abs(s.values[i]) / max) * 100)}`)}"></span>`;
182
187
  // several series: each bar is its own link
183
188
  return multi && link ? mark('span', i, si, 'bar-track', `${l} · ${s.name}: ${fmt.format(s.values[i])}`, b, false) : html`<span class="bar-track">${b}</span>`;
184
189
  })}</span>
185
190
  <span class="bar-value">${multi ? '' : fmt.format(series[0].values[i])}</span>`;
186
191
  return !multi && link ? mark('div', i, 0, 'bar-row', tip(l, series, i), inner) : html`<div class="bar-row" data-tip="${tip(l, series, i)}">${inner}</div>`;
187
- })}</div>`;
192
+ })}<div class="bar-axis" aria-hidden="true"><span></span><span class="bar-ticks">${ticks.map((t) => html`<span class="${at(t)}">${compact.format(t)}</span>`)}</span><span></span></div></div>`;
188
193
  }
189
194
 
190
195
  // ---------------------------------------------------------------- shared y axis
@@ -22,6 +22,7 @@ import { html, raw, type Raw } from '../html.ts';
22
22
  import { hashToken, newToken } from '../security.ts';
23
23
  import { saveState, sessionLimits, type Session } from '../session.ts';
24
24
  import { documentShell } from '../layout.ts';
25
+ import { icon } from '../icons.ts';
25
26
  import type { PageContext } from './context.ts';
26
27
 
27
28
  export const DEVBAR_COOKIE = 'pgkiln_devbar';
@@ -73,30 +74,50 @@ export const sessionDebugLevel = (s: Session) => {
73
74
  return DEBUG_LEVELS.some(([level]) => level === n) ? n : 0;
74
75
  };
75
76
 
76
- /** The toolbar at the bottom of a page (not in dialogs). */
77
+ /**
78
+ * The toolbar (not in dialogs): (sprint 72) a compact bar of icons with tooltips,
79
+ * centred at the bottom like APEX's. Debug, Info and Settings open small panels
80
+ * (<details>, so they work without JavaScript). With JavaScript (app.js) the bar
81
+ * shrinks to a slim tab after a few seconds without use and can be docked at the
82
+ * top, left or right (Settings; kept in the browser).
83
+ */
77
84
  export function devbarHtml(ctx: PageContext): Raw | '' {
78
85
  if (!ctx.devbar || ctx.dialog) return '';
79
86
  const level = sessionDebugLevel(ctx.session);
80
87
  const appLevel = Number(ctx.app.debug_level ?? 0);
81
88
  const page = ctx.page.page_no;
82
- return html`<details class="devbar" open>
83
- <summary><span>Developer</span></summary>
84
- <nav aria-label="Developer toolbar"><ul>
85
- <li><a href="${BUILDER}">App Builder</a></li>
86
- <li><a href="${BUILDER}/apps/${ctx.app.id}">Application ${ctx.app.id}</a></li>
87
- <li><a href="${BUILDER}/pages/${ctx.page.id}">Edit page ${page}</a></li>
88
- <li><button type="button" class="devbar-quick" data-quick-edit="${BUILDER}/pages/${ctx.page.id}" aria-pressed="false" hidden>Quick edit</button></li>
89
- <li><a href="${ctx.base}/${page}/devbar/session">Session</a></li>
90
- <li><form method="post" action="${ctx.base}/${page}/devbar/debug" class="devbar-debug">
89
+ const tool = (href: string, label: string, name: string) =>
90
+ html`<li><a class="devbar-tool" href="${href}" title="${label}" aria-label="${label}">${icon(name)}</a></li>`;
91
+ const menu = (label: string, name: string, cls: string, body: Raw, hidden = false) =>
92
+ html`<li${hidden ? raw(' hidden') : ''}><details class="devbar-menu ${cls}"><summary class="devbar-tool" title="${label}" aria-label="${label}">${icon(name)}</summary>
93
+ <div class="devbar-pop" role="group" aria-label="${label}">${body}</div></details></li>`;
94
+ return html`<div class="devbar" data-dock="bottom">
95
+ <nav aria-label="Developer toolbar"><ul class="devbar-tools">
96
+ ${tool(BUILDER, 'App Builder', 'home')}
97
+ ${tool(`${BUILDER}/apps/${ctx.app.id}`, `Application ${ctx.app.id}`, 'grid')}
98
+ ${tool(`${BUILDER}/pages/${ctx.page.id}`, `Edit page ${page}`, 'edit')}
99
+ ${tool(`${ctx.base}/${page}/devbar/session`, 'Session', 'database')}
100
+ ${menu('Debug', 'bolt', `devbar-debug-menu${level || appLevel ? ' is-on' : ''}`, html`<form method="post" action="${ctx.base}/${page}/devbar/debug" class="devbar-debug">
91
101
  <input type="hidden" name="__csrf" value="${ctx.session.csrf_token}">
92
- <label for="devbar-debug">Debug</label>
102
+ <label for="devbar-debug">Debug messages</label>
93
103
  <select id="devbar-debug" name="level">${DEBUG_LEVELS.map(([n, label]) =>
94
104
  html`<option value="${n}"${n === level ? raw(' selected') : ''}>${n === 0 ? (appLevel ? `Application (${appLevel})` : 'Off') : label}</option>`)}</select>
95
105
  <button>Set</button>
96
- </form></li>
97
- ${level || appLevel ? html`<li><a href="${BUILDER}/apps/${ctx.app.id}/debug">View debug</a></li>` : ''}
106
+ </form>${level || appLevel ? html`<a href="${BUILDER}/apps/${ctx.app.id}/debug">View debug</a>` : ''}`)}
107
+ <li><button type="button" class="devbar-tool devbar-quick" data-quick-edit="${BUILDER}/pages/${ctx.page.id}" aria-pressed="false" title="Quick edit" aria-label="Quick edit" hidden>${icon('scan')}</button></li>
108
+ ${tool(`${BUILDER}/apps/${ctx.app.id}/theme`, 'Customize (Theme Roller)', 'sliders')}
109
+ ${menu('Info', 'info', 'devbar-info', html`<dl class="devbar-facts">
110
+ <dt>Application</dt><dd>${ctx.app.name} (${ctx.app.id})</dd>
111
+ <dt>Page</dt><dd>${page}: ${ctx.page.name}</dd>
112
+ <dt>User</dt><dd>${ctx.user ?? 'nobody'}</dd>
113
+ <dt>Developer</dt><dd>${ctx.devbar.username}</dd>
114
+ </dl>`)}
115
+ ${menu('Settings', 'settings', 'devbar-settings', html`<fieldset class="devbar-dock"><legend>Position</legend>
116
+ ${(['bottom', 'top', 'left', 'right'] as const).map((d) => html`<label><input type="radio" name="devbar-dock" value="${d}"${d === 'bottom' ? raw(' checked') : ''}> ${d[0].toUpperCase() + d.slice(1)}</label>`)}
117
+ </fieldset>
118
+ <label class="devbar-autohide"><input type="checkbox" name="devbar-autohide" checked> Hide when not used</label>`, true)}
98
119
  </ul></nav>
99
- </details>`;
120
+ </div>`;
100
121
  }
101
122
 
102
123
  const INTERNAL = /^__/;
@@ -198,6 +198,12 @@ export async function validate(ctx: PageContext) {
198
198
  const example = mask ? formatNumber('1234.5', mask, ctx.locale.numbers) : undefined;
199
199
  fail(i.name, example && !example.startsWith('#') ? ctx.locale.t('error.number_format', { label, example }) : ctx.locale.t('error.not_number', { label }));
200
200
  }
201
+ // (sprint 76) a date item holds a date (one in the application's date format was read back on submit)
202
+ for (const i of deleting ? [] : ctx.page.items) {
203
+ const v = state[i.name];
204
+ if (i.type !== 'date' || !v || !vis.editable.has(i.name) || errors.items[i.name] || /^\d{4}-\d{2}-\d{2}$/.test(v)) continue;
205
+ fail(i.name, ctx.locale.t('error.not_date', { label: i.label ?? i.name }));
206
+ }
201
207
  for (const i of deleting ? [] : ctx.page.items) {
202
208
  const v = state[i.name];
203
209
  if (i.type !== 'location' || !v || !vis.editable.has(i.name) || errors.items[i.name]) continue;
@@ -271,7 +277,7 @@ async function formDml(ctx: PageContext, p: Process): Promise<string | null> {
271
277
  if (op === 'update' || v !== null) assignments.push([ident(i.source_column!), literal(v)]);
272
278
  continue;
273
279
  }
274
- const conf = (i.config ?? {}) as { filename_column?: string; mime_column?: string; size_column?: string };
280
+ const conf = (i.config ?? {}) as { filename_column?: string; mime_column?: string; size_column?: string; updated_column?: string };
275
281
  const store = storeOf(i);
276
282
  if (store && (isTempId(v) || (v === REMOVE && op === 'update'))) {
277
283
  if (op === 'update') replaced.push([store, (await storedKey(i))!]);
@@ -282,8 +288,9 @@ async function formDml(ctx: PageContext, p: Process): Promise<string | null> {
282
288
  if (conf.filename_column) assignments.push([ident(conf.filename_column), literal(f.filename)]);
283
289
  if (conf.mime_column) assignments.push([ident(conf.mime_column), literal(f.mime)]);
284
290
  if (conf.size_column) assignments.push([ident(conf.size_column), literal(String(f.size))]);
291
+ if (conf.updated_column) assignments.push([ident(conf.updated_column), 'now()']);
285
292
  } else {
286
- for (const col of [i.source_column!, conf.filename_column, conf.mime_column, conf.size_column]) if (col) assignments.push([ident(col), 'null']);
293
+ for (const col of [i.source_column!, conf.filename_column, conf.mime_column, conf.size_column, conf.updated_column]) if (col) assignments.push([ident(col), 'null']);
287
294
  }
288
295
  continue;
289
296
  }
@@ -293,10 +300,12 @@ async function formDml(ctx: PageContext, p: Process): Promise<string | null> {
293
300
  assignments.push([ident(i.source_column!), file('content')]);
294
301
  if (conf.filename_column) assignments.push([ident(conf.filename_column), file('filename')]);
295
302
  if (conf.mime_column) assignments.push([ident(conf.mime_column), file('mime_type')]);
303
+ // (097) the size and the time of the change, when the table keeps them
304
+ if (conf.size_column) assignments.push([ident(conf.size_column), file('size')]);
305
+ if (conf.updated_column) assignments.push([ident(conf.updated_column), 'now()']);
296
306
  } else if (v === REMOVE && op === 'update') {
297
307
  assignments.push([ident(i.source_column!), 'null']);
298
- if (conf.filename_column) assignments.push([ident(conf.filename_column), 'null']);
299
- if (conf.mime_column) assignments.push([ident(conf.mime_column), 'null']);
308
+ for (const col of [conf.filename_column, conf.mime_column, conf.size_column, conf.updated_column]) if (col) assignments.push([ident(col), 'null']);
300
309
  }
301
310
  }
302
311
  // saved into the row: the temporary files are no longer needed, replaced objects go after the commit
@@ -74,6 +74,58 @@ export function applyMask(value: string, mask: string, lang: string) {
74
74
  });
75
75
  }
76
76
 
77
+ /** (sprint 76) The month names of a language (long and short), for the date picker. */
78
+ export function monthNames(lang: string) {
79
+ const n = localeNames(lang);
80
+ return { long: n.months, short: n.monthsShort };
81
+ }
82
+
83
+ /**
84
+ * (sprint 76) Read a date typed in a date mask back as "YYYY-MM-DD": YYYY, YY (2000–2099), MM, DD, MON and
85
+ * MONTH (the language's names, any case), DY and DAY (names, not checked), quoted text and other characters as
86
+ * they are. An ISO date is read too. Null when the text doesn't fit the mask or isn't a real date.
87
+ */
88
+ export function parseDateMask(value: string, mask: string, lang: string): string | null {
89
+ const v = value.trim();
90
+ const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v);
91
+ const n = localeNames(lang);
92
+ let y: number | undefined, mo: number | undefined, d: number | undefined;
93
+ if (iso) [y, mo, d] = iso.slice(1).map(Number);
94
+ else {
95
+ const parts: string[] = [];
96
+ const groups: string[] = [];
97
+ let last = 0;
98
+ const esc = (x: string) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
99
+ for (const m of mask.matchAll(TOKEN)) {
100
+ parts.push(esc(mask.slice(last, m.index)));
101
+ last = m.index! + m[0].length;
102
+ const tok = m[0];
103
+ if (tok.startsWith('"')) { parts.push(esc(tok.slice(1, -1))); continue; }
104
+ const up = tok.toUpperCase();
105
+ const names = (list: string[]) => `(${list.map(esc).join('|')})`;
106
+ const re: Record<string, string> = { YYYY: '(\\d{4})', YY: '(\\d{2})', MM: '(\\d{1,2})', DD: '(\\d{1,2})', MONTH: names(n.months), MON: names(n.monthsShort), DAY: names(n.days), DY: names(n.daysShort) };
107
+ if (!re[up]) return null; // a time in a date mask: not a date item's
108
+ parts.push(re[up]);
109
+ groups.push(up);
110
+ }
111
+ parts.push(esc(mask.slice(last)));
112
+ const m = new RegExp(`^${parts.join('')}$`, 'i').exec(v);
113
+ if (!m) return null;
114
+ groups.forEach((g, i) => {
115
+ const x = m[i + 1];
116
+ if (g === 'YYYY') y = Number(x);
117
+ else if (g === 'YY') y = 2000 + Number(x);
118
+ else if (g === 'MM') mo = Number(x);
119
+ else if (g === 'DD') d = Number(x);
120
+ else if (g === 'MONTH' || g === 'MON') mo = (g === 'MONTH' ? n.months : n.monthsShort).findIndex((name) => name.toLowerCase() === x.toLowerCase()) + 1;
121
+ });
122
+ }
123
+ if (!y || !mo || !d) return null;
124
+ const date = new Date(Date.UTC(y, mo - 1, d));
125
+ if (date.getUTCFullYear() !== y || date.getUTCMonth() !== mo - 1 || date.getUTCDate() !== d) return null;
126
+ return `${String(y).padStart(4, '0')}-${pad(mo)}-${pad(d)}`;
127
+ }
128
+
77
129
  /** A formatter for dates and timestamps with the given masks (null: ISO, as sent by Postgres). */
78
130
  export function dateFormatter(lang: string, dateMask: string | null, timestampMask: string | null): Formatter {
79
131
  return (v, oid) => {
@@ -12,6 +12,7 @@ import { markdownHtml, sanitizeHtml } from '../richtext.ts';
12
12
  import { qrSvg, type Ecc } from '../qrcode.ts';
13
13
  import { formatNumber, isPlainNumber, maskError } from '../numformat.ts';
14
14
  import { addressControl, imageControl } from './data-items.ts';
15
+ import { applyMask, formatMaskError, monthNames } from './format.ts';
15
16
 
16
17
  const TRUTHY = new Set(['true', 't', 'on', '1', 'yes', 'y']);
17
18
  export const isTruthy = (v: string | null | undefined) => !!v && TRUTHY.has(v.toLowerCase());
@@ -180,6 +181,17 @@ export const splitRange = (v: string | null | undefined): [string, string] => {
180
181
  return [from, to];
181
182
  };
182
183
 
184
+ /**
185
+ * (sprint 76) The date mask a date item shows its value in: its own format mask (a date mask), else the
186
+ * application's date format; null: the browser's date field (in the browser's notation).
187
+ */
188
+ export function itemDateMask(ctx: PageContext, item: Item): string | null {
189
+ if (item.type !== 'date') return null;
190
+ const own = typeof item.config?.format_mask === 'string' ? item.config.format_mask.trim() : '';
191
+ if (own && /(YYYY|YY|MM|DD|MON)/i.test(own) && !formatMaskError(own)) return own;
192
+ return ctx.locale.dateMask?.trim() || null;
193
+ }
194
+
183
195
  /** A number or display item's number format mask ({"format_mask": "999G990D00"}), when it is a valid one. */
184
196
  export function itemMask(item: Item): string | null {
185
197
  if (item.type !== 'number' && item.type !== 'display') return null;
@@ -383,6 +395,21 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
383
395
  control = html`<div class="input-with-button"><input type="text" id="${id}" name="${id}" value="${value}" inputmode="decimal" placeholder="52.01160,4.35710"${aria}>
384
396
  <button type="button" class="btn" data-locate="${id}">${icon('map')} ${ctx.locale.t('item.locate')}</button></div>`;
385
397
  break;
398
+ case 'date': {
399
+ // (sprint 76) in the application's date format: a text field and a calendar button (app.js), read back on submit
400
+ const mask = itemDateMask(ctx, item);
401
+ if (!mask) {
402
+ control = html`<input type="date" id="${id}" name="${id}" value="${value}"${aria}${editable ? '' : raw(' readonly')}>`;
403
+ break;
404
+ }
405
+ const iso = /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : '';
406
+ const shown = iso ? applyMask(iso, mask, ctx.locale.lang) : value;
407
+ const months = monthNames(ctx.locale.lang);
408
+ 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')}>
409
+ ${editable ? html`<input type="date" class="date-native" value="${iso}" tabindex="-1" aria-hidden="true" data-date-for="${id}">
410
+ <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>`;
411
+ break;
412
+ }
386
413
  default: {
387
414
  const typed: Record<string, string> = { number: 'number', date: 'date', password: 'password', email: 'email', tel: 'tel', url: 'url' };
388
415
  // a number with a format mask is text in the language's notation (1.234,50), read back on submit
@@ -26,11 +26,13 @@ export interface Locale {
26
26
  languages: string[];
27
27
  theme: ThemeMode;
28
28
  /** the app's base style when not Standard (html data-style) */
29
- style?: 'iris';
29
+ style?: 'iris' | 'lavender';
30
30
  /** whether users may pick light/dark themselves */
31
31
  themeChoice: boolean;
32
32
  /** dates and timestamps for display (undefined: not a date) */
33
33
  format: Formatter;
34
+ /** (sprint 76) the application's own date format (or its FORMAT.DATE text message): date items show it with a picker; null: the browser's date field */
35
+ dateMask: string | null;
34
36
  number: Intl.NumberFormat;
35
37
  /** separators and currency for number format masks */
36
38
  numbers: NumberSymbols;
@@ -199,9 +201,10 @@ export async function resolveLocale(req: FastifyRequest, app: App, session?: Ses
199
201
  messages,
200
202
  languages,
201
203
  theme: themeFor(app, session, req),
202
- style: baseStyleOf(app.theme) === 'iris' ? 'iris' : undefined,
204
+ style: baseStyleOf(app.theme) === 'standard' ? undefined : (baseStyleOf(app.theme) as 'iris' | 'lavender'),
203
205
  themeChoice: app.theme?.user_choice !== false,
204
206
  format,
207
+ dateMask: messages['FORMAT.DATE'] ?? app.date_format ?? null,
205
208
  number,
206
209
  numbers,
207
210
  masked: (m) => maskedFormatter(format, lang, numbers, m),
@@ -100,9 +100,9 @@ export async function renderButton(ctx: PageContext, b: Button) {
100
100
  return html`<button type="submit" class="${cls}" name="__request" value="${b.name}" data-button="${b.name}"${confirm}>${b.label}${badge}</button>`;
101
101
  }
102
102
 
103
- export async function buttonsFor(ctx: PageContext, regionId: number | null) {
103
+ export async function buttonsFor(ctx: PageContext, regionId: number | null, only: (b: Button) => boolean = () => true) {
104
104
  const out = [];
105
- for (const b of ctx.vis!.buttons.values()) if (b.region_id === regionId && b.id > 0) out.push(await renderButton(ctx, b));
105
+ for (const b of ctx.vis!.buttons.values()) if (b.region_id === regionId && b.id > 0 && only(b)) out.push(await renderButton(ctx, b));
106
106
  return out.some(Boolean) ? html`<div class="buttons">${out}</div>` : '';
107
107
  }
108
108
 
@@ -140,8 +140,15 @@ async function renderChart(ctx: PageContext, r: Region) {
140
140
  }
141
141
  if (!res.rows.length) return html`<p class="empty">${r.config.empty ?? ctx.locale.t('report.no_data')}</p>`;
142
142
  const kind = CHART_KINDS.includes(r.config.kind) ? r.config.kind : 'bar';
143
- return html`${renderChartBody(kind, r.title ?? '', res.rows, res.fields, ctx.css, ctx.locale.lang, ctx.locale.t, {
144
- ...(await chartLink(ctx, r, res.rows, res.fields)),
143
+ const rows = topRows(r, kind, res.rows, ctx.locale.t('chart.other'));
144
+ const drill: { hidden?: string[]; link?: (i: number, si: number | null) => Raw | null } = await chartLink(ctx, r, rows, res.fields);
145
+ // the "Other" row of a top-N chart has no drill-down
146
+ if (drill.link && rows !== res.rows) {
147
+ const inner = drill.link;
148
+ drill.link = (i, si) => (i === rows.length - 1 ? null : inner(i, si));
149
+ }
150
+ return html`${renderChartBody(kind, r.title ?? '', rows, res.fields, ctx.css, ctx.locale.lang, ctx.locale.t, {
151
+ ...drill,
145
152
  gauge: gaugeConfig(r.config.gauge),
146
153
  ...chartFormat(ctx, r),
147
154
  // the query's timestamps are in the session's time zone: so is "today"
@@ -151,6 +158,22 @@ async function renderChart(ctx: PageContext, r: Region) {
151
158
  })}${firstRows(ctx, res)}`;
152
159
  }
153
160
 
161
+ /** Charts that may show the top rows and an "Other" row (bar, pie, donut, funnel, column). */
162
+ const TOP_KINDS = new Set(['bar', 'column', 'donut', 'pie', 'funnel']);
163
+
164
+ /**
165
+ * (097) {"top": 10}: the first top-1 rows and one "Other" row with the sums of
166
+ * the rest (the query orders the rows, largest first). Other charts, and
167
+ * charts with no more rows than that, keep their rows.
168
+ */
169
+ export function topRows(r: Region, kind: string, rows: unknown[][], other: string): unknown[][] {
170
+ const top = Number(r.config.top);
171
+ if (!Number.isInteger(top) || top < 2 || top > 100 || !TOP_KINDS.has(kind) || rows.length <= top) return rows;
172
+ const rest = rows.slice(top - 1);
173
+ const sums = (rows[0] ?? []).map((_, ci) => (ci === 0 ? other : rest.reduce((n: number, row) => n + (Number(row[ci]) || 0), 0)));
174
+ return [...rows.slice(0, top - 1), sums];
175
+ }
176
+
154
177
  /** {"format_mask": "FML999G990"}: the chart's values (labels, tips, data table) with a number format mask. */
155
178
  function chartFormat(ctx: PageContext, r: Region): { format?: (v: number) => string } {
156
179
  const mask = typeof r.config.format_mask === 'string' ? r.config.format_mask.trim() : '';
@@ -385,7 +408,13 @@ async function renderBody(ctx: PageContext, r: Region, hidden: Set<string>): Pro
385
408
  return body!;
386
409
  }
387
410
 
411
+ /** (099) Button positions in a region: its top bar, the bottom bar's left end, the bottom bar's right end. */
412
+ const TOP_SLOTS = new Set(['create', 'edit', 'change', 'copy', 'help', 'up']);
413
+ const LEFT_SLOTS = new Set(['previous', 'close', 'delete']);
414
+
388
415
  async function regionShell(ctx: PageContext, r: Region, hidden: Set<string>, body: Raw) {
416
+ const slotted = [...ctx.vis!.buttons.values()].some((b) => b.region_id === r.id && b.slot && b.slot !== 'region');
417
+ if (slotted && (r.template === 'standard' || r.template === 'collapsible' || r.template === 'plain')) return slottedShell(ctx, r, hidden, body);
389
418
  const buttons = await buttonsFor(ctx, r.id);
390
419
  const buttonsOnTop = r.type !== 'form' && r.type !== 'static';
391
420
  // (a grid renders its own Save button in its toolbar)
@@ -446,3 +475,31 @@ async function regionShell(ctx: PageContext, r: Region, hidden: Set<string>, bod
446
475
  </section>`;
447
476
  }
448
477
 
478
+ /**
479
+ * (099) A region whose buttons have positions (APEX: Button Position): create, edit, change, copy, help and up in
480
+ * the header; previous, close and delete at the left end of the bottom bar, next at its right end; the others
481
+ * where they always go (the header for reports and charts, the bottom bar for forms and static content).
482
+ */
483
+ async function slottedShell(ctx: PageContext, r: Region, hidden: Set<string>, body: Raw) {
484
+ const onTop = r.type !== 'form' && r.type !== 'static';
485
+ const plain = (b: Button) => !b.slot || b.slot === 'region';
486
+ const top = await buttonsFor(ctx, r.id, (b) => TOP_SLOTS.has(b.slot ?? '') || (onTop && plain(b)));
487
+ const left = await buttonsFor(ctx, r.id, (b) => LEFT_SLOTS.has(b.slot ?? ''));
488
+ const right = await buttonsFor(ctx, r.id, (b) => b.slot === 'next' || (!onTop && plain(b)));
489
+ const cls = `region region-${r.type} region-${r.template} col-${r.columns}${templateClasses('region', r.template_options)}`;
490
+ const hiddenAttr = hidden.has(`R${r.id}`) ? raw(' hidden') : '';
491
+ const staticAttr = r.static_id ? raw(` data-static-id="${esc(r.static_id)}"`) : '';
492
+ const titleId = `R${r.id}_title`;
493
+ const footer = left || right ? html`<footer class="region-footer region-footer-split"><div class="region-footer-start">${left}</div><div class="region-footer-end">${right}</div></footer>` : '';
494
+ if (r.template === 'plain')
495
+ return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-label="${r.title}">
496
+ ${top ? html`<div class="region-toolbar">${top}</div>` : ''}${body}${footer}</section>`;
497
+ const header = html`<h2 id="${titleId}">${r.title}</h2>${top}`;
498
+ if (r.template === 'collapsible')
499
+ return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-labelledby="${titleId}">
500
+ <details open><summary class="region-header">${header}</summary><div class="region-body">${body}</div>${footer}</details></section>`;
501
+ return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr}${r.title ? raw(` aria-labelledby="${titleId}"`) : ''}>
502
+ ${r.title || top ? html`<header class="region-header">${header}</header>` : ''}
503
+ <div class="region-body">${body}</div>${footer}
504
+ </section>`;
505
+ }