pgkiln 0.70.0 → 0.71.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 (46) hide show
  1. package/db/migrations/137_create_page_types.sql +904 -0
  2. package/db/migrations/138_app_utilities.sql +153 -0
  3. package/docs/apex-feature-parity.md +6 -5
  4. package/docs/guide/03-builder.md +107 -4
  5. package/docs/guide/04-pages-and-regions.md +3 -1
  6. package/docs/guide/05-items.md +1 -1
  7. package/docs/guide/06-processing.md +18 -0
  8. package/docs/guide/09-reference.md +15 -1
  9. package/docs/guide/12-development.md +22 -1
  10. package/docs/guide/16-files.md +1 -1
  11. package/examples/hr/hr_73_configuration_options.sql +17 -0
  12. package/examples/hr/hr_74_app_utilities.sql +22 -0
  13. package/package.json +1 -1
  14. package/public/app.css +9 -0
  15. package/public/builder.css +45 -0
  16. package/src/app.ts +10 -0
  17. package/src/builder/advisor.ts +2 -2
  18. package/src/builder/app-updates.ts +3 -0
  19. package/src/builder/components.ts +3 -1
  20. package/src/builder/create-page.ts +259 -0
  21. package/src/builder/dependencies.ts +311 -0
  22. package/src/builder/features.ts +313 -0
  23. package/src/builder/home.ts +2 -0
  24. package/src/builder/i18n/de.ts +4 -0
  25. package/src/builder/i18n/en.ts +4 -0
  26. package/src/builder/i18n/es.ts +4 -0
  27. package/src/builder/i18n/fr.ts +4 -0
  28. package/src/builder/i18n/it.ts +4 -0
  29. package/src/builder/i18n/ja.ts +4 -0
  30. package/src/builder/i18n/ko.ts +4 -0
  31. package/src/builder/i18n/nl.ts +4 -0
  32. package/src/builder/i18n/pt.ts +4 -0
  33. package/src/builder/i18n/zh-cn.ts +4 -0
  34. package/src/builder/i18n/zh-tw.ts +4 -0
  35. package/src/builder/login-page.ts +17 -14
  36. package/src/builder/newapp.ts +41 -23
  37. package/src/builder/routes.ts +13 -2
  38. package/src/builder/ui-defaults.ts +161 -0
  39. package/src/builder/ui.ts +2 -1
  40. package/src/builder/upgrade.ts +154 -0
  41. package/src/builder/utilities.ts +181 -0
  42. package/src/builder/wizards.ts +211 -26
  43. package/src/cli/replace.ts +9 -1
  44. package/src/history.ts +155 -0
  45. package/src/runtime/builtin-components.ts +23 -2
  46. package/src/runtime/items.ts +4 -2
