pgkiln 0.85.0 → 0.87.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/155_mail_available.sql +23 -0
- package/docs/apex-feature-parity.md +3 -3
- package/docs/guide/04-pages-and-regions.md +24 -5
- package/docs/guide/05-items.md +20 -1
- package/docs/guide/06-processing.md +7 -3
- package/docs/guide/07-dynamic-actions.md +3 -3
- package/docs/guide/08-security.md +6 -2
- package/docs/guide/09-reference.md +1 -0
- package/docs/guide/11-from-apex.md +3 -0
- package/docs/guide/12-development.md +4 -0
- package/examples/gallery/README.md +23 -6
- package/examples/gallery/gallery.json +4 -0
- package/examples/gallery/samples/sample-approvals.sql +102 -11
- package/examples/gallery/samples/sample-calendar.sql +40 -5
- package/examples/gallery/samples/sample-dynamic-actions.sql +240 -9
- package/examples/gallery/samples/sample-grids.sql +16 -4
- package/examples/gallery/samples/sample-maps.sql +65 -7
- package/examples/gallery/samples/sample-processes.sql +466 -0
- package/examples/gallery/samples/sample-validations.sql +407 -0
- package/examples/gallery/samples/ux-patterns.sql +154 -14
- package/examples/hr/README.md +2 -0
- package/examples/hr/hr_80_notifications.sql +125 -0
- package/examples/hr/hr_81_vector_search_type.sql +33 -0
- package/package.json +1 -1
- package/public/app.css +10 -1
- package/public/app.js +30 -9
- package/src/builder/components.ts +1 -1
- package/src/builder/region-help.ts +2 -2
- package/src/runtime/authz.ts +7 -0
- package/src/runtime/engine.ts +6 -3
- package/src/runtime/grid.ts +69 -9
- package/src/runtime/items.ts +9 -1
- package/src/runtime/lists.ts +2 -2
- package/src/runtime/push.ts +7 -1
- package/src/runtime/regions.ts +25 -8
- package/src/runtime/signin.ts +5 -5
package/src/runtime/engine.ts
CHANGED
|
@@ -301,9 +301,10 @@ export async function validate(ctx: PageContext) {
|
|
|
301
301
|
};
|
|
302
302
|
try {
|
|
303
303
|
const outcome = await validationPasses(ctx, v, value);
|
|
304
|
-
|
|
304
|
+
// (166) &ITEM. in the message, as in APEX (plain text: the message is escaped where it is shown)
|
|
305
|
+
if (outcome !== true) report(typeof outcome === 'string' ? outcome : substitute(v.message, ctx, (x) => x));
|
|
305
306
|
} catch (e) {
|
|
306
|
-
report(`${v.message} (${await publicError(ctx, e, `validation ${v.name}`)})`);
|
|
307
|
+
report(`${substitute(v.message, ctx, (x) => x)} (${await publicError(ctx, e, `validation ${v.name}`)})`);
|
|
307
308
|
}
|
|
308
309
|
}
|
|
309
310
|
|
|
@@ -683,7 +684,9 @@ export async function runProcesses(ctx: PageContext, point: 'submit' | 'load') {
|
|
|
683
684
|
continue;
|
|
684
685
|
}
|
|
685
686
|
const msg = await runOne(ctx, p, names, 0);
|
|
686
|
-
|
|
687
|
+
// (166) &ITEM. in a success message, with the values after the process (a new row's key, say), as in APEX;
|
|
688
|
+
// once here, so a chain's messages (joined by runOne) are filled in once
|
|
689
|
+
if (msg) messages.push(substitute(msg, ctx, (x) => x));
|
|
687
690
|
}
|
|
688
691
|
return messages;
|
|
689
692
|
}
|
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/lists.ts
CHANGED
|
@@ -231,9 +231,9 @@ export function navbarMarkup(nodes: ListNode[], label: string, live?: string): R
|
|
|
231
231
|
if (!nodes.length) return raw('');
|
|
232
232
|
return html`<nav class="t-navbar" aria-label="${label}"><ul>${nodes.map((n) =>
|
|
233
233
|
n.children.length
|
|
234
|
-
? html`<li><details class="menu"><summary${hasIcon(n) ? html` class="nb-icon"
|
|
234
|
+
? html`<li><details class="menu"><summary${hasIcon(n) ? html` class="nb-icon"` : ''}>${icon(n.icon)}<span>${n.label}</span>${badge(n, live)}</summary><div class="menu-panel align-right"><ul class="t-navbar-menu">${
|
|
235
235
|
[...(n.href ? [n] : []), ...n.children].map((c) => html`<li><a ${linkAttrs(c)}>${icon(c.icon)}<span>${c.label}</span>${badge(c, live)}</a></li>`)}</ul></div></details></li>`
|
|
236
|
-
: html`<li><a ${linkAttrs(n, hasIcon(n) ? 'nb-icon' : undefined)}
|
|
236
|
+
: html`<li><a ${linkAttrs(n, hasIcon(n) ? 'nb-icon' : undefined)}>${icon(n.icon)}<span>${n.label}</span>${badge(n, live)}</a></li>`)}</ul></nav>`;
|
|
237
237
|
}
|
|
238
238
|
|
|
239
239
|
function linksMarkup(nodes: ListNode[]): Raw {
|
package/src/runtime/push.ts
CHANGED
|
@@ -12,6 +12,8 @@ import { loadContext, type Req } from './routes.ts';
|
|
|
12
12
|
// action push_subscribe: both after a click, as browsers require) and posts
|
|
13
13
|
// it here. The application's public key reaches the page as data-push.
|
|
14
14
|
|
|
15
|
+
let pushKeyWarned = false;
|
|
16
|
+
|
|
15
17
|
/** Whether the app offers notifications. */
|
|
16
18
|
export const pushOn = (a: Pick<App, 'pwa' | 'pwa_push'>) => a.pwa && a.pwa_push;
|
|
17
19
|
|
|
@@ -22,7 +24,11 @@ export async function pushBody(a: Pick<App, 'id' | 'pwa' | 'pwa_push'>, username
|
|
|
22
24
|
return { 'data-push': await appPublicKey(a.id), 'data-push-user': username.toLowerCase() };
|
|
23
25
|
} catch (e) {
|
|
24
26
|
// no key yet and no PGKILN_SECRET_KEY to store one: the page works, without notifications
|
|
25
|
-
|
|
27
|
+
// (166) said once per server, not on every page
|
|
28
|
+
if (!pushKeyWarned) {
|
|
29
|
+
pushKeyWarned = true;
|
|
30
|
+
console.error('push notifications:', (e as Error).message);
|
|
31
|
+
}
|
|
26
32
|
return {};
|
|
27
33
|
}
|
|
28
34
|
}
|
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
|
|
|
@@ -481,9 +492,12 @@ async function regionShell(ctx: PageContext, r: Region, hidden: Set<string>, bod
|
|
|
481
492
|
</dialog>`;
|
|
482
493
|
|
|
483
494
|
const header = html`<h2 id="${titleId}">${r.title}</h2>${buttonsOnTop ? buttons : ''}`;
|
|
495
|
+
// (166) the header buttons sit beside the summary, not in it: a summary is the toggle, and a button inside it is
|
|
496
|
+
// nested interactive content (axe); they come first so they are reached before the region's body
|
|
484
497
|
if (r.template === 'collapsible')
|
|
485
|
-
return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-labelledby="${titleId}">
|
|
486
|
-
|
|
498
|
+
return html`<section class="${cls}${buttonsOnTop && buttons ? ' has-header-buttons' : ''}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-labelledby="${titleId}">
|
|
499
|
+
${buttonsOnTop && buttons ? html`<div class="region-header-buttons">${buttons}</div>` : ''}
|
|
500
|
+
<details open><summary class="region-header"><h2 id="${titleId}">${r.title}</h2></summary>
|
|
487
501
|
<div class="region-body"${scrollable(r)}>${body}</div>
|
|
488
502
|
${!buttonsOnTop && buttons ? html`<footer class="region-footer">${buttons}</footer>` : ''}
|
|
489
503
|
</details></section>`;
|
|
@@ -508,7 +522,8 @@ async function slottedShell(ctx: PageContext, r: Region, hidden: Set<string>, bo
|
|
|
508
522
|
const plain = (b: Button) => !b.slot || b.slot === 'region';
|
|
509
523
|
const top = await buttonsFor(ctx, r.id, (b) => TOP_SLOTS.has(b.slot ?? '') || (onTop && plain(b)));
|
|
510
524
|
const left = await buttonsFor(ctx, r.id, (b) => LEFT_SLOTS.has(b.slot ?? ''));
|
|
511
|
-
|
|
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));
|
|
512
527
|
const cls = `region region-${r.type} region-${r.template} col-${r.columns}${templateClasses('region', r.template_options)}`;
|
|
513
528
|
const hiddenAttr = hidden.has(`R${r.id}`) ? raw(' hidden') : '';
|
|
514
529
|
const staticAttr = r.static_id ? raw(` data-static-id="${esc(r.static_id)}"`) : '';
|
|
@@ -518,9 +533,11 @@ async function slottedShell(ctx: PageContext, r: Region, hidden: Set<string>, bo
|
|
|
518
533
|
return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-label="${r.title}">
|
|
519
534
|
${top ? html`<div class="region-toolbar">${top}</div>` : ''}${body}${footer}</section>`;
|
|
520
535
|
const header = html`<h2 id="${titleId}">${r.title}</h2>${top}`;
|
|
536
|
+
// (166) as above: the header buttons beside the summary, not in it
|
|
521
537
|
if (r.template === 'collapsible')
|
|
522
|
-
return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-labelledby="${titleId}">
|
|
523
|
-
|
|
538
|
+
return html`<section class="${cls}${top ? ' has-header-buttons' : ''}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-labelledby="${titleId}">
|
|
539
|
+
${top ? html`<div class="region-header-buttons">${top}</div>` : ''}
|
|
540
|
+
<details open><summary class="region-header"><h2 id="${titleId}">${r.title}</h2></summary><div class="region-body"${scrollable(r)}>${body}</div>${footer}</details></section>`;
|
|
524
541
|
return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr}${r.title ? raw(` aria-labelledby="${titleId}"`) : ''}>
|
|
525
542
|
${r.title || top ? html`<header class="region-header">${header}</header>` : ''}
|
|
526
543
|
<div class="region-body"${scrollable(r)}>${body}</div>${footer}
|
package/src/runtime/signin.ts
CHANGED
|
@@ -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
|
}
|