pgkiln 0.53.0 → 0.54.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.
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import type { FastifyInstance } from 'fastify';
4
- import { owner, ownerUrl } from '../db.ts';
4
+ import { owner, ownerUrl, type Client } from '../db.ts';
5
5
  import { root } from '../env.ts';
6
6
  import { html, raw } from '../html.ts';
7
7
  import { icon } from '../icons.ts';
@@ -13,16 +13,21 @@ import { back, BASE, csrf, developer, flash, send, shell, type Req } from './ui.
13
13
  import { currentWorkspace, inWorkspaceSql, placeApp } from './workspaces.ts';
14
14
  import { workspacesChanged } from '../workspace-db.ts';
15
15
 
16
- // The App Gallery (sprint 61; APEX: App Gallery with Sample, Starter and Custom Apps).
16
+ // The App Gallery (sprint 61, sprint 120; APEX: App Gallery with Sample, Starter, Utility and
17
+ // Custom Apps).
17
18
  //
18
- // Sample apps applications shipped with pgkiln (examples/): an administrator installs
19
- // one with its schema, data and demo accounts, as `pgkiln migrate --example`;
19
+ // Sample apps applications shipped with pgkiln: an administrator installs one with its
20
+ // schema, data and demo account, either an example directory (`example`, as
21
+ // `pgkiln migrate --example`) or one SQL file (`file`: examples/*.sql or
22
+ // examples/gallery/samples/*.sql, run in one transaction as the owner);
20
23
  // Starter apps blueprints shipped with pgkiln (examples/gallery/starters/): Install opens
21
24
  // the blueprint's review, and the developer creates the application from it
22
25
  // in the current workspace (a new role and schema, like every new application);
26
+ // Utility apps (sprint 120) blueprints too (examples/gallery/utilities/), of applications a
27
+ // workspace uses rather than learns from, installed the same way;
23
28
  // Custom apps the workspace's boilerplate applications: Create application starts from one.
24
29
  //
25
- // The list is examples/gallery/gallery.json.
30
+ // The list is examples/gallery/gallery.json; requests only ever name a key of it.
26
31
 