@@ -285,6 +285,7 @@ export const de: BuilderMessages = {
285
285
  'app.tab.api': 'REST-API',
286
286
  'app.tab.settings': 'Einstellungen',
287
287
  'app.tab.advisor': 'Advisor',
288
+ 'app.tab.utilities': 'Dienstprogramme',
288
289
  'app.lock_refused': 'Änderungen werden abgelehnt, bis die Sperre aufgehoben ist.',
289
290
  'app.search_pages': 'Seiten durchsuchen',
290
291
  'app.search_placeholder': 'Seitenname oder -nummer',
@@ -318,6 +319,9 @@ export const de: BuilderMessages = {
318
319
  'app.table_or_view': 'Tabelle oder View (Mastertabelle für Master-Detail)',
319
320
  'app.no_access': '{table} (kein Zugriff für {role})',
320
321
  'app.ai_pages': 'Seiten mit KI erstellen',
322
+ 'app.all_page_types': "Alle Seitentypen, Funktionen und Kopien",
323
+ 'app.task.add_feature': "Funktionsseite hinzufügen",
324
+ 'app.task.copy_page': "Seite als Kopie erstellen",
321
325
  'app.ai_pages_note': '(beschreiben Sie sie mit eigenen Worten)',
322
326
  'app.blank': 'Leere Seite erstellen',
323
327
  'app.page_number': 'Seitennummer',
@@ -288,6 +288,7 @@ export const en = {
288
288
  'app.tab.api': 'REST API',
289
289
  'app.tab.settings': 'Settings',
290
290
  'app.tab.advisor': 'Advisor',
291
+ 'app.tab.utilities': 'Utilities',
291
292
  'app.lock_refused': 'Changes are refused until it is unlocked.',
292
293
  'app.search_pages': 'Search pages',
293
294
  'app.search_placeholder': 'Page name or number',
@@ -321,6 +322,9 @@ export const en = {
321
322
  'app.table_or_view': 'Table or view (master table for master detail)',
322
323
  'app.no_access': '{table} (no access for {role})',
323
324
  'app.ai_pages': 'Create pages with AI',
325
+ 'app.all_page_types': "All page types, features and copies",
326
+ 'app.task.add_feature': "Add feature page",
327
+ 'app.task.copy_page': "Create page as copy",
324
328
  'app.ai_pages_note': '(describe them in your own words)',
325
329
  'app.blank': 'Create blank page',
326
330
  'app.page_number': 'Page number',
@@ -285,6 +285,7 @@ export const es: BuilderMessages = {
285
285
  'app.tab.api': 'API REST',
286
286
  'app.tab.settings': 'Configuración',
287
287
  'app.tab.advisor': 'Asesor',
288
+ 'app.tab.utilities': 'Utilidades',
288
289
  'app.lock_refused': 'Los cambios se rechazan hasta que se desbloquee.',
289
290
  'app.search_pages': 'Buscar páginas',
290
291
  'app.search_placeholder': 'Nombre o número de página',
@@ -318,6 +319,9 @@ export const es: BuilderMessages = {
318
319
  'app.table_or_view': 'Tabla o vista (tabla maestra para maestro-detalle)',
319
320
  'app.no_access': '{table} (sin acceso para {role})',
320
321
  'app.ai_pages': 'Crear páginas con IA',
322
+ 'app.all_page_types': "Todos los tipos de página, funciones y copias",
323
+ 'app.task.add_feature': "Agregar página de función",
324
+ 'app.task.copy_page': "Crear página como copia",
321
325
  'app.ai_pages_note': '(descríbalas con sus propias palabras)',
322
326
  'app.blank': 'Crear página en blanco',
323
327
  'app.page_number': 'Número de página',
@@ -285,6 +285,7 @@ export const fr: BuilderMessages = {
285
285
  'app.tab.api': 'API REST',
286
286
  'app.tab.settings': 'Paramètres',
287
287
  'app.tab.advisor': 'Conseiller',
288
+ 'app.tab.utilities': 'Utilitaires',
288
289
  'app.lock_refused': "Les modifications sont refusées jusqu'à son déverrouillage.",
289
290
  'app.search_pages': 'Rechercher des pages',
290
291
  'app.search_placeholder': 'Nom ou numéro de page',
@@ -318,6 +319,9 @@ export const fr: BuilderMessages = {
318
319
  'app.table_or_view': 'Table ou vue (table maître pour maître-détail)',
319
320
  'app.no_access': '{table} (aucun accès pour {role})',
320
321
  'app.ai_pages': "Créer des pages avec l'IA",
322
+ 'app.all_page_types': "Tous les types de page, fonctionnalités et copies",
323
+ 'app.task.add_feature': "Ajouter une page de fonctionnalité",
324
+ 'app.task.copy_page': "Créer une page par copie",
321
325
  'app.ai_pages_note': '(décrivez-les avec vos propres mots)',
322
326
  'app.blank': 'Créer une page vierge',
323
327
  'app.page_number': 'Numéro de page',
@@ -285,6 +285,7 @@ export const it: BuilderMessages = {
285
285
  'app.tab.api': 'API REST',
286
286
  'app.tab.settings': 'Impostazioni',
287
287
  'app.tab.advisor': 'Advisor',
288
+ 'app.tab.utilities': 'Utilità',
288
289
  'app.lock_refused': 'Le modifiche vengono rifiutate finché non viene sbloccata.',
289
290
  'app.search_pages': 'Cerca pagine',
290
291
  'app.search_placeholder': 'Nome o numero della pagina',
@@ -318,6 +319,9 @@ export const it: BuilderMessages = {
318
319
  'app.table_or_view': 'Tabella o vista (tabella master per master-dettaglio)',
319
320
  'app.no_access': '{table} (nessun accesso per {role})',
320
321
  'app.ai_pages': 'Crea pagine con l’AI',
322
+ 'app.all_page_types': "Tutti i tipi di pagina, funzionalità e copie",
323
+ 'app.task.add_feature': "Aggiungi pagina di funzionalità",
324
+ 'app.task.copy_page': "Crea pagina come copia",
321
325
  'app.ai_pages_note': '(descriverle con parole proprie)',
322
326
  'app.blank': 'Crea pagina vuota',
323
327
  'app.page_number': 'Numero di pagina',
@@ -285,6 +285,7 @@ export const ja: BuilderMessages = {
285
285
  'app.tab.api': 'REST API',
286
286
  'app.tab.settings': '設定',
287
287
  'app.tab.advisor': 'アドバイザ',
288
+ 'app.tab.utilities': 'ユーティリティ',
288
289
  'app.lock_refused': 'ロックが解除されるまで、変更は拒否されます。',
289
290
  'app.search_pages': 'ページの検索',
290
291
  'app.search_placeholder': 'ページ名または番号',
@@ -318,6 +319,9 @@ export const ja: BuilderMessages = {
318
319
  'app.table_or_view': '表またはビュー(マスター詳細の場合はマスター表)',
319
320
  'app.no_access': '{table}({role} にはアクセス権なし)',
320
321
  'app.ai_pages': 'AI でページを作成',
322
+ 'app.all_page_types': "すべてのページタイプ、機能、コピー",
323
+ 'app.task.add_feature': "機能ページを追加",
324
+ 'app.task.copy_page': "コピーとしてページを作成",
321
325
  'app.ai_pages_note': '(自分の言葉で説明してください)',
322
326
  'app.blank': '空白ページの作成',
323
327
  'app.page_number': 'ページ番号',
@@ -285,6 +285,7 @@ export const ko: BuilderMessages = {
285
285
  'app.tab.api': 'REST API',
286
286
  'app.tab.settings': '설정',
287
287
  'app.tab.advisor': '어드바이저',
288
+ 'app.tab.utilities': '유틸리티',
288
289
  'app.lock_refused': '잠금이 해제될 때까지 변경이 거부됩니다.',
289
290
  'app.search_pages': '페이지 검색',
290
291
  'app.search_placeholder': '페이지 이름 또는 번호',
@@ -318,6 +319,9 @@ export const ko: BuilderMessages = {
318
319
  'app.table_or_view': '테이블 또는 뷰(마스터 세부의 경우 마스터 테이블)',
319
320
  'app.no_access': '{table}({role}에 대한 액세스 권한 없음)',
320
321
  'app.ai_pages': 'AI로 페이지 생성',
322
+ 'app.all_page_types': "모든 페이지 유형, 기능 및 복사",
323
+ 'app.task.add_feature': "기능 페이지 추가",
324
+ 'app.task.copy_page': "복사하여 페이지 생성",
321
325
  'app.ai_pages_note': '(원하는 내용을 직접 설명하세요)',
322
326
  'app.blank': '빈 페이지 생성',
323
327
  'app.page_number': '페이지 번호',
@@ -285,6 +285,7 @@ export const nl: BuilderMessages = {
285
285
  'app.tab.api': 'REST-API',
286
286
  'app.tab.settings': 'Instellingen',
287
287
  'app.tab.advisor': 'Adviseur',
288
+ 'app.tab.utilities': 'Hulpmiddelen',
288
289
  'app.lock_refused': 'Wijzigingen worden geweigerd tot de vergrendeling is opgeheven.',
289
290
  'app.search_pages': "Pagina's zoeken",
290
291
  'app.search_placeholder': 'Paginanaam of -nummer',
@@ -318,6 +319,9 @@ export const nl: BuilderMessages = {
318
319
  'app.table_or_view': 'Tabel of view (mastertabel voor master-detail)',
319
320
  'app.no_access': '{table} (geen toegang voor {role})',
320
321
  'app.ai_pages': "Pagina's maken met AI",
322
+ 'app.all_page_types': "Alle paginatypen, functies en kopieën",
323
+ 'app.task.add_feature': "Functiepagina toevoegen",
324
+ 'app.task.copy_page': "Pagina als kopie maken",
321
325
  'app.ai_pages_note': '(beschrijf ze in je eigen woorden)',
322
326
  'app.blank': 'Lege pagina maken',
323
327
  'app.page_number': 'Paginanummer',
@@ -285,6 +285,7 @@ export const pt: BuilderMessages = {
285
285
  'app.tab.api': 'API REST',
286
286
  'app.tab.settings': 'Configurações',
287
287
  'app.tab.advisor': 'Consultor',
288
+ 'app.tab.utilities': 'Utilitários',
288
289
  'app.lock_refused': 'As alterações são recusadas até que seja desbloqueado.',
289
290
  'app.search_pages': 'Pesquisar páginas',
290
291
  'app.search_placeholder': 'Nome ou número da página',
@@ -318,6 +319,9 @@ export const pt: BuilderMessages = {
318
319
  'app.table_or_view': 'Tabela ou view (tabela mestre para mestre-detalhe)',
319
320
  'app.no_access': '{table} (sem acesso para {role})',
320
321
  'app.ai_pages': 'Criar páginas com IA',
322
+ 'app.all_page_types': "Todos os tipos de página, recursos e cópias",
323
+ 'app.task.add_feature': "Adicionar página de recurso",
324
+ 'app.task.copy_page': "Criar página como cópia",
321
325
  'app.ai_pages_note': '(descreva-as com suas próprias palavras)',
322
326
  'app.blank': 'Criar página em branco',
323
327
  'app.page_number': 'Número da página',
@@ -285,6 +285,7 @@ export const zhCN: BuilderMessages = {
285
285
  'app.tab.api': 'REST API',
286
286
  'app.tab.settings': '设置',
287
287
  'app.tab.advisor': '顾问',
288
+ 'app.tab.utilities': '实用工具',
288
289
  'app.lock_refused': '解锁前将拒绝所有更改。',
289
290
  'app.search_pages': '搜索页面',
290
291
  'app.search_placeholder': '页面名称或编号',
@@ -318,6 +319,9 @@ export const zhCN: BuilderMessages = {
318
319
  'app.table_or_view': '表或视图(主从页面的主表)',
319
320
  'app.no_access': '{table}({role} 无访问权限)',
320
321
  'app.ai_pages': '使用 AI 创建页面',
322
+ 'app.all_page_types': "所有页面类型、功能和副本",
323
+ 'app.task.add_feature': "添加功能页",
324
+ 'app.task.copy_page': "以副本方式创建页面",
321
325
  'app.ai_pages_note': '(用您自己的话描述它们)',
322
326
  'app.blank': '创建空白页面',
323
327
  'app.page_number': '页码',
@@ -285,6 +285,7 @@ export const zhTW: BuilderMessages = {
285
285
  'app.tab.api': 'REST API',
286
286
  'app.tab.settings': '設定',
287
287
  'app.tab.advisor': '顧問',
288
+ 'app.tab.utilities': '公用程式',
288
289
  'app.lock_refused': '在解除鎖定前,所有變更都會被拒絕。',
289
290
  'app.search_pages': '搜尋頁面',
290
291
  'app.search_placeholder': '頁面名稱或編號',
@@ -318,6 +319,9 @@ export const zhTW: BuilderMessages = {
318
319
  'app.table_or_view': '表格或檢視(主從式的主表格)',
319
320
  'app.no_access': '{table}({role} 無存取權)',
320
321
  'app.ai_pages': '以 AI 建立頁面',
322
+ 'app.all_page_types': "所有頁面類型、功能與副本",
323
+ 'app.task.add_feature': "新增功能頁",
324
+ 'app.task.copy_page': "以副本建立頁面",
321
325
  'app.ai_pages_note': '(用您自己的話描述)',
322
326
  'app.blank': '建立空白頁面',
323
327
  'app.page_number': '頁面編號',
@@ -1,5 +1,5 @@
1
1
  import type { FastifyInstance } from 'fastify';
2
- import { owner } from '../db.ts';
2
+ import { owner, type Client } from '../db.ts';
3
3
  import { APP_MFA, EMAIL_CODE_MINUTES, EMAIL_CODES_PER_HOUR, type AppMfa } from '../account-mfa.ts';
4
4
  import { html, raw } from '../html.ts';
5
5
  import { secretKeyConfigured } from '../secrets.ts';
@@ -78,6 +78,21 @@ export async function saveLoginSettings(appId: string, b: Record<string, string
78
78
  [appId, page, b.password_reset === 'true', mfa, roles, b.mfa_email === 'true']);
79
79
  }
80
80
 
81
+ /** Page 9999 (or the next free number), public, minimal, with a login region, made the login page. (137) Also Add feature → Login page. */
82
+ export async function createLoginPage(c: Client, a: { id: number; name: string }) {
83
+ const n = (await c.query<{ n: number }>(
84
+ `select case when not exists (select 1 from meta.page where app_id = $1 and page_no = 9999) then 9999
85
+ else (select max(page_no) + 1 from meta.page where app_id = $1) end as n`, [a.id],
86
+ )).rows[0].n;
87
+ const id = (await c.query<{ id: number }>(
88
+ `insert into meta.page (app_id, page_no, name, title, requires_auth, page_template) values ($1, $2, 'Login', $3, false, 'minimal') returning id`,
89
+ [a.id, n, a.name],
90
+ )).rows[0].id;
91
+ await c.query(`insert into meta.region (page_id, seq, title, type, template) values ($1, 10, $2, 'login', 'standard')`, [id, a.name]);
92
+ await c.query('update meta.app set login_page = $2, updated_at = now() where id = $1', [a.id, n]);
93
+ return { page: id, no: n };
94
+ }
95
+
81
96
  export async function loginPageRoutes(app: FastifyInstance) {
82
97
  // Create a login page: page 9999 (or the next free number), public, minimal, with a login region
83
98
  app.post(`${BASE}/apps/:id/login-page`, async (req: Req, reply) => {
@@ -86,19 +101,7 @@ export async function loginPageRoutes(app: FastifyInstance) {
86
101
  const a = await owner.one<{ id: number; name: string }>('select id, name from meta.app where id = $1', [req.params.id]);
87
102
  if (!a) return reply.code(404).send('Not found');
88
103
  try {
89
- const { page, no } = await owner.tx(async (c) => {
90
- const n = (await c.query<{ n: number }>(
91
- `select case when not exists (select 1 from meta.page where app_id = $1 and page_no = 9999) then 9999
92
- else (select max(page_no) + 1 from meta.page where app_id = $1) end as n`, [a.id],
93
- )).rows[0].n;
94
- const id = (await c.query<{ id: number }>(
95
- `insert into meta.page (app_id, page_no, name, title, requires_auth, page_template) values ($1, $2, 'Login', $3, false, 'minimal') returning id`,
96
- [a.id, n, a.name],
97
- )).rows[0].id;
98
- await c.query(`insert into meta.region (page_id, seq, title, type, template) values ($1, 10, $2, 'login', 'standard')`, [id, a.name]);
99
- await c.query('update meta.app set login_page = $2, updated_at = now() where id = $1', [a.id, n]);
100
- return { page: id, no: n };
101
- });
104
+ const { page, no } = await owner.tx((c) => createLoginPage(c, a));
102
105
  flash(s, `Page ${no} "Login" created and made the login page. Add regions around the sign-in form in the Page Designer.`);
103
106
  return back(reply, s, `${BASE}/pages/${page}`);
104
107
  } catch (e) {
@@ -71,34 +71,52 @@ export const featuresFrom = (b: Record<string, unknown>, posted: boolean): Featu
71
71
  /** (sprint 73) The Activity page of a generated application. */
72
72
  export const ACTIVITY_PAGE = 9010;
73
73
 
74
- async function applyFeatures(c: Client, appId: number, f: Partial<Features>, notes: string[]) {
75
- if (f.access) {
76
- await c.query(
77
- `insert into meta.authz_scheme (app_id, name, type, value, error_message) values
78
- ($1, 'CONTRIBUTOR', 'sql', 'meta.has_role(''admin'') or meta.has_role(''contributor'')', 'Only contributors can do this.'),
79
- ($1, 'READER', 'sql', 'meta.has_role(''admin'') or meta.has_role(''contributor'') or meta.has_role(''reader'')', 'Only readers can open this page.')
80
- on conflict do nothing`,
81
- [appId],
82
- );
83
- }
84
- if (f.activity && !(await c.query('select 1 from meta.page where app_id = $1 and page_no = $2', [appId, ACTIVITY_PAGE])).rowCount) {
85
- const home = (await c.query('select home_page from meta.app where id = $1', [appId])).rows[0].home_page;
86
- const page = (await c.query(`insert into meta.page (app_id, page_no, name, title, parent_page, authz) values ($1, $2, 'Activity', 'Activity', $3, 'ADMIN') returning id`, [appId, ACTIVITY_PAGE, home])).rows[0].id;
87
- await c.query(
88
- `insert into meta.region (page_id, seq, title, type, source, config) values
89
- ($1, 10, 'Page views per day', 'chart', $2, '{"kind": "column"}'),
90
- ($1, 20, 'Latest activity', 'report', $3, '{"headings": {"at": "Time", "username": "User", "event": "Event", "page_no": "Page", "elapsed_ms": "Time taken (ms)"}}')`,
91
- [page,
92
- `select to_char(date_trunc('day', at), 'YYYY-MM-DD') as "Day", count(*) as "Page views"
74
+ /** (sprint 73; exported in 137 for Add feature) The authorization schemes CONTRIBUTOR and READER (and ADMIN when missing). */
75
+ export async function addAccessSchemes(c: Client, appId: number) {
76
+ await ensureAdminScheme(c, appId);
77
+ await c.query(
78
+ `insert into meta.authz_scheme (app_id, name, type, value, error_message) values
79
+ ($1, 'CONTRIBUTOR', 'sql', 'meta.has_role(''admin'') or meta.has_role(''contributor'')', 'Only contributors can do this.'),
80
+ ($1, 'READER', 'sql', 'meta.has_role(''admin'') or meta.has_role(''contributor'') or meta.has_role(''reader'')', 'Only readers can open this page.')
81
+ on conflict do nothing`,
82
+ [appId],
83
+ );
84
+ }
85
+
86
+ /** (137) The authorization scheme ADMIN (role admin) that administrators' pages use, unless the application has one. */
87
+ export async function ensureAdminScheme(c: Client, appId: number) {
88
+ await c.query(
89
+ `insert into meta.authz_scheme (app_id, name, type, value, error_message) values ($1, 'ADMIN', 'role', 'admin', 'Only administrators can access this page.')
90
+ on conflict do nothing`,
91
+ [appId],
92
+ );
93
+ }
94
+
95
+ /** (sprint 73; exported in 137 for Add feature) The Activity page for administrators and its navigation entry; nothing when the page number is taken. */
96
+ export async function addActivityPage(c: Client, appId: number) {
97
+ if ((await c.query('select 1 from meta.page where app_id = $1 and page_no = $2', [appId, ACTIVITY_PAGE])).rowCount) return;
98
+ await ensureAdminScheme(c, appId);
99
+ const home = (await c.query('select home_page from meta.app where id = $1', [appId])).rows[0].home_page;
100
+ const page = (await c.query(`insert into meta.page (app_id, page_no, name, title, parent_page, authz) values ($1, $2, 'Activity', 'Activity', $3, 'ADMIN') returning id`, [appId, ACTIVITY_PAGE, home])).rows[0].id;
101
+ await c.query(
102
+ `insert into meta.region (page_id, seq, title, type, source, config) values
103
+ ($1, 10, 'Page views per day', 'chart', $2, '{"kind": "column"}'),
104
+ ($1, 20, 'Latest activity', 'report', $3, '{"headings": {"at": "Time", "username": "User", "event": "Event", "page_no": "Page", "elapsed_ms": "Time taken (ms)"}}')`,
105
+ [page,
106
+ `select to_char(date_trunc('day', at), 'YYYY-MM-DD') as "Day", count(*) as "Page views"
93
107
  from meta.app_activity(30)
94
108
  where event = 'page_view'
95
109
  group by 1
96
110
  order by 1`,
97
- `select at, username, event, page_no, elapsed_ms
111
+ `select at, username, event, page_no, elapsed_ms
98
112
  from meta.app_activity(30)`],
99
- );
100
- await c.query(`insert into meta.nav_entry (app_id, seq, label, icon, target_page, authz) values ($1, 9010, 'Activity', 'activity', $2, 'ADMIN')`, [appId, ACTIVITY_PAGE]);
101
- }
113
+ );
114
+ await c.query(`insert into meta.nav_entry (app_id, seq, label, icon, target_page, authz) values ($1, 9010, 'Activity', 'activity', $2, 'ADMIN')`, [appId, ACTIVITY_PAGE]);
115
+ }
116
+
117
+ async function applyFeatures(c: Client, appId: number, f: Partial<Features>, notes: string[]) {
118
+ if (f.access) await addAccessSchemes(c, appId);
119
+ if (f.activity) await addActivityPage(c, appId);
102
120
  if (f.feedback) await c.query('update meta.app set feedback = true where id = $1', [appId]);
103
121
  if (f.theme === false) await c.query(`update meta.app set theme = theme || '{"user_choice": false}' where id = $1`, [appId]);
104
122
  if (f.pwa || f.push) await c.query('update meta.app set pwa = true where id = $1', [appId]);
@@ -1,4 +1,5 @@
1
1
  import { advise } from './advisor.ts';
2
+ import { appChecksum } from '../history.ts';
2
3
  import { appLocks, isAdmin, lockPanel, lockText } from './locks.ts';
3
4
  import { parseRoleList, validRoleName } from '../dbauth.ts';
4
5
  import { DEFAULT_HEADER, headerProxiesConfigured } from '../headerauth.ts';
@@ -23,6 +24,8 @@ import { securityRoutes } from './security.ts';
23
24
  import { instanceRoutes } from './instance.ts';
24
25
  import { saveTimeZoneSettings, timeZoneSettings } from './globalization.ts';
25
26
  import { WIZARD_KINDS, wizardRoutes, wizardTables } from './wizards.ts';
27
+ import { createPageRoutes } from './create-page.ts';
28
+ import { featureRoutes } from './features.ts';
26
29
  import { appFromFileRoutes } from './appfromfile.ts';
27
30
  import { clearDevbar } from '../runtime/devbar.ts';
28
31
  import { EMAIL } from './find-workspace.ts';
@@ -564,6 +567,9 @@ export async function builderRoutes(app: FastifyInstance) {
564
567
  await securityRoutes(app);
565
568
  await builderSsoRoutes(app);
566
569
  await wizardRoutes(app);
570
+ // (137) Create page: the grid of page types, features and copy (create-page.ts, features.ts)
571
+ await createPageRoutes(app);
572
+ await featureRoutes(app);
567
573
  await appFromFileRoutes(app);
568
574
 
569
575
  // (sprint 73) POST /apps, creating an application, is in appwizard.ts
@@ -653,7 +659,7 @@ export async function builderRoutes(app: FastifyInstance) {
653
659
  <button class="btn">${icon('search')}<span class="sr-only">${tx('common.search')}</span></button></form>
654
660
  <div class="seg view-toggle" role="group" aria-label="${tx('ab.view')}">
655
661
  <a href="${keep({})}"${icons ? '' : raw(' aria-current="true"')}>${icon('list')} ${tx('ab.view_report')}</a><a href="${keep({ view: 'icons' })}"${icons ? raw(' aria-current="true"') : ''}>${icon('grid')} ${tx('app.view_icons')}</a></div>
656
- <a class="btn btn-hot" href="#create-page">${icon('plus')} ${tx('app.create_page')}</a>
662
+ <a class="btn btn-hot" href="${BASE}/apps/${a.id}/create-page">${icon('plus')} ${tx('app.create_page')}</a>
657
663
  </div>
658
664
  ${region(tx('app.tab.pages'), html`${grouped
659
665
  ? html`<form method="get" class="search u-mwnone u-mb1"><div class="field"><label class="label" for="f_group">${tx('app.page_group')}</label>
@@ -681,6 +687,7 @@ export async function builderRoutes(app: FastifyInstance) {
681
687
  </div>
682
688
  <div class="buttons"><button class="btn btn-hot">${tx('common.next')}</button></div>
683
689
  </form>
690
+ <p><a href="${BASE}/apps/${a.id}/create-page">${tx('app.all_page_types')} ▸</a></p>
684
691
  <p><a href="${BASE}/apps/${a.id}/ai-pages">${tx('app.ai_pages')} ▸</a> <span class="muted">${tx('app.ai_pages_note')}</span></p>`)}
685
692
  ${region(tx('app.blank'), html`
686
693
  <form method="post" action="${BASE}/apps/${a.id}/pages">${csrf(s)}
@@ -697,7 +704,9 @@ export async function builderRoutes(app: FastifyInstance) {
697
704
  <aside class="app-tasks region region-standard" aria-labelledby="app-tasks-h"><header class="region-header"><h2 id="app-tasks-h">${tx('common.tasks')}</h2></header>
698
705
  <ul class="sc-links">
699
706
  <li><a href="/a/${a.alias}" target="_blank" rel="noopener">${icon('play')} ${tx('app.task.run')}</a></li>
700
- <li><a href="#create-page">${icon('plus')} ${tx('app.create_page')}</a></li>
707
+ <li><a href="${BASE}/apps/${a.id}/create-page">${icon('plus')} ${tx('app.create_page')}</a></li>
708
+ <li><a href="${BASE}/apps/${a.id}/create-page?tab=feature">${icon('puzzle')} ${tx('app.task.add_feature')}</a></li>
709
+ <li><a href="${BASE}/apps/${a.id}/create-page?tab=copy">${icon('copy')} ${tx('app.task.copy_page')}</a></li>
701
710
  <li><a href="${BASE}/apps/${a.id}/ai-pages">${icon('lightbulb')} ${tx('app.ai_pages')}</a></li>
702
711
  <li><a href="${BASE}/apps/${a.id}/shared">${icon('layers')} ${tx('app.tab.shared')}</a></li>
703
712
  <li><a href="${BASE}/apps/${a.id}/shared?type=page_group">${icon('folder')} ${tx('app.task.page_groups')}</a></li>
@@ -743,6 +752,8 @@ export async function builderRoutes(app: FastifyInstance) {
743
752
  const a = await appOr404(req.params.id);
744
753
  if (!a) return reply.code(404).send('Not found');
745
754
  const r = await owner.one('select meta.export_app($1) as doc', [a.alias]);
755
+ // (sprint 138) the application checksum (src/history.ts), as on Utilities
756
+ reply.header('x-pgkiln-checksum', appChecksum(r.doc));
746
757
  // (sprint 106) the change log marks the change sets made before an application's latest export
747
758
  await logActivity({ appId: a.id, username: s.username, event: 'app_export', ip: clientIp(req), detail: String(req.query?.format ?? 'json') });
748
759
  // ?format=dir|text: one file per component, as `pgkiln export --format dir|text` writes it (docs/guide/18-cli.md)
@@ -0,0 +1,161 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { owner } from '../db.ts';
3
+ import { html, raw } from '../html.ts';
4
+ import { icon } from '../icons.ts';
5
+ import { back, BASE, csrf, developer, flash, region, send, shell, type Body, type Req } from './ui.ts';
6
+ import { usableSchemas, workshopDb } from './workspaces.ts';
7
+
8
+ // (sprint 138, AP-5) UI defaults (APEX: Attribute Dictionary / Table UI
9
+ // Defaults): per table column a label, format mask, help text, alignment and
10
+ // list of values, in meta.ui_default (migration 138). The page wizards and the
11
+ // Create Application wizard take the label from meta.wizard_column_label (a UI
12
+ // default wins); a form item a wizard makes also gets the mask, help and list
13
+ // of values (the meta.item trigger of migration 138). "Seed from comments" fills
14
+ // the columns without a UI default from their COMMENT ON: a short comment (60
15
+ // characters at most) as the label, a longer one as the help text.
16
+ //
17
+ // The tables are those the workspace's SQL Workshop connection can see
18
+ // (workshopDb: an isolated workspace's own schemas); a table or column name
19
+ // from a request is only used after it was found in that catalog.
20
+
21
+ export const ALIGNMENTS = ['', 'start', 'center', 'end'] as const;
22
+ /** Format masks: numbers (999G990D00, FML…) and dates (DD.MM.YYYY HH24:MI); the database checks the same. */
23
+ export const MASK = /^[A-Za-z0-9GDLSCFM.,:/ -]{1,60}$/;
24
+ const MAX_COLUMNS = 400;
25
+
26
+ interface Column {
27
+ name: string;
28
+ type: string;
29
+ comment: string | null;
30
+ wizard: string;
31
+ d: { label: string | null; format_mask: string | null; help: string | null; alignment: string | null; lov: string | null; updated_by: string | null; updated_at: string | null } | null;
32
+ }
33
+
34
+ /** A table the session's catalog can see: [schema, table], or null. */
35
+ async function findTable(s: Parameters<typeof workshopDb>[0], schema: unknown, table: unknown) {
36
+ if (typeof schema !== 'string' || typeof table !== 'string' || !schema || !table) return null;
37
+ const db = await workshopDb(s);
38
+ if (!(await usableSchemas(db)).includes(schema)) return null;
39
+ const r = await db.one<{ oid: number }>(
40
+ `select c.oid::int as oid from pg_class c join pg_namespace n on n.oid = c.relnamespace
41
+ where n.nspname = $1 and c.relname = $2 and c.relkind in ('r', 'p', 'v', 'm', 'f')`, [schema, table]);
42
+ return r ? { schema, table, oid: r.oid } : null;
43
+ }
44
+
45
+ async function columnsOf(oid: number, schema: string, table: string): Promise<Column[]> {
46
+ const cols = (await owner.query(
47
+ `select a.attname as name, format_type(a.atttypid, a.atttypmod) as type, col_description(a.attrelid, a.attnum) as comment,
48
+ meta.wizard_column_label(a.attrelid::regclass, a.attname) as wizard
49
+ from pg_attribute a where a.attrelid = $1 and a.attnum > 0 and not a.attisdropped order by a.attnum limit ${MAX_COLUMNS}`, [oid])).rows;
50
+ const defaults = new Map((await owner.query(
51
+ `select column_name, label, format_mask, help, alignment, lov, updated_by, updated_at::text from meta.ui_default where schema_name = $1 and table_name = $2`,
52
+ [schema, table])).rows.map((r) => [r.column_name, r]));
53
+ return cols.map((c) => ({ ...c, d: defaults.get(c.name) ?? null }));
54
+ }
55
+
56
+ const trim = (v: unknown, max: number) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);
57
+
58
+ export async function uiDefaultRoutes(app: FastifyInstance) {
59
+ app.get(`${BASE}/ui-defaults`, async (req: Req, reply) => {
60
+ const s = await developer(req, reply);
61
+ if (!s) return;
62
+ const db = await workshopDb(s);
63
+ const schemas = await usableSchemas(db);
64
+ const q = req.query ?? {};
65
+ const schema = typeof q.schema === 'string' && schemas.includes(q.schema) ? q.schema : '';
66
+ const tables = schema
67
+ ? (await db.query<{ name: string; n: number }>(
68
+ `select c.relname as name, (select count(*) from meta.ui_default d where d.schema_name = $1 and d.table_name = c.relname)::int as n
69
+ from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = $1 and c.relkind in ('r', 'p', 'v', 'm', 'f') and not c.relispartition order by 1`,
70
+ [schema])).rows
71
+ : [];
72
+ const t = schema ? await findTable(s, schema, q.table) : null;
73
+ const counts = (await owner.query<{ schema_name: string; n: number }>('select schema_name, count(*)::int as n from meta.ui_default group by 1')).rows;
74
+ const pick = html`<form method="get" class="util-filters">
75
+ <div class="field"><label class="label" for="f_schema">Schema</label><select id="f_schema" name="schema"><option value="">Choose a schema</option>
76
+ ${schemas.map((n) => html`<option value="${n}"${n === schema ? raw(' selected') : ''}>${n}${counts.find((c) => c.schema_name === n) ? ` (${counts.find((c) => c.schema_name === n)!.n})` : ''}</option>`)}</select></div>
77
+ ${schema ? html`<div class="field"><label class="label" for="f_table">Table or view</label><select id="f_table" name="table"><option value="">Choose a table</option>
78
+ ${tables.map((x) => html`<option value="${x.name}"${x.name === t?.table ? raw(' selected') : ''}>${x.name}${x.n ? ` (${x.n})` : ''}</option>`)}</select></div>` : ''}
79
+ <div class="buttons"><button class="btn btn-sm">${icon('table')} Show</button></div></form>`;
80
+ let body = html`${pick}`;
81
+ if (t) {
82
+ const cols = await columnsOf(t.oid, t.schema, t.table);
83
+ body = html`${pick}
84
+ <form method="post" action="${BASE}/ui-defaults">${csrf(s)}<input type="hidden" name="schema" value="${t.schema}"><input type="hidden" name="table" value="${t.table}">
85
+ <div class="table-wrap"><table class="table ui-defaults report-reflow"><thead><tr><th scope="col">Column</th><th scope="col">Label</th><th scope="col">Format mask</th><th scope="col">Alignment</th><th scope="col">Help</th><th scope="col">List of values</th></tr></thead><tbody>
86
+ ${cols.map((c, i) => html`<tr>
87
+ <td data-label="Column"><code>${c.name}</code><br><span class="muted small">${c.type}</span><input type="hidden" name="c.${i}.name" value="${c.name}"></td>
88
+ <td data-label="Label"><input id="f_l${i}" aria-label="Label of ${c.name}" name="c.${i}.label" value="${c.d?.label ?? ''}" maxlength="200" placeholder="${c.wizard}"></td>
89
+ <td data-label="Format mask"><input id="f_m${i}" aria-label="Format mask of ${c.name}" name="c.${i}.format_mask" value="${c.d?.format_mask ?? ''}" maxlength="60" placeholder="${/numeric|integer|bigint|smallint|real|double/.test(c.type) ? '999G990D00' : /date|time/.test(c.type) ? 'DD.MM.YYYY' : ''}"></td>
90
+ <td data-label="Alignment"><select id="f_a${i}" aria-label="Alignment of ${c.name}" name="c.${i}.alignment">${ALIGNMENTS.map((x) => html`<option value="${x}"${(c.d?.alignment ?? '') === x ? raw(' selected') : ''}>${x || '–'}</option>`)}</select></td>
91
+ <td data-label="Help"><textarea id="f_h${i}" aria-label="Help of ${c.name}" name="c.${i}.help" rows="1" maxlength="4000">${c.d?.help ?? ''}</textarea></td>
92
+ <td data-label="List of values"><input id="f_v${i}" aria-label="List of values of ${c.name}" name="c.${i}.lov" value="${c.d?.lov ?? ''}" maxlength="4000" placeholder="LOV:NAME"></td>
93
+ </tr>`)}</tbody></table></div>
94
+ <div class="buttons"><button class="btn btn-hot" name="mode" value="save">${icon('save')} Save</button>
95
+ <button class="btn" name="mode" value="seed">${icon('edit')} Seed from comments</button></div>
96
+ <p class="muted small">An empty row has no UI default. The placeholder of a label shows what the wizards use without one. Lists of values: <code>LOV:NAME</code> (a shared list of values of the application), <code>STATIC:Yes;Y,No;N</code> or a query (<code>select name d, id r from sales.status</code>).</p>
97
+ </form>`;
98
+ }
99
+ const main = html`<h1 class="u-mb1">UI defaults</h1>
100
+ ${region('UI defaults per column', html`<p class="muted u-mt0">Label, format mask, help text, alignment and list of values per table column (APEX: Attribute Dictionary). The page wizards and the Create Application wizard use the label for every generated report heading and form item, and a generated form item also gets the format mask, help and list of values. They apply to pages generated after the change, in every application.</p>${body}`)}`;
101
+ return send(reply, s, shell(s, 'UI defaults', [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['UI defaults']], main));
102
+ });
103
+
104
+ app.post(`${BASE}/ui-defaults`, async (req: Req, reply) => {
105
+ const s = await developer(req, reply);
106
+ if (!s) return;
107
+ const b: Body = req.body ?? {};
108
+ const t = await findTable(s, b.schema, b.table);
109
+ if (!t) return reply.code(404).send('Not found');
110
+ const to = `${BASE}/ui-defaults?${new URLSearchParams({ schema: t.schema, table: t.table })}`;
111
+ const cols = await columnsOf(t.oid, t.schema, t.table);
112
+ const known = new Map(cols.map((c) => [c.name, c]));
113
+ try {
114
+ if (b.mode === 'seed') {
115
+ // columns without a UI default: a short comment is the label, a longer one the help text
116
+ let n = 0;
117
+ for (const c of cols) {
118
+ if (c.d || !c.comment?.trim()) continue;
119
+ const text = c.comment.trim();
120
+ const short = text.length <= 60 && !text.includes('\n');
121
+ await owner.query(
122
+ `insert into meta.ui_default (schema_name, table_name, column_name, label, help, updated_by) values ($1, $2, $3, $4, $5, $6) on conflict do nothing`,
123
+ [t.schema, t.table, c.name, short ? text : null, short ? null : text.slice(0, 4000), s.username]);
124
+ n++;
125
+ }
126
+ flash(s, n ? `UI defaults of ${n} column${n === 1 ? '' : 's'} made from their comments.` : 'No column without a UI default has a comment.');
127
+ return back(reply, s, to);
128
+ }
129
+ const rows: { name: string; label: string | null; mask: string | null; help: string | null; align: string | null; lov: string | null }[] = [];
130
+ for (let i = 0; i < MAX_COLUMNS; i++) {
131
+ const name = b[`c.${i}.name`];
132
+ if (typeof name !== 'string' || !known.has(name)) continue;
133
+ const mask = trim(b[`c.${i}.format_mask`], 60);
134
+ if (mask && !MASK.test(mask)) throw new Error(`Format mask of ${name}: letters, digits and . , : / - only (e.g. 999G990D00 or DD.MM.YYYY).`);
135
+ const align = trim(b[`c.${i}.alignment`], 10);
136
+ if (align && !(ALIGNMENTS as readonly string[]).includes(align)) throw new Error(`Alignment of ${name}: start, center or end.`);
137
+ rows.push({ name, label: trim(b[`c.${i}.label`], 200), mask, help: trim(b[`c.${i}.help`], 4000), align, lov: trim(b[`c.${i}.lov`], 4000) });
138
+ }
139
+ await owner.tx(async (c) => {
140
+ for (const r of rows) {
141
+ const before = known.get(r.name)!.d;
142
+ if (!r.label && !r.mask && !r.help && !r.align && !r.lov) {
143
+ if (before) await c.query('delete from meta.ui_default where schema_name = $1 and table_name = $2 and column_name = $3', [t.schema, t.table, r.name]);
144
+ continue;
145
+ }
146
+ if (before && before.label === r.label && before.format_mask === r.mask && before.help === r.help && before.alignment === r.align && before.lov === r.lov) continue;
147
+ await c.query(
148
+ `insert into meta.ui_default (schema_name, table_name, column_name, label, format_mask, help, alignment, lov, updated_by)
149
+ values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
150
+ on conflict (schema_name, table_name, column_name) do update set label = excluded.label, format_mask = excluded.format_mask, help = excluded.help,
151
+ alignment = excluded.alignment, lov = excluded.lov, updated_by = excluded.updated_by, updated_at = now()`,
152
+ [t.schema, t.table, r.name, r.label, r.mask, r.help, r.align, r.lov, s.username]);
153
+ }
154
+ });
155
+ flash(s, `UI defaults of ${t.schema}.${t.table} saved.`);
156
+ } catch (e) {
157
+ flash(s, (e as Error).message, 'error');
158
+ }
159
+ return back(reply, s, to);
160
+ });
161
+ }
package/src/builder/ui.ts CHANGED
@@ -243,7 +243,7 @@ export const region = (title: string, body: Raw | Raw[], extra: Raw | '' = '', i
243
243
  html`<section class="region region-standard"${id ? raw(` id="${id.replace(/[^a-z0-9-]/g, '')}"`) : ''}><header class="region-header"><h2>${title}</h2>${extra}</header><div class="region-body">${body}</div></section>`;
244
244
 
245
245
  /** Title and tabs of an application's builder pages (Pages, Shared Components, …), in the session's builder language. */
246
- export const appHeader = (a: any, active: 'pages' | 'shared' | 'settings' | 'activity' | 'api' | 'search' | 'advisor', s?: Pick<Session, 'state'>) => {
246
+ export const appHeader = (a: any, active: 'pages' | 'shared' | 'settings' | 'activity' | 'api' | 'search' | 'advisor' | 'utilities', s?: Pick<Session, 'state'>) => {
247
247
  const tx = tr(s);
248
248
  const tab = (key: typeof active, href: string, ic: string, label: string) =>
249
249
  html`<a class="ide-tab" href="${href}"${active === key ? raw(' aria-current="page"') : ''}>${bicon(ic)}<span>${label}</span></a>`;
@@ -266,6 +266,7 @@ export const appHeader = (a: any, active: 'pages' | 'shared' | 'settings' | 'act
266
266
  ${tab('settings', `${BASE}/apps/${a.id}/settings`, 'settings', tx('app.tab.settings'))}
267
267
  ${tab('search', `${BASE}/apps/${a.id}/search`, 'search', tx('common.search'))}
268
268
  ${tab('advisor', `${BASE}/apps/${a.id}/advisor`, 'check', tx('app.tab.advisor'))}
269
+ ${tab('utilities', `${BASE}/apps/${a.id}/utilities`, 'wrench', tx('app.tab.utilities'))}
269
270
  </nav>
270
271
  </div>`;
271
272
  };