pgkiln 0.86.0 → 0.88.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/156_pwa_prefetch.sql +13 -0
- package/docs/apex-feature-parity.md +4 -4
- package/docs/guide/04-pages-and-regions.md +23 -4
- package/docs/guide/05-items.md +20 -1
- package/docs/guide/07-dynamic-actions.md +1 -1
- package/docs/guide/08-security.md +6 -2
- package/docs/guide/09-reference.md +1 -1
- package/docs/guide/11-from-apex.md +3 -0
- package/docs/guide/17-mobile.md +35 -4
- package/examples/gallery/samples/sample-dynamic-actions.sql +7 -4
- package/examples/gallery/samples/sample-grids.sql +9 -29
- package/examples/gallery/samples/ux-patterns.sql +3 -3
- package/examples/hr/hr_82_pwa_prefetch.sql +11 -0
- package/package.json +1 -1
- package/public/app.css +2 -0
- package/public/app.js +13 -0
- package/public/sw.js +51 -1
- package/src/builder/components.ts +1 -1
- package/src/builder/i18n/de.ts +6 -0
- package/src/builder/i18n/en.ts +6 -0
- package/src/builder/i18n/es.ts +6 -0
- package/src/builder/i18n/fr.ts +6 -0
- package/src/builder/i18n/it.ts +6 -0
- package/src/builder/i18n/ja.ts +6 -0
- package/src/builder/i18n/ko.ts +6 -0
- package/src/builder/i18n/nl.ts +6 -0
- package/src/builder/i18n/pt.ts +6 -0
- package/src/builder/i18n/zh-cn.ts +6 -0
- package/src/builder/i18n/zh-tw.ts +6 -0
- package/src/builder/pwa.ts +14 -3
- package/src/builder/region-help.ts +2 -2
- package/src/metadata.ts +3 -1
- package/src/runtime/authz.ts +7 -0
- package/src/runtime/grid.ts +69 -9
- package/src/runtime/items.ts +9 -1
- package/src/runtime/pwa.ts +8 -3
- package/src/runtime/regions.ts +16 -4
- package/src/runtime/routes.ts +25 -6
- package/src/runtime/signin.ts +5 -5
package/public/sw.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// (offline, the cached copy of another version)
|
|
5
5
|
// pages (navigations): network first; offline, the cached copy (only when the app keeps pages) or the offline page
|
|
6
6
|
// signing in or out empties the page cache, so the next person on the device doesn't see them
|
|
7
|
+
// (168) with prefetchHours: the pages app.js hands over (the navigation's) are fetched one by one and kept, again
|
|
8
|
+
// when the copies are older than prefetchHours (a stamp in the page cache, so a sign-in starts over)
|
|
7
9
|
// push notifications (migration 074): shown as they arrive, a click opens their page of the app;
|
|
8
10
|
// signing out turns them off on this device
|
|
9
11
|
/* global PGKILN */
|
|
@@ -77,10 +79,58 @@ self.addEventListener('message', (event) => {
|
|
|
77
79
|
caches
|
|
78
80
|
.open(PAGES)
|
|
79
81
|
.then((c) => c.keys())
|
|
80
|
-
.then((keys) => event.source.postMessage({ type: 'pgkiln:pages', pages: keys.map((k) => k.url) }));
|
|
82
|
+
.then((keys) => event.source.postMessage({ type: 'pgkiln:pages', pages: keys.map((k) => k.url).filter((u) => new URL(u).pathname !== STAMP) }));
|
|
81
83
|
}
|
|
84
|
+
if (event.data && event.data.type === 'pgkiln:prefetch' && Array.isArray(event.data.urls)) event.waitUntil(prefetchPages(event.data.urls));
|
|
82
85
|
});
|
|
83
86
|
|
|
87
|
+
// ------------------------------------------------------------------ every page offline (168, PGKILN.prefetchHours)
|
|
88
|
+
// The server answers a request with X-Pgkiln-Prefetch read-only, keeps no session state, and refuses (409) pages
|
|
89
|
+
// that run processes when they open; only 200 pages are kept. One page at a time, a short pause between them;
|
|
90
|
+
// the stamp is written when the list is done, so a list cut short by the network is tried again next time.
|
|
91
|
+
const STAMP = `${PGKILN.base}/__prefetched`;
|
|
92
|
+
const MAX_PREFETCH = 60;
|
|
93
|
+
let prefetching = false;
|
|
94
|
+
async function prefetchPages(urls) {
|
|
95
|
+
if (!PGKILN.offlinePages || !PGKILN.prefetchHours || prefetching) return;
|
|
96
|
+
prefetching = true;
|
|
97
|
+
try {
|
|
98
|
+
const cache = await caches.open(PAGES);
|
|
99
|
+
const stamp = await cache.match(STAMP);
|
|
100
|
+
if (stamp && Date.now() - Number(await stamp.text()) < PGKILN.prefetchHours * 3600 * 1000) return;
|
|
101
|
+
const seen = new Set();
|
|
102
|
+
let kept = 0;
|
|
103
|
+
for (const u of urls) {
|
|
104
|
+
if (seen.size >= MAX_PREFETCH) break;
|
|
105
|
+
let url;
|
|
106
|
+
try {
|
|
107
|
+
url = new URL(u, self.location.origin);
|
|
108
|
+
} catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
url.hash = '';
|
|
112
|
+
if (url.origin !== self.location.origin || !url.pathname.startsWith(`${PGKILN.base}/`) || signInOrOut(url) || seen.has(url.href)) continue;
|
|
113
|
+
seen.add(url.href);
|
|
114
|
+
let res;
|
|
115
|
+
try {
|
|
116
|
+
res = await fetch(url.href, { credentials: 'same-origin', redirect: 'manual', headers: { 'X-Pgkiln-Prefetch': '1' } });
|
|
117
|
+
} catch {
|
|
118
|
+
return; // offline: again on the next page
|
|
119
|
+
}
|
|
120
|
+
if (res.status === 200 && (res.headers.get('content-type') || '').startsWith('text/html')) {
|
|
121
|
+
await cache.put(new Request(url.href), res);
|
|
122
|
+
kept++;
|
|
123
|
+
}
|
|
124
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
125
|
+
}
|
|
126
|
+
if (!seen.size) return;
|
|
127
|
+
await cache.put(STAMP, new Response(String(Date.now())));
|
|
128
|
+
for (const c of await self.clients.matchAll({ type: 'window' })) c.postMessage({ type: 'pgkiln:prefetched', kept });
|
|
129
|
+
} finally {
|
|
130
|
+
prefetching = false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
84
134
|
// ------------------------------------------------------------------ offline form queue (PGKILN.offlineSubmit)
|
|
85
135
|
// A form posted while the network is down is kept on the device (IndexedDB, files included) and the
|
|
86
136
|
// browser shows the page again with ?queued=1. It is sent later (on "online", when a page of the app
|
|
@@ -180,7 +180,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
180
180
|
{ name: 'readonly_condition', label: 'Read-only condition (SQL)', kind: 'code', group: 'Security', help: 'When true the item is shown read-only and ignored on submit.' },
|
|
181
181
|
{ name: 'authz', label: 'Authorization', kind: 'authz', group: 'Security', help: AUTHZ_HELP },
|
|
182
182
|
buildOption('Security'),
|
|
183
|
-
{ name: 'config', label: 'Attributes (JSON)', kind: 'json', group: 'Attributes', help: '{"submit_on_change":true,"null_label":"- All -","cascade_parents":"P3_DEPTNO","wide":true}. {"warn_unsaved":false}: a change of this item doesn\'t count for the page\'s "Warn on unsaved changes". Lists of values read at most {"max_rows":5000} rows. File items: {"filename_column":"photo_name","mime_column":"photo_mime","size_column":"photo_size","updated_column":"photo_updated","accept":"image/*,.pdf","max_mb":2,"capture":"environment","max_px":1600} (capture opens the camera on phones; max_px makes photos smaller before upload). {"crop":"1:1"} lets the user crop a picture before upload (free, 1:1, 4:3, 3:4, 16:9, 3:2, 2:3). Several files: {"multiple":true,"max_files":5,"table":"doc.attachment","parent_column":"ticket_id","key_column":"id"} with the content column as source (one row per file). Text items: {"scan":true} adds a barcode/QR scan button where the browser can read codes. Location items hold "lat,lng" with a "Use my location" button. Rich text: sanitised HTML; markdown: Markdown text ({"rows":10}). Rating: {"max":5} stars, stored as 1..max. Combobox: free text with list-of-values suggestions, values colon-separated ({"multiple":false} for one value). Shuttle: two lists (available, chosen) with move and order buttons; the chosen values colon-separated in their order. Select One / Select Many: a search field over the list of values (Select Many: chosen values as chips, colon-separated); {"null_label":"- None -"} or false for Select One. Date range: "from:to" (ISO dates; split_part(:P1_X, \':\', 1)). QR code: shows the value as a QR code ({"ecc":"M","size":200,"show_value":true}). Password: {"reveal":true} adds a show/hide button. Image (display image): the value is an https URL, a path or a data:image URL; or {"sql":"select photo, photo_mime from hr.emp where id = :P3_ID"} shows the first column of the first row (a BLOB); {"alt":"Photo of &P3_NAME.","width":160,"height":160}. Address: {"coordinates_item":"P3_LOCATION","country":"nl"} (a "Check address" button where the installation has a geocoder, GEOCODER_URL; the chosen address\'s position goes into the item as "lat,lng"). Any item: {"persist":"user"} keeps its value per user across sessions (a user preference named like the item). Number and display items: {"format_mask":"999G999G990D00"} shows the number in the language\'s notation (FML999G990D00 adds the currency) and reads it back on submit. · plugin: {"plugin":"name","attributes":{"NAME":"value"}} (an item plug-in: a text field its JavaScript enhances)' },
|
|
183
|
+
{ name: 'config', label: 'Attributes (JSON)', kind: 'json', group: 'Attributes', help: '{"submit_on_change":true,"null_label":"- All -","cascade_parents":"P3_DEPTNO","wide":true}. {"warn_unsaved":false}: a change of this item doesn\'t count for the page\'s "Warn on unsaved changes". Lists of values read at most {"max_rows":5000} rows. File items: {"filename_column":"photo_name","mime_column":"photo_mime","size_column":"photo_size","updated_column":"photo_updated","accept":"image/*,.pdf","max_mb":2,"capture":"environment","max_px":1600} (capture opens the camera on phones; max_px makes photos smaller before upload). {"crop":"1:1"} lets the user crop a picture before upload (free, 1:1, 4:3, 3:4, 16:9, 3:2, 2:3). Several files: {"multiple":true,"max_files":5,"table":"doc.attachment","parent_column":"ticket_id","key_column":"id"} with the content column as source (one row per file). Text items: {"scan":true} adds a barcode/QR scan button where the browser can read codes. Location items hold "lat,lng" with a "Use my location" button. Rich text: sanitised HTML; markdown: Markdown text ({"rows":10}). Rating: {"max":5} stars, stored as 1..max. Combobox: free text with list-of-values suggestions, values colon-separated ({"multiple":false} for one value). Shuttle: two lists (available, chosen) with move and order buttons; the chosen values colon-separated in their order. Select One / Select Many: a search field over the list of values (Select Many: chosen values as chips, colon-separated); {"null_label":"- None -"} or false for Select One. Date range: "from:to" (ISO dates; split_part(:P1_X, \':\', 1)). QR code: shows the value as a QR code ({"ecc":"M","size":200,"show_value":true}). Password: {"reveal":true} adds a show/hide button. Image (display image): the value is an https URL, a path or a data:image URL; or {"sql":"select photo, photo_mime from hr.emp where id = :P3_ID"} shows the first column of the first row (a BLOB); {"alt":"Photo of &P3_NAME.","width":160,"height":160}. Address: {"coordinates_item":"P3_LOCATION","country":"nl"} (a "Check address" button where the installation has a geocoder, GEOCODER_URL; the chosen address\'s position goes into the item as "lat,lng"). Hidden items: {"value_protected":false} renders the item as a hidden input that dynamic actions and JavaScript may set; its posted value is user input, like a text field\'s (protected, the default: the value stays on the server). Any item: {"persist":"user"} keeps its value per user across sessions (a user preference named like the item). Number and display items: {"format_mask":"999G999G990D00"} shows the number in the language\'s notation (FML999G990D00 adds the currency) and reads it back on submit. · plugin: {"plugin":"name","attributes":{"NAME":"value"}} (an item plug-in: a text field its JavaScript enhances)' },
|
|
184
184
|
],
|
|
185
185
|
},
|
|
186
186
|
button: {
|
package/src/builder/i18n/de.ts
CHANGED
|
@@ -7510,4 +7510,10 @@ export const de: BuilderMessages = {
|
|
|
7510
7510
|
'pjb.steps': 'Schritte',
|
|
7511
7511
|
'pjb.message': 'Meldung',
|
|
7512
7512
|
'pjb.no_runs': 'Noch keine Ausführungen.',
|
|
7513
|
+
// ------------------------------------------------------------ PWA: every page offline (sprint 168)
|
|
7514
|
+
'pwa.prefetch': 'Alle Seiten der Navigation nach der Anmeldung offline verfügbar machen',
|
|
7515
|
+
'pwa.prefetch_help': 'Erfordert „Besuchte Seiten behalten“. Nach einer Anmeldung und auf der ersten auf einem Gerät geöffneten Seite lädt das Gerät nacheinander alle Seiten des Navigationsmenüs und der Navigationsleiste, die der Benutzer öffnen darf. Der Server zeigt sie schreibgeschützt: Seiten, die beim Öffnen Prozesse ausführen, werden ausgelassen und erst gespeichert, wenn der Benutzer sie öffnet.',
|
|
7516
|
+
'pwa.prefetch_hours': 'Erneut laden nach (Stunden)',
|
|
7517
|
+
'pwa.prefetch_hours_help': '1 bis 168. Die Seiten werden nach dieser Zeit beim ersten Seitenaufruf erneut geladen, sofern eine Verbindung besteht.',
|
|
7518
|
+
'pwa.prefetch_hours_range': 'Erneut laden nach: eine ganze Zahl von Stunden zwischen 1 und 168.',
|
|
7513
7519
|
};
|
package/src/builder/i18n/en.ts
CHANGED
|
@@ -7514,4 +7514,10 @@ export const en = {
|
|
|
7514
7514
|
'pjb.steps': 'Steps',
|
|
7515
7515
|
'pjb.message': 'Message',
|
|
7516
7516
|
'pjb.no_runs': 'No runs yet.',
|
|
7517
|
+
// ------------------------------------------------------------ PWA: every page offline (sprint 168)
|
|
7518
|
+
'pwa.prefetch': 'Make every page of the navigation available offline after sign-in',
|
|
7519
|
+
'pwa.prefetch_help': 'Needs Keep visited pages. After a sign-in, and on the first page opened on a device, the device fetches every page of the navigation menu and navigation bar the user may open, one at a time. The server shows them read-only: pages that run processes when they open are left out and kept only when the user opens them.',
|
|
7520
|
+
'pwa.prefetch_hours': 'Fetch them again after (hours)',
|
|
7521
|
+
'pwa.prefetch_hours_help': '1 to 168. The pages are fetched again on the first page opened after this time, while there is a connection.',
|
|
7522
|
+
'pwa.prefetch_hours_range': 'Fetch them again after: a whole number of hours from 1 to 168.',
|
|
7517
7523
|
} as const;
|
package/src/builder/i18n/es.ts
CHANGED
|
@@ -7510,4 +7510,10 @@ export const es: BuilderMessages = {
|
|
|
7510
7510
|
'pjb.steps': 'Pasos',
|
|
7511
7511
|
'pjb.message': 'Mensaje',
|
|
7512
7512
|
'pjb.no_runs': 'Aún no hay ejecuciones.',
|
|
7513
|
+
// ------------------------------------------------------------ PWA: every page offline (sprint 168)
|
|
7514
|
+
'pwa.prefetch': 'Hacer que todas las páginas de la navegación estén disponibles sin conexión después de iniciar sesión',
|
|
7515
|
+
'pwa.prefetch_help': 'Requiere Guardar las páginas visitadas. Después de iniciar sesión, y en la primera página abierta en un dispositivo, el dispositivo obtiene una por una todas las páginas del menú y de la barra de navegación que el usuario puede abrir. El servidor las muestra en solo lectura: las páginas que ejecutan procesos al abrirse se omiten y solo se guardan cuando el usuario las abre.',
|
|
7516
|
+
'pwa.prefetch_hours': 'Volver a obtenerlas después de (horas)',
|
|
7517
|
+
'pwa.prefetch_hours_help': 'De 1 a 168. Las páginas se vuelven a obtener en la primera página abierta después de este tiempo, mientras haya conexión.',
|
|
7518
|
+
'pwa.prefetch_hours_range': 'Volver a obtenerlas después de: un número entero de horas entre 1 y 168.',
|
|
7513
7519
|
};
|
package/src/builder/i18n/fr.ts
CHANGED
|
@@ -7510,4 +7510,10 @@ export const fr: BuilderMessages = {
|
|
|
7510
7510
|
'pjb.steps': 'Étapes',
|
|
7511
7511
|
'pjb.message': 'Message',
|
|
7512
7512
|
'pjb.no_runs': 'Aucune exécution pour l’instant.',
|
|
7513
|
+
// ------------------------------------------------------------ PWA: every page offline (sprint 168)
|
|
7514
|
+
'pwa.prefetch': 'Rendre toutes les pages de la navigation disponibles hors ligne après la connexion',
|
|
7515
|
+
'pwa.prefetch_help': 'Nécessite Conserver les pages visitées. Après une connexion, et sur la première page ouverte sur un appareil, l’appareil récupère une à une toutes les pages du menu et de la barre de navigation que l’utilisateur peut ouvrir. Le serveur les affiche en lecture seule : les pages qui exécutent des processus à l’ouverture sont ignorées et conservées seulement quand l’utilisateur les ouvre.',
|
|
7516
|
+
'pwa.prefetch_hours': 'Les récupérer à nouveau après (heures)',
|
|
7517
|
+
'pwa.prefetch_hours_help': 'De 1 à 168. Les pages sont récupérées à nouveau à la première page ouverte après ce délai, s’il y a une connexion.',
|
|
7518
|
+
'pwa.prefetch_hours_range': 'Les récupérer à nouveau après : un nombre entier d’heures entre 1 et 168.',
|
|
7513
7519
|
};
|
package/src/builder/i18n/it.ts
CHANGED
|
@@ -7510,4 +7510,10 @@ export const it: BuilderMessages = {
|
|
|
7510
7510
|
'pjb.steps': 'Passi',
|
|
7511
7511
|
'pjb.message': 'Messaggio',
|
|
7512
7512
|
'pjb.no_runs': 'Ancora nessuna esecuzione.',
|
|
7513
|
+
// ------------------------------------------------------------ PWA: every page offline (sprint 168)
|
|
7514
|
+
'pwa.prefetch': 'Rendi disponibili offline tutte le pagine della navigazione dopo l’accesso',
|
|
7515
|
+
'pwa.prefetch_help': 'Richiede Conserva le pagine visitate. Dopo l’accesso, e alla prima pagina aperta su un dispositivo, il dispositivo recupera una alla volta tutte le pagine del menu e della barra di navigazione che l’utente può aprire. Il server le mostra in sola lettura: le pagine che eseguono processi all’apertura vengono escluse e conservate solo quando l’utente le apre.',
|
|
7516
|
+
'pwa.prefetch_hours': 'Recuperarle di nuovo dopo (ore)',
|
|
7517
|
+
'pwa.prefetch_hours_help': 'Da 1 a 168. Le pagine vengono recuperate di nuovo alla prima pagina aperta dopo questo tempo, se c’è connessione.',
|
|
7518
|
+
'pwa.prefetch_hours_range': 'Recuperarle di nuovo dopo: un numero intero di ore da 1 a 168.',
|
|
7513
7519
|
};
|
package/src/builder/i18n/ja.ts
CHANGED
|
@@ -7510,4 +7510,10 @@ export const ja: BuilderMessages = {
|
|
|
7510
7510
|
'pjb.steps': 'ステップ',
|
|
7511
7511
|
'pjb.message': 'メッセージ',
|
|
7512
7512
|
'pjb.no_runs': 'まだ実行はありません。',
|
|
7513
|
+
// ------------------------------------------------------------ PWA: every page offline (sprint 168)
|
|
7514
|
+
'pwa.prefetch': 'サインイン後、ナビゲーションのすべてのページをオフラインで使用できるようにする',
|
|
7515
|
+
'pwa.prefetch_help': '「表示したページを保持」が必要です。サインイン後、およびデバイスで最初のページを開いたときに、ユーザーが開けるナビゲーション メニューとナビゲーション バーのすべてのページを 1 つずつ取得します。サーバーはそれらを読み取り専用で表示します。開くときにプロセスを実行するページは除外され、ユーザーが開いたときにのみ保持されます。',
|
|
7516
|
+
'pwa.prefetch_hours': '再取得までの時間(時間)',
|
|
7517
|
+
'pwa.prefetch_hours_help': '1~168。この時間が過ぎた後、接続があるときに最初にページを開くと再取得されます。',
|
|
7518
|
+
'pwa.prefetch_hours_range': '再取得までの時間: 1~168 の整数の時間を指定してください。',
|
|
7513
7519
|
};
|
package/src/builder/i18n/ko.ts
CHANGED
|
@@ -7510,4 +7510,10 @@ export const ko: BuilderMessages = {
|
|
|
7510
7510
|
'pjb.steps': '단계',
|
|
7511
7511
|
'pjb.message': '메시지',
|
|
7512
7512
|
'pjb.no_runs': '아직 실행이 없습니다.',
|
|
7513
|
+
// ------------------------------------------------------------ PWA: every page offline (sprint 168)
|
|
7514
|
+
'pwa.prefetch': '로그인 후 탐색의 모든 페이지를 오프라인으로 사용할 수 있게 함',
|
|
7515
|
+
'pwa.prefetch_help': '‘방문한 페이지 보관’이 필요합니다. 로그인 후와 기기에서 처음 페이지를 열 때, 기기는 사용자가 열 수 있는 탐색 메뉴와 탐색 모음의 모든 페이지를 하나씩 가져옵니다. 서버는 이를 읽기 전용으로 표시합니다. 열 때 프로세스를 실행하는 페이지는 제외되며 사용자가 열 때만 보관됩니다.',
|
|
7516
|
+
'pwa.prefetch_hours': '다시 가져오기까지의 시간(시간)',
|
|
7517
|
+
'pwa.prefetch_hours_help': '1~168. 이 시간이 지난 뒤 연결되어 있을 때 처음 페이지를 열면 다시 가져옵니다.',
|
|
7518
|
+
'pwa.prefetch_hours_range': '다시 가져오기까지의 시간: 1~168 사이의 정수 시간입니다.',
|
|
7513
7519
|
};
|
package/src/builder/i18n/nl.ts
CHANGED
|
@@ -7510,4 +7510,10 @@ export const nl: BuilderMessages = {
|
|
|
7510
7510
|
'pjb.steps': 'Stappen',
|
|
7511
7511
|
'pjb.message': 'Melding',
|
|
7512
7512
|
'pjb.no_runs': 'Nog geen uitvoeringen.',
|
|
7513
|
+
// ------------------------------------------------------------ PWA: every page offline (sprint 168)
|
|
7514
|
+
'pwa.prefetch': 'Alle pagina\'s van de navigatie na het aanmelden offline beschikbaar maken',
|
|
7515
|
+
'pwa.prefetch_help': 'Vereist Bezochte pagina\'s bewaren. Na het aanmelden, en op de eerste pagina die op een apparaat wordt geopend, haalt het apparaat één voor één alle pagina\'s van het navigatiemenu en de navigatiebalk op die de gebruiker mag openen. De server toont ze alleen-lezen: pagina\'s die bij het openen processen uitvoeren worden overgeslagen en pas bewaard als de gebruiker ze opent.',
|
|
7516
|
+
'pwa.prefetch_hours': 'Opnieuw ophalen na (uren)',
|
|
7517
|
+
'pwa.prefetch_hours_help': '1 tot 168. De pagina\'s worden opnieuw opgehaald bij de eerste pagina die na deze tijd wordt geopend, als er verbinding is.',
|
|
7518
|
+
'pwa.prefetch_hours_range': 'Opnieuw ophalen na: een geheel aantal uren van 1 tot 168.',
|
|
7513
7519
|
};
|
package/src/builder/i18n/pt.ts
CHANGED
|
@@ -7510,4 +7510,10 @@ export const pt: BuilderMessages = {
|
|
|
7510
7510
|
'pjb.steps': 'Etapas',
|
|
7511
7511
|
'pjb.message': 'Mensagem',
|
|
7512
7512
|
'pjb.no_runs': 'Nenhuma execução ainda.',
|
|
7513
|
+
// ------------------------------------------------------------ PWA: every page offline (sprint 168)
|
|
7514
|
+
'pwa.prefetch': 'Disponibilizar todas as páginas da navegação offline após o login',
|
|
7515
|
+
'pwa.prefetch_help': 'Requer Manter as páginas visitadas. Após o login, e na primeira página aberta em um dispositivo, o dispositivo busca uma a uma todas as páginas do menu e da barra de navegação que o usuário pode abrir. O servidor as exibe somente leitura: páginas que executam processos ao abrir são deixadas de fora e mantidas só quando o usuário as abre.',
|
|
7516
|
+
'pwa.prefetch_hours': 'Buscá-las novamente após (horas)',
|
|
7517
|
+
'pwa.prefetch_hours_help': 'De 1 a 168. As páginas são buscadas novamente na primeira página aberta após esse tempo, quando houver conexão.',
|
|
7518
|
+
'pwa.prefetch_hours_range': 'Buscá-las novamente após: um número inteiro de horas de 1 a 168.',
|
|
7513
7519
|
};
|
|
@@ -7510,4 +7510,10 @@ export const zhCN: BuilderMessages = {
|
|
|
7510
7510
|
'pjb.steps': '步骤',
|
|
7511
7511
|
'pjb.message': '消息',
|
|
7512
7512
|
'pjb.no_runs': '尚无运行记录。',
|
|
7513
|
+
// ------------------------------------------------------------ PWA: every page offline (sprint 168)
|
|
7514
|
+
'pwa.prefetch': '登录后使导航中的所有页面可离线使用',
|
|
7515
|
+
'pwa.prefetch_help': '需要“将访问过的页面保留在设备上”。登录后,以及在设备上打开第一个页面时,设备会逐个获取用户可以打开的导航菜单和导航栏中的所有页面。服务器以只读方式显示它们:打开时运行进程的页面会被跳过,只有在用户打开时才会保留。',
|
|
7516
|
+
'pwa.prefetch_hours': '多少小时后重新获取',
|
|
7517
|
+
'pwa.prefetch_hours_help': '1 到 168。超过此时间后,在有网络连接时打开第一个页面时会重新获取这些页面。',
|
|
7518
|
+
'pwa.prefetch_hours_range': '重新获取时间:1 到 168 之间的整数小时。',
|
|
7513
7519
|
};
|
|
@@ -7510,4 +7510,10 @@ export const zhTW: BuilderMessages = {
|
|
|
7510
7510
|
'pjb.steps': '步驟',
|
|
7511
7511
|
'pjb.message': '訊息',
|
|
7512
7512
|
'pjb.no_runs': '尚無執行記錄。',
|
|
7513
|
+
// ------------------------------------------------------------ PWA: every page offline (sprint 168)
|
|
7514
|
+
'pwa.prefetch': '登入後使導覽中的所有頁面可離線使用',
|
|
7515
|
+
'pwa.prefetch_help': '需要「將造訪過的頁面保留在裝置上」。登入後,以及在裝置上開啟第一個頁面時,裝置會逐一取得使用者可以開啟的導覽選單和導覽列中的所有頁面。伺服器以唯讀方式顯示它們:開啟時執行處理程序的頁面會被略過,只有在使用者開啟時才會保留。',
|
|
7516
|
+
'pwa.prefetch_hours': '多少小時後重新取得',
|
|
7517
|
+
'pwa.prefetch_hours_help': '1 到 168。超過此時間後,在有網路連線時開啟第一個頁面時會重新取得這些頁面。',
|
|
7518
|
+
'pwa.prefetch_hours_range': '重新取得時間:1 到 168 之間的整數小時。',
|
|
7513
7519
|
};
|
package/src/builder/pwa.ts
CHANGED
|
@@ -32,7 +32,7 @@ async function pushStats(appId: number) {
|
|
|
32
32
|
))!;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export async function pwaSection(a: { id: number; alias: string; pwa: boolean; pwa_short_name: string | null; pwa_offline_pages: boolean; pwa_offline_submit: boolean; pwa_push: boolean; has_icon: boolean }, s: Session) {
|
|
35
|
+
export async function pwaSection(a: { id: number; alias: string; pwa: boolean; pwa_short_name: string | null; pwa_offline_pages: boolean; pwa_prefetch_hours: number | null; pwa_offline_submit: boolean; pwa_push: boolean; has_icon: boolean }, s: Session) {
|
|
36
36
|
const check = (name: string, label: string, on: boolean, help: string) =>
|
|
37
37
|
html`<div class="field"><label class="check"><input type="checkbox" name="${name}" value="true"${on ? raw(' checked') : ''}> ${label}</label><small class="help">${help}</small></div>`;
|
|
38
38
|
const stats = a.pwa && a.pwa_push ? await pushStats(a.id) : null;
|
|
@@ -42,6 +42,10 @@ export async function pwaSection(a: { id: number; alias: string; pwa: boolean; p
|
|
|
42
42
|
${input('pwa_short_name', tx('pwa.short_name'), a.pwa_short_name ?? '', { placeholder: tx('pwa.short_name_ph') })}
|
|
43
43
|
</div>
|
|
44
44
|
${check('pwa_offline_pages', tx('pwa.offline_pages'), a.pwa_offline_pages, tx('pwa.offline_pages_help'))}
|
|
45
|
+
${check('pwa_prefetch', tx('pwa.prefetch'), a.pwa_prefetch_hours !== null, tx('pwa.prefetch_help'))}
|
|
46
|
+
<div class="form-grid">
|
|
47
|
+
${input('pwa_prefetch_hours', tx('pwa.prefetch_hours'), a.pwa_prefetch_hours ?? 12, { type: 'number', help: tx('pwa.prefetch_hours_help') })}
|
|
48
|
+
</div>
|
|
45
49
|
${check('pwa_offline_submit', tx('pwa.offline_submit'), a.pwa_offline_submit, tx('pwa.offline_submit_help'))}
|
|
46
50
|
${check('pwa_push', tx('pwa.push'), a.pwa_push, tx('pwa.push_help'))}
|
|
47
51
|
<div class="field" data-wide><label class="label" for="f_pwa_icon">${tx('pwa.icon')}</label>
|
|
@@ -98,10 +102,17 @@ export async function pwaBuilderRoutes(app: FastifyInstance) {
|
|
|
98
102
|
flash(s, tx('pwa.push_needs_key'), 'error');
|
|
99
103
|
return back(reply, s, target);
|
|
100
104
|
}
|
|
105
|
+
// (168) every navigation page offline: only with visited pages kept, every 1 to 168 hours
|
|
106
|
+
const hours = Number(b.pwa_prefetch_hours ?? 12);
|
|
107
|
+
if (b.pwa_prefetch === 'true' && (!Number.isInteger(hours) || hours < 1 || hours > 168)) {
|
|
108
|
+
flash(s, tx('pwa.prefetch_hours_range'), 'error');
|
|
109
|
+
return back(reply, s, target);
|
|
110
|
+
}
|
|
111
|
+
const prefetch = b.pwa_prefetch === 'true' && b.pwa_offline_pages === 'true' ? hours : null;
|
|
101
112
|
await owner.query(
|
|
102
113
|
`update meta.app set pwa = $2, pwa_short_name = $3, pwa_offline_pages = $4, pwa_offline_submit = $5,
|
|
103
|
-
pwa_icon = case when $7 then $6 else pwa_icon end, pwa_push = $8, updated_at = now() where id = $1`,
|
|
104
|
-
[id, b.pwa === 'true', (b.pwa_short_name ?? '').trim().slice(0, 30) || null, b.pwa_offline_pages === 'true', b.pwa_offline_submit === 'true', icon ?? null, icon !== undefined, push],
|
|
114
|
+
pwa_icon = case when $7 then $6 else pwa_icon end, pwa_push = $8, pwa_prefetch_hours = $9, updated_at = now() where id = $1`,
|
|
115
|
+
[id, b.pwa === 'true', (b.pwa_short_name ?? '').trim().slice(0, 30) || null, b.pwa_offline_pages === 'true', b.pwa_offline_submit === 'true', icon ?? null, icon !== undefined, push, prefetch],
|
|
105
116
|
);
|
|
106
117
|
if (push) await appPublicKey(Number(id));
|
|
107
118
|
flash(s, push && b.pwa !== 'true' ? tx('pwa.saved_not_installable') : tx('pwa.saved'));
|
|
@@ -13,7 +13,7 @@ export const REGION_CONFIG_HELP: [string[], string][] = [
|
|
|
13
13
|
[['chart'], '{"kind":"bar"|"column"|"line"|"area"|"donut","max_rows":1000,"format_mask":"FML999G990"}'],
|
|
14
14
|
[['cards'], '{"style":"metric","link":{...},"max_rows":500}'],
|
|
15
15
|
[['dynamic'], '{"max_rows":1000}'],
|
|
16
|
-
[['grid'], '{"page_size":25,"allow":{"insert":true,"update":true,"delete":true},"readonly":["col"],"columns":{"deptno":{"lov":"LOV:DEPARTMENTS","required":true}},"aggregates":{"sal":["sum","avg"]},"layout":{"order":["col"],"hidden":["col"],"widths":{"col":160},"frozen":1},"row_actions":{"edit":{"page":3,"items":{"P3_ID":"#id#"}},"duplicate":true,"delete":true,"links":[{"label":"…","page":4,"items":{}}]},"select_row":{"column":"id","item":"P1_ID"} (a master),"master":{"item":"P1_ID","column":"parent_id"} (a detail; any region type),"actions":true,"saved_reports":true,"public_reports":"SCHEME","views":["row","group","chart"] (views besides the grid; left out: all three)}'],
|
|
16
|
+
[['grid'], '{"page_size":25,"allow":{"insert":true,"update":true,"delete":true},"readonly":["col"],"columns":{"deptno":{"lov":"LOV:DEPARTMENTS","required":true,"default":"&P1_DEPTNO."},"hiredate":{"default_sql":"select current_date"}} (default / default_sql: the value a new row starts with),"aggregates":{"sal":["sum","avg"]},"layout":{"order":["col"],"hidden":["col"],"widths":{"col":160},"frozen":1},"row_actions":{"edit":{"page":3,"items":{"P3_ID":"#id#"}},"duplicate":true,"delete":true,"links":[{"label":"…","page":4,"items":{}}]},"select_row":{"column":"id","item":"P1_ID"} (a master),"master":{"item":"P1_ID","column":"parent_id"} (a detail; any region type),"actions":true,"saved_reports":true,"public_reports":"SCHEME","views":["row","group","chart"] (views besides the grid; left out: all three)}'],
|
|
17
17
|
[['calendar'], '{"link":{...}}'],
|
|
18
18
|
[['facets'], '{"report":<region id>,"search":true,"facets":[{"column":"job","label":"Job","exclude":true},{"column":"sal","type":"range","ranges":[{"to":1000},{"from":1000}],"custom":true},{"column":"rating","type":"star","max":5}]}'],
|
|
19
19
|
[['smart_filters'], '{"report":<region id>,"suggestions":3,"placeholder":"…","ask":true,"facets":[…as facets]}'],
|
|
@@ -27,7 +27,7 @@ export const REGION_CONFIG_HELP: [string[], string][] = [
|
|
|
27
27
|
[['report'], '{"ai_filter":{}} adds an "Ask in your own words" box: the question becomes the report\'s settings, shown as chips (built-in phrases; {"service":"NAME"} uses an AI service)'],
|
|
28
28
|
[['plugin'], '{"plugin":"name","attributes":{"NAME":"value"}} (a region plug-in; a Source SELECT feeds its template)'],
|
|
29
29
|
[['form'], '{"lost_update":false} saves without checking that the row is unchanged since it was shown'],
|
|
30
|
-
[['login'], '(
|
|
30
|
+
[['login'], '{"autofocus":false} (the cursor is not put in the user name field) · the sign-in form of the application\'s login page (Application settings → Login page): providers, user name and password, "Keep me signed in", "Forgot password?"'],
|
|
31
31
|
[['url'], '{"height":480,"sandbox":["allow-scripts","allow-forms","allow-popups","allow-same-origin"] (the default; false: no sandbox),"fullscreen":true}'],
|
|
32
32
|
[['classic_report'], '{"report_template":"standard"|"value_pairs"|"badges"|"media_list","pagination":"total" (rows X–Y of N, the default)|"range" (rows X–Y)|"none" (one page of at most max_rows, 500),"page_size":15,"max_rows":500,"sortable":true,"searchable":false,"mobile":"reflow"|"scroll","hidden":["col"],"headings":{"col":"Label"},"link":{"column":"id","page":3,"items":{"P3_ID":"#id#"}},"empty":"No rows"} (a report without the Actions menu; badges: label and value columns; media_list: title and description columns)'],
|
|
33
33
|
[['breadcrumb'], '{"current":false} leaves the current page out of the trail (the pages above it come from each page\'s Breadcrumb parent, else Home); put it in the Breadcrumb bar position with template plain'],
|
package/src/metadata.ts
CHANGED
|
@@ -99,6 +99,8 @@ export interface App {
|
|
|
99
99
|
pwa_short_name: string | null;
|
|
100
100
|
pwa_has_icon: boolean;
|
|
101
101
|
pwa_offline_pages: boolean;
|
|
102
|
+
/** (168) fetch every navigation page for offline use, again after this many hours; null: off */
|
|
103
|
+
pwa_prefetch_hours: number | null;
|
|
102
104
|
pwa_offline_submit: boolean;
|
|
103
105
|
/** (074) users may turn on push notifications (src/push.ts) */
|
|
104
106
|
pwa_push: boolean;
|
|
@@ -448,7 +450,7 @@ const agg = (table: string, fk: string, parent: string, appId: string) =>
|
|
|
448
450
|
// No caching on purpose: edits made in the builder show up on the next request.
|
|
449
451
|
export async function loadApp(alias: string) {
|
|
450
452
|
return runtime.one<App>(
|
|
451
|
-
`select a.id, a.alias, a.name, a.version, a.home_page, a.authentication, a.access_control, a.sso_providers, a.local_login, a.remember_me_days, a.login_page, a.password_reset, a.session_group, a.mfa, a.mfa_roles, a.mfa_email, a.api_self_tokens, a.ldap_directories, a.header_name, a.header_auto_create, a.logout_url, a.db_auth_roles, a.db_auth_member_of, a.custom_auth_function, a.custom_auth_code, a.custom_auth_post_code, a.nav_list, a.navbar_list, a.pwa, a.pwa_short_name, a.pwa_icon is not null as pwa_has_icon, a.pwa_offline_pages, a.pwa_offline_submit, a.pwa_push, a.db_role, a.debug, a.debug_level, a.theme,
|
|
453
|
+
`select a.id, a.alias, a.name, a.version, a.home_page, a.authentication, a.access_control, a.sso_providers, a.local_login, a.remember_me_days, a.login_page, a.password_reset, a.session_group, a.mfa, a.mfa_roles, a.mfa_email, a.api_self_tokens, a.ldap_directories, a.header_name, a.header_auto_create, a.logout_url, a.db_auth_roles, a.db_auth_member_of, a.custom_auth_function, a.custom_auth_code, a.custom_auth_post_code, a.nav_list, a.navbar_list, a.pwa, a.pwa_short_name, a.pwa_icon is not null as pwa_has_icon, a.pwa_offline_pages, a.pwa_prefetch_hours, a.pwa_offline_submit, a.pwa_push, a.db_role, a.debug, a.debug_level, a.theme,
|
|
452
454
|
a.availability, a.availability_message, a.availability_users, a.feedback, a.notifications, a.live_badges_s, a.error_function, a.session_warning_seconds, a.component_settings,
|
|
453
455
|
a.language, a.languages, a.language_from, a.date_format, a.timestamp_format, a.time_zone, a.time_zone_auto, a.currency,
|
|
454
456
|
coalesce((select jsonb_agg(jsonb_build_object('name', l.name, 'query', l.query, 'rest_source', l.rest_source)) from meta.lov l where l.app_id = a.id), '[]') as lovs,
|
package/src/runtime/authz.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { savepoint } from '../db.ts';
|
|
|
5
5
|
import { bindValues, publicError, stripSemicolon, type PageContext, type Visibility } from './context.ts';
|
|
6
6
|
import { saveButton, saveRequest } from './grid.ts';
|
|
7
7
|
import { selectionOf } from './report.ts';
|
|
8
|
+
import { unprotectedHidden } from './items.ts';
|
|
8
9
|
import { conditionHolds, conditionOf, serverConditionOf } from './logic.ts';
|
|
9
10
|
|
|
10
11
|
export class Forbidden extends Error {}
|
|
@@ -99,6 +100,12 @@ export async function computeVisibility(ctx: PageContext): Promise<Visibility> {
|
|
|
99
100
|
if (!(await isAuthorized(ctx, i.authz))) continue;
|
|
100
101
|
if (i.condition_type && !(await conditionHolds(ctx, i as Condition, `condition of item ${i.name}`))) continue;
|
|
101
102
|
vis.items.add(i.name);
|
|
103
|
+
// (167) a hidden item with {"value_protected": false} is a field of the page (APEX: Value Protected = No):
|
|
104
|
+
// the browser may set it, and a submit or dynamic action takes its posted value, as any field's
|
|
105
|
+
if (i.type === 'hidden' && unprotectedHidden(i)) {
|
|
106
|
+
vis.editable.add(i.name);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
102
109
|
if (i.type === 'hidden' || i.type === 'display' || i.type === 'qrcode' || i.type === 'image' || i.type === 'percent_graph' || i.type === 'display_map') continue;
|
|
103
110
|
// fail closed: a broken read-only condition makes the item read-only
|
|
104
111
|
if (i.readonly_condition && (await sqlTrue(ctx, i.readonly_condition, `read-only condition of ${i.name}`, true))) continue;
|
package/src/runtime/grid.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { componentSetting } from './component-settings.ts';
|
|
2
2
|
import pg from 'pg';
|
|
3
|
-
import { literal } from '../binds.ts';
|
|
3
|
+
import { applyBinds, literal } from '../binds.ts';
|
|
4
4
|
import { savepoint, type Client } from '../db.ts';
|
|
5
5
|
import { html, raw, type Raw } from '../html.ts';
|
|
6
6
|
import { icon } from '../icons.ts';
|
|
7
7
|
import type { Button, Process, Region } from '../metadata.ts';
|
|
8
8
|
import { checksumValid, urlChecksum } from '../security.ts';
|
|
9
9
|
import { isAuthorized, pageAllowed } from './authz.ts';
|
|
10
|
-
import { publicError, toState, type PageContext } from './context.ts';
|
|
10
|
+
import { bindValues, publicError, stripSemicolon, substitute, 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
12
|
import { appDateMask, dateField, lovContains, lovOptions, type LovOption } from './items.ts';
|
|
13
13
|
import { parseDateMask } from './format.ts';
|
|
@@ -22,7 +22,8 @@ import { GRID_VIEWS, renderView, type View } from './report-views.ts';
|
|
|
22
22
|
//
|
|
23
23
|
// config: {"page_size": 25,
|
|
24
24
|
// "allow": {"insert": true, "update": true, "delete": true},
|
|
25
|
-
// "columns": {"deptno": {"lov": "LOV:DEPARTMENTS"}, "sal": {"required": true}
|
|
25
|
+
// "columns": {"deptno": {"lov": "LOV:DEPARTMENTS"}, "sal": {"required": true},
|
|
26
|
+
// "hiredate": {"default_sql": "select current_date"}, "job": {"default": "&P3_JOB."}},
|
|
26
27
|
// "readonly": ["hiredate"],
|
|
27
28
|
// "aggregates": {"sal": ["sum", "avg"], "empno": "count"},
|
|
28
29
|
// "layout": {"order": ["ename", "sal"], "hidden": ["comm"], "widths": {"ename": 180}, "frozen": 1},
|
|
@@ -240,6 +241,14 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
|
|
|
240
241
|
const insertable = savable && allow(r, 'insert') && cols.some((x) => writable.has(x.name));
|
|
241
242
|
const deletable = savable && allow(r, 'delete');
|
|
242
243
|
const g = `g${r.id}`;
|
|
244
|
+
// (167) the values a new row starts with (APEX: a column's Default)
|
|
245
|
+
let defaults: Record<number, string> = {};
|
|
246
|
+
if (insertable)
|
|
247
|
+
try {
|
|
248
|
+
defaults = await newRowDefaults(ctx, r, cols, writable);
|
|
249
|
+
} catch (e) {
|
|
250
|
+
return html`<div class="alert alert-error" role="alert">${await publicError(ctx, e, `grid "${r.title ?? r.id}" column defaults`)}</div>`;
|
|
251
|
+
}
|
|
243
252
|
const posted = ctx.body && Object.keys(ctx.body).some((k) => k.startsWith(`${g}_`)) ? ctx.body : null;
|
|
244
253
|
const postedByPk = new Map<string, number>();
|
|
245
254
|
if (posted) for (const k of Object.keys(posted)) {
|
|
@@ -360,7 +369,10 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
|
|
|
360
369
|
});
|
|
361
370
|
|
|
362
371
|
// New rows: re-show posted ones after a failed save, a duplicated row, plus a blank template.
|
|
363
|
-
|
|
372
|
+
// a new row posts the values it started with (_def): one left at them is blank, as an empty one (gridDml)
|
|
373
|
+
const defInput = (name: string, from: Record<number, string>) =>
|
|
374
|
+
Object.keys(from).length ? html`<input type="hidden" name="${name}" value="${JSON.stringify(from)}">` : '';
|
|
375
|
+
const newRow = (j: number, values: Record<number, string> = defaults, started: Record<number, string> = defaults) =>
|
|
364
376
|
html`<tr class="grid-new" data-new-row="${j}">
|
|
365
377
|
${leads.map((k) => html`<td class="${leadCls(k)}" data-label="${k === 'sel' ? t('grid.new') : ''}">${k === 'sel' ? html`<span class="badge-pill">${t('grid.new')}</span>` : ''}</td>`)}
|
|
366
378
|
${placed.map((p, pIdx) => {
|
|
@@ -371,6 +383,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
|
|
|
371
383
|
? 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>`
|
|
372
384
|
: html`<td class="${colCls(p, pIdx)}" data-col="${col.ci}" data-label="${lbl}"${hiddenAttr(p)}></td>`;
|
|
373
385
|
})}
|
|
386
|
+
${Object.keys(started).length ? html`<td hidden>${defInput(`${g}_n${j}_def`, started)}</td>` : ''}
|
|
374
387
|
</tr>`;
|
|
375
388
|
const postedNew: Raw[] = [];
|
|
376
389
|
const usedNew = posted ? newRowIndexes(posted, g) : [];
|
|
@@ -378,7 +391,8 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
|
|
|
378
391
|
for (const j of usedNew) {
|
|
379
392
|
const values: Record<number, string> = {};
|
|
380
393
|
cols.forEach((col) => (values[col.ci] = pv(`${g}_n${j}_c${col.ci}`) ?? ''));
|
|
381
|
-
|
|
394
|
+
const started = postedDefaults(pv(`${g}_n${j}_def`));
|
|
395
|
+
if (!blankRow(values, started)) postedNew.push(newRow(j, values, started));
|
|
382
396
|
}
|
|
383
397
|
let nextNew = usedNew.length ? Math.max(...usedNew) + 1 : 0;
|
|
384
398
|
// Duplicate without JavaScript (r<id>_dup=<key>): a row of this page as a new row
|
|
@@ -387,7 +401,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
|
|
|
387
401
|
if (dupRow) {
|
|
388
402
|
const values: Record<number, string> = {};
|
|
389
403
|
for (const col of cols) if (writable.has(col.name)) values[col.ci] = toState(dupRow[col.idx]) ?? '';
|
|
390
|
-
postedNew.push(newRow(nextNew++, values));
|
|
404
|
+
postedNew.push(newRow(nextNew++, values, {}));
|
|
391
405
|
}
|
|
392
406
|
|
|
393
407
|
// The single row view: one row as a form, with the grid's field names (row 0, or new row 0)
|
|
@@ -440,9 +454,10 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
|
|
|
440
454
|
const col = p.col;
|
|
441
455
|
if (masterCol && col.name === masterCol) return [field(p, masterValue, false)];
|
|
442
456
|
if (!writable.has(col.name)) return [];
|
|
443
|
-
return [field(p, control(ctx, { ...col, editable: true }, `${g}_n0_c${col.ci}`, pv(`${g}_n0_c${col.ci}`) ?? '', label(col.name)), true)];
|
|
457
|
+
return [field(p, control(ctx, { ...col, editable: true }, `${g}_n0_c${col.ci}`, pv(`${g}_n0_c${col.ci}`) ?? defaults[col.ci] ?? '', label(col.name)), true)];
|
|
444
458
|
});
|
|
445
|
-
|
|
459
|
+
const started = pv(`${g}_n0_def`) !== undefined ? postedDefaults(pv(`${g}_n0_def`)) : defaults;
|
|
460
|
+
return html`<div class="grid-record grid-new" data-new-row="0"><div class="form-grid">${fieldsHtml}</div>${defInput(`${g}_n0_def`, started)}</div>
|
|
446
461
|
${recordNav()}`;
|
|
447
462
|
};
|
|
448
463
|
|
|
@@ -681,6 +696,48 @@ export function layoutFromForm(b: Record<string, string | undefined>): GridLayou
|
|
|
681
696
|
});
|
|
682
697
|
}
|
|
683
698
|
|
|
699
|
+
/**
|
|
700
|
+
* (167) The values a new row starts with, by column position: config.columns.<col>.default (text with &ITEM.
|
|
701
|
+
* substitutions, e.g. "&P3_DEPTNO.") or .default_sql (a SELECT: its first column of the first row, with :BINDS),
|
|
702
|
+
* for the columns a new row may set.
|
|
703
|
+
*/
|
|
704
|
+
async function newRowDefaults(ctx: PageContext, r: Region, cols: { name: string; ci: number }[], writable: Set<string>) {
|
|
705
|
+
const cfg = (r.config.columns ?? {}) as Record<string, { default?: unknown; default_sql?: unknown }>;
|
|
706
|
+
const out: Record<number, string> = {};
|
|
707
|
+
for (const col of cols) {
|
|
708
|
+
const c = cfg[col.name];
|
|
709
|
+
if (!c || !writable.has(col.name)) continue;
|
|
710
|
+
if (typeof c.default_sql === 'string' && c.default_sql.trim()) {
|
|
711
|
+
const sql = stripSemicolon(applyBinds(c.default_sql, bindValues(ctx)));
|
|
712
|
+
const client = ctx.client!;
|
|
713
|
+
const res = await savepoint(client, () => client.query({ text: sql, rowMode: 'array' }));
|
|
714
|
+
const v = toState(res.rows[0]?.[0]);
|
|
715
|
+
if (v !== null && v !== '') out[col.ci] = v;
|
|
716
|
+
} else if (typeof c.default === 'string' || typeof c.default === 'number' || typeof c.default === 'boolean') {
|
|
717
|
+
const v = substitute(String(c.default), ctx, (x) => x);
|
|
718
|
+
if (v !== '') out[col.ci] = v;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return out;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** A new row's posted _def field: the values it started with (only short strings by column position are kept). */
|
|
725
|
+
function postedDefaults(raw: string | undefined): Record<number, string> {
|
|
726
|
+
if (!raw || raw.length > 20000) return {};
|
|
727
|
+
try {
|
|
728
|
+
const v = JSON.parse(raw) as unknown;
|
|
729
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) return {};
|
|
730
|
+
return Object.fromEntries(Object.entries(v).filter(([k, x]) => /^\d+$/.test(k) && typeof x === 'string'));
|
|
731
|
+
} catch {
|
|
732
|
+
return {};
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** A new row nobody filled in: every value empty (or an unticked checkbox) or still the value it started with. */
|
|
737
|
+
function blankRow(values: Record<number, string>, started: Record<number, string>) {
|
|
738
|
+
return Object.entries(values).every(([ci, v]) => v === '' || v === 'false' || (started[Number(ci)] !== undefined && v === started[Number(ci)]));
|
|
739
|
+
}
|
|
740
|
+
|
|
684
741
|
function newRowIndexes(body: Record<string, unknown>, g: string) {
|
|
685
742
|
const out = new Set<number>();
|
|
686
743
|
for (const k of Object.keys(body)) {
|
|
@@ -797,12 +854,15 @@ export async function gridDml(ctx: PageContext, p: Process): Promise<string | nu
|
|
|
797
854
|
if (allow(r, 'insert'))
|
|
798
855
|
for (const j of newRowIndexes(body, g)) {
|
|
799
856
|
const values: [string, string][] = [];
|
|
857
|
+
const posted: Record<number, string> = {};
|
|
800
858
|
cols.forEach((col, ci) => {
|
|
801
859
|
if (!writable.has(col.name)) return;
|
|
802
860
|
const v = norm(ci, one(`${g}_n${j}_c${ci}`));
|
|
861
|
+
posted[ci] = v;
|
|
803
862
|
if (v !== '' && !(col.dataTypeID === 16 && v === 'false')) values.push([col.name, v]);
|
|
804
863
|
});
|
|
805
|
-
|
|
864
|
+
// a blank template row, or one left at its default values (167)
|
|
865
|
+
if (!values.length || blankRow(posted, postedDefaults(one(`${g}_n${j}_def`)))) continue;
|
|
806
866
|
const notOffered = [];
|
|
807
867
|
for (const [n, v] of values) if (!(await offered(n, v))) notOffered.push(n);
|
|
808
868
|
if (notOffered.length) {
|
package/src/runtime/items.ts
CHANGED
|
@@ -260,8 +260,16 @@ function displayMap(ctx: PageContext, item: Item, value: string): Raw {
|
|
|
260
260
|
</figure>`;
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
+
/** (167) A hidden item the browser may set ({"value_protected": false}): a hidden input, its posted value taken like a field's. */
|
|
264
|
+
export const unprotectedHidden = (i: { type: string; config?: Record<string, any> | null }) => i.type === 'hidden' && i.config?.value_protected === false;
|
|
265
|
+
|
|
263
266
|
export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = false): Promise<Raw | ''> {
|
|
264
|
-
if (
|
|
267
|
+
if (!ctx.vis!.items.has(item.name)) return '';
|
|
268
|
+
if (item.type === 'hidden') {
|
|
269
|
+
// (167) protected (the default): the value stays on the server; {"value_protected": false}: a hidden input
|
|
270
|
+
if (!unprotectedHidden(item)) return '';
|
|
271
|
+
return html`<input type="hidden" id="${item.name}" name="${item.name}" value="${ctx.session.state[item.name] ?? item.default_value ?? ''}" data-item="${item.name}">`;
|
|
272
|
+
}
|
|
265
273
|
const value = ctx.session.state[item.name] ?? item.default_value ?? '';
|
|
266
274
|
const id = item.name;
|
|
267
275
|
const error = ctx.errors.items[item.name];
|
package/src/runtime/pwa.ts
CHANGED
|
@@ -38,9 +38,14 @@ export function pwaHead(a: Pick<App, 'alias' | 'pwa' | 'theme'>): Raw | '' {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/** <body> data attributes app.js uses: register the service worker, queue offline submissions. */
|
|
41
|
-
export function pwaBody(a: Pick<App, 'alias' | 'pwa' | 'pwa_offline_submit'>, user: string): Record<string, string> {
|
|
41
|
+
export function pwaBody(a: Pick<App, 'alias' | 'pwa' | 'pwa_offline_submit' | 'pwa_offline_pages' | 'pwa_prefetch_hours'>, user: string): Record<string, string> {
|
|
42
42
|
if (!a.pwa) return {};
|
|
43
|
-
return {
|
|
43
|
+
return {
|
|
44
|
+
'data-sw': `/a/${a.alias}/sw.js`,
|
|
45
|
+
...(a.pwa_offline_submit ? { 'data-offline-queue': '1', 'data-user': user } : {}),
|
|
46
|
+
// (168) app.js hands the navigation's pages to the service worker, which keeps them for offline use
|
|
47
|
+
...(a.pwa_offline_pages && a.pwa_prefetch_hours ? { 'data-prefetch': String(a.pwa_prefetch_hours) } : {}),
|
|
48
|
+
};
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
// ------------------------------------------------------------------ icons (PNG, no image library)
|
|
@@ -175,7 +180,7 @@ export async function pwaRoutes(app: FastifyInstance) {
|
|
|
175
180
|
const base = `/a/${a.alias}`;
|
|
176
181
|
// the settings the worker needs, as a JSON literal (no user input in it)
|
|
177
182
|
const version = staticVersion();
|
|
178
|
-
const config = JSON.stringify({ base, offlinePages: a.pwa_offline_pages, offlineSubmit: a.pwa_offline_submit, version });
|
|
183
|
+
const config = JSON.stringify({ base, offlinePages: a.pwa_offline_pages, prefetchHours: a.pwa_offline_pages ? a.pwa_prefetch_hours : null, offlineSubmit: a.pwa_offline_submit, version });
|
|
179
184
|
return reply
|
|
180
185
|
.type('text/javascript; charset=utf-8')
|
|
181
186
|
.header('cache-control', 'no-cache')
|
package/src/runtime/regions.ts
CHANGED
|
@@ -104,9 +104,11 @@ export async function renderButton(ctx: PageContext, b: Button) {
|
|
|
104
104
|
return html`<button type="submit" class="${cls}" name="__request" value="${b.name}" data-button="${b.name}"${confirm}>${b.label}${badge}</button>`;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
export async function buttonsFor(ctx: PageContext, regionId: number | null, only: (b: Button) => boolean = () => true) {
|
|
107
|
+
export async function buttonsFor(ctx: PageContext, regionId: number | null, only: (b: Button) => boolean = () => true, rank: (b: Button) => number = () => 0) {
|
|
108
108
|
const out = [];
|
|
109
|
-
|
|
109
|
+
// in sequence, within their rank (a stable sort: the buttons come in sequence order)
|
|
110
|
+
const shown = [...ctx.vis!.buttons.values()].filter((b) => b.region_id === regionId && b.id > 0 && only(b)).sort((a, b) => rank(a) - rank(b));
|
|
111
|
+
for (const b of shown) out.push(await renderButton(ctx, b));
|
|
110
112
|
return out.some(Boolean) ? html`<div class="buttons">${out}</div>` : '';
|
|
111
113
|
}
|
|
112
114
|
|
|
@@ -314,6 +316,9 @@ async function renderRegionNow(ctx: PageContext, r: Region, hidden: Set<string>)
|
|
|
314
316
|
return regionShell(ctx, r, hidden, body);
|
|
315
317
|
}
|
|
316
318
|
|
|
319
|
+
/** Region types that place their items themselves (and login, whose fields post to a form of their own). */
|
|
320
|
+
const ITEMS_IN_BODY = new Set(['report', 'classic_report', 'form', 'static', 'login']);
|
|
321
|
+
|
|
317
322
|
/** The region's content (what the region's type renders), without its frame and buttons. */
|
|
318
323
|
async function renderBody(ctx: PageContext, r: Region, hidden: Set<string>): Promise<Raw> {
|
|
319
324
|
const items = ctx.page.items.filter((i) => i.region_id === r.id);
|
|
@@ -407,7 +412,7 @@ async function renderBody(ctx: PageContext, r: Region, hidden: Set<string>): Pro
|
|
|
407
412
|
body = await renderSearch(ctx, r);
|
|
408
413
|
break;
|
|
409
414
|
case 'login':
|
|
410
|
-
body = await renderLoginRegion(ctx);
|
|
415
|
+
body = await renderLoginRegion(ctx, r.config ?? {});
|
|
411
416
|
break;
|
|
412
417
|
case 'dynamic':
|
|
413
418
|
// A SELECT returning HTML (like APEX "PL/SQL Dynamic Content"). The
|
|
@@ -425,6 +430,12 @@ async function renderBody(ctx: PageContext, r: Region, hidden: Set<string>): Pro
|
|
|
425
430
|
break;
|
|
426
431
|
}
|
|
427
432
|
}
|
|
433
|
+
// (167) items placed in any other region (a tree, chart, map, cards …) are shown under its content, as in APEX;
|
|
434
|
+
// a login region's fields belong to a form of their own, so its items stay out
|
|
435
|
+
if (items.length && !ITEMS_IN_BODY.has(r.type) && !restFailed) {
|
|
436
|
+
const itemsHtml = await renderItems(ctx, items, hidden);
|
|
437
|
+
if (itemsHtml.length) body = html`${body!}<div class="form-grid region-items">${itemsHtml}</div>`;
|
|
438
|
+
}
|
|
428
439
|
return body!;
|
|
429
440
|
}
|
|
430
441
|
|
|
@@ -511,7 +522,8 @@ async function slottedShell(ctx: PageContext, r: Region, hidden: Set<string>, bo
|
|
|
511
522
|
const plain = (b: Button) => !b.slot || b.slot === 'region';
|
|
512
523
|
const top = await buttonsFor(ctx, r.id, (b) => TOP_SLOTS.has(b.slot ?? '') || (onTop && plain(b)));
|
|
513
524
|
const left = await buttonsFor(ctx, r.id, (b) => LEFT_SLOTS.has(b.slot ?? ''));
|
|
514
|
-
|
|
525
|
+
// (167) Next is the last button of the bar, as in APEX: the region's own buttons come before it
|
|
526
|
+
const right = await buttonsFor(ctx, r.id, (b) => b.slot === 'next' || (!onTop && plain(b)), (b) => (b.slot === 'next' ? 1 : 0));
|
|
515
527
|
const cls = `region region-${r.type} region-${r.template} col-${r.columns}${templateClasses('region', r.template_options)}`;
|
|
516
528
|
const hiddenAttr = hidden.has(`R${r.id}`) ? raw(' hidden') : '';
|
|
517
529
|
const staticAttr = r.static_id ? raw(` data-static-id="${esc(r.static_id)}"`) : '';
|