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.
- package/db/migrations/097_generated_app_quality.sql +911 -0
- package/db/migrations/098_app_activity.sql +37 -0
- package/db/migrations/099_layout_positions.sql +29 -0
- package/docs/apex-feature-parity.md +38 -11
- package/docs/guide/03-builder.md +133 -31
- package/docs/guide/04-pages-and-regions.md +46 -3
- package/docs/guide/05-items.md +1 -1
- package/docs/guide/09-reference.md +10 -3
- package/docs/guide/11-from-apex.md +1 -1
- package/docs/guide/12-development.md +3 -2
- package/docs/guide/14-globalization.md +7 -3
- package/docs/guide/17-mobile.md +1 -1
- package/package.json +1 -1
- package/public/app.css +233 -33
- package/public/app.js +263 -0
- package/public/builder.css +142 -2
- package/public/builder.js +311 -3
- package/src/builder/appfromfile.ts +5 -4
- package/src/builder/appsheets.ts +23 -9
- package/src/builder/appwizard.ts +215 -70
- package/src/builder/arrange.ts +133 -17
- package/src/builder/blueprints.ts +2 -1
- package/src/builder/components.ts +29 -23
- package/src/builder/designer.ts +67 -18
- package/src/builder/forms.ts +4 -1
- package/src/builder/home.ts +2 -32
- package/src/builder/newapp.ts +182 -5
- package/src/builder/object-browser.ts +342 -0
- package/src/builder/pwa.ts +3 -3
- package/src/builder/routes.ts +86 -51
- package/src/builder/shared.ts +103 -1
- package/src/builder/sql.ts +13 -65
- package/src/builder/ui.ts +21 -6
- package/src/i18n/ar.ts +17 -0
- package/src/i18n/cs.ts +17 -0
- package/src/i18n/da.ts +17 -0
- package/src/i18n/de.ts +17 -0
- package/src/i18n/el.ts +17 -0
- package/src/i18n/es.ts +17 -0
- package/src/i18n/fi.ts +17 -0
- package/src/i18n/fr.ts +17 -0
- package/src/i18n/he.ts +17 -0
- package/src/i18n/it.ts +17 -0
- package/src/i18n/ja.ts +17 -0
- package/src/i18n/ko.ts +17 -0
- package/src/i18n/nb.ts +17 -0
- package/src/i18n/pl.ts +17 -0
- package/src/i18n/pt.ts +17 -0
- package/src/i18n/ru.ts +17 -0
- package/src/i18n/sv.ts +17 -0
- package/src/i18n/tr.ts +17 -0
- package/src/i18n/uk.ts +17 -0
- package/src/i18n/zh.ts +17 -0
- package/src/i18n.ts +34 -0
- package/src/icons.ts +2 -1
- package/src/layout.ts +2 -2
- package/src/metadata.ts +5 -1
- package/src/runtime/charts.ts +8 -3
- package/src/runtime/devbar.ts +35 -14
- package/src/runtime/engine.ts +13 -4
- package/src/runtime/format.ts +52 -0
- package/src/runtime/items.ts +27 -0
- package/src/runtime/locale.ts +5 -2
- package/src/runtime/regions.ts +61 -4
- package/src/runtime/render.ts +38 -8
- package/src/runtime/report.ts +149 -88
- package/src/runtime/routes.ts +34 -3
- 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
|
-
|
|
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="
|
|
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. */
|
package/src/runtime/charts.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/runtime/devbar.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
<
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
97
|
-
|
|
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
|
-
</
|
|
120
|
+
</div>`;
|
|
100
121
|
}
|
|
101
122
|
|
|
102
123
|
const INTERNAL = /^__/;
|
package/src/runtime/engine.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/runtime/format.ts
CHANGED
|
@@ -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) => {
|
package/src/runtime/items.ts
CHANGED
|
@@ -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
|
package/src/runtime/locale.ts
CHANGED
|
@@ -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) === '
|
|
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),
|
package/src/runtime/regions.ts
CHANGED
|
@@ -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
|
-
|
|
144
|
-
|
|
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
|
+
}
|