pgkiln 0.88.0 → 0.90.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/docs/guide/03-builder.md +77 -36
- package/package.json +1 -1
- package/public/builder.css +91 -3
- package/public/builder.js +101 -2
- package/public/palette.js +8 -1
- package/public/worksheet.css +39 -4
- package/public/worksheet.js +15 -1
- package/src/builder/appwizard.ts +45 -22
- package/src/builder/datasets.ts +12 -11
- package/src/builder/designer.ts +15 -6
- package/src/builder/errors.ts +44 -6
- package/src/builder/gallery.ts +5 -5
- package/src/builder/home.ts +78 -67
- package/src/builder/i18n/de.ts +40 -5
- package/src/builder/i18n/en.ts +41 -6
- package/src/builder/i18n/es.ts +41 -6
- package/src/builder/i18n/fr.ts +41 -6
- package/src/builder/i18n/it.ts +41 -6
- package/src/builder/i18n/ja.ts +37 -2
- package/src/builder/i18n/ko.ts +37 -2
- package/src/builder/i18n/nl.ts +41 -6
- package/src/builder/i18n/pt.ts +41 -6
- package/src/builder/i18n/zh-cn.ts +37 -2
- package/src/builder/i18n/zh-tw.ts +37 -2
- package/src/builder/routes.ts +40 -16
- package/src/builder/sql.ts +2 -2
- package/src/builder/ui.ts +47 -33
- package/src/builder/users.ts +15 -13
- package/src/builder/worksheet.ts +33 -24
- package/src/builder/workspace-home.ts +17 -0
- package/src/session.ts +2 -0
package/public/worksheet.js
CHANGED
|
@@ -104,11 +104,15 @@
|
|
|
104
104
|
out.dataset.active = key;
|
|
105
105
|
};
|
|
106
106
|
show(out.dataset.active || 'result');
|
|
107
|
+
// (UX-1) before a run the output is only its tab strip; choosing a tab opens it
|
|
107
108
|
out.querySelector('[role=tablist]').addEventListener('click', (e) => {
|
|
108
109
|
const t = e.target.closest('[role=tab]');
|
|
109
|
-
if (t)
|
|
110
|
+
if (!t) return;
|
|
111
|
+
delete out.dataset.empty;
|
|
112
|
+
show(t.dataset.wsTab);
|
|
110
113
|
});
|
|
111
114
|
out.querySelector('[role=tablist]').addEventListener('keydown', (e) => {
|
|
115
|
+
delete out.dataset.empty;
|
|
112
116
|
const k = tabs.findIndex((t) => t.getAttribute('aria-selected') === 'true');
|
|
113
117
|
const next = { ArrowRight: k + 1, ArrowLeft: k - 1, Home: 0, End: tabs.length - 1 }[e.key];
|
|
114
118
|
if (next === undefined) return;
|
|
@@ -552,6 +556,16 @@
|
|
|
552
556
|
(k === 'h' ? fr : k === 'g' ? bar.querySelector('[data-ws-goto]') : fq).focus();
|
|
553
557
|
};
|
|
554
558
|
form.querySelector('[data-ws-find]').addEventListener('click', () => openFind('f'));
|
|
559
|
+
// (UX-1) Suggest and Check in the toolbar: the code editor's own buttons (its bar below the editor stays hidden
|
|
560
|
+
// until it has a message, such as Check's result)
|
|
561
|
+
const ceButton = (cls) => form.querySelector(`.ws-editor .${cls}`);
|
|
562
|
+
for (const [attr, cls] of [['data-ws-suggest', 'ce-suggest'], ['data-ws-check', 'ce-check']]) {
|
|
563
|
+
const btn = form.querySelector(`[${attr}]`);
|
|
564
|
+
btn?.addEventListener('click', () => ceButton(cls)?.click());
|
|
565
|
+
const sync = () => { if (btn) btn.hidden = !ceButton(cls) || ceButton(cls).hidden; };
|
|
566
|
+
sync();
|
|
567
|
+
window.addEventListener('load', sync);
|
|
568
|
+
}
|
|
555
569
|
form.querySelector('[data-ws-format]').addEventListener('click', format);
|
|
556
570
|
form.querySelector('[data-ws-case]').addEventListener('click', convertCase);
|
|
557
571
|
bar.addEventListener('keydown', (e) => {
|
package/src/builder/appwizard.ts
CHANGED
|
@@ -233,6 +233,9 @@ export async function appWizardRoutes(app: FastifyInstance) {
|
|
|
233
233
|
return createPage(s, reply, schema, null);
|
|
234
234
|
});
|
|
235
235
|
|
|
236
|
+
// (UX-7) the addresses a developer guesses
|
|
237
|
+
for (const path of ['apps/new', 'apps/create']) app.get(`${BASE}/${path}`, (_req: Req, reply) => reply.redirect(`${BASE}/create`, 302));
|
|
238
|
+
|
|
236
239
|
// the earlier address of "From existing tables"
|
|
237
240
|
app.get(`${BASE}/create/tables`, async (req: Req, reply) => {
|
|
238
241
|
const s = await developer(req, reply);
|
|
@@ -325,6 +328,11 @@ const rlsBadge = (r: Relation) =>
|
|
|
325
328
|
? html` <span class="tag aw-rls" title="${r.policies === 1 ? tx('aw.rls_on_1') : tx('aw.rls_on', { n: r.policies })}">${icon('shield')} RLS · ${r.policies}</span>`
|
|
326
329
|
: html` <span class="tag tag-error aw-rls" title="${tx('aw.row_level_security_is_on')}">${icon('shield')} ${tx('aw.rls_no_policies')}</span>`;
|
|
327
330
|
|
|
331
|
+
/** A select that belongs to another form (the form attribute), with its label and help. */
|
|
332
|
+
const selectFor = (form: string, name: string, label: string, value: string, options: [string, string][], help?: string) =>
|
|
333
|
+
html`<div class="field"><label class="label" for="f_${name}">${label}</label>
|
|
334
|
+
<select id="f_${name}" name="${name}" form="${form}">${options.map(([v, l]) => html`<option value="${v}"${value === v ? raw(' selected') : ''}>${l}</option>`)}</select>${help ? html`<small class="help">${help}</small>` : ''}</div>`;
|
|
335
|
+
|
|
328
336
|
async function createPage(s: Session, reply: FastifyReply, schema: string, b: Body | null, message: Raw | '' = '') {
|
|
329
337
|
const schemas = await appSchemas();
|
|
330
338
|
const known = schemas.includes(schema);
|
|
@@ -336,6 +344,19 @@ async function createPage(s: Session, reply: FastifyReply, schema: string, b: Bo
|
|
|
336
344
|
const suggested = known ? schema.replace(/_/g, ' ').replace(/^./, (x) => x.toUpperCase()) : '';
|
|
337
345
|
const kindOf = (r: Relation, i: number) => (b ? (b[`t_${i}`] === r.relname ? (b[`k_${i}`] === undefined ? defaultKind(r) : String(b[`k_${i}`])) : '') : defaultKind(r));
|
|
338
346
|
const rows = (r: Relation) => (r.rows === null ? tx('aw.not_analyzed') : tx('aw.about_rows', { n: Number(r.rows).toLocaleString('en') }));
|
|
347
|
+
// (UX-7) how many pages each page type makes for a relation: a report and a form, or one page
|
|
348
|
+
const pagesOf = (r: Relation, k: string) => (!k ? 0 : (k === 'report_form' || (r.kind === 'table' && !!r.pk && ['facets', 'cards', 'calendar'].includes(k)) ? 2 : 1));
|
|
349
|
+
const plannedPages = 1 + relations.reduce((n, r, i) => n + pagesOf(r, kindOf(r, i)), 0)
|
|
350
|
+
+ (relations.some((r, i) => kindOf(r, i)) && (b ? b.chart === 'true' : true) ? 1 : 0);
|
|
351
|
+
const authValue = v('authentication', 'app_users');
|
|
352
|
+
const langValue = v('language', 'en');
|
|
353
|
+
const summary = [
|
|
354
|
+
known ? tx(plannedPages === 1 ? 'aw.sum_on_1' : 'aw.sum_on', { n: plannedPages, schema }) : tx('aw.sum_new'),
|
|
355
|
+
authValue === 'none' ? tx('aw.none_public') : tx('aw.app_users_login_page'),
|
|
356
|
+
LANGUAGE_NAMES[langValue as keyof typeof LANGUAGE_NAMES] ?? langValue,
|
|
357
|
+
].join(' · ');
|
|
358
|
+
// (UX-7) name first, then the data and its pages, features, appearance and sign-in; Create in a sticky footer.
|
|
359
|
+
// The schema picker belongs to a GET form of its own (form="aw-schema-get"), placed inside the create form.
|
|
339
360
|
const main = html`<div class="ab-narrow create-app">
|
|
340
361
|
<h1>${tx('aw.create_an_application')}</h1>
|
|
341
362
|
<nav class="create-sources" aria-label="${tx('aw.other_ways_to_start')}">${SOURCES.map(
|
|
@@ -343,45 +364,46 @@ async function createPage(s: Session, reply: FastifyReply, schema: string, b: Bo
|
|
|
343
364
|
)}</nav>
|
|
344
365
|
${message}
|
|
345
366
|
${schema && !known && !message ? html`<div class="alert alert-error" role="alert">${tx('aw.choose_one_of_the_schemas')}</div>` : ''}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
tx('aw.schema_help'))}</div>
|
|
349
|
-
<div class="buttons"><button class="btn">${icon('search')} ${tx('aw.show_its_tables')}</button></div></form>`)}
|
|
350
|
-
<form method="post" action="${BASE}/apps" data-progress="Creating the application and its pages…">${csrf(s)}
|
|
367
|
+
<form method="get" action="${BASE}/create" class="create-schema" id="aw-schema-get"></form>
|
|
368
|
+
<form method="post" action="${BASE}/apps" class="aw-form" data-progress="Creating the application and its pages…">${csrf(s)}
|
|
351
369
|
<input type="hidden" name="features" value="1">
|
|
352
370
|
<div id="aw-schema-fields"><input type="hidden" name="schema" value="${known ? schema : ''}">${known ? html`<input type="hidden" name="tables" value="1">` : ''}</div>
|
|
353
371
|
${region(tx('aw.application'), html`<div class="form-grid">
|
|
354
372
|
<div class="field"><label class="label" for="f_name">${tx('aw.name')}</label>
|
|
355
|
-
<input id="f_name" name="name" value="${v('name', suggested)}" required data-select-once></div>
|
|
356
|
-
|
|
373
|
+
<input id="f_name" name="name" value="${v('name', suggested)}" required data-select-once autofocus></div>
|
|
374
|
+
<div class="field"><label class="label" for="f_alias">${tx('aw.alias_url')}</label>
|
|
375
|
+
<input id="f_alias" name="alias" value="${v('alias', known ? await aliasFor(schema) : '')}" required pattern="[a-z][a-z0-9_\-]*" maxlength="50" data-derive-from="f_name"${b && b.alias ? raw(' data-edited') : ''}>
|
|
376
|
+
<small class="help">${tx('aw.lowercase_e_g_inventory_a')}</small></div>
|
|
357
377
|
${iconPicker('f_icon', 'icon', tx('aw.icon'), v('icon', DEFAULT_APP_ICON))}
|
|
378
|
+
${input('description', tx('aw.description'), v('description', ''), { placeholder: tx('aw.one_line_for_the_home') })}
|
|
358
379
|
</div>`)}
|
|
359
|
-
${region(tx('aw.
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
</div>`)}
|
|
364
|
-
${region(tx('aw.pages'), html`<ul class="aw-pages">
|
|
380
|
+
${region(tx('aw.data_and_pages'), html`<div class="form-grid aw-schema-row">${selectFor('aw-schema-get', 'schema', tx('aw.pages_on_the_tables_of'), schema,
|
|
381
|
+
[['', tx('aw.a_new_empty_schema_home')], ...schemas.map((x): [string, string] => [x, x])], tx('aw.schema_help'))}
|
|
382
|
+
<div class="buttons"><button class="btn" form="aw-schema-get">${icon('search')} ${tx('aw.show_its_tables')}</button></div></div>
|
|
383
|
+
<div id="aw-pages"><ul class="aw-pages">
|
|
365
384
|
<li><span class="aw-page-kind">${icon('home')} ${tx('aw.home')}</span><span class="muted">${tx('aw.a_hero_with_the_name')}</span></li>
|
|
366
385
|
${known
|
|
367
386
|
? relations.length
|
|
368
387
|
? relations.map((r, i) => html`<li><input type="hidden" name="t_${i}" value="${r.relname}">
|
|
369
388
|
<label class="aw-page-kind" for="f_k_${i}">${icon(r.kind === 'view' ? 'eye' : 'table')} ${r.label}</label>
|
|
370
|
-
<select id="f_k_${i}" name="k_${i}">${kindsFor(r).map((k) => html`<option value="${k}"${k === kindOf(r, i) ? raw(' selected') : ''}>${tx.dyn(`aw.k.${k || 'none'}`, PAGE_KINDS[k])}</option>`)}</select>
|
|
389
|
+
<select id="f_k_${i}" name="k_${i}">${kindsFor(r).map((k) => html`<option value="${k}" data-pages="${pagesOf(r, k)}"${k === kindOf(r, i) ? raw(' selected') : ''}>${tx.dyn(`aw.k.${k || 'none'}`, PAGE_KINDS[k])}</option>`)}</select>
|
|
371
390
|
<small class="muted"><code>${r.relname}</code> · ${r.kind === 'view' ? tx('aw.view') : rows(r)}${r.kind === 'table' && !r.pk ? tx('aw.no_key') : ''}${Number(r.rows ?? 0) > LARGE_TABLE ? tx('aw.paged_grid') : ''}</small>${rlsBadge(r)}</li>`)
|
|
372
391
|
: html`<li class="muted">${tx('aw.no_relations', { schema })}</li>`
|
|
373
392
|
: html`<li class="muted">${tx('aw.choose_a_schema_under_data')}</li>`}
|
|
374
393
|
</ul>
|
|
375
|
-
${known ? check('chart', tx('aw.dashboard_a_chart_per_table'), b ? b.chart === 'true' : true) : ''}
|
|
394
|
+
${known ? check('chart', tx('aw.dashboard_a_chart_per_table'), b ? b.chart === 'true' : true) : ''}
|
|
395
|
+
<template id="aw-summary-pages" data-schema="${known ? schema : ''}" data-one="${tx('aw.sum_on_1', { n: 1, schema: known ? schema : '' })}" data-many="${tx('aw.sum_on', { n: '{n}', schema: known ? schema : '' })}" data-new="${tx('aw.sum_new')}"></template></div>`)}
|
|
376
396
|
${region(tx('aw.features'), html`<div class="aw-features">${FEATURES.map(([k, label, help]) => { const [l, h] = featureText(k, label, help); return check(`f_${k}`, l, features[k], h); })}</div>`)}
|
|
377
|
-
${region(tx('aw.
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
397
|
+
${region(tx('aw.appearance'), html`<div class="form-grid">
|
|
398
|
+
${select('base', tx('aw.theme_style'), v('base', 'iris'), [['iris', tx('aw.iris_light_body_dark_header')], ['lavender', tx('aw.lavender_rounded_indigo')], ['standard', tx('aw.standard_blue_navy_header')]])}
|
|
399
|
+
${select('nav', tx('aw.navigation'), v('nav', 'side'), [['side', tx('aw.side_menu')], ['top', tx('aw.top_menu')]])}
|
|
400
|
+
${input('accent', tx('aw.accent_colour'), v('accent', BASE_STYLES.iris.accent), { type: 'color', help: tx('aw.of_the_theme_style_the') })}
|
|
401
|
+
</div>`)}
|
|
402
|
+
${region(tx('aw.sign_in'), html`<div class="form-grid">
|
|
403
|
+
${select('authentication', tx('aw.authentication'), authValue, [['app_users', tx('aw.app_users_login_page')], ['none', tx('aw.none_public')]])}
|
|
382
404
|
${input('admin_user', tx('aw.first_user'), v('admin_user', ''), { placeholder: tx('aw.e_g_your_name'), help: tx('aw.gets_the_admin_role_an') })}
|
|
383
405
|
${input('admin_password', tx('aw.password'), '', { type: 'password', auto: 'new-password', help: tx('aw.for_a_new_account_at') })}
|
|
384
|
-
${select('language', tx('aw.language'),
|
|
406
|
+
${select('language', tx('aw.language'), langValue, Object.entries(LANGUAGE_NAMES).map(([code, name]): [string, string] => [code, `${name} (${code})`]))}
|
|
385
407
|
</div>`)}
|
|
386
408
|
${!known && bp.length
|
|
387
409
|
? html`<details class="region region-standard aw-advanced"><summary class="region-header"><h2>${tx('aw.advanced')}</h2></summary><div class="region-body"><div class="form-grid">
|
|
@@ -389,7 +411,8 @@ async function createPage(s: Session, reply: FastifyReply, schema: string, b: Bo
|
|
|
389
411
|
tx('aw.bp_help'))}
|
|
390
412
|
</div></div></details>`
|
|
391
413
|
: ''}
|
|
392
|
-
<div class="
|
|
414
|
+
<div class="aw-footer"><p class="aw-summary muted" id="aw-summary" aria-live="polite">${summary}</p>
|
|
415
|
+
<div class="buttons"><a class="btn" href="${BASE}">${tx('aw.cancel')}</a><button class="btn btn-hot">${tx('aw.create_application')}</button></div></div>
|
|
393
416
|
</form>
|
|
394
417
|
</div>`;
|
|
395
418
|
return send(reply, s, shell(s, tx('aw.create_application'), [[tx('aw.app_builder'), BASE], [tx('aw.create')]], main));
|
package/src/builder/datasets.ts
CHANGED
|
@@ -133,27 +133,28 @@ export async function datasetRoutes(app: FastifyInstance) {
|
|
|
133
133
|
const main = html`<div class="title-row"><h1>${tx('dts.data_reporter')}</h1>
|
|
134
134
|
<div class="buttons"><a class="btn btn-hot" href="${root}/datasets/new">${icon('plus')} ${tx('dts.new_dataset')}</a><a class="btn" href="${root}/import">${icon('upload')} ${tx('dts.import')}</a>${sets.length ? html`<a class="btn" href="${root}/export">${icon('download')} ${tx('dts.export_all')}</a>` : ''}</div></div>
|
|
135
135
|
<p class="muted u-mt0">${tx('dts.intro', { workspace: s.workspace?.name ?? tx('dts.this_workspace') })}</p>
|
|
136
|
-
${region(tx('dts.datasets'), html`<div class="table-wrap"><table class="report report-reflow">
|
|
137
|
-
<thead><tr><th scope="col">${tx('dts.dataset')}</th><th scope="col">${tx('dts.schema')}</th><th scope="col">${tx('dts.tables_and_views')}</th><th scope="col" class="num">${tx('dts.columns')}</th><th scope="col">${tx('dts.updated_utc')}</th><th scope="col">${tx('dts.actions')}</th></tr></thead>
|
|
136
|
+
${region(tx('dts.datasets'), html`<div class="table-wrap rep-fit"><table class="report report-reflow">
|
|
137
|
+
<thead><tr><th scope="col">${tx('dts.dataset')}</th><th scope="col" class="col-low">${tx('dts.schema')}</th><th scope="col">${tx('dts.tables_and_views')}</th><th scope="col" class="num">${tx('dts.columns')}</th><th scope="col" class="col-low">${tx('dts.updated_utc')}</th><th scope="col" class="col-actions">${tx('dts.actions')}</th></tr></thead>
|
|
138
138
|
<tbody>${sets.length ? sets.map((d) => html`<tr>
|
|
139
|
-
<td data-label="${tx('dts.dataset')}"><a href="${root}/datasets/${d.id}">${d.label}</a> <code>${d.name}</code>${d.description ? html`<br><span class="muted small">${d.description.slice(0, 160)}</span>` : ''}</td>
|
|
140
|
-
<td data-label="${tx('dts.schema')}"><code>${d.schema_name}</code></td>
|
|
141
|
-
<td data-label="${tx('dts.tables_and_views')}">${d.tables.map((t) => t.table).join(', ')}</td>
|
|
139
|
+
<td class="col-wrap" data-label="${tx('dts.dataset')}"><a href="${root}/datasets/${d.id}">${d.label}</a> <code>${d.name}</code>${d.description ? html`<br><span class="muted small">${d.description.slice(0, 160)}</span>` : ''}</td>
|
|
140
|
+
<td class="col-low" data-label="${tx('dts.schema')}"><code>${d.schema_name}</code></td>
|
|
141
|
+
<td class="col-wrap" data-label="${tx('dts.tables_and_views')}">${d.tables.map((t) => t.table).join(', ')}</td>
|
|
142
142
|
<td class="num" data-label="${tx('dts.columns')}">${d.columns.length}</td>
|
|
143
|
-
<td data-label="${tx('dts.updated_utc')}">${when(d.updated_at)}${d.updated_by ? html` <span class="muted">${d.updated_by}</span>` : ''}</td>
|
|
144
|
-
<td data-label=""><a class="btn" href="${root}/apps/new?dataset=${encodeURIComponent(d.name)}">${icon('plus')} ${tx('dts.create_reporting_application')}</a></td>
|
|
143
|
+
<td class="col-low" data-label="${tx('dts.updated_utc')}">${when(d.updated_at)}${d.updated_by ? html` <span class="muted">${d.updated_by}</span>` : ''}</td>
|
|
144
|
+
<td class="col-actions" data-label=""><a class="btn" href="${root}/apps/new?dataset=${encodeURIComponent(d.name)}">${icon('plus')} ${tx('dts.create_reporting_application')}</a></td>
|
|
145
145
|
</tr>`) : html`<tr><td colspan="6" class="empty">${tx('dts.no_datasets_yet_create_one')}</td></tr>`}</tbody></table></div>`)}
|
|
146
|
-
${region(tx('dts.reporting_applications'), html`<div class="table-wrap"><table class="report report-reflow">
|
|
147
|
-
<thead><tr><th scope="col">${tx('dts.application')}</th><th scope="col">${tx('dts.datasets')}</th><th scope="col" class="num">${tx('dts.users')}</th><th scope="col" class="num">${tx('dts.published_reports')}</th><th scope="col">${tx('dts.actions')}</th></tr></thead>
|
|
146
|
+
${region(tx('dts.reporting_applications'), html`<div class="table-wrap rep-fit"><table class="report report-reflow">
|
|
147
|
+
<thead><tr><th scope="col">${tx('dts.application')}</th><th scope="col">${tx('dts.datasets')}</th><th scope="col" class="num">${tx('dts.users')}</th><th scope="col" class="num">${tx('dts.published_reports')}</th><th scope="col" class="col-actions">${tx('dts.actions')}</th></tr></thead>
|
|
148
148
|
<tbody>${apps.length ? apps.map((a) => html`<tr>
|
|
149
149
|
<td data-label="${tx('dts.application')}"><a href="${BASE}/apps/${a.id}">${a.name}</a> <code>${a.alias}</code></td>
|
|
150
150
|
<td data-label="${tx('dts.datasets')}">${a.datasets.join(', ')}</td>
|
|
151
151
|
<td class="num" data-label="${tx('dts.users')}">${a.users}</td>
|
|
152
152
|
<td class="num" data-label="${tx('dts.published_reports')}">${a.reports}</td>
|
|
153
|
-
<td data-label=""><a class="btn" href="/a/${a.alias}/">${icon('play')} ${tx('dts.run')}</a> <a class="btn" href="${BASE}/apps/${a.id}/shared">${icon('users')} ${tx('dts.users_and_roles')}</a></td>
|
|
153
|
+
<td class="col-actions" data-label=""><a class="btn" href="/a/${a.alias}/">${icon('play')} ${tx('dts.run')}</a> <a class="btn" href="${BASE}/apps/${a.id}/shared">${icon('users')} ${tx('dts.users_and_roles')}</a></td>
|
|
154
154
|
</tr>`) : html`<tr><td colspan="5" class="empty">${tx('dts.no_reporting_applications_yet_choose')}</td></tr>`}</tbody></table></div>
|
|
155
155
|
<p class="muted small">${tx.rich('dts.roles_help', { viewer: html`<b>viewer</b>`, editor: html`<b>editor</b>`, administrator: html`<b>administrator</b>` })}</p>`)}`;
|
|
156
|
-
|
|
156
|
+
// (UX-8) the lists take the page's width
|
|
157
|
+
return send(reply, s, shell(s, tx('dts.data_reporter'), crumbs([]), html`<div class="dataset-page">${main}</div>`, 'reporter'));
|
|
157
158
|
});
|
|
158
159
|
|
|
159
160
|
// ---------------------------------------------------------------- a new dataset: schema, then tables and views
|
package/src/builder/designer.ts
CHANGED
|
@@ -123,7 +123,7 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
123
123
|
// (sprint 90) a commented-out component: struck through in the tree, Uncomment in its menu
|
|
124
124
|
const commented = (r: any) => (r.build_option === 'COMMENTED_OUT' ? raw(' data-commented') : '');
|
|
125
125
|
const leaf = (k: string, r: any, ic: Raw | '', label: string, note = '') =>
|
|
126
|
-
html`<li><a class="pd-node" href="${url(`c=${k}-${r.id}`)}"${cur(`${k}-${r.id}`)} data-kind="${k}" data-id="${r.id}"${commented(r)}>${ic}<span class="pd-label">${label}</span>${badges(r)}${note ? html`<span class="pd-note">${note}</span>` : ''}</a></li>`;
|
|
126
|
+
html`<li><a class="pd-node" href="${url(`c=${k}-${r.id}`)}"${cur(`${k}-${r.id}`)} data-kind="${k}" data-id="${r.id}" title="${note ? `${label} (${note})` : label}"${commented(r)}>${ic}<span class="pd-label">${label}</span>${badges(r)}${note ? html`<span class="pd-note">${note}</span>` : ''}</a></li>`;
|
|
127
127
|
const folder = (label: string, children: Raw[] | Raw, open = true) =>
|
|
128
128
|
html`<li${open ? '' : raw(' data-collapsed')}><span class="pd-node pd-folder"><span class="pd-label">${label}</span></span><ul>${children}</ul></li>`;
|
|
129
129
|
const none = (text: string) => html`<li class="pd-empty">${text}</li>`;
|
|
@@ -136,7 +136,7 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
136
136
|
const bySlot = ['region', ...TOP_SLOTS, ...BOTTOM_SLOTS].map((sl) => [sl, buttons.filter((b) => (b.slot ?? 'region') === sl)] as const).filter(([, list]) => list.length);
|
|
137
137
|
const kids = [...(items.length ? [folder(tx('pd.tree.items'), items.map(itemLeaf))] : []),
|
|
138
138
|
...bySlot.map(([sl, list]) => folder(sl === 'region' ? tx('pd.tree.buttons') : tx('pd.tree.buttons_at', { slot: slotLabel(sl) }), list.map(buttonLeaf)))];
|
|
139
|
-
return html`<li><a class="pd-node" href="${url(`c=region-${r.id}`)}"${cur(`region-${r.id}`)} data-kind="region" data-id="${r.id}" data-position="${r.position ?? ''}" data-next="${next?.id ?? ''}"${commented(r)}>${bicon(regionIcon(r.type))}<span class="pd-label">${regionTitle(r)}</span>${badges(r)}<span class="pd-note">${r.type}</span></a>${kids.length ? html`<ul>${kids}</ul>` : ''}</li>`;
|
|
139
|
+
return html`<li><a class="pd-node" href="${url(`c=region-${r.id}`)}"${cur(`region-${r.id}`)} data-kind="region" data-id="${r.id}" data-position="${r.position ?? ''}" data-next="${next?.id ?? ''}" title="${regionTitle(r)} (${r.type})"${commented(r)}>${bicon(regionIcon(r.type))}<span class="pd-label">${regionTitle(r)}</span>${badges(r)}<span class="pd-note">${r.type}</span></a>${kids.length ? html`<ul>${kids}</ul>` : ''}</li>`;
|
|
140
140
|
};
|
|
141
141
|
// (sprint 77, APEX: the rendering tree by display position) the page template's positions, and others in use
|
|
142
142
|
const treeSide = p.page_template === 'left_side_column' ? 'left' : p.page_template === 'right_side_column' ? 'right' : null;
|
|
@@ -419,9 +419,7 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
419
419
|
</div></fieldset>
|
|
420
420
|
<div class="buttons"><button class="btn btn-hot">${tx('pd.save_page')}</button></div>
|
|
421
421
|
</form>`, true)])}
|
|
422
|
-
${await usedInPanel(p.app_id, 'page', p)}
|
|
423
|
-
<form method="post" action="${BASE}/pages/${p.id}/delete" class="danger-zone">${csrf(s)}
|
|
424
|
-
<button class="btn btn-sm btn-danger" data-confirm="${tx('pd.delete_page_confirm', { n: p.page_no })}">${tx('pd.delete_page')}</button></form>`;
|
|
422
|
+
${await usedInPanel(p.app_id, 'page', p)}`;
|
|
425
423
|
}
|
|
426
424
|
// (sprint 59) the help pane: the guide section of the selected component's type
|
|
427
425
|
const guide = (() => {
|
|
@@ -465,7 +463,11 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
465
463
|
<a href="${BASE}/apps/${p.app_id}/shared">${bicon('shapes')} ${tx('pd.tab.shared')}</a>
|
|
466
464
|
<a href="${BASE}/apps/${p.app_id}">${bicon('pages')} ${tx('pd.menu.all_pages')}</a>
|
|
467
465
|
<a href="${BASE}/apps/${p.app_id}/export">${icon('download')} ${tx('pd.menu.export')}</a>
|
|
468
|
-
</div
|
|
466
|
+
</div>
|
|
467
|
+
<!-- (UX-6) away from Save: deleting the page takes its number typed -->
|
|
468
|
+
<div class="menu-section"><form method="post" action="${BASE}/pages/${p.id}/delete" class="danger-form">${csrf(s)}
|
|
469
|
+
<label class="field"><span class="label">${tx('pd.delete_type', { n: p.page_no })}</span><input name="confirm" inputmode="numeric" autocomplete="off" required pattern="${p.page_no}"></label>
|
|
470
|
+
<button class="btn btn-sm btn-danger">${icon('trash')} ${tx('pd.delete_page')}</button></form></div></div></details>`;
|
|
469
471
|
const toolbar = html`
|
|
470
472
|
<div class="tb-group pd-pagenav" role="group" aria-label="${tx('pd.copy.page')}">
|
|
471
473
|
${prev ? html`<a class="tb-btn" href="${designerUrl(p.app_id, prev.page_no)}" title="${tx('pd.prev_title', { n: prev.page_no, name: prev.name })}">${bicon('left')}<span class="sr-only">${tx('pd.prev')}</span></a>` : html`<span class="tb-btn" aria-disabled="true">${bicon('left')}</span>`}
|
|
@@ -537,6 +539,13 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
537
539
|
app.post(`${BASE}/pages/:pid/delete`, async (req: Req, reply) => {
|
|
538
540
|
const s = await developer(req, reply);
|
|
539
541
|
if (!s) return;
|
|
542
|
+
// (UX-6) only with the page number typed
|
|
543
|
+
const target = await owner.one<{ page_no: number }>('select page_no from meta.page where id = $1', [Number(req.params.pid) || 0]);
|
|
544
|
+
if (!target) return reply.code(404).send('Not found');
|
|
545
|
+
if (String(req.body?.confirm ?? '').trim() !== String(target.page_no)) {
|
|
546
|
+
flash(s, tr(s)('pd.delete_mismatch', { n: target.page_no }), 'error');
|
|
547
|
+
return back(reply, s, `${BASE}/pages/${req.params.pid}`);
|
|
548
|
+
}
|
|
540
549
|
const p = await owner.one('delete from meta.page where id = $1 returning app_id, page_no', [req.params.pid]);
|
|
541
550
|
if (p) for (const t of ['builder_lock', 'dev_comment']) await owner.query(`delete from meta.${t} where app_id = $1 and page_no = $2`, [p.app_id, p.page_no]);
|
|
542
551
|
flash(s, tr(s)('pd.page_deleted'));
|
package/src/builder/errors.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
|
2
2
|
import { html } from '../html.ts';
|
|
3
3
|
import { documentShell } from '../layout.ts';
|
|
4
|
-
import { BASE, THEME_COOKIE, validTheme } from './ui.ts';
|
|
5
|
-
import { cookieLanguage, translatorFor } from './i18n.ts';
|
|
4
|
+
import { BASE, shell, THEME_COOKIE, validTheme } from './ui.ts';
|
|
5
|
+
import { cookieLanguage, setRequestTranslator, tr, translatorFor } from './i18n.ts';
|
|
6
|
+
import { getSession } from '../session.ts';
|
|
7
|
+
import { loadLanguage } from './language.ts';
|
|
8
|
+
import { loadWorkspaces } from './workspaces.ts';
|
|
9
|
+
import { recentApps } from './home.ts';
|
|
6
10
|
|
|
7
11
|
// (sprint 86) The builder's error pages: a browser that opens an address under /builder that
|
|
8
12
|
// doesn't exist (or an application or page that doesn't, or not for this developer), or that
|
|
@@ -33,8 +37,42 @@ export function builderErrorPage(req: FastifyRequest, status: number) {
|
|
|
33
37
|
);
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
/** "/builder/apps/new?x" → "apps new": the words of an address, for the search field of the page */
|
|
41
|
+
export const searchFromPath = (url: string) =>
|
|
42
|
+
url.split(/[?#]/)[0].slice(BASE.length).split('/').map((w) => { try { return decodeURIComponent(w); } catch { return w; } })
|
|
43
|
+
.filter((w) => w && !/^\d+$/.test(w)).join(' ').replace(/[^\p{L}\p{N} _.-]+/gu, ' ').replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* (UX-10) "Page not found" for a signed-in developer: inside the builder's frame (rail, header search, status bar),
|
|
47
|
+
* with a search field filled from the address and the recent applications. The session is only read: nothing is
|
|
48
|
+
* saved, so a waiting message stays for the next page. Null: not signed in (the standalone page then).
|
|
49
|
+
*/
|
|
50
|
+
export async function framedNotFound(req: FastifyRequest, reply: FastifyReply) {
|
|
51
|
+
const s = await getSession(req, reply, null, BASE, { touch: false }).catch(() => null);
|
|
52
|
+
if (!s?.username) return null;
|
|
53
|
+
if (s.state.__BLANG === undefined) await loadLanguage(s);
|
|
54
|
+
const tx = tr(s);
|
|
55
|
+
setRequestTranslator(tx);
|
|
56
|
+
await loadWorkspaces(s);
|
|
57
|
+
s.path = req.url;
|
|
58
|
+
const title = tx('error.not_found_title');
|
|
59
|
+
const recent = await recentApps(s).catch(() => []);
|
|
60
|
+
const main = html`<div class="ab-narrow nf-page">
|
|
61
|
+
<h1>${title}</h1>
|
|
62
|
+
<p>${tx('error.not_found_text')}</p>
|
|
63
|
+
<p class="muted">${tx('error.label_not_found')}: <code>${req.url.split('?')[0].slice(0, 200)}</code></p>
|
|
64
|
+
<form method="get" action="${BASE}/palette" class="search nf-search" role="search">
|
|
65
|
+
<label class="sr-only" for="nf-q">${tx('bar.search_label')}</label>
|
|
66
|
+
<input id="nf-q" type="search" name="q" value="${searchFromPath(req.url)}" autofocus><button class="btn">${tx('common.search')}</button></form>
|
|
67
|
+
${recent.length ? html`<h2>${tx('ab.recent')}</h2><ul class="ab-links">${recent.map((a) => html`<li><a href="${BASE}/apps/${a.id}">${a.name}</a></li>`)}</ul>` : ''}
|
|
68
|
+
<p><a class="btn btn-hot" href="${BASE}">${tx('error.home')}</a></p>
|
|
69
|
+
</div>`;
|
|
70
|
+
return shell(s, title, [[title]], main);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const sendPage = async (req: FastifyRequest, reply: FastifyReply, status: number) =>
|
|
74
|
+
reply.code(status).type('text/html; charset=utf-8').header('cache-control', 'no-store')
|
|
75
|
+
.send((status === 404 && (await framedNotFound(req, reply))) || builderErrorPage(req, status));
|
|
38
76
|
|
|
39
77
|
/**
|
|
40
78
|
* The hooks (registered on the root instance): unknown builder addresses, the builder's own
|
|
@@ -50,7 +88,7 @@ export function builderErrorPages(app: FastifyInstance) {
|
|
|
50
88
|
if (reply.statusCode !== 404 || !isBuilder(req.url) || !wantsHtml(req)) return payload;
|
|
51
89
|
if (!/^text\/plain\b/.test(String(reply.getHeader('content-type') ?? ''))) return payload;
|
|
52
90
|
reply.type('text/html; charset=utf-8');
|
|
53
|
-
return builderErrorPage(req, 404);
|
|
91
|
+
return (await framedNotFound(req, reply)) || builderErrorPage(req, 404);
|
|
54
92
|
});
|
|
55
93
|
}
|
|
56
94
|
|
|
@@ -58,6 +96,6 @@ export function builderErrorPages(app: FastifyInstance) {
|
|
|
58
96
|
export function builderServerError(req: FastifyRequest, reply: FastifyReply, err: Error & { statusCode?: number }) {
|
|
59
97
|
if (!isBuilder(req.url) || !wantsHtml(req) || (err.statusCode !== undefined && err.statusCode < 500)) return false;
|
|
60
98
|
req.log.error(err);
|
|
61
|
-
|
|
99
|
+
reply.code(500).type('text/html; charset=utf-8').header('cache-control', 'no-store').send(builderErrorPage(req, 500));
|
|
62
100
|
return true;
|
|
63
101
|
}
|
package/src/builder/gallery.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { migrate } from '../migrate.ts';
|
|
|
9
9
|
import { clientIp, logActivity } from '../session.ts';
|
|
10
10
|
import { openBlueprint } from './blueprints.ts';
|
|
11
11
|
import { isAdmin } from './locks.ts';
|
|
12
|
-
import { back, BASE, csrf, developer, flash, send, shell, type Req } from './ui.ts';
|
|
12
|
+
import { back, BASE, csrf, developer, flash, pageHeader, send, shell, type Req } from './ui.ts';
|
|
13
13
|
import { currentWorkspace, inWorkspaceSql, placeApp } from './workspaces.ts';
|
|
14
14
|
import { workspacesChanged } from '../workspace-db.ts';
|
|
15
15
|
import { scopedTx as tx, type BuilderKey } from './i18n.ts';
|
|
@@ -104,7 +104,7 @@ export async function galleryRoutes(app: FastifyInstance) {
|
|
|
104
104
|
const tabs = html`<nav class="gal-tabs segmented" aria-label="${tx('gal.kinds')}">${TABS.map(([t, l]) =>
|
|
105
105
|
html`<a href="${BASE}/gallery?tab=${t}${q ? `&q=${encodeURIComponent(q)}` : ''}"${tab === t ? raw(' aria-current="page"') : ''}>${tx(l)} <span class="gal-count">${counts[t]}</span></a>`)}</nav>`;
|
|
106
106
|
const blueprintCard = (kind: 'starters' | 'utilities') => (x: Starter) => card(x.icon, x.title, 'pgkiln', x.description,
|
|
107
|
-
html`<form method="post" action="${BASE}/gallery/${kind}/${x.key}">${csrf(s)}<button class="btn btn-
|
|
107
|
+
html`<form method="post" action="${BASE}/gallery/${kind}/${x.key}">${csrf(s)}<button class="btn btn-sm">${tx('gal.install')}</button></form>`);
|
|
108
108
|
const cards =
|
|
109
109
|
tab === 'samples'
|
|
110
110
|
? samples.filter(matches).map((x) => {
|
|
@@ -115,13 +115,13 @@ export async function galleryRoutes(app: FastifyInstance) {
|
|
|
115
115
|
? html`<a class="btn btn-sm" href="${BASE}/apps/${it.id}">${tx('gal.edit')}</a>`
|
|
116
116
|
: html`<span class="muted small">${tx('gal.other_workspace')}</span>`}`
|
|
117
117
|
: admin
|
|
118
|
-
? html`<form method="post" action="${BASE}/gallery/samples/${x.key}">${csrf(s)}<button class="btn btn-
|
|
118
|
+
? html`<form method="post" action="${BASE}/gallery/samples/${x.key}">${csrf(s)}<button class="btn btn-sm" data-confirm="${tx('gal.confirm_install', { title: x.title })}">${tx('gal.install')}</button></form>`
|
|
119
119
|
: html`<span class="muted small">${tx('gal.admin_installs')}</span>`);
|
|
120
120
|
})
|
|
121
121
|
: tab === 'starters' || tab === 'utilities'
|
|
122
122
|
? (tab === 'starters' ? starters : utilities).filter(matches).map(blueprintCard(tab))
|
|
123
123
|
: custom.filter(matches).map((x) => card('copy', x.name, tx('gal.this_workspace'), x.description ?? tx('gal.boilerplate_desc'),
|
|
124
|
-
html`<a class="btn btn-
|
|
124
|
+
html`<a class="btn btn-sm" href="${BASE}/create?boilerplate=${x.id}">${tx('gal.create_from')}</a><a class="btn btn-sm" href="${BASE}/apps/${x.id}">${tx('gal.edit')}</a>`));
|
|
125
125
|
const empty = q && counts[tab]
|
|
126
126
|
? html`<p class="muted">${tx.rich('gal.no_match', { q: html`<strong>${q}</strong>` })} <a href="${BASE}/gallery?tab=${tab}">${tx('gal.show_all')}</a></p>`
|
|
127
127
|
: tab === 'custom'
|
|
@@ -129,13 +129,13 @@ export async function galleryRoutes(app: FastifyInstance) {
|
|
|
129
129
|
: html`<p class="muted">${tx('gal.empty')}</p>`;
|
|
130
130
|
const intro = tx(({ samples: 'gal.intro.samples', starters: 'gal.intro.starters', utilities: 'gal.intro.utilities', custom: 'gal.intro.custom' } as const)[tab]);
|
|
131
131
|
const main = html`<div class="gal">
|
|
132
|
+
${pageHeader(tx('rail.gallery'))}
|
|
132
133
|
<div class="gal-bar">
|
|
133
134
|
<form class="ab-search gal-search" role="search" method="get" action="${BASE}/gallery"><label class="sr-only" for="gal-q">${tx('gal.search')}</label>${icon('search')}
|
|
134
135
|
<input type="hidden" name="tab" value="${tab}">
|
|
135
136
|
<input id="gal-q" name="q" type="search" placeholder="${tx('gal.search')}" autocomplete="off" value="${q}" data-filter-list=".gal [data-filter-row]"></form>
|
|
136
137
|
${tabs}
|
|
137
138
|
</div>
|
|
138
|
-
<h1 class="sr-only">${tx('rail.gallery')}</h1>
|
|
139
139
|
<p class="muted">${intro}</p>
|
|
140
140
|
${cards.length ? html`<div class="gal-grid">${cards}</div>` : empty}
|
|
141
141
|
</div>`;
|