pgkiln 0.42.0 → 0.44.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 (60) hide show
  1. package/db/migrations/099_layout_positions.sql +29 -0
  2. package/db/migrations/100_generated_labels.sql +381 -0
  3. package/db/migrations/101_theme_choice.sql +15 -0
  4. package/docs/apex-feature-parity.md +36 -9
  5. package/docs/guide/03-builder.md +82 -21
  6. package/docs/guide/04-pages-and-regions.md +55 -5
  7. package/docs/guide/05-items.md +1 -1
  8. package/docs/guide/09-reference.md +5 -1
  9. package/docs/guide/12-development.md +2 -1
  10. package/docs/guide/14-globalization.md +5 -1
  11. package/package.json +1 -1
  12. package/public/app.css +80 -0
  13. package/public/app.js +189 -1
  14. package/public/builder.css +112 -0
  15. package/public/builder.js +280 -3
  16. package/src/builder/appsheets.ts +53 -13
  17. package/src/builder/appwizard.ts +5 -3
  18. package/src/builder/arrange.ts +133 -17
  19. package/src/builder/components.ts +28 -22
  20. package/src/builder/designer.ts +67 -18
  21. package/src/builder/forms.ts +4 -1
  22. package/src/builder/object-browser.ts +342 -0
  23. package/src/builder/pwa.ts +3 -3
  24. package/src/builder/routes.ts +85 -26
  25. package/src/builder/shared.ts +103 -1
  26. package/src/builder/sql.ts +13 -65
  27. package/src/builder/ui.ts +21 -6
  28. package/src/i18n/ar.ts +18 -0
  29. package/src/i18n/cs.ts +18 -0
  30. package/src/i18n/da.ts +18 -0
  31. package/src/i18n/de.ts +18 -0
  32. package/src/i18n/el.ts +18 -0
  33. package/src/i18n/es.ts +18 -0
  34. package/src/i18n/fi.ts +18 -0
  35. package/src/i18n/fr.ts +18 -0
  36. package/src/i18n/he.ts +18 -0
  37. package/src/i18n/it.ts +18 -0
  38. package/src/i18n/ja.ts +18 -0
  39. package/src/i18n/ko.ts +18 -0
  40. package/src/i18n/nb.ts +18 -0
  41. package/src/i18n/pl.ts +18 -0
  42. package/src/i18n/pt.ts +18 -0
  43. package/src/i18n/ru.ts +18 -0
  44. package/src/i18n/sv.ts +18 -0
  45. package/src/i18n/tr.ts +18 -0
  46. package/src/i18n/uk.ts +18 -0
  47. package/src/i18n/zh.ts +18 -0
  48. package/src/i18n.ts +36 -0
  49. package/src/metadata.ts +5 -1
  50. package/src/runtime/account.ts +5 -5
  51. package/src/runtime/devbar.ts +16 -13
  52. package/src/runtime/engine.ts +6 -0
  53. package/src/runtime/facets.ts +34 -3
  54. package/src/runtime/format.ts +52 -0
  55. package/src/runtime/items.ts +27 -0
  56. package/src/runtime/locale.ts +7 -1
  57. package/src/runtime/regions.ts +36 -2
  58. package/src/runtime/render.ts +27 -6
  59. package/src/runtime/report.ts +141 -81
  60. package/src/runtime/routes.ts +39 -7
