pgkiln 0.41.0 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/db/migrations/097_generated_app_quality.sql +911 -0
- package/db/migrations/098_app_activity.sql +37 -0
- package/db/migrations/099_layout_positions.sql +29 -0
- package/docs/apex-feature-parity.md +38 -11
- package/docs/guide/03-builder.md +133 -31
- package/docs/guide/04-pages-and-regions.md +46 -3
- package/docs/guide/05-items.md +1 -1
- package/docs/guide/09-reference.md +10 -3
- package/docs/guide/11-from-apex.md +1 -1
- package/docs/guide/12-development.md +3 -2
- package/docs/guide/14-globalization.md +7 -3
- package/docs/guide/17-mobile.md +1 -1
- package/package.json +1 -1
- package/public/app.css +233 -33
- package/public/app.js +263 -0
- package/public/builder.css +142 -2
- package/public/builder.js +311 -3
- package/src/builder/appfromfile.ts +5 -4
- package/src/builder/appsheets.ts +23 -9
- package/src/builder/appwizard.ts +215 -70
- package/src/builder/arrange.ts +133 -17
- package/src/builder/blueprints.ts +2 -1
- package/src/builder/components.ts +29 -23
- package/src/builder/designer.ts +67 -18
- package/src/builder/forms.ts +4 -1
- package/src/builder/home.ts +2 -32
- package/src/builder/newapp.ts +182 -5
- package/src/builder/object-browser.ts +342 -0
- package/src/builder/pwa.ts +3 -3
- package/src/builder/routes.ts +86 -51
- package/src/builder/shared.ts +103 -1
- package/src/builder/sql.ts +13 -65
- package/src/builder/ui.ts +21 -6
- package/src/i18n/ar.ts +17 -0
- package/src/i18n/cs.ts +17 -0
- package/src/i18n/da.ts +17 -0
- package/src/i18n/de.ts +17 -0
- package/src/i18n/el.ts +17 -0
- package/src/i18n/es.ts +17 -0
- package/src/i18n/fi.ts +17 -0
- package/src/i18n/fr.ts +17 -0
- package/src/i18n/he.ts +17 -0
- package/src/i18n/it.ts +17 -0
- package/src/i18n/ja.ts +17 -0
- package/src/i18n/ko.ts +17 -0
- package/src/i18n/nb.ts +17 -0
- package/src/i18n/pl.ts +17 -0
- package/src/i18n/pt.ts +17 -0
- package/src/i18n/ru.ts +17 -0
- package/src/i18n/sv.ts +17 -0
- package/src/i18n/tr.ts +17 -0
- package/src/i18n/uk.ts +17 -0
- package/src/i18n/zh.ts +17 -0
- package/src/i18n.ts +34 -0
- package/src/icons.ts +2 -1
- package/src/layout.ts +2 -2
- package/src/metadata.ts +5 -1
- package/src/runtime/charts.ts +8 -3
- package/src/runtime/devbar.ts +35 -14
- package/src/runtime/engine.ts +13 -4
- package/src/runtime/format.ts +52 -0
- package/src/runtime/items.ts +27 -0
- package/src/runtime/locale.ts +5 -2
- package/src/runtime/regions.ts +61 -4
- package/src/runtime/render.ts +38 -8
- package/src/runtime/report.ts +149 -88
- package/src/runtime/routes.ts +34 -3
- package/src/runtime/styles.ts +5 -2
package/src/builder/routes.ts
CHANGED
|
@@ -17,11 +17,10 @@ import { APP_COLORS, appHeader, back, BASE, builderHead, csrf, developer, flash,
|
|
|
17
17
|
import { appOr404 } from './forms.ts';
|
|
18
18
|
import { docToFiles, filesToZip } from '../appfiles.ts';
|
|
19
19
|
import { homeRoutes, rememberApp } from './home.ts';
|
|
20
|
-
import {
|
|
20
|
+
import { currentWorkspace, placeApp, workshopDb, workspaceRoutes } from './workspaces.ts';
|
|
21
21
|
import { instanceRoutes } from './instance.ts';
|
|
22
22
|
import { saveTimeZoneSettings, timeZoneSettings } from './globalization.ts';
|
|
23
23
|
import { WIZARD_KINDS, wizardRoutes, wizardTables } from './wizards.ts';
|
|
24
|
-
import { checkNewApp, createApp, createAppError, startFromBoilerplate } from './newapp.ts';
|
|
25
24
|
import { appFromFileRoutes } from './appfromfile.ts';
|
|
26
25
|
import { clearDevbar } from '../runtime/devbar.ts';
|
|
27
26
|
import { EMAIL } from './find-workspace.ts';
|
|
@@ -187,29 +186,14 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
187
186
|
await wizardRoutes(app);
|
|
188
187
|
await appFromFileRoutes(app);
|
|
189
188
|
|
|
190
|
-
|
|
189
|
+
// (sprint 73) POST /apps, creating an application, is in appwizard.ts
|
|
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) => {
|
|
191
193
|
const s = await developer(req, reply);
|
|
192
194
|
if (!s) return;
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const checked = await checkNewApp(b, currentWorkspace(s));
|
|
196
|
-
const boilerplate = /^\d{1,9}$/.test(b.boilerplate ?? '') ? Number(b.boilerplate) : null;
|
|
197
|
-
if (boilerplate && !(await appAllowed(s, boilerplate))) throw new Error('Choose a boilerplate application (an application of type Boilerplate).');
|
|
198
|
-
const { id, existingAccount } = await owner.tx(async (c) => {
|
|
199
|
-
const made = await createApp(c, checked);
|
|
200
|
-
if (boilerplate) await startFromBoilerplate(c, made.id, checked, boilerplate);
|
|
201
|
-
return made;
|
|
202
|
-
}).catch((e) => {
|
|
203
|
-
throw new Error(createAppError(e, checked.alias));
|
|
204
|
-
});
|
|
205
|
-
flash(s, existingAccount
|
|
206
|
-
? `Application created. The existing account ${checked.adminUser} got the admin role (its password was not changed).`
|
|
207
|
-
: 'Application created.');
|
|
208
|
-
return back(reply, s, `${BASE}/apps/${id}`);
|
|
209
|
-
} catch (e) {
|
|
210
|
-
flash(s, (e as Error).message, 'error');
|
|
211
|
-
return back(reply, s, `${BASE}/create`);
|
|
212
|
-
}
|
|
195
|
+
s.state.__WEAK_HIDDEN = '1';
|
|
196
|
+
return back(reply, s, BASE);
|
|
213
197
|
});
|
|
214
198
|
|
|
215
199
|
app.post(`${BASE}/import`, async (req: Req, reply) => {
|
|
@@ -266,7 +250,12 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
266
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);
|
|
267
251
|
const grouped = groups.length > 0 || pages.rows.some((p) => p.page_group);
|
|
268
252
|
const groupFilter = typeof req.query?.group === 'string' ? req.query.group : '';
|
|
269
|
-
|
|
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 })}`;
|
|
270
259
|
const locks = new Map((await appLocks(a.id)).map((l) => [l.page_no, l]));
|
|
271
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]));
|
|
272
261
|
const appLock = locks.get(0);
|
|
@@ -274,21 +263,33 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
274
263
|
const main = html`
|
|
275
264
|
${appHeader(a, 'pages')}
|
|
276
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>
|
|
277
276
|
${region('Pages', html`${grouped
|
|
278
277
|
? html`<form method="get" class="search u-mwnone u-mb1"><div class="field"><label class="label" for="f_group">Page group</label>
|
|
279
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>
|
|
280
279
|
<button class="btn u-selfend">Show</button></form>`
|
|
281
|
-
: ''}
|
|
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">
|
|
282
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>
|
|
283
|
-
<tbody>${shownPages.map((p) => html`<tr>
|
|
284
|
+
<tbody id="pages-rows">${shownPages.map((p) => html`<tr>
|
|
284
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>
|
|
285
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>
|
|
286
287
|
<td>${p.requires_auth ? (p.authz ?? '—') : 'public'}</td><td>${p.protection}</td>
|
|
287
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>
|
|
288
289
|
<td><a href="/a/${a.alias}/${p.page_no}" target="_blank" rel="noopener">Run ▸</a></td></tr>`)}</tbody>
|
|
289
|
-
</table></div>`)}
|
|
290
|
-
|
|
291
|
-
<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">
|
|
292
293
|
${region('Create pages from a table', html`
|
|
293
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>
|
|
294
295
|
<form method="get" action="${BASE}/apps/${a.id}/wizard">
|
|
@@ -309,6 +310,21 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
309
310
|
</div>
|
|
310
311
|
<div class="buttons"><button class="btn btn-hot">Create page</button></div>
|
|
311
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>
|
|
312
328
|
</div>`;
|
|
313
329
|
return send(reply, s, shell(s, a.name, [['App Builder', BASE], [a.name]], main));
|
|
314
330
|
});
|
|
@@ -357,6 +373,11 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
357
373
|
});
|
|
358
374
|
|
|
359
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
|
+
];
|
|
360
381
|
app.get(`${BASE}/apps/:id/settings`, async (req: Req, reply) => {
|
|
361
382
|
const s = await developer(req, reply);
|
|
362
383
|
if (!s) return;
|
|
@@ -366,18 +387,25 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
366
387
|
const directories = (await owner.query('select name, display_name, enabled from meta.ldap_directory order by display_name')).rows;
|
|
367
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);
|
|
368
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'}>`);
|
|
369
396
|
const main = html`${appHeader(a, 'settings')}
|
|
370
|
-
<
|
|
371
|
-
|
|
372
|
-
|
|
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`
|
|
373
401
|
<div class="form-grid">
|
|
374
402
|
${input('name', 'Name', a.name, { required: true })}
|
|
375
403
|
${input('alias', 'Alias', a.alias, { required: true })}
|
|
376
404
|
${input('home_page', 'Home page', a.home_page, { type: 'number' })}
|
|
377
405
|
${select('app_type', 'Application type', a.app_type ?? 'standard', APP_TYPES.map((t): [string, string] => [t, APP_TYPE_LABELS[t]]),
|
|
378
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.')}
|
|
379
|
-
</div
|
|
380
|
-
|
|
407
|
+
</div>`)}${end}
|
|
408
|
+
${panel('security')}${region('Authentication', html`
|
|
381
409
|
<div class="form-grid">
|
|
382
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)']])}
|
|
383
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.' })}
|
|
@@ -386,7 +414,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
386
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>
|
|
387
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>
|
|
388
416
|
</div>
|
|
389
|
-
<h3>HTTP header authentication</h3>
|
|
417
|
+
${authPart('header')}<h3>HTTP header authentication</h3>
|
|
390
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>
|
|
391
419
|
<div class="form-grid">
|
|
392
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.' })}
|
|
@@ -394,13 +422,13 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
394
422
|
</div>
|
|
395
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>
|
|
396
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>
|
|
397
|
-
<h3>Database accounts</h3>
|
|
425
|
+
</div>${authPart('database')}<h3>Database accounts</h3>
|
|
398
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>
|
|
399
427
|
<div class="form-grid">
|
|
400
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).' })}
|
|
401
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.' })}
|
|
402
430
|
</div>
|
|
403
|
-
<h3>Custom authentication</h3>
|
|
431
|
+
</div>${authPart('custom')}<h3>Custom authentication</h3>
|
|
404
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>
|
|
405
433
|
<div class="form-grid">
|
|
406
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.' })}
|
|
@@ -411,6 +439,8 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
411
439
|
<div class="field" data-wide><label class="label" for="f_custom_auth_post_code">Post-authentication code</label>
|
|
412
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>
|
|
413
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`
|
|
414
444
|
<h3>Sign-in methods</h3>
|
|
415
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>
|
|
416
446
|
${directories.length
|
|
@@ -423,9 +453,11 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
423
453
|
${providers.length
|
|
424
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>`)
|
|
425
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`
|
|
426
458
|
<h3>Theme</h3>
|
|
427
459
|
<div class="form-grid">
|
|
428
|
-
${select('base', 'Base style', baseStyleOf(a.theme), [['iris', 'Iris (the default for new applications)'], ['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.')}
|
|
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.')}
|
|
429
461
|
${input('accent', 'Accent colour', a.theme?.accent ?? BASE_STYLES[baseStyleOf(a.theme)].accent, { type: 'color' })}
|
|
430
462
|
${input('header', 'Header colour', a.theme?.header ?? BASE_STYLES[baseStyleOf(a.theme)].header, { type: 'color' })}
|
|
431
463
|
${(['accent_dark', 'header_dark'] as const).map((k) => html`<div class="field"><label class="label" for="f_${k}">${k === 'accent_dark' ? 'Accent colour in dark mode' : 'Header colour in dark mode'}</label>
|
|
@@ -440,7 +472,8 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
440
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>
|
|
441
473
|
<p><a class="btn" href="${BASE}/apps/${a.id}/theme">${icon('settings')} Theme Roller: style variants…</a>
|
|
442
474
|
<span class="muted">${(() => { const n = appStyles(a.theme).length; return n ? `${n} style${n === 1 ? '' : 's'}` : 'no styles yet'; })()}</span></p>
|
|
443
|
-
|
|
475
|
+
`)}${end}
|
|
476
|
+
${panel('globalization')}${region('Globalization', html`
|
|
444
477
|
<div class="form-grid">
|
|
445
478
|
${input('language', 'Primary language', a.language, { help: 'The language the app is built in, e.g. en, nl, de, en-GB.' })}
|
|
446
479
|
${input('languages', 'Translated languages', (a.languages ?? []).join(', '), { placeholder: 'e.g. nl, de', help: 'Comma separated. Translate texts under Shared Components → Globalization.' })}
|
|
@@ -448,22 +481,22 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
448
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' })}
|
|
449
482
|
${input('timestamp_format', 'Date and time format', a.timestamp_format, { placeholder: 'e.g. DD-MM-YYYY HH24:MI' })}
|
|
450
483
|
</div>
|
|
451
|
-
${await timeZoneSettings(a)}
|
|
452
|
-
<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>
|
|
453
486
|
</form>
|
|
454
|
-
<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)}
|
|
455
488
|
<button class="btn btn-danger" data-confirm="Delete application ${a.name} and all its pages?">Delete application</button>
|
|
456
|
-
</form
|
|
457
|
-
${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)}
|
|
458
491
|
<div class="form-grid">
|
|
459
492
|
${input('error_function', 'Error handling function', a.error_function ?? '', { placeholder: 'e.g. sales.error_message',
|
|
460
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.' })}
|
|
461
494
|
${input('session_warning_seconds', 'Session timeout warning (seconds)', a.session_warning_seconds ?? 120, { type: 'number',
|
|
462
495
|
help: 'Signed-in users are warned this long before their session ends and can stay signed in (0: no warning).' })}
|
|
463
496
|
</div>
|
|
464
|
-
<div class="buttons"><button class="btn">Save</button></div></form>`)}
|
|
465
|
-
${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))}
|
|
466
|
-
${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">
|
|
467
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>
|
|
468
501
|
<li>${a.authentication !== 'none' ? '✓' : '•'} ${a.authentication !== 'none' ? 'Users must sign in' : 'Public application'}</li>
|
|
469
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>` : ''}
|
|
@@ -474,7 +507,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
474
507
|
<li>${a.debug_level ? `✗ Debug messages are on (level ${a.debug_level}): every request is recorded` : '✓ Debug messages are off'}</li>
|
|
475
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>
|
|
476
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>
|
|
477
|
-
</ul>`)}
|
|
510
|
+
</ul>`)}${end}
|
|
478
511
|
</div>`;
|
|
479
512
|
return send(reply, s, shell(s, `${a.name} settings`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Settings']], main));
|
|
480
513
|
});
|
|
@@ -497,7 +530,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
497
530
|
} catch (e) {
|
|
498
531
|
flash(s, (e as Error).message, 'error');
|
|
499
532
|
}
|
|
500
|
-
return back(reply, s, `${BASE}/apps/${req.params.id}/settings`);
|
|
533
|
+
return back(reply, s, `${BASE}/apps/${req.params.id}/settings?tab=errors`);
|
|
501
534
|
});
|
|
502
535
|
|
|
503
536
|
app.post(`${BASE}/apps/:id/settings`, async (req: Req, reply) => {
|
|
@@ -516,7 +549,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
516
549
|
app_type = $28, session_group = $29, updated_at = now() where id = $1`,
|
|
517
550
|
[req.params.id, b.name?.trim(), b.alias?.trim().toLowerCase(), Number(b.home_page) || 1, b.authentication, b.db_role?.trim() || null, b.debug === 'true',
|
|
518
551
|
JSON.stringify({
|
|
519
|
-
base: baseStyleOf({ base: b.base }) === '
|
|
552
|
+
base: baseStyleOf({ base: b.base }) === 'standard' ? undefined : baseStyleOf({ base: b.base }),
|
|
520
553
|
accent_dark: b.accent_dark_own === 'true' && /^#[0-9a-f]{6}$/i.test(b.accent_dark ?? '') ? b.accent_dark!.toLowerCase() : undefined,
|
|
521
554
|
header_dark: b.header_dark_own === 'true' && /^#[0-9a-f]{6}$/i.test(b.header_dark ?? '') ? b.header_dark!.toLowerCase() : undefined,
|
|
522
555
|
// a colour equal to a base style's own is not stored, so changing the base style changes it too
|
|
@@ -554,7 +587,9 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
554
587
|
} catch (e) {
|
|
555
588
|
flash(s, (e as Error).message, 'error');
|
|
556
589
|
}
|
|
557
|
-
|
|
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}`);
|
|
558
593
|
});
|
|
559
594
|
|
|
560
595
|
app.post(`${BASE}/apps/:id/delete`, async (req: Req, reply) => {
|
package/src/builder/shared.ts
CHANGED
|
@@ -32,7 +32,70 @@ function listExtras(appId: number, list: any, entries: any[]): Raw {
|
|
|
32
32
|
<p><a class="btn" href="?new=list_entry&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
|
-
|
|
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) => {
|
package/src/builder/sql.ts
CHANGED
|
@@ -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
|
-
|
|
177
|
-
|
|
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
|
-
${
|
|
170
|
-
${
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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). */
|