27
32
  interface Sample {
28
33
  key: string;
@@ -44,64 +49,94 @@ interface Starter {
44
49
  const GALLERY = join(root, 'examples', 'gallery');
45
50
  const KEY = /^[a-z][a-z0-9-]{0,40}$/;
46
51
 
47
- function catalog(): { samples: Sample[]; starters: Starter[] } {
52
+ const list = <T>(v: unknown): T[] => (Array.isArray(v) ? v : []);
53
+
54
+ function catalog(): { samples: Sample[]; starters: Starter[]; utilities: Starter[] } {
48
55
  try {
49
56
  const c = JSON.parse(readFileSync(join(GALLERY, 'gallery.json'), 'utf8'));
50
- return { samples: Array.isArray(c.samples) ? c.samples : [], starters: Array.isArray(c.starters) ? c.starters : [] };
57
+ return { samples: list(c.samples), starters: list(c.starters), utilities: list(c.utilities) };
51
58
  } catch {
52
- return { samples: [], starters: [] };
59
+ return { samples: [], starters: [], utilities: [] };
53
60
  }
54
61
  }
55
62
 
63
+ /** The blueprint files a kind of entry may name: starters/x.json or utilities/x.json. */
64
+ const BLUEPRINT_FILE = { starters: /^starters\/[a-z0-9-]+\.json$/, utilities: /^utilities\/[a-z0-9-]+\.json$/ } as const;
65
+ /** The SQL files a sample may name: examples/x.sql or examples/gallery/samples/x.sql. */
66
+ const SAMPLE_FILE = /^(samples\/)?[a-z0-9-]+\.sql$/;
67
+
56
68
  /** A file of the gallery: only names from the catalogue, never a path from the request. */
57
69
  const galleryFile = (rel: string) => readFileSync(join(rel.includes('/') ? GALLERY : join(root, 'examples'), rel), 'utf8');
58
70
 
59
- type Tab = 'samples' | 'starters' | 'custom';
71
+ type Tab = 'samples' | 'starters' | 'utilities' | 'custom';
72
+ const TABS: readonly [Tab, string][] = [['samples', 'Sample Apps'], ['starters', 'Starter Apps'], ['utilities', 'Utility Apps'], ['custom', 'Custom Apps']];
60
73
 
61
74
  export async function galleryRoutes(app: FastifyInstance) {
62
75
  app.get(`${BASE}/gallery`, async (req: Req, reply) => {
63
76
  const s = await developer(req, reply);
64
77
  if (!s) return;
65
- const tab: Tab = (['samples', 'starters', 'custom'] as const).find((t) => t === req.query?.tab) ?? 'samples';
78
+ const tab: Tab = TABS.map(([t]) => t).find((t) => t === req.query?.tab) ?? 'samples';
79
+ const q = typeof req.query?.q === 'string' ? req.query.q.trim().slice(0, 100) : '';
66
80
  const admin = await isAdmin(s.username);
67
- const { samples, starters } = catalog();
81
+ const ws = currentWorkspace(s);
82
+ const { samples, starters, utilities } = catalog();
83
+ // an installed sample: Edit only in its own workspace
68
84
  const installed = new Map(
69
- (await owner.query<{ alias: string; id: number }>('select alias, id from meta.app where alias = any($1)', [samples.map((x) => x.alias)])).rows.map((r) => [r.alias, r.id]),
85
+ (await owner.query<{ alias: string; id: number; here: boolean }>(`select a.alias, a.id, ${inWorkspaceSql('a', 2)} as here from meta.app a where a.alias = any($1)`,
86
+ [samples.map((x) => x.alias), ws])).rows.map((r) => [r.alias, r]),
70
87
  );
71
88
  const custom = (await owner.query<{ id: number; name: string; description: string | null }>(
72
89
  `select a.id, a.name, null::text as description from meta.app a where a.app_type = 'boilerplate' and ${inWorkspaceSql('a', 1)} order by lower(a.name), a.id`,
73
- [currentWorkspace(s)],
90
+ [ws],
74
91
  )).rows;
92
+ // (sprint 120) the search also works without JavaScript: ?q= leaves out what doesn't match
93
+ // (like the script's filter: every word of the search in the title or description)
94
+ const words = q.toLowerCase().split(/\s+/).filter(Boolean);
95
+ const matches = (x: { title?: string; name?: string; description?: string | null }) => {
96
+ const text = `${x.title ?? x.name ?? ''} ${x.description ?? ''}`.toLowerCase();
97
+ return words.every((w) => text.includes(w));
98
+ };
99
+ const counts: Record<Tab, number> = { samples: samples.length, starters: starters.length, utilities: utilities.length, custom: custom.length };
75
100
  const card = (ic: string, title: string, by: string, text: string, action: unknown) =>
76
101
  html`<article class="gal-card" data-filter-row><div class="gal-head"><span class="gal-icon">${icon(ic)}</span><div><h2>${title}</h2><span class="muted small">${by}</span></div></div>
77
102
  <p>${text}</p><div class="gal-foot">${action}</div></article>`;
78
- const tabs = html`<nav class="gal-tabs segmented" aria-label="Kinds of applications">${([['samples', 'Sample Apps'], ['starters', 'Starter Apps'], ['custom', 'Custom Apps']] as const).map(([t, l]) =>
79
- html`<a href="${BASE}/gallery?tab=${t}"${tab === t ? raw(' aria-current="page"') : ''}>${l}</a>`)}</nav>`;
103
+ const tabs = html`<nav class="gal-tabs segmented" aria-label="Kinds of applications">${TABS.map(([t, l]) =>
104
+ html`<a href="${BASE}/gallery?tab=${t}${q ? `&q=${encodeURIComponent(q)}` : ''}"${tab === t ? raw(' aria-current="page"') : ''}>${l} <span class="gal-count">${counts[t]}</span></a>`)}</nav>`;
105
+ const blueprintCard = (kind: 'starters' | 'utilities') => (x: Starter) => card(x.icon, x.title, 'pgkiln', x.description,
106
+ html`<form method="post" action="${BASE}/gallery/${kind}/${x.key}">${csrf(s)}<button class="btn btn-hot btn-sm">Install</button></form>`);
80
107
  const cards =
81
108
  tab === 'samples'
82
- ? samples.map((x) => card(x.icon, x.title, 'pgkiln', x.description,
83
- installed.has(x.alias)
84
- ? html`<span class="tag tag-ok">Installed</span><a class="btn btn-sm" href="/a/${x.alias}" target="_blank" rel="noopener">Run</a><a class="btn btn-sm" href="${BASE}/apps/${installed.get(x.alias)}">Edit</a>`
85
- : admin
86
- ? html`<form method="post" action="${BASE}/gallery/samples/${x.key}">${csrf(s)}<button class="btn btn-hot btn-sm" data-confirm="Install ${x.title}? It adds its own schema, data and demo accounts with known passwords: not for a production instance.">Install</button></form>`
87
- : html`<span class="muted small">An administrator installs it.</span>`))
88
- : tab === 'starters'
89
- ? starters.map((x) => card(x.icon, x.title, 'pgkiln', x.description,
90
- html`<form method="post" action="${BASE}/gallery/starters/${x.key}">${csrf(s)}<button class="btn btn-hot btn-sm">Install</button></form>`))
91
- : custom.map((x) => card('copy', x.name, 'This workspace', x.description ?? 'A boilerplate application: a new application starts with its pages, shared components and settings.',
109
+ ? samples.filter(matches).map((x) => {
110
+ const it = installed.get(x.alias);
111
+ return card(x.icon, x.title, 'pgkiln', x.description,
112
+ it
113
+ ? html`<span class="tag tag-ok">Installed</span><a class="btn btn-sm" href="/a/${x.alias}" target="_blank" rel="noopener">Run</a>${it.here
114
+ ? html`<a class="btn btn-sm" href="${BASE}/apps/${it.id}">Edit</a>`
115
+ : html`<span class="muted small">in another workspace</span>`}`
116
+ : admin
117
+ ? html`<form method="post" action="${BASE}/gallery/samples/${x.key}">${csrf(s)}<button class="btn btn-hot btn-sm" data-confirm="Install ${x.title}? It adds its own schema, data and demo accounts with known passwords: not for a production instance.">Install</button></form>`
118
+ : html`<span class="muted small">An administrator installs it.</span>`);
119
+ })
120
+ : tab === 'starters' || tab === 'utilities'
121
+ ? (tab === 'starters' ? starters : utilities).filter(matches).map(blueprintCard(tab))
122
+ : custom.filter(matches).map((x) => card('copy', x.name, 'This workspace', x.description ?? 'A boilerplate application: a new application starts with its pages, shared components and settings.',
92
123
  html`<a class="btn btn-hot btn-sm" href="${BASE}/create?boilerplate=${x.id}">Create from it</a><a class="btn btn-sm" href="${BASE}/apps/${x.id}">Edit</a>`));
93
- const empty = tab === 'custom'
94
- ? html`<p class="muted">No custom apps yet: make an application of type <em>Boilerplate</em> (Application settings → Application type) and it appears here.</p>`
95
- : html`<p class="muted">The gallery is empty.</p>`;
124
+ const empty = q && counts[tab]
125
+ ? html`<p class="muted">No app matches <strong>${q}</strong>. <a href="${BASE}/gallery?tab=${tab}">Show all</a></p>`
126
+ : tab === 'custom'
127
+ ? html`<p class="muted">No custom apps yet: make an application of type <em>Boilerplate</em> (Application settings → Application type) and it appears here.</p>`
128
+ : html`<p class="muted">The gallery is empty.</p>`;
96
129
  const intro = {
97
- samples: 'Applications that show what pgkiln can do, with their own data and demo accounts. Install them on a development or demo instance.',
130
+ samples: 'Applications that show what pgkiln can do, each with its own schema, data and demo account: the showcase, the tutorial, one app per feature area and the UX Pattern Catalog. An administrator installs them on a development or demo instance.',
98
131
  starters: 'Small, ready-to-use applications. Install shows what will be created (tables, pages, sample data) before anything happens; the application gets its own role and schema in this workspace.',
132
+ utilities: 'Applications a workspace uses rather than learns from: shared lookup lists, release notes, announcements. Like starter apps, Install shows what will be created first, and the application gets its own role and schema in this workspace.',
99
133
  custom: 'The boilerplate applications of this workspace: start a new application from one.',
100
134
  }[tab];
101
135
  const main = html`<div class="gal">
102
136
  <div class="gal-bar">
103
- <div class="ab-search gal-search" role="search"><label class="sr-only" for="gal-q">Search apps</label>${icon('search')}
104
- <input id="gal-q" type="search" placeholder="Search apps" autocomplete="off" data-filter-list=".gal [data-filter-row]"></div>
137
+ <form class="ab-search gal-search" role="search" method="get" action="${BASE}/gallery"><label class="sr-only" for="gal-q">Search apps</label>${icon('search')}
138
+ <input type="hidden" name="tab" value="${tab}">
139
+ <input id="gal-q" name="q" type="search" placeholder="Search apps" autocomplete="off" value="${q}" data-filter-list=".gal [data-filter-row]"></form>
105
140
  ${tabs}
106
141
  </div>
107
142
  <h1 class="sr-only">App Gallery</h1>
@@ -120,19 +155,30 @@ export async function galleryRoutes(app: FastifyInstance) {
120
155
  if (!x) return reply.code(404).send('Not found');
121
156
  try {
122
157
  if (await owner.one('select 1 as ok from meta.app where alias = $1', [x.alias])) throw new Error(`an application with the alias ${x.alias} exists already.`);
123
- if (x.example && KEY.test(x.example)) await migrate({ root, databaseUrl: ownerUrl, example: x.example, log: () => {} });
124
- else if (x.file && /^[a-z0-9-]+\.sql$/.test(x.file)) await owner.query(galleryFile(x.file));
125
- else throw new Error('the gallery entry names nothing to install.');
126
- const made = await owner.one<{ id: number; db_role: string | null }>('select id, db_role from meta.app where alias = $1', [x.alias]);
127
- if (!made) throw new Error(`the scripts ran, but there is no application ${x.alias}.`);
128
- // into the current workspace (an isolated one gets the application's role too)
129
158
  const ws = currentWorkspace(s);
130
- if (ws > 1)
131
- await owner.tx(async (c) => {
159
+ // into the current workspace (an isolated one gets the application's role too)
160
+ const place = async (c: Client) => {
161
+ const made = (await c.query('select id, db_role from meta.app where alias = $1', [x.alias])).rows[0] as { id: number; db_role: string | null } | undefined;
162
+ if (!made) throw new Error(`the scripts ran, but there is no application ${x.alias}.`);
163
+ if (ws > 1) {
132
164
  if (made.db_role && (await c.query('select 1 from meta.workspace where id = $1 and isolated', [ws])).rowCount)
133
165
  await c.query('insert into meta.workspace_role (workspace_id, role_name) values ($1, $2) on conflict do nothing', [ws, made.db_role]);
134
166
  await placeApp(c, made.id, ws);
167
+ }
168
+ return made;
169
+ };
170
+ let made: { id: number; db_role: string | null };
171
+ if (x.example && KEY.test(x.example)) {
172
+ await migrate({ root, databaseUrl: ownerUrl, example: x.example, log: () => {} });
173
+ made = await owner.tx(place);
174
+ } else if (x.file && SAMPLE_FILE.test(x.file)) {
175
+ // (sprint 120) the file and the placement in one transaction: a failing script leaves nothing behind
176
+ const sql = galleryFile(x.file);
177
+ made = await owner.tx(async (c) => {
178
+ await c.query(sql);
179
+ return place(c);
135
180
  });
181
+ } else throw new Error('the gallery entry names nothing to install.');
136
182
  await workspacesChanged();
137
183
  await logActivity({ appId: made.id, username: s.username, event: 'gallery', ip: clientIp(req), detail: `installed sample app ${x.key}` });
138
184
  flash(s, `${x.title} installed (/a/${x.alias}).`);
@@ -143,12 +189,13 @@ export async function galleryRoutes(app: FastifyInstance) {
143
189
  }
144
190
  });
145
191
 
146
- // a starter app: its blueprint's review (nothing is created before the developer confirms there)
147
- app.post(`${BASE}/gallery/starters/:key`, async (req: Req, reply) => {
148
- const s = await developer(req, reply);
149
- if (!s) return;
150
- const x = KEY.test(req.params.key) ? catalog().starters.find((c) => c.key === req.params.key) : undefined;
151
- if (!x || !/^starters\/[a-z0-9-]+\.json$/.test(x.file)) return reply.code(404).send('Not found');
152
- return openBlueprint(s, reply, x.title, JSON.stringify(JSON.parse(galleryFile(x.file)), null, 2));
153
- });
192
+ // a starter or utility app: its blueprint's review (nothing is created before the developer confirms there)
193
+ for (const kind of ['starters', 'utilities'] as const)
194
+ app.post(`${BASE}/gallery/${kind}/:key`, async (req: Req, reply) => {
195
+ const s = await developer(req, reply);
196
+ if (!s) return;
197
+ const x = KEY.test(req.params.key) ? catalog()[kind].find((c) => c.key === req.params.key) : undefined;
198
+ if (!x || typeof x.file !== 'string' || !BLUEPRINT_FILE[kind].test(x.file)) return reply.code(404).send('Not found');
199
+ return openBlueprint(s, reply, x.title, JSON.stringify(JSON.parse(galleryFile(x.file)), null, 2));
200
+ });
154
201
  }
@@ -230,7 +230,10 @@ function column(labels: string[], series: Series[]) {
230
230
  const col = html`<span class="col s${si + 1}${v < 0 ? ' neg' : ''} ${css.cls(`bottom:${pct(bottom)};height:${pct(top - bottom)}`)}"></span>`;
231
231
  return multi && link ? mark('span', i, si, 'col-slot', `${l} · ${s.name}: ${fmt.format(v)}`, col, false) : html`<span class="col-slot">${col}</span>`;
232
232
  })}${!multi && labels.length <= 12
233
- ? html`<span class="col-value ${css.cls(`bottom:${pct(Math.max(y(series[0].values[i]), zero))}`)}">${compact.format(series[0].values[i])}</span>`
233
+ // (sprint 120) inside a drill link the value is drawn by CSS (attr()), so the link's name is its tip alone
234
+ ? link
235
+ ? html`<span class="col-value ${css.cls(`bottom:${pct(Math.max(y(series[0].values[i]), zero))}`)}" aria-hidden="true" data-value="${compact.format(series[0].values[i])}"></span>`
236
+ : html`<span class="col-value ${css.cls(`bottom:${pct(Math.max(y(series[0].values[i]), zero))}`)}" aria-hidden="true">${compact.format(series[0].values[i])}</span>`
234
237
  : ''}`;
235
238
  return multi ? html`<div class="col-group" role="img" data-tip="${tip(l, series, i)}" tabindex="0" aria-label="${tip(l, series, i)}">${inner}</div>` : mark('div', i, 0, 'col-group', tip(l, series, i), inner);
236
239
  })}</div>
@@ -465,7 +465,7 @@ async function regionShell(ctx: PageContext, r: Region, hidden: Set<string>, bod
465
465
  return html`<dialog class="${cls}" id="R${r.id}"${staticAttr} aria-labelledby="${titleId}" data-inline-region>
466
466
  <header class="region-header"><h2 id="${titleId}">${r.title}</h2>
467
467
  <a class="region-dialog-close icon-button" href="#" role="button" data-region-close aria-label="${ctx.locale.t('common.dismiss')}">${icon('close')}</a></header>
468
- <div class="region-body">${body}</div>
468
+ <div class="region-body"${scrollable(r)}>${body}</div>
469
469
  ${buttons ? html`<footer class="region-footer">${buttons}</footer>` : ''}
470
470
  </dialog>`;
471
471
 
@@ -473,17 +473,20 @@ async function regionShell(ctx: PageContext, r: Region, hidden: Set<string>, bod
473
473
  if (r.template === 'collapsible')
474
474
  return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-labelledby="${titleId}">
475
475
  <details open><summary class="region-header">${header}</summary>
476
- <div class="region-body">${body}</div>
476
+ <div class="region-body"${scrollable(r)}>${body}</div>
477
477
  ${!buttonsOnTop && buttons ? html`<footer class="region-footer">${buttons}</footer>` : ''}
478
478
  </details></section>`;
479
479
 
480
480
  return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr}${r.title ? raw(` aria-labelledby="${titleId}"`) : ''}>
481
481
  ${r.title || (buttonsOnTop && buttons) ? html`<header class="region-header">${header}</header>` : ''}
482
- <div class="region-body">${body}</div>
482
+ <div class="region-body"${scrollable(r)}>${body}</div>
483
483
  ${!buttonsOnTop && buttons ? html`<footer class="region-footer">${buttons}</footer>` : ''}
484
484
  </section>`;
485
485
  }
486
486
 
487
+ /** (sprint 120) A body that scrolls (template option to-scroll) can be reached and scrolled from the keyboard. */
488
+ const scrollable = (r: Region) => (r.template_options?.includes('to-scroll') ? raw(' tabindex="0"') : '');
489
+
487
490
  /**
488
491
  * (099) A region whose buttons have positions (APEX: Button Position): create, edit, change, copy, help and up in
489
492
  * the header; previous, close and delete at the left end of the bottom bar, next at its right end; the others
@@ -506,9 +509,9 @@ async function slottedShell(ctx: PageContext, r: Region, hidden: Set<string>, bo
506
509
  const header = html`<h2 id="${titleId}">${r.title}</h2>${top}`;
507
510
  if (r.template === 'collapsible')
508
511
  return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-labelledby="${titleId}">
509
- <details open><summary class="region-header">${header}</summary><div class="region-body">${body}</div>${footer}</details></section>`;
512
+ <details open><summary class="region-header">${header}</summary><div class="region-body"${scrollable(r)}>${body}</div>${footer}</details></section>`;
510
513
  return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr}${r.title ? raw(` aria-labelledby="${titleId}"`) : ''}>
511
514
  ${r.title || top ? html`<header class="region-header">${header}</header>` : ''}
512
- <div class="region-body">${body}</div>${footer}
515
+ <div class="region-body"${scrollable(r)}>${body}</div>${footer}
513
516
  </section>`;
514
517
  }