@@ -188,6 +188,14 @@ export async function builderRoutes(app: FastifyInstance) {
188
188
 
189
189
  // (sprint 73) POST /apps, creating an application, is in appwizard.ts
190
190
 
191
+ // (sprint 74) hide the default-password warning until the next sign-in (the avatar keeps a dot)
192
+ app.post(`${BASE}/weak/dismiss`, async (req: Req, reply) => {
193
+ const s = await developer(req, reply);
194
+ if (!s) return;
195
+ s.state.__WEAK_HIDDEN = '1';
196
+ return back(reply, s, BASE);
197
+ });
198
+
191
199
  app.post(`${BASE}/import`, async (req: Req, reply) => {
192
200
  const s = await developer(req, reply);
193
201
  if (!s) return;
@@ -242,7 +250,12 @@ export async function builderRoutes(app: FastifyInstance) {
242
250
  const groups = (await owner.query<{ name: string }>('select name from meta.page_group where app_id = $1 order by name', [a.id])).rows.map((g) => g.name);
243
251
  const grouped = groups.length > 0 || pages.rows.some((p) => p.page_group);
244
252
  const groupFilter = typeof req.query?.group === 'string' ? req.query.group : '';
245
- const shownPages = pages.rows.filter((p) => !groupFilter || (groupFilter === '-' ? !p.page_group : p.page_group === groupFilter));
253
+ // (sprint 74) search by name or number, and a report or icon view (APEX: the application home)
254
+ const q = typeof req.query?.q === 'string' ? req.query.q.trim().slice(0, 100) : '';
255
+ const icons = req.query?.view === 'icons';
256
+ const shownPages = pages.rows.filter((p) => (!groupFilter || (groupFilter === '-' ? !p.page_group : p.page_group === groupFilter))
257
+ && (!q || String(p.page_no) === q || String(p.name).toLowerCase().includes(q.toLowerCase())));
258
+ const keep = (extra: Record<string, string>) => `?${new URLSearchParams({ ...(q ? { q } : {}), ...(groupFilter ? { group: groupFilter } : {}), ...extra })}`;
246
259
  const locks = new Map((await appLocks(a.id)).map((l) => [l.page_no, l]));
247
260
  const commentsPerPage = new Map((await owner.query('select page_no, count(*)::int as n from meta.dev_comment where app_id = $1 group by 1', [a.id])).rows.map((r) => [r.page_no, r.n]));
248
261
  const appLock = locks.get(0);
@@ -250,21 +263,33 @@ export async function builderRoutes(app: FastifyInstance) {
250
263
  const main = html`
251
264
  ${appHeader(a, 'pages')}
252
265
  ${appLock && appLock.locked_by !== s.username ? html`<div class="alert alert-error" role="status">${icon('key')} ${lockText(appLock)} Changes are refused until it is unlocked.</div>` : ''}
266
+ <div class="app-home">
267
+ <div class="app-home-main">
268
+ <div class="pages-bar">
269
+ <form method="get" class="search u-mwnone" role="search">${groupFilter ? html`<input type="hidden" name="group" value="${groupFilter}">` : ''}${icons ? html`<input type="hidden" name="view" value="icons">` : ''}
270
+ <label class="sr-only" for="f_pq">Search pages</label><input id="f_pq" name="q" type="search" value="${q}" placeholder="Page name or number" data-filter-rows="pages-rows">
271
+ <button class="btn">${icon('search')}<span class="sr-only">Search</span></button></form>
272
+ <div class="seg view-toggle" role="group" aria-label="View">
273
+ <a href="${keep({})}"${icons ? '' : raw(' aria-current="true"')}>${icon('list')} Report</a><a href="${keep({ view: 'icons' })}"${icons ? raw(' aria-current="true"') : ''}>${icon('grid')} Icons</a></div>
274
+ <a class="btn btn-hot" href="#create-page">${icon('plus')} Create page</a>
275
+ </div>
253
276
  ${region('Pages', html`${grouped
254
277
  ? html`<form method="get" class="search u-mwnone u-mb1"><div class="field"><label class="label" for="f_group">Page group</label>
255
278
  <select id="f_group" name="group">${[['', 'All pages'], ...groups.map((g) => [g, g]), ['-', 'Without a group']].map(([v, l]) => html`<option value="${v}"${v === groupFilter ? raw(' selected') : ''}>${l}</option>`)}</select></div>
256
279
  <button class="btn u-selfend">Show</button></form>`
257
- : ''}<div class="table-wrap"><table class="report">
280
+ : ''}${icons
281
+ ? html`<ul class="page-icons" id="pages-rows">${shownPages.map((p) => html`<li><a href="${BASE}/pages/${p.id}" class="page-icon"><span class="page-icon-no">${p.page_no}</span><b>${p.name}</b><small class="muted">${p.mode}, ${p.regions} region${p.regions === 1 ? '' : 's'}</small></a></li>`)}</ul>`
282
+ : html`<div class="table-wrap"><table class="report">
258
283
  <thead><tr><th class="num">Page</th><th>Name</th>${grouped ? html`<th>Group</th>` : ''}<th>Mode</th><th class="num">Regions</th><th class="num">Items</th><th class="num">DAs</th><th class="num">Processes</th><th>Authorization</th><th>Protection</th><th>Lock</th><th></th></tr></thead>
259
- <tbody>${shownPages.map((p) => html`<tr>
284
+ <tbody id="pages-rows">${shownPages.map((p) => html`<tr>
260
285
  <td class="num">${p.page_no}</td><td><a href="${BASE}/pages/${p.id}">${p.name}</a>${commentsPerPage.get(p.page_no) ? html` <span class="tag" title="Developer comments">${commentsPerPage.get(p.page_no)} comment${commentsPerPage.get(p.page_no) === 1 ? '' : 's'}</span>` : ''}</td>${grouped ? html`<td>${p.page_group ?? '—'}</td>` : ''}<td>${p.mode}</td>
261
286
  <td class="num">${p.regions}</td><td class="num">${p.items}</td><td class="num">${p.das}</td><td class="num">${p.processes}</td>
262
287
  <td>${p.requires_auth ? (p.authz ?? '—') : 'public'}</td><td>${p.protection}</td>
263
288
  <td>${((l) => (l ? html`<span class="lock-tag" title="${lockText(l)}">${icon('key')}<span>${l.locked_by === s.username ? 'you' : l.locked_by}</span></span>` : '—'))(locks.get(p.page_no))}</td>
264
289
  <td><a href="/a/${a.alias}/${p.page_no}" target="_blank" rel="noopener">Run ▸</a></td></tr>`)}</tbody>
265
- </table></div>`)}
266
- ${region('Application lock and comments', await lockPanel(s, a.id, 0, { headings: true }))}
267
- <div class="columns">
290
+ </table></div>`}${q && !shownPages.length ? html`<p class="muted">No page matches “${q}”.</p>` : ''}`)}
291
+ <div id="app-lock">${region('Application lock and comments', await lockPanel(s, a.id, 0, { headings: true }))}</div>
292
+ <div class="columns" id="create-page">
268
293
  ${region('Create pages from a table', html`
269
294
  <p class="muted u-mt0">Choose a page type and a table or view; the next step proposes the columns, key, dates, positions and foreign keys from the database, and creates the page with a menu entry.</p>
270
295
  <form method="get" action="${BASE}/apps/${a.id}/wizard">
@@ -285,6 +310,21 @@ export async function builderRoutes(app: FastifyInstance) {
285
310
  </div>
286
311
  <div class="buttons"><button class="btn btn-hot">Create page</button></div>
287
312
  </form>`)}
313
+ </div>
314
+ </div>
315
+ <aside class="app-tasks region region-standard" aria-labelledby="app-tasks-h"><header class="region-header"><h2 id="app-tasks-h">Tasks</h2></header>
316
+ <ul class="sc-links">
317
+ <li><a href="/a/${a.alias}" target="_blank" rel="noopener">${icon('play')} Run application</a></li>
318
+ <li><a href="#create-page">${icon('plus')} Create page</a></li>
319
+ <li><a href="${BASE}/apps/${a.id}/ai-pages">${icon('lightbulb')} Create pages with AI</a></li>
320
+ <li><a href="${BASE}/apps/${a.id}/shared">${icon('layers')} Shared Components</a></li>
321
+ <li><a href="${BASE}/apps/${a.id}/shared?type=page_group">${icon('folder')} Page groups</a></li>
322
+ <li><a href="${BASE}/apps/${a.id}/export">${icon('download')} Export</a></li>
323
+ <li><a href="${BASE}/apps/${a.id}/backups">${icon('history')} Backups</a></li>
324
+ <li><a href="${BASE}/apps/${a.id}/supporting-objects">${icon('database')} Supporting objects</a></li>
325
+ <li><a href="#app-lock">${icon('lock')} Lock the application</a></li>
326
+ <li><a href="${BASE}/apps/${a.id}/settings?tab=definition">${icon('trash')} Delete the application</a></li>
327
+ </ul></aside>
288
328
  </div>`;
289
329
  return send(reply, s, shell(s, a.name, [['App Builder', BASE], [a.name]], main));
290
330
  });
@@ -333,6 +373,11 @@ export async function builderRoutes(app: FastifyInstance) {
333
373
  });
334
374
 
335
375
  // ---------------------------------------------------------------- settings
376
+ /** (sprint 74) The tabs of an application's Settings. */
377
+ const SETTINGS_TABS: [string, string][] = [
378
+ ['definition', 'Definition'], ['security', 'Security'], ['signin', 'Sign-in'], ['ui', 'User Interface'],
379
+ ['globalization', 'Globalization'], ['errors', 'Errors and Sessions'], ['pwa', 'Progressive Web App'],
380
+ ];
336
381
  app.get(`${BASE}/apps/:id/settings`, async (req: Req, reply) => {
337
382
  const s = await developer(req, reply);
338
383
  if (!s) return;
@@ -342,18 +387,25 @@ export async function builderRoutes(app: FastifyInstance) {
342
387
  const directories = (await owner.query('select name, display_name, enabled from meta.ldap_directory order by display_name')).rows;
343
388
  const lists = (await owner.query('select name from meta.list where app_id = $1 order by name', [a.id])).rows.map((r) => r.name as string);
344
389
  const listChoices = (v: string | null, none: string): [string, string][] => [['', none], ...lists.map((n): [string, string] => [n, n]), ...(v && !lists.includes(v) ? [[v, `${v} (missing!)`] as [string, string]] : [])];
390
+ // (sprint 74) tabs like APEX's Edit Application Definition and Security Attributes: every tab of the
391
+ // main form is in the page (the others hidden), so Save keeps all of them; without JavaScript the tabs are links
392
+ const tab = SETTINGS_TABS.some(([k]) => k === req.query?.tab) ? String(req.query!.tab) : 'definition';
393
+ const panel = (k: string) => raw(`<section class="settings-panel" data-tab="${k}"${k === tab ? '' : ' hidden'}>`);
394
+ const end = raw('</section>');
395
+ const authPart = (k: string) => raw(`<div class="auth-part" data-auth="${k}"${a.authentication === k ? '' : ' hidden'}>`);
345
396
  const main = html`${appHeader(a, 'settings')}
346
- <div class="columns">
347
- ${region('Application settings', html`
348
- <form method="post" action="${BASE}/apps/${a.id}/settings">${csrf(s)}
397
+ <nav class="settings-tabs" aria-label="Settings">${SETTINGS_TABS.map(([k, label]) => html`<a href="?tab=${k}" data-tab-link="${k}"${k === tab ? raw(' aria-current="page"') : ''}>${label}</a>`)}</nav>
398
+ <div class="settings-body">
399
+ <form method="post" action="${BASE}/apps/${a.id}/settings" class="settings-form">${csrf(s)}<input type="hidden" name="tab" value="${tab}" data-settings-tab>
400
+ ${panel('definition')}${region('Definition', html`
349
401
  <div class="form-grid">
350
402
  ${input('name', 'Name', a.name, { required: true })}
351
403
  ${input('alias', 'Alias', a.alias, { required: true })}
352
404
  ${input('home_page', 'Home page', a.home_page, { type: 'number' })}
353
405
  ${select('app_type', 'Application type', a.app_type ?? 'standard', APP_TYPES.map((t): [string, string] => [t, APP_TYPE_LABELS[t]]),
354
406
  'Theme and library applications offer their theme or shared components to other applications (Shared Components → Subscriptions); Create application can start from a boilerplate application.')}
355
- </div>
356
- <h3>Security</h3>
407
+ </div>`)}${end}
408
+ ${panel('security')}${region('Authentication', html`
357
409
  <div class="form-grid">
358
410
  ${select('authentication', 'Authentication', a.authentication, [['app_users', 'App users (login page)'], ['header', 'HTTP header (reverse proxy)'], ['database', 'Database accounts (PostgreSQL roles)'], ['custom', 'Custom (a PL/pgSQL function)'], ['none', 'None (public)']])}
359
411
  ${input('db_role', 'Database role (parsing schema)', a.db_role, { help: 'All application SQL runs as this role (SET LOCAL ROLE), so grants and row level security apply. Leave empty only for trusted internal apps.' })}
@@ -362,7 +414,7 @@ export async function builderRoutes(app: FastifyInstance) {
362
414
  <div class="field"><span class="label">Debug messages</span><span>${a.debug_level ? html`<b>on, level ${a.debug_level}</b>` : 'off'} · <a href="${BASE}/apps/${a.id}/debug">Debug messages</a></span>
363
415
  <small class="help">Records every request's steps with timings and the messages of <code>meta.debug(level, text)</code>, kept ${a.debug_retention_days} days.</small></div>
364
416
  </div>
365
- <h3>HTTP header authentication</h3>
417
+ ${authPart('header')}<h3>HTTP header authentication</h3>
366
418
  <p class="muted">Only used when Authentication is "HTTP header". A reverse proxy or single sign-on gateway signs users in and passes the user name in a header; pgkiln trusts it only from the proxy addresses in <code>PGKILN_AUTH_HEADER_PROXIES</code>${headerProxiesConfigured() ? '' : html` (<b>not set on this server: header sign-in is refused</b>)`}.</p>
367
419
  <div class="form-grid">
368
420
  ${input('header_name', 'User name header', a.header_name ?? '', { placeholder: DEFAULT_HEADER, help: 'The request header with the user name (APEX: HTTP Header Variable). Empty: X-Remote-User. A changed or missing header ends the session.' })}
@@ -370,13 +422,13 @@ export async function builderRoutes(app: FastifyInstance) {
370
422
  </div>
371
423
  <div class="field"><label class="check"><input type="checkbox" name="header_auto_create" value="true"${a.header_auto_create ? raw(' checked') : ''}> Create accounts automatically</label>
372
424
  <small class="help">An unknown user name gets a new account with access to this app. Otherwise the account must exist and have access.</small></div>
373
- <h3>Database accounts</h3>
425
+ </div>${authPart('database')}<h3>Database accounts</h3>
374
426
  <p class="muted">Only used when Authentication is "Database accounts". Users sign in with a PostgreSQL login role and its password, checked by a short connection to this database as that role. Superusers and pgkiln's own roles are always refused; with neither field set nobody can sign in.</p>
375
427
  <div class="form-grid">
376
428
  ${input('db_auth_roles', 'Allowed roles', (a.db_auth_roles ?? []).join(', '), { placeholder: 'e.g. alice, bob', help: 'Login roles that may sign in, comma separated (exact names).' })}
377
429
  ${input('db_auth_member_of', 'Or members of role', a.db_auth_member_of ?? '', { placeholder: 'e.g. app_users_group', help: 'Every member of this role may sign in too.' })}
378
430
  </div>
379
- <h3>Custom authentication</h3>
431
+ </div>${authPart('custom')}<h3>Custom authentication</h3>
380
432
  <p class="muted">Only used when Authentication is "Custom". Your own PL/pgSQL checks the user name and password on the sign-in form, as the application's database role; the password is passed as a parameter and never logged. Failed attempts are throttled like other sign-ins. With neither field set nobody can sign in.</p>
381
433
  <div class="form-grid">
382
434
  ${input('custom_auth_function', 'Function name', a.custom_auth_function ?? '', { placeholder: 'e.g. app.check_login', help: 'A function (p_username text, p_password text) returns boolean, in lower case, optionally with its schema. The app\'s role needs EXECUTE on it. Takes precedence over the body below.' })}
@@ -387,6 +439,8 @@ export async function builderRoutes(app: FastifyInstance) {
387
439
  <div class="field" data-wide><label class="label" for="f_custom_auth_post_code">Post-authentication code</label>
388
440
  <textarea id="f_custom_auth_post_code" name="custom_auth_post_code" class="code" rows="4" spellcheck="false" data-code="plpgsql">${a.custom_auth_post_code ?? ''}</textarea>
389
441
  <small class="help">Optional PL/pgSQL run after a successful check, with p_username (and meta.app_user()), e.g. to record the last sign-in. Raising an exception refuses the sign-in. Application processes "after login" run afterwards as usual.</small></div>
442
+ </div>`)}${end}
443
+ ${panel('signin')}${region('Sign-in', html`
390
444
  <h3>Sign-in methods</h3>
391
445
  <div class="field"><label class="check"><input type="checkbox" name="local_login" value="true"${a.local_login ? raw(' checked') : ''}> Username and password</label></div>
392
446
  ${directories.length
@@ -399,6 +453,8 @@ export async function builderRoutes(app: FastifyInstance) {
399
453
  ${providers.length
400
454
  ? providers.map((pr) => html`<div class="field"><label class="check"><input type="checkbox" name="sso_providers" value="${pr.name}"${a.sso_providers.includes(pr.name) ? raw(' checked') : ''}> Sign in with ${pr.display_name}${pr.enabled ? '' : ' (disabled)'}</label></div>`)
401
455
  : html`<p class="muted">No identity providers configured. <a href="${BASE}/users/providers">Add one</a> for single sign-on.</p>`}
456
+ `)}${end}
457
+ ${panel('ui')}${region('User Interface', html`
402
458
  <h3>Theme</h3>
403
459
  <div class="form-grid">
404
460
  ${select('base', 'Base style', baseStyleOf(a.theme), [['iris', 'Iris (the default for new applications)'], ['lavender', 'Lavender (Iris until pgkiln 0.41)'], ['standard', 'Standard (pgkiln until 0.28)']], 'Colours, corners, font and shadows of the whole application, light and dark. The colours below override its accent and header.')}
@@ -416,7 +472,8 @@ export async function builderRoutes(app: FastifyInstance) {
416
472
  <small class="help">Adds a switch to the user menu and My account; the choice is saved on the account (APEX: "Enable End Users to Choose Theme Style").</small></div>
417
473
  <p><a class="btn" href="${BASE}/apps/${a.id}/theme">${icon('settings')} Theme Roller: style variants…</a>
418
474
  <span class="muted">${(() => { const n = appStyles(a.theme).length; return n ? `${n} style${n === 1 ? '' : 's'}` : 'no styles yet'; })()}</span></p>
419
- <h3>Globalization</h3>
475
+ `)}${end}
476
+ ${panel('globalization')}${region('Globalization', html`
420
477
  <div class="form-grid">
421
478
  ${input('language', 'Primary language', a.language, { help: 'The language the app is built in, e.g. en, nl, de, en-GB.' })}
422
479
  ${input('languages', 'Translated languages', (a.languages ?? []).join(', '), { placeholder: 'e.g. nl, de', help: 'Comma separated. Translate texts under Shared Components → Globalization.' })}
@@ -424,22 +481,22 @@ export async function builderRoutes(app: FastifyInstance) {
424
481
  ${input('date_format', 'Date format', a.date_format, { placeholder: 'e.g. DD-MM-YYYY (empty: per language)', help: 'Masks: YYYY YY MM MON MONTH DD DY DAY HH24 HH MI SS AM' })}
425
482
  ${input('timestamp_format', 'Date and time format', a.timestamp_format, { placeholder: 'e.g. DD-MM-YYYY HH24:MI' })}
426
483
  </div>
427
- ${await timeZoneSettings(a)}
428
- <div class="buttons"><button class="btn btn-hot">Save settings</button></div>
484
+ ${await timeZoneSettings(a)}`)}${end}
485
+ <div class="buttons settings-save"${['errors', 'pwa'].includes(tab) ? raw(' hidden') : ''}><button class="btn btn-hot">Save settings</button></div>
429
486
  </form>
430
- <form method="post" action="${BASE}/apps/${a.id}/delete" class="danger-zone">${csrf(s)}
487
+ ${panel('definition')}<form method="post" action="${BASE}/apps/${a.id}/delete" class="danger-zone">${csrf(s)}
431
488
  <button class="btn btn-danger" data-confirm="Delete application ${a.name} and all its pages?">Delete application</button>
432
- </form>`)}
433
- ${region('Errors and sessions', html`<form method="post" action="${BASE}/apps/${a.id}/settings/behaviour">${csrf(s)}
489
+ </form>${end}
490
+ ${panel('errors')}${region('Errors and sessions', html`<form method="post" action="${BASE}/apps/${a.id}/settings/behaviour">${csrf(s)}
434
491
  <div class="form-grid">
435
492
  ${input('error_function', 'Error handling function', a.error_function ?? '', { placeholder: 'e.g. sales.error_message',
436
493
  help: 'schema.function(jsonb) returns jsonb, run as the application\'s role: it receives the message and what is known of an error (code, constraint, table, column, component, page, user, internal, reference) and may return {"message": "…"} to show instead. Empty: none.' })}
437
494
  ${input('session_warning_seconds', 'Session timeout warning (seconds)', a.session_warning_seconds ?? 120, { type: 'number',
438
495
  help: 'Signed-in users are warned this long before their session ends and can stay signed in (0: no warning).' })}
439
496
  </div>
440
- <div class="buttons"><button class="btn">Save</button></div></form>`)}
441
- ${region('Progressive Web App', await pwaSection({ ...a, has_icon: !!(await owner.one('select pwa_icon is not null as h from meta.app where id = $1', [a.id]))?.h }, s))}
442
- ${region('Security checklist', html`<ul class="checklist">
497
+ <div class="buttons"><button class="btn">Save</button></div></form>`)}${end}
498
+ ${panel('pwa')}${region('Progressive Web App', await pwaSection({ ...a, has_icon: !!(await owner.one('select pwa_icon is not null as h from meta.app where id = $1', [a.id]))?.h }, s))}${end}
499
+ ${panel('security')}${region('Security checklist', html`<ul class="checklist">
443
500
  <li>${a.db_role ? '✓' : '✗'} Runs as a dedicated database role ${a.db_role ? html`(<code>${a.db_role}</code>)` : html`<b>(runs as the runtime connection)</b>`}</li>
444
501
  <li>${a.authentication !== 'none' ? '✓' : '•'} ${a.authentication !== 'none' ? 'Users must sign in' : 'Public application'}</li>
445
502
  ${a.authentication === 'header' ? html`<li>${headerProxiesConfigured() ? '✓' : '✗'} Sign-in: HTTP header <code>${a.header_name || DEFAULT_HEADER}</code> ${headerProxiesConfigured() ? 'from the proxies in PGKILN_AUTH_HEADER_PROXIES' : html`<b>refused: PGKILN_AUTH_HEADER_PROXIES is not set</b>`}; access: ${a.access_control === 'any_user' ? 'any active account' : 'listed accounts only'}${a.header_auto_create ? ', new accounts created automatically' : ''}</li>` : ''}
@@ -450,7 +507,7 @@ export async function builderRoutes(app: FastifyInstance) {
450
507
  <li>${a.debug_level ? `✗ Debug messages are on (level ${a.debug_level}): every request is recorded` : '✓ Debug messages are off'}</li>
451
508
  <li>Pages without checksum protection: ${(await owner.one("select count(*)::int as n from meta.page where app_id = $1 and protection = 'unrestricted'", [a.id])).n}</li>
452
509
  <li>Public pages: ${(await owner.one('select count(*)::int as n from meta.page where app_id = $1 and not requires_auth', [a.id])).n}</li>
453
- </ul>`)}
510
+ </ul>`)}${end}
454
511
  </div>`;
455
512
  return send(reply, s, shell(s, `${a.name} settings`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Settings']], main));
456
513
  });
@@ -473,7 +530,7 @@ export async function builderRoutes(app: FastifyInstance) {
473
530
  } catch (e) {
474
531
  flash(s, (e as Error).message, 'error');
475
532
  }
476
- return back(reply, s, `${BASE}/apps/${req.params.id}/settings`);
533
+ return back(reply, s, `${BASE}/apps/${req.params.id}/settings?tab=errors`);
477
534
  });
478
535
 
479
536
  app.post(`${BASE}/apps/:id/settings`, async (req: Req, reply) => {
@@ -530,7 +587,9 @@ export async function builderRoutes(app: FastifyInstance) {
530
587
  } catch (e) {
531
588
  flash(s, (e as Error).message, 'error');
532
589
  }
533
- return back(reply, s, `${BASE}/apps/${req.params.id}/settings`);
590
+ // (sprint 74) back to the tab the form was saved from
591
+ const tab = /^[a-z]{1,20}$/.test(String(b.tab ?? '')) ? `?tab=${b.tab}` : '';
592
+ return back(reply, s, `${BASE}/apps/${req.params.id}/settings${tab}`);
534
593
  });
535
594
 
536
595
  app.post(`${BASE}/apps/:id/delete`, async (req: Req, reply) => {
@@ -32,7 +32,70 @@ function listExtras(appId: number, list: any, entries: any[]): Raw {
32
32
  <p><a class="btn" href="?new=list_entry&amp;list=${encodeURIComponent(list.name)}">+ Add entry</a></p>`;
33
33
  }
34
34
 
35
+ /** (sprint 74) A row's second column in a component list. */
36
+ function detailOf(kind: string, r: any): string {
37
+ switch (kind) {
38
+ case 'nav_entry': return r.target_page ? `page ${r.target_page}` : r.target_url ?? '';
39
+ case 'authz_scheme': return r.type;
40
+ case 'app_process': return r.point;
41
+ case 'automation': return r.enabled ? r.schedule ?? '' : 'off';
42
+ case 'list': return r.type;
43
+ case 'list_entry': return `${r.list_name}${r.target_page ? `, page ${r.target_page}` : ''}`;
44
+ case 'web_credential': return r.type;
45
+ case 'rest_source': return `${r.method ?? ''} ${r.url ?? ''}`.trim();
46
+ case 'supporting_script': return r.kind;
47
+ case 'search_config': return r.source_type;
48
+ case 'lov': return r.type ?? '';
49
+ case 'app_item': return r.scope ?? '';
50
+ default: return r.description ?? '';
51
+ }
52
+ }
53
+
54
+ interface ScItem { label: string; href: string; kind?: string }
55
+ /**
56
+ * (sprint 74) The groups of the Shared Components page, like APEX's. Component types pgkiln keeps in Settings
57
+ * open the right Settings tab; a few APEX types have no counterpart (docs/apex-feature-parity.md says why).
58
+ */
59
+ export function sharedGroups(appId: number): { key: string; title: string; icon: string; items: ScItem[] }[] {
60
+ const t = (kind: string, label = COMPONENTS[kind].plural): ScItem => ({ label, href: `${BASE}/apps/${appId}/shared?type=${kind}`, kind });
61
+ const settings = (tab: string, label: string): ScItem => ({ label, href: `${BASE}/apps/${appId}/settings?tab=${tab}` });
62
+ const page = (path: string, label: string): ScItem => ({ label, href: `${BASE}/apps/${appId}/${path}` });
63
+ return [
64
+ { key: 'logic', title: 'Application Logic', icon: 'code', items: [t('app_item', 'Application Items'), t('app_process', 'Application Processes'), t('app_setting', 'Application Settings'), t('build_option', 'Build Options'), page('settings?tab=definition', 'Application Definition')] },
65
+ { key: 'security', title: 'Security', icon: 'shield', items: [{ label: 'Access Control', href: `${BASE}/apps/${appId}/shared?view=tree`, kind: 'access' }, settings('security', 'Authentication'), t('authz_scheme', 'Authorization Schemes'), settings('security', 'Security Attributes'), settings('signin', 'Sign-in and Session Management')] },
66
+ { key: 'other', title: 'Other Components', icon: 'layers', items: [t('lov', 'Lists of Values'), t('list', 'Lists'), t('list_entry', 'List Entries'), t('email_template', 'Email Templates'), t('supporting_script', 'Supporting Objects'), page('subscriptions', 'Subscriptions')] },
67
+ { key: 'navigation', title: 'Navigation and Search', icon: 'compass', items: [t('nav_entry', 'Navigation Menu'), settings('ui', 'Navigation Bar List'), page('breadcrumbs', 'Breadcrumbs'), t('page_group', 'Page Groups'), t('search_config', 'Search Configurations')] },
68
+ { key: 'ui', title: 'User Interface', icon: 'grid', items: [page('theme', 'Themes and Theme Roller'), settings('ui', 'User Interface Attributes'), t('template_component', 'Template Components'), page('plugins', 'Plug-ins'), settings('pwa', 'Progressive Web App')] },
69
+ { key: 'files', title: 'Files and Reports', icon: 'file', items: [page('static-files', 'Static Application Files'), t('report_layout', 'Report Layouts'), t('document_template', 'Document Templates')] },
70
+ { key: 'data', title: 'Data Sources', icon: 'database', items: [t('rest_source', 'REST Data Sources'), t('web_credential', 'Web Credentials'), t('rest_module', 'RESTful Services'), t('data_load_def', 'Data Load Definitions')] },
71
+ { key: 'workflows', title: 'Workflows and Automations', icon: 'activity', items: [t('workflow_definition', 'Workflows'), t('task_definition', 'Task Definitions'), t('automation', 'Automations')] },
72
+ { key: 'globalization', title: 'Globalization', icon: 'globe', items: [page('globalization', 'Translations and Text Messages'), t('dynamic_translation', 'Dynamic Translations'), settings('globalization', 'Globalization Attributes')] },
73
+ { key: 'ai', title: 'Generative AI', icon: 'lightbulb', items: [page('ai', 'AI Services')] },
74
+ ];
75
+ }
76
+
35
77
  export async function sharedRoutes(app: FastifyInstance) {
78
+ // (sprint 74) Breadcrumbs (APEX: a breadcrumb component): the pages by their breadcrumb parent
79
+ app.get(`${BASE}/apps/:id/breadcrumbs`, async (req: Req, reply) => {
80
+ const s = await developer(req, reply);
81
+ if (!s) return;
82
+ const a = await appOr404(req.params.id);
83
+ if (!a) return reply.code(404).send('Not found');
84
+ const pages = (await owner.query<{ id: number; page_no: number; name: string; parent_page: number | null }>('select id, page_no, name, parent_page from meta.page where app_id = $1 order by page_no', [a.id])).rows;
85
+ const known = new Set(pages.map((p) => p.page_no));
86
+ const tree = (parent: number | null, seen: Set<number>): Raw => {
87
+ const kids = pages.filter((p) => (parent === null ? p.parent_page === null || !known.has(p.parent_page) || p.parent_page === p.page_no : p.parent_page === parent && p.page_no !== parent) && !seen.has(p.page_no));
88
+ if (!kids.length) return html``;
89
+ return html`<ul class="tree">${kids.map((p) => {
90
+ const next = new Set(seen).add(p.page_no);
91
+ return html`<li><a href="${BASE}/pages/${p.id}">${icon('file')}<span>${p.name}</span><span class="kind">p${p.page_no}</span></a>${tree(p.page_no, next)}</li>`;
92
+ })}</ul>`;
93
+ };
94
+ const main = html`${appHeader(a, 'shared')}
95
+ ${region('Breadcrumbs', html`<p class="muted u-mt0">A page's breadcrumb lists the pages above it: each page names its breadcrumb parent (Page Designer → page → Breadcrumb parent). Pages without one show Home.</p>${tree(null, new Set())}`)}`;
96
+ return send(reply, s, shell(s, 'Breadcrumbs', [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Shared Components', `${BASE}/apps/${a.id}/shared`], ['Breadcrumbs']], main));
97
+ });
98
+
36
99
  // ---------------------------------------------------------------- shared components
37
100
  const SHARED = ['nav_entry', 'authz_scheme', 'build_option', 'page_group', 'lov', 'app_item', 'app_process', 'automation', 'automation_action', 'report_layout', 'document_template', 'task_definition', 'workflow_definition', 'rest_module', 'template_component', 'web_credential', 'rest_source', 'data_load_def', 'list', 'list_entry', 'supporting_script', 'search_config', 'email_template', 'app_setting', 'dynamic_translation'];
38
101
 
@@ -61,6 +124,44 @@ export async function sharedRoutes(app: FastifyInstance) {
61
124
 
62
125
  const [selKind, selId] = (req.query.c ?? '').split('-');
63
126
  const newKind = req.query.new;
127
+ const type = typeof req.query.type === 'string' ? req.query.type : '';
128
+ const crumbsBase: [string, string?][] = [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Shared Components', `${BASE}/apps/${a.id}/shared`]];
129
+ // (sprint 74) APEX's Shared Components: a grid of component types in groups, with counts
130
+ if (!selKind && !newKind && !type && req.query.view !== 'tree') {
131
+ const counts: Record<string, number> = Object.fromEntries(SHARED.map((k) => [k, rows[k].length]));
132
+ counts.access = users.length;
133
+ const main = html`${appHeader(a, 'shared')}
134
+ <div class="sc-head"><p class="muted u-m0">The components the pages of ${a.name} share.</p><a class="btn" href="?view=tree">${icon('list')} Tree view</a></div>
135
+ <div class="sc-grid">${sharedGroups(a.id).map((g) => html`<section class="region region-standard sc-group" aria-labelledby="sc-${g.key}">
136
+ <header class="region-header"><h2 id="sc-${g.key}">${icon(g.icon)} ${g.title}</h2></header>
137
+ <ul class="sc-links">${g.items.map((it) => html`<li><a href="${it.href}">${it.label}</a>${it.kind && counts[it.kind] !== undefined ? html`<span class="sc-count" aria-label="${counts[it.kind]} ${it.label}">${counts[it.kind]}</span>` : ''}</li>`)}</ul>
138
+ </section>`)}</div>`;
139
+ return send(reply, s, shell(s, 'Shared Components', [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Shared Components']], main));
140
+ }
141
+ // (sprint 74) one component type: a searchable, sortable list with Create; a row opens its editor
142
+ if (type && SHARED.includes(type)) {
143
+ const spec = COMPONENTS[type];
144
+ const q = typeof req.query.q === 'string' ? req.query.q.trim().slice(0, 100) : '';
145
+ const desc = req.query.sort === 'desc';
146
+ const shown = rows[type]
147
+ .map((r) => ({ r, name: String(spec.summary(r)) }))
148
+ .filter((x) => !q || x.name.toLowerCase().includes(q.toLowerCase()))
149
+ .sort((x, y) => (desc ? -1 : 1) * x.name.localeCompare(y.name, 'en', { numeric: true }));
150
+ const main = html`${appHeader(a, 'shared')}
151
+ <div class="sc-list-head"><h1>${spec.plural}</h1><a class="btn btn-hot" href="?new=${type}">${icon('plus')} Create</a></div>
152
+ <form method="get" class="search u-mwnone u-mb1" role="search"><input type="hidden" name="type" value="${type}">
153
+ <div class="field"><label class="label" for="f_q">Search</label><input id="f_q" name="q" type="search" value="${q}" placeholder="Name" data-filter-rows="sc-rows"></div>
154
+ <button class="btn u-selfend">${icon('search')} Go</button></form>
155
+ <div class="table-wrap"><table class="report"><thead><tr>
156
+ <th scope="col" aria-sort="${desc ? 'descending' : 'ascending'}"><a href="?type=${type}${q ? `&q=${encodeURIComponent(q)}` : ''}${desc ? '' : '&sort=desc'}">Name ${desc ? '▼' : '▲'}</a></th>
157
+ <th scope="col">Details</th></tr></thead>
158
+ <tbody id="sc-rows">${shown.length
159
+ ? shown.map(({ r, name }) => html`<tr><td data-label="Name"><a href="?c=${type}-${r.id}">${icon(type === 'nav_entry' ? (r.icon ?? 'chevron') : spec.icon)} ${r.parent_id ? '↳ ' : ''}${name}</a></td>
160
+ <td data-label="Details">${detailOf(type, r)}</td></tr>`)
161
+ : html`<tr><td colspan="2" class="empty">${q ? 'Nothing matches.' : `No ${spec.plural.toLowerCase()} yet.`}</td></tr>`}</tbody></table></div>
162
+ <p class="muted">${shown.length} of ${rows[type].length}</p>`;
163
+ return send(reply, s, shell(s, spec.plural, [...crumbsBase, [spec.plural]], main));
164
+ }
64
165
  let editor: Raw;
65
166
  if (newKind && SHARED.includes(newKind)) {
66
167
  const spec = COMPONENTS[newKind];
@@ -153,7 +254,8 @@ export async function sharedRoutes(app: FastifyInstance) {
153
254
 
154
255
  const main = html`${appHeader(a, 'shared')}
155
256
  <div class="designer"><aside class="region region-standard">${tree}</aside><div>${editor}</div></div>`;
156
- return send(reply, s, shell(s, 'Shared Components', [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Shared Components']], main));
257
+ const kindShown = selKind && SHARED.includes(selKind) ? selKind : newKind && SHARED.includes(newKind) ? newKind : '';
258
+ return send(reply, s, shell(s, 'Shared Components', kindShown ? [...crumbsBase, [COMPONENTS[kindShown].plural, `${BASE}/apps/${a.id}/shared?type=${kindShown}`], [selKind ? 'Edit' : 'Create']] : [...crumbsBase.slice(0, 2), ['Shared Components']], main));
157
259
  });
158
260
 
159
261
  app.post(`${BASE}/apps/:id/shared/:kind/:cid?`, async (req: Req, reply) => {
@@ -12,6 +12,7 @@ import { queryBuilderRoutes } from './querybuilder.ts';
12
12
  import { unloadRoutes } from './unload.ts';
13
13
  import { ddlToolRoutes } from './ddl-tools.ts';
14
14
  import { sqlUtilityRoutes } from './sql-utilities.ts';
15
+ import { createTemplate, objectBrowserRoutes } from './object-browser.ts';
15
16
  import { currentWorkspace, sessionSchema, usableSchemas, workshopDb, workshopRunsAs } from './workspaces.ts';
16
17
 
17
18
  // SQL Workshop: SQL commands and the object browser (owner connection; an isolated
@@ -121,6 +122,16 @@ export async function sqlRoutes(app: FastifyInstance) {
121
122
  if (r) text = r.detail;
122
123
  }
123
124
  const schema = await sessionSchema(s, db);
125
+ if (req.method === 'GET') {
126
+ // (sprint 75) a statement to review: from the Object Browser's column actions, or a CREATE template
127
+ if (typeof s.state.__SQLDRAFT === 'string') {
128
+ text = s.state.__SQLDRAFT;
129
+ delete s.state.__SQLDRAFT;
130
+ } else if (typeof req.query?.template === 'string') {
131
+ const into = typeof req.query.schema === 'string' && (await usableSchemas(db)).includes(req.query.schema) ? req.query.schema : (schema ?? 'public');
132
+ text = createTemplate(req.query.template, into) ?? text;
133
+ }
134
+ }
124
135
  if (req.method === 'POST' && sql.trim()) {
125
136
  const started = performance.now();
126
137
  try {
@@ -173,69 +184,6 @@ export async function sqlRoutes(app: FastifyInstance) {
173
184
  return back(reply, s, `${BASE}/sql`);
174
185
  });
175
186
 
176
- app.get(`${BASE}/sql/objects`, async (req: Req, reply) => {
177
- const s = await developer(req, reply);
178
- if (!s) return;
179
- const db = await workshopDb(s);
180
- const objects = (
181
- await db.query(
182
- `select n.nspname as schema, c.relname as name, c.oid::regclass::text as qname,
183
- case c.relkind when 'r' then 'table' when 'p' then 'table' when 'v' then 'view' when 'm' then 'view' end as kind,
184
- c.relrowsecurity as rls
185
- from pg_class c join pg_namespace n on n.oid = c.relnamespace
186
- where c.relkind in ('r', 'p', 'v', 'm') and n.nspname !~ '^pg_' and n.nspname not in ('information_schema')
187
- and has_schema_privilege(n.oid, 'USAGE')
188
- union all
189
- select n.nspname, p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')', p.oid::regprocedure::text, 'function', false
190
- from pg_proc p join pg_namespace n on n.oid = p.pronamespace
191
- where n.nspname !~ '^pg_' and n.nspname not in ('information_schema') and p.prokind in ('f', 'p')
192
- and has_schema_privilege(n.oid, 'USAGE')
193
- order by 1, 4 desc, 2`,
194
- )
195
- ).rows;
196
- const o = req.query.o;
197
- const selected = objects.find((x) => x.qname === o);
198
- let detail: Raw = html`<p class="muted">Select a table, view or function.</p>`;
199
- if (selected) {
200
- if (selected.kind === 'function') {
201
- const src = await db.one('select pg_get_functiondef($1::regprocedure) as def', [selected.qname]);
202
- detail = region(selected.qname, html`<pre class="source">${src.def}</pre>`);
203
- } else {
204
- const [cols, policies, grants, data] = await Promise.all([
205
- db.query(
206
- `select a.attname as column, format_type(a.atttypid, a.atttypmod) as type, not a.attnotnull as nullable,
207
- pg_get_expr(d.adbin, d.adrelid) as "default"
208
- from pg_attribute a left join pg_attrdef d on d.adrelid = a.attrelid and d.adnum = a.attnum
209
- where a.attrelid = $1::regclass and a.attnum > 0 and not a.attisdropped order by a.attnum`,
210
- [selected.qname],
211
- ),
212
- db.query('select policyname as policy, cmd, roles::text, qual as using, with_check from pg_policies where schemaname = $1 and tablename = $2', [selected.schema, selected.name]),
213
- db.query(
214
- `select grantee, string_agg(privilege_type, ', ' order by privilege_type) as privileges
215
- from information_schema.role_table_grants where table_schema = $1 and table_name = $2 group by grantee order by grantee`,
216
- [selected.schema, selected.name],
217
- ),
218
- db.pool.query({ text: `select * from ${selected.qname} limit 25`, rowMode: 'array' }).catch((e) => e as Error),
219
- ]);
220
- const asArray = (r: pg.QueryResult) => ({ ...r, rows: r.rows.map((x) => Object.values(x)) }) as unknown as pg.QueryResult<any[]>;
221
- detail = html`${region(`${selected.kind === 'view' ? 'View' : 'Table'} ${selected.qname}`, resultTable(asArray(cols)),
222
- html`<span class="count">${selected.rls ? 'row level security ON' : 'no RLS'}</span>`)}
223
- <div class="u-spacer"></div>
224
- ${region('Row level security policies', policies.rowCount ? resultTable(asArray(policies)) : html`<p class="muted">No policies.</p>`)}
225
- <div class="u-spacer"></div>
226
- ${region('Grants', resultTable(asArray(grants)))}
227
- <div class="u-spacer"></div>
228
- ${region('Data (first 25 rows)', data instanceof Error ? html`<div class="alert alert-error">${data.message}</div>` : resultTable(data))}`;
229
- }
230
- }
231
- let lastSchema = '';
232
- const list = html`<ul class="tree">${objects.map((x) => {
233
- const header = x.schema !== lastSchema ? html`<li class="group">${(lastSchema = x.schema)}</li>` : '';
234
- return html`${header}<li><a href="?o=${encodeURIComponent(x.qname)}"${x.qname === o ? raw(' aria-current="page"') : ''}>${icon(x.kind === 'function' ? 'code' : x.kind === 'view' ? 'layers' : 'table')}<span>${x.name}</span>${x.rls ? html`<span class="tag">RLS</span>` : ''}</a></li>`;
235
- })}</ul>`;
236
- const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('objects')}
237
- <div class="designer"><aside class="region region-standard" aria-label="Database objects">${list}</aside><div>${detail}</div></div>`;
238
- return send(reply, s, shell(s, 'Object Browser', [['SQL Workshop', `${BASE}/sql`], ['Object Browser']], main, 'sql'));
239
- });
240
-
187
+ // (sprint 75) the Object Browser: object-browser.ts
188
+ await app.register(objectBrowserRoutes);
241
189
  }
package/src/builder/ui.ts CHANGED
@@ -100,6 +100,10 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
100
100
  const rail = (key: Section | 'search' | 'help', href: string, ic: string, label: string, extra: Raw | '' = '') =>
101
101
  html`<li><a href="${href}" class="rail-link" title="${label}"${section === key ? raw(' aria-current="page"') : ''}${extra}>${bicon(ic)}<span class="rail-label">${label}</span></a></li>`;
102
102
  const back = [...crumbs].reverse().find(([, href], i) => i > 0 && href);
103
+ // (sprint 74) the default-password warning: on the App Builder home and the Developers page only, until
104
+ // dismissed (for this sign-in); a dot on the avatar stays until the password is changed
105
+ const weak = !!s.state.__WEAK;
106
+ const weakBanner = weak && !s.state.__WEAK_HIDDEN && crumbs.length === 1 && (crumbs[0][0] === 'App Builder' || crumbs[0][0] === 'Developers');
103
107
  const themeButton = (t: BuilderTheme, ic: string, label: string) =>
104
108
  html`<button name="theme" value="${t}" aria-pressed="${theme === t ? 'true' : 'false'}">${bicon(ic)}<span>${label}</span></button>`;
105
109
  return documentShell(
@@ -143,9 +147,10 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
143
147
  </div></div></details></li>` : ''}
144
148
  ${rail('help', 'https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md', 'help', 'Help', raw(' target="_blank" rel="noopener"'))}
145
149
  <li><details class="menu rail-user">
146
- <summary class="rail-avatar" title="${s.username}"><span aria-hidden="true">${initials(s.username ?? '')}</span><span class="sr-only">Account: ${s.username}</span></summary>
150
+ <summary class="rail-avatar${weak ? ' has-dot' : ''}" title="${weak ? `${s.username}: change the default password` : s.username}"><span aria-hidden="true">${initials(s.username ?? '')}</span><span class="sr-only">Account: ${s.username}${weak ? ' (the default password: change it)' : ''}</span></summary>
147
151
  <div class="menu-panel">
148
152
  <div class="menu-section"><strong>${s.username}</strong><span class="muted small">Developer</span></div>
153
+ ${weak ? html`<div class="menu-section"><a class="weak-note" href="${BASE}/developers">${icon('alert')} You use the default password: change it</a></div>` : ''}
149
154
  ${workspaceMenu(s)}
150
155
  <div class="menu-section"><span class="small muted" id="theme-label">Builder theme</span>
151
156
  <form method="post" action="${BASE}/theme" class="segmented ide-theme" role="group" aria-labelledby="theme-label">${csrf(s)}
@@ -166,8 +171,9 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
166
171
  <div class="ide-tools">${opts.toolbar ?? ''}</div>
167
172
  </header>
168
173
  ${s.workspace?.banner_label ? html`<div class="ide-banner ide-banner-${BANNER_COLORS.includes(s.workspace.banner_color ?? '') ? s.workspace.banner_color : 'blue'}" role="note" aria-label="Environment">${s.workspace.banner_label}</div>` : ''}
169
- ${s.state.__WEAK || ok || err ? html`<div class="ide-alerts">
170
- ${s.state.__WEAK ? html`<div class="alert alert-error" role="alert">You signed in with the default password. <a href="${BASE}/developers">Change it now.</a></div>` : ''}
174
+ ${weakBanner || ok || err ? html`<div class="ide-alerts">
175
+ ${weakBanner ? html`<div class="alert alert-error alert-dismissible" role="alert"><span>You signed in with the default password. <a href="${BASE}/developers">Change it now.</a></span>
176
+ <form method="post" action="${BASE}/weak/dismiss">${csrf(s)}<button class="link-button" aria-label="Dismiss until the next sign-in" title="Dismiss until the next sign-in">${icon('close')}</button></form></div>` : ''}
171
177
  ${ok ? html`<div class="alert alert-success" role="status">${ok}</div>` : ''}
172
178
  ${err ? html`<div class="alert alert-error" role="alert">${err}</div>` : ''}
173
179
  </div>` : ''}
@@ -243,9 +249,18 @@ export type WorkshopTab = 'home' | 'sql' | 'scripts' | 'quicksql' | 'query' | 'o
243
249
  export const workshopTabs = (active: WorkshopTab) => {
244
250
  const tab = (key: WorkshopTab, href: string, ic: string, label: string) =>
245
251
  html`<a class="ide-tab" href="${BASE}${href}"${active === key ? raw(' aria-current="page"') : ''}>${icon(ic)}<span>${label}</span></a>`;
246
- return html`<nav class="ide-tabs u-mb1" aria-label="SQL Workshop">
247
- ${tab('home', '/sql', 'home', 'Home')}${tab('sql', '/sql/commands', 'code', 'SQL Commands')}${tab('scripts', '/sql/scripts', 'file', 'SQL Scripts')}${tab('quicksql', '/sql/quick', 'bolt', 'Quick SQL')}
248
- ${tab('query', '/sql/query', 'filter', 'Query Builder')}${tab('objects', '/sql/objects', 'database', 'Object Browser')}${tab('load', '/sql/load', 'upload', 'Load Data')}${tab('unload', '/sql/unload', 'download', 'Unload Data')}${tab('sample', '/sql/sample-data', 'layers', 'Sample Data')}${tab('ddl', '/sql/ddl', 'file', 'DDL')}${tab('compare', '/sql/compare', 'layers', 'Compare')}${tab('ai', '/sql/ai', 'bolt', 'AI')}${tab('utilities', '/sql/utilities', 'wrench', 'Utilities')}</nav>`;
252
+ // (sprint 75) the main tools as tabs, the others in a More menu (the open one is a tab too), so the strip never scrolls
253
+ const more: [WorkshopTab, string, string, string][] = [
254
+ ['quicksql', '/sql/quick', 'bolt', 'Quick SQL'], ['query', '/sql/query', 'filter', 'Query Builder'], ['load', '/sql/load', 'upload', 'Load Data'],
255
+ ['unload', '/sql/unload', 'download', 'Unload Data'], ['sample', '/sql/sample-data', 'layers', 'Sample Data'], ['ddl', '/sql/ddl', 'file', 'DDL'],
256
+ ['compare', '/sql/compare', 'layers', 'Compare'], ['ai', '/sql/ai', 'bolt', 'AI'],
257
+ ];
258
+ const open = more.find(([k]) => k === active);
259
+ return html`<nav class="ide-tabs sw-tabs u-mb1" aria-label="SQL Workshop">
260
+ ${tab('home', '/sql', 'home', 'Home')}${tab('objects', '/sql/objects', 'database', 'Object Browser')}${tab('sql', '/sql/commands', 'code', 'SQL Commands')}${tab('scripts', '/sql/scripts', 'file', 'SQL Scripts')}${tab('utilities', '/sql/utilities', 'wrench', 'Utilities')}
261
+ ${open ? tab(...open) : ''}
262
+ <details class="menu sw-more"><summary class="ide-tab">${icon('chevron-down')}<span>More</span></summary>
263
+ <div class="menu-panel align-right"><div class="menu-section menu-links">${more.map(([k, href, ic, label]) => html`<a href="${BASE}${href}"${k === active ? raw(' aria-current="page"') : ''}>${icon(ic)} ${label}</a>`)}</div></div></details></nav>`;
249
264
  };
250
265
 
251
266
  /** Number of app tile colours (.app-color-0 … -7 in app.css). */
package/src/i18n/ar.ts CHANGED
@@ -111,6 +111,21 @@ export const ar: 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': "حفظ",
@@ -154,6 +169,7 @@ export const ar: Record<MessageKey, string> = {
154
169
  'pwa.status.invalid': "مرفوض: افتح الصفحة وصحّحه",
155
170
  'pwa.status.error': "لم يُرسل",
156
171
  'item.locate': "استخدام موقعي",
172
+ 'item.choose_date': "اختيار تاريخ",
157
173
  'item.show_map': "خريطة",
158
174
  'item.scan': "مسح",
159
175
  'item.locate_error': "الموقع غير متاح.",
@@ -374,6 +390,8 @@ export const ar: Record<MessageKey, string> = {
374
390
  'facets.stars_up': "{n} نجوم فأكثر",
375
391
  'facets.star_up': "نجمة واحدة فأكثر",
376
392
  'facets.clear_short': "مسح",
393
+ 'facets.show_all': "عرض الكل ({n})",
394
+ 'facets.total': "إجمالي عدد الصفوف {n}",
377
395
  'smart.placeholder': "بحث أو تصفية…",
378
396
  'smart.active': "عوامل التصفية المستخدمة",
379
397
  'smart.remove': "إزالة عامل التصفية {filter}",