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.
Files changed (39) hide show
  1. package/db/migrations/156_pwa_prefetch.sql +13 -0
  2. package/docs/apex-feature-parity.md +4 -4
  3. package/docs/guide/04-pages-and-regions.md +23 -4
  4. package/docs/guide/05-items.md +20 -1
  5. package/docs/guide/07-dynamic-actions.md +1 -1
  6. package/docs/guide/08-security.md +6 -2
  7. package/docs/guide/09-reference.md +1 -1
  8. package/docs/guide/11-from-apex.md +3 -0
  9. package/docs/guide/17-mobile.md +35 -4
  10. package/examples/gallery/samples/sample-dynamic-actions.sql +7 -4
  11. package/examples/gallery/samples/sample-grids.sql +9 -29
  12. package/examples/gallery/samples/ux-patterns.sql +3 -3
  13. package/examples/hr/hr_82_pwa_prefetch.sql +11 -0
  14. package/package.json +1 -1
  15. package/public/app.css +2 -0
  16. package/public/app.js +13 -0
  17. package/public/sw.js +51 -1
  18. package/src/builder/components.ts +1 -1
  19. package/src/builder/i18n/de.ts +6 -0
  20. package/src/builder/i18n/en.ts +6 -0
  21. package/src/builder/i18n/es.ts +6 -0
  22. package/src/builder/i18n/fr.ts +6 -0
  23. package/src/builder/i18n/it.ts +6 -0
  24. package/src/builder/i18n/ja.ts +6 -0
  25. package/src/builder/i18n/ko.ts +6 -0
  26. package/src/builder/i18n/nl.ts +6 -0
  27. package/src/builder/i18n/pt.ts +6 -0
  28. package/src/builder/i18n/zh-cn.ts +6 -0
  29. package/src/builder/i18n/zh-tw.ts +6 -0
  30. package/src/builder/pwa.ts +14 -3
  31. package/src/builder/region-help.ts +2 -2
  32. package/src/metadata.ts +3 -1
  33. package/src/runtime/authz.ts +7 -0
  34. package/src/runtime/grid.ts +69 -9
  35. package/src/runtime/items.ts +9 -1
  36. package/src/runtime/pwa.ts +8 -3
  37. package/src/runtime/regions.ts +16 -4
  38. package/src/runtime/routes.ts +25 -6
  39. package/src/runtime/signin.ts +5 -5
@@ -444,24 +444,37 @@ export async function runtimeRoutes(app: FastifyInstance) {
444
444
  const normalised = normaliseReportParams(ctx.params);
445
445
  if (normalised !== null) return reply.redirect(`${ctx.base}/${ctx.page.page_no}${normalised ? `?${normalised}` : ''}`);
446
446
 
447
+ // (168) the service worker fetching the page for offline use (pwa_prefetch_hours): the page runs read-only,
448
+ // its session state isn't kept and nothing waiting for the next page (a message) is taken; a page that runs
449
+ // processes when it opens is refused (kept only when the user opens it)
450
+ const prefetch = req.headers['x-pgkiln-prefetch'] === '1';
451
+ if (prefetch && (ctx.dialog || ctx.page.processes.some((p) => p.point === 'load')))
452
+ return reply.code(409).header('cache-control', 'no-store').send('Not kept for offline use: this page runs processes when it opens.');
453
+
447
454
  if (!applyUrlItems(ctx)) return forbidden(ctx, reply, ctx.locale.t('error.checksum'), `checksum error: ${req.url}`);
448
455
  // a master grid's selected row (signed link) into its item
449
456
  applyRowSelection(ctx);
450
- const flash = takeFlash(ctx.session);
451
- if (flash) ctx.messages.push(flash);
452
- const flashError = takeFlash(ctx.session, '__FLASH_ERROR');
453
- if (flashError) ctx.errors.page.push(flashError);
454
- const saved = takeFlash(ctx.session, '__GRID_SAVED');
455
- if (typeof saved === 'string') ctx.savedGrids = saved.split(',').map(Number).filter(Number.isInteger);
457
+ if (!prefetch) {
458
+ const flash = takeFlash(ctx.session);
459
+ if (flash) ctx.messages.push(flash);
460
+ const flashError = takeFlash(ctx.session, '__FLASH_ERROR');
461
+ if (flashError) ctx.errors.page.push(flashError);
462
+ const saved = takeFlash(ctx.session, '__GRID_SAVED');
463
+ if (typeof saved === 'string') ctx.savedGrids = saved.split(',').map(Number).filter(Number.isInteger);
464
+ }
456
465
  // Actions → Download CSV / Excel / PDF: r<region id>_csv=1, _xlsx=1 or _pdf=1
457
466
  const downloadKey = [...ctx.params.keys()].find((k) => /^r\d+_(csv|xlsx|pdf)$/.test(k));
458
467
  // a document template: ?doc=NAME (see documents.ts)
459
468
  const docName = ctx.params.get('doc');
460
469
 
470
+ if (prefetch && (docName || downloadKey)) return reply.code(409).header('cache-control', 'no-store').send('Not kept for offline use.');
471
+
461
472
  let result: { html?: string; streamed?: boolean; file?: Buffer; type?: string; name?: string; redirect?: string; download?: Download };
462
473
  try {
463
474
  result = await appTx(txContext(ctx), async (c) => {
464
475
  ctx.client = c;
476
+ // (168) a prefetch writes nothing: an application process or region that tries fails
477
+ if (prefetch) await c.query('set local transaction_read_only = on');
465
478
  await timed(ctx, 4, 'page', 'page authorization', () => checkPageAccess(ctx));
466
479
  await timed(ctx, 4, 'page', 'application processes (before page)', () => runAppProcesses(ctx, 'before_page'));
467
480
  // (086) items kept per user: their values from the user's preferences
@@ -516,6 +529,12 @@ export async function runtimeRoutes(app: FastifyInstance) {
516
529
  if (e instanceof Forbidden) return forbidden(ctx, reply, e.message, `page ${ctx.page.page_no}`);
517
530
  throw e;
518
531
  }
532
+ if (prefetch) {
533
+ // a redirect (a branch, a sign-in) or anything but the page itself is not kept on the device
534
+ logActivity({ appId: ctx.app.id, pageNo: ctx.page.page_no, username: ctx.user, event: 'page_prefetch', ip: ctx.ip, elapsedMs: Math.round(performance.now() - started) });
535
+ if (result.html === undefined) return reply.code(409).header('cache-control', 'no-store').send('Not kept for offline use.');
536
+ return reply.type('text/html').header('cache-control', 'private, no-store').send(result.html);
537
+ }
519
538
  await saveState(ctx.session);
520
539
  if (result.redirect) return reply.redirect(result.redirect, 303);
521
540
  logActivity({ appId: ctx.app.id, pageNo: ctx.page.page_no, username: ctx.user, event: 'page_view', ip: ctx.ip, elapsedMs: Math.round(performance.now() - started) });
@@ -34,7 +34,7 @@ export const LOGIN_USER = '__LOGIN_USER';
34
34
 
35
35
  export async function signInForm(
36
36
  app: App, locale: Locale, session: Session, next: string,
37
- o: { error?: string; username?: string; formId?: string } = {},
37
+ o: { error?: string; username?: string; formId?: string; autofocus?: boolean } = {},
38
38
  ): Promise<Raw> {
39
39
  const t = locale.t;
40
40
  // database accounts and custom authentication: the user name and password form only
@@ -48,7 +48,7 @@ export async function signInForm(
48
48
  const fields = html`<input type="hidden" name="__csrf" value="${session.csrf_token}"${fa}>
49
49
  <input type="hidden" name="next" value="${next}"${fa}>
50
50
  ${app.time_zone_auto ? html`<input type="hidden" name="__tz" value=""${fa}>` : ''}
51
- <div class="field"><label class="label" for="username">${t('login.username')}</label><input id="username" name="username" value="${o.username ?? ''}" autocomplete="username" autofocus required maxlength="100"${fa}></div>
51
+ <div class="field"><label class="label" for="username">${t('login.username')}</label><input id="username" name="username" value="${o.username ?? ''}" autocomplete="username"${o.autofocus === false ? '' : raw(' autofocus')} required maxlength="100"${fa}></div>
52
52
  <div class="field"><label class="label" for="password">${t('login.password')}</label><input id="password" name="password" type="password" autocomplete="current-password" required maxlength="200"${fa}></div>
53
53
  ${remember ? html`<label class="check"><input type="checkbox" name="remember" value="true"${fa}> ${t('login.remember', { days: remember })}</label>` : ''}
54
54
  <button class="btn btn-hot"${fa}>${t('login.submit')}</button>
@@ -65,8 +65,8 @@ export async function signInForm(
65
65
  : providers.length ? '' : html`<p>${t('login.none')}</p>`}`;
66
66
  }
67
67
 
68
- /** The "login" region of an application's own login page. */
69
- export async function renderLoginRegion(ctx: PageContext): Promise<Raw> {
68
+ /** The "login" region of an application's own login page; (167) {"autofocus": false} leaves the focus where the page puts it. */
69
+ export async function renderLoginRegion(ctx: PageContext, config: { autofocus?: unknown } = {}): Promise<Raw> {
70
70
  const s = ctx.session.state;
71
71
  const error = s[LOGIN_ERROR] ?? undefined;
72
72
  const username = s[LOGIN_USER] ?? undefined;
@@ -76,6 +76,6 @@ export async function renderLoginRegion(ctx: PageContext): Promise<Raw> {
76
76
  ctx.detached.push(html`<form id="${formId}" method="post" action="/a/${ctx.app.alias}/login"></form>`);
77
77
  const next = safeNext(ctx.app, ctx.params.get('next') ?? undefined);
78
78
  const here = `${ctx.base}/${ctx.page.page_no}${ctx.params.get('next') ? `?next=${encodeURIComponent(next)}` : ''}`;
79
- return html`<div class="login-region">${await signInForm(ctx.app, ctx.locale, ctx.session, next, { error, username, formId })}
79
+ return html`<div class="login-region">${await signInForm(ctx.app, ctx.locale, ctx.session, next, { error, username, formId, autofocus: config.autofocus !== false })}
80
80
  ${languagePicker(ctx.app, ctx.locale, here)}</div>`;
81
81
  }