pgkiln 0.89.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 +24 -11
- package/package.json +1 -1
- package/public/builder.css +34 -0
- package/public/builder.js +35 -2
- package/src/builder/appwizard.ts +45 -22
- package/src/builder/datasets.ts +12 -11
- package/src/builder/designer.ts +13 -4
- package/src/builder/errors.ts +44 -6
- package/src/builder/home.ts +7 -7
- package/src/builder/i18n/de.ts +18 -5
- package/src/builder/i18n/en.ts +19 -6
- package/src/builder/i18n/es.ts +19 -6
- package/src/builder/i18n/fr.ts +19 -6
- package/src/builder/i18n/it.ts +19 -6
- package/src/builder/i18n/ja.ts +15 -2
- package/src/builder/i18n/ko.ts +15 -2
- package/src/builder/i18n/nl.ts +19 -6
- package/src/builder/i18n/pt.ts +19 -6
- package/src/builder/i18n/zh-cn.ts +15 -2
- package/src/builder/i18n/zh-tw.ts +15 -2
- package/src/builder/routes.ts +19 -6
- package/src/builder/users.ts +15 -13
package/docs/guide/03-builder.md
CHANGED
|
@@ -504,7 +504,9 @@ other sites.
|
|
|
504
504
|
(sprint 86) An address under `/builder` that leads nowhere (a deleted application or page, a typo, an
|
|
505
505
|
application of a workspace you are not a member of) shows a **Page not found** page in the builder's
|
|
506
506
|
theme with a link to the App Builder home; a server error shows **Something went wrong** (the error
|
|
507
|
-
is in the server log, not on the page). Scripts get the plain answers they always got.
|
|
507
|
+
is in the server log, not on the page). Scripts get the plain answers they always got. (UX-10) Signed in, the
|
|
508
|
+
page stays inside the builder (rail, header search, status bar), with a search field filled with the words of the
|
|
509
|
+
address and your recent applications. `/builder/apps/new` and `/builder/apps/create` lead to Create application.
|
|
508
510
|
|
|
509
511
|
### Choosing the builder language
|
|
510
512
|
|
|
@@ -727,19 +729,22 @@ you like. `meta.reporting_app_count(workspace_id)` counts a workspace's reportin
|
|
|
727
729
|
|
|
728
730
|
### Creating an application
|
|
729
731
|
|
|
730
|
-
**Create** (
|
|
731
|
-
start from a file, pasted data, a blueprint or the App Gallery instead.
|
|
732
|
+
**Create** (the App Builder's primary button, or `/builder/create`) is one page, like APEX's Create Application
|
|
733
|
+
wizard. Links at the top start from a file, pasted data, a blueprint or the App Gallery instead. (UX-7) The name
|
|
734
|
+
comes first, then the data and its pages, features, appearance and sign-in; **Create application** and Cancel stay
|
|
735
|
+
in a footer at the bottom of the window, next to a one-line summary (*12 pages on hr · App users (login page) ·
|
|
736
|
+
English*) that follows your choices. The sections:
|
|
732
737
|
|
|
733
738
|
| Section | Field | Meaning |
|
|
734
739
|
|---|---|---|
|
|
735
|
-
| Data | Pages on the tables of | The parsing schema: an existing schema, or a new, empty one named after the alias. (sprint 87) Choosing a schema lists its tables under Pages at once, with the name and alias proposed for it (unless you already typed them); without JavaScript, *Show its tables* does it |
|
|
736
740
|
| Application | Name | Display name, shown in the header. Proposed from the schema and selected when you click it, so typing replaces it |
|
|
737
|
-
| |
|
|
738
|
-
|
|
|
739
|
-
|
|
|
741
|
+
| | Alias | Lowercase URL name: `inventory` gives `/a/inventory`. (sprint 87) It follows the name as you type it (*Café Orders* → `cafe-orders`) until you change it yourself |
|
|
742
|
+
| | Icon, Description | Shown on the home page's hero and the About page |
|
|
743
|
+
| Data and pages | Pages on the tables of | The parsing schema: an existing schema, or a new, empty one named after the alias. (sprint 87) Choosing a schema lists its tables at once, with the name and alias proposed for it (unless you already typed them); without JavaScript, *Show its tables* does it (it reloads the page, so choose the schema before typing) |
|
|
744
|
+
| | a page type per table | *Interactive report with form*, *Faceted search*, *Cards*, *Calendar* (a table with a date column), *Master detail* (a table another table refers to), *Interactive grid*, *Report* or *No page*. Tables with a single-column primary key get a modal form with their report, faceted search, cards and calendar; views and tables without such a key can't have a form or a grid. Proposed: a report with form, an interactive grid for a table of more than 100,000 rows, a report for a view. (sprint 87) A table with [row level security](08-security.md) shows *RLS · n* (its number of policies), or *RLS, no policies* in red: the application's role would see no rows. *Dashboard*: a chart per table |
|
|
740
745
|
| Features | | *About page* (in a Help menu), *Access control* (authorization schemes `CONTRIBUTOR` and `READER` next to `ADMIN`, for the roles admin, contributor and reader), *Activity reporting* (page 9010 for administrators: page views per day and the latest activity, from `meta.app_activity`), *Feedback*, *Theme style selection* (users may choose light or dark), *Install as an app* (PWA) and *Push notifications* (needs `PGKILN_SECRET_KEY`) |
|
|
741
|
-
|
|
|
742
|
-
| | Authentication | *App users* (a login page and a user list), *HTTP header* (a trusted reverse proxy names the user, see [chapter 8](08-security.md#http-header-authentication-reverse-proxy)), *Database accounts* (PostgreSQL login roles and their passwords, see [chapter 8](08-security.md#database-accounts-postgresql-roles)), *Custom* (your own PL/pgSQL function, see [chapter 8](08-security.md#custom-authentication-a-plpgsql-function)) or *None* (a public app) |
|
|
746
|
+
| Appearance | Theme style, Navigation, Accent colour | Iris, Lavender or Standard; a side or top menu; an own accent colour |
|
|
747
|
+
| Sign-in | Authentication | *App users* (a login page and a user list), *HTTP header* (a trusted reverse proxy names the user, see [chapter 8](08-security.md#http-header-authentication-reverse-proxy)), *Database accounts* (PostgreSQL login roles and their passwords, see [chapter 8](08-security.md#database-accounts-postgresql-roles)), *Custom* (your own PL/pgSQL function, see [chapter 8](08-security.md#custom-authentication-a-plpgsql-function)) or *None* (a public app) |
|
|
743
748
|
| | First user / Password | The first user; they get the `admin` role. An existing account is reused (its password isn't changed). (sprint 87) Hidden while Authentication is *None* |
|
|
744
749
|
| | Language | The application's primary language |
|
|
745
750
|
| Advanced | Start from | Shown when there are **boilerplate** applications (Settings → Application type): their pages, shared components and settings are copied into the new application, which keeps its own name, alias, role and authentication ([application types](#application-types-and-subscriptions)) |
|
|
@@ -953,7 +958,11 @@ they are **not** run until you choose to.
|
|
|
953
958
|
(sprint 74) Above the pages: a **search** by page name or number (the rows filter while you type),
|
|
954
959
|
**Report** and **Icons** views, and **Create page** (the page wizards below). **Tasks** on the right: run the
|
|
955
960
|
application, create pages (also with AI), (sprint 137) **Add feature page** and **Create page as copy**, Shared
|
|
956
|
-
Components, page groups, export, backups, supporting objects
|
|
961
|
+
Components, page groups, export, backups, supporting objects and lock the application. (UX-9) The report shows a
|
|
962
|
+
page's mode in the property editor's words (*Normal*, *Modal dialog*), and Group stays empty for a page without one.
|
|
963
|
+
(UX-6) Deleting the application is not a task: it is the **Danger zone** at the bottom of Settings → Definition,
|
|
964
|
+
where you type the application's alias to confirm (the server checks it too). Its pages, shared components,
|
|
965
|
+
working copies and backups go with it, so export it first if you may need it again.
|
|
957
966
|
|
|
958
967
|
The **default-password** warning shows on the App Builder home (and Developers) only; dismiss it with × until the
|
|
959
968
|
next sign-in. The avatar keeps a red dot until the password is changed.
|
|
@@ -1092,6 +1101,9 @@ leaving the page with one asks first. On a window under 800 pixels high the gall
|
|
|
1092
1101
|
its tabs (*Regions*, *Items*, *Buttons*): choose one to open it, choose the open one to fold it again; the
|
|
1093
1102
|
designer remembers which you prefer.
|
|
1094
1103
|
|
|
1104
|
+
(UX-6) **Delete page** is in the toolbar's page menu (the wrench), away from Save: type the page number to
|
|
1105
|
+
confirm. Its regions, items, buttons, dynamic actions and processes go with it, as do its lock and comments.
|
|
1106
|
+
|
|
1095
1107
|
- **Left: the component tree**, with four tabs:
|
|
1096
1108
|
- **Rendering**: the page, *Pre-Rendering* (branches, computations and processes that run
|
|
1097
1109
|
before the page is shown), the regions by
|
|
@@ -1602,7 +1614,8 @@ on import), downloads it again and removes it. See
|
|
|
1602
1614
|
## Users (the user directory)
|
|
1603
1615
|
|
|
1604
1616
|
**Builder → Users** lists every account with the applications (and roles) it can use. Create
|
|
1605
|
-
accounts here (a password is optional for single sign-on-only accounts)
|
|
1617
|
+
accounts here (a password is optional for single sign-on-only accounts): (UX-8) **Create account** in the header
|
|
1618
|
+
opens a side panel, so the list keeps the page's width. Open an account to edit
|
|
1606
1619
|
its name and e-mail, set or remove its password, deactivate or delete it, and grant, change or
|
|
1607
1620
|
revoke access per application. Password changes, deactivation and access changes end the
|
|
1608
1621
|
affected sessions. See [chapter 8](08-security.md#the-user-directory).
|
package/package.json
CHANGED
package/public/builder.css
CHANGED
|
@@ -1725,3 +1725,37 @@ a.cp-type:hover, a.cp-type:focus-visible { border-color: var(--accent); }
|
|
|
1725
1725
|
.wh-attention h2 { margin: 0 0 4px; font-size: 14px; }
|
|
1726
1726
|
.wh-attention ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
|
|
1727
1727
|
.wh-attention .icon { width: 15px; height: 15px; vertical-align: -2px; }
|
|
1728
|
+
/* (UX-6) deleting an application or a page: apart from everyday actions, the name typed to confirm */
|
|
1729
|
+
.danger-box { margin-top: 24px; padding: 12px 16px; border: 1px solid color-mix(in srgb, var(--danger) 45%, var(--border)); border-radius: 8px; background: var(--danger-soft); }
|
|
1730
|
+
.danger-box h2 { margin: 0 0 4px; font-size: 15px; color: var(--danger); }
|
|
1731
|
+
.danger-box p { margin: 0 0 8px; }
|
|
1732
|
+
.danger-form { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px; }
|
|
1733
|
+
.danger-form .field { margin: 0; flex: 1 1 14rem; max-width: 22rem; }
|
|
1734
|
+
.menu-section .danger-form { margin-top: 6px; padding-top: 8px; border-top: 1px solid var(--border); }
|
|
1735
|
+
/* (UX-7) Create application: Create always in reach, with a one-line summary */
|
|
1736
|
+
.aw-footer { position: sticky; bottom: 29px; z-index: 15; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px;
|
|
1737
|
+
margin-top: 16px; padding: 10px 14px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-md); }
|
|
1738
|
+
.aw-footer .buttons { margin: 0; }
|
|
1739
|
+
.aw-summary { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
|
1740
|
+
.aw-schema-row { align-items: end; }
|
|
1741
|
+
.aw-schema-row .buttons { margin: 0 0 1rem; }
|
|
1742
|
+
/* (UX-10) Page not found inside the builder */
|
|
1743
|
+
.nf-page h1 { margin-top: 8px; }
|
|
1744
|
+
.nf-search { margin: 12px 0 16px; max-width: 32rem; }
|
|
1745
|
+
.nf-page h2 { font-size: 14px; margin: 12px 0 4px; }
|
|
1746
|
+
/* (UX-8) builder reports at laptop width, one rule for all (opt in with .rep-fit): low-value columns (.col-low, under 1150 px of table width) give
|
|
1747
|
+
way first, long text wraps (.col-wrap), and the Actions column (.col-actions) stays on screen at the right */
|
|
1748
|
+
.table-wrap.rep-fit { container-type: inline-size; }
|
|
1749
|
+
@container (max-width: 1150px) { .rep-fit .report .col-low { display: none; } }
|
|
1750
|
+
.ide .report td.col-wrap { white-space: normal; min-width: 12rem; overflow-wrap: anywhere; }
|
|
1751
|
+
.ide .report .col-actions { position: sticky; right: 0; background: var(--surface); box-shadow: inset 1px 0 0 var(--border); }
|
|
1752
|
+
.ide .report th.col-actions { background: var(--surface-2); }
|
|
1753
|
+
/* (UX-8) a side drawer opened from the page header (details.drawer): Create account on Users */
|
|
1754
|
+
.drawer { display: inline-block; }
|
|
1755
|
+
.drawer > summary { list-style: none; cursor: pointer; }
|
|
1756
|
+
.drawer > summary::-webkit-details-marker { display: none; }
|
|
1757
|
+
.drawer-panel { position: fixed; top: 0; right: 0; bottom: 0; z-index: 60; width: min(26rem, 100vw); overflow-y: auto; padding: 16px 18px 24px;
|
|
1758
|
+
background: var(--surface); color: var(--text); border-left: 1px solid var(--border-strong); box-shadow: var(--shadow-lg); text-align: left; }
|
|
1759
|
+
.drawer-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; }
|
|
1760
|
+
.drawer-head h2 { margin: 0; font-size: 16px; }
|
|
1761
|
+
.drawer-panel .buttons { justify-content: flex-end; }
|
package/public/builder.js
CHANGED
|
@@ -1272,7 +1272,30 @@ document.querySelectorAll('input[data-filter-tree]').forEach((el) => {
|
|
|
1272
1272
|
sync();
|
|
1273
1273
|
});
|
|
1274
1274
|
|
|
1275
|
-
|
|
1275
|
+
// (UX-7) the footer's summary follows the page types, the sign-in and the language
|
|
1276
|
+
const awForm = document.querySelector('form.aw-form');
|
|
1277
|
+
const summary = document.getElementById('aw-summary');
|
|
1278
|
+
const sum = () => {
|
|
1279
|
+
const tpl = document.getElementById('aw-summary-pages');
|
|
1280
|
+
if (!awForm || !summary || !tpl) return;
|
|
1281
|
+
let parts;
|
|
1282
|
+
if (tpl.dataset.schema) {
|
|
1283
|
+
let n = 1;
|
|
1284
|
+
awForm.querySelectorAll('#aw-pages select').forEach((sel) => (n += Number(sel.selectedOptions[0]?.dataset.pages ?? 0)));
|
|
1285
|
+
const chart = awForm.querySelector('#aw-pages input[name="chart"]');
|
|
1286
|
+
if (chart?.checked && n > 1) n += 1;
|
|
1287
|
+
parts = [n === 1 ? tpl.dataset.one : tpl.dataset.many.replace('{n}', String(n))];
|
|
1288
|
+
} else parts = [tpl.dataset.new];
|
|
1289
|
+
for (const name of ['authentication', 'language']) {
|
|
1290
|
+
const sel = awForm.elements.namedItem(name);
|
|
1291
|
+
const text = sel?.selectedOptions?.[0]?.textContent?.replace(/\s*\([a-z-]+\)$/i, '');
|
|
1292
|
+
if (text) parts.push(text);
|
|
1293
|
+
}
|
|
1294
|
+
summary.textContent = parts.join(' · ');
|
|
1295
|
+
};
|
|
1296
|
+
awForm?.addEventListener('change', sum);
|
|
1297
|
+
|
|
1298
|
+
const picker = document.querySelector('select[name="schema"][form="aw-schema-get"]');
|
|
1276
1299
|
const pages = document.getElementById('aw-pages');
|
|
1277
1300
|
const hidden = document.getElementById('aw-schema-fields');
|
|
1278
1301
|
if (!picker || !pages || !hidden) return;
|
|
@@ -1297,12 +1320,13 @@ document.querySelectorAll('input[data-filter-tree]').forEach((el) => {
|
|
|
1297
1320
|
if (alias && !alias.hasAttribute('data-edited')) alias.value = doc.getElementById('f_alias')?.value ?? alias.value;
|
|
1298
1321
|
history.replaceState(null, '', url);
|
|
1299
1322
|
document.getElementById('aw-pages')?.removeAttribute('aria-busy');
|
|
1323
|
+
sum();
|
|
1300
1324
|
} catch {
|
|
1301
1325
|
picker.form.submit();
|
|
1302
1326
|
}
|
|
1303
1327
|
});
|
|
1304
1328
|
// the button is for pages without JavaScript
|
|
1305
|
-
|
|
1329
|
+
document.querySelector('button[form="aw-schema-get"]')?.setAttribute('hidden', '');
|
|
1306
1330
|
})();
|
|
1307
1331
|
|
|
1308
1332
|
// (sprint 101, DA-12) SQL Workshop › Scheduling: the schedule builder's five fields and the common schedules fill the
|
|
@@ -1401,3 +1425,12 @@ document.querySelectorAll('input[data-filter-tree]').forEach((el) => {
|
|
|
1401
1425
|
})();
|
|
1402
1426
|
|
|
1403
1427
|
// (sprint 127) Passkeys: the [data-passkey] buttons are handled by app.js (sprint 131), which every builder page loads too.
|
|
1428
|
+
|
|
1429
|
+
// (UX-8) a side drawer (details.drawer): Close, Escape and a click outside close it; the first field gets the focus
|
|
1430
|
+
document.querySelectorAll('details.drawer').forEach((d) => {
|
|
1431
|
+
const close = () => { d.open = false; d.querySelector('summary')?.focus(); };
|
|
1432
|
+
d.querySelector('[data-drawer-close]')?.addEventListener('click', close);
|
|
1433
|
+
d.addEventListener('toggle', () => { if (d.open) d.querySelector('.drawer-panel input:not([type=hidden]), .drawer-panel select, .drawer-panel textarea')?.focus(); });
|
|
1434
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && d.open) close(); });
|
|
1435
|
+
document.addEventListener('click', (e) => { if (d.open && !d.contains(e.target)) d.open = false; });
|
|
1436
|
+
});
|
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
|
@@ -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/home.ts
CHANGED
|
@@ -72,7 +72,7 @@ const TASKS: [BuilderKey, string][] = [
|
|
|
72
72
|
['admin.developers', `${BASE}/developers`],
|
|
73
73
|
];
|
|
74
74
|
|
|
75
|
-
async function recentApps(s: Session) {
|
|
75
|
+
export async function recentApps(s: Session) {
|
|
76
76
|
const ids = recentIds(s);
|
|
77
77
|
const ws = currentWorkspace(s);
|
|
78
78
|
if (ids.length) {
|
|
@@ -142,20 +142,20 @@ export async function homeRoutes(app: FastifyInstance) {
|
|
|
142
142
|
<a class="tb-btn" href="${BASE}/apps/${a.id}" title="${tx('ab.edit', { name: a.name })}">${icon('edit')}<span class="sr-only">${tx('ab.edit', { name: a.name })}</span></a>
|
|
143
143
|
<a class="tb-btn" href="/a/${a.alias}" target="_blank" rel="noopener" title="${tx('ab.run', { name: a.name })}">${icon('play')}<span class="sr-only">${tx('ab.run', { name: a.name })}</span></a>
|
|
144
144
|
</div></article>`)}</div>`
|
|
145
|
-
: html`<div class="table-wrap ab-table"><table class="report ab-apps">
|
|
145
|
+
: html`<div class="table-wrap ab-table rep-fit"><table class="report ab-apps">
|
|
146
146
|
<caption class="sr-only">${tx('ab.col.apps')}</caption>
|
|
147
|
-
<thead><tr>${th('id', tx('ab.col.app'), 'num')}${th('name', tx('ab.col.name'))}${th('alias', tx('ab.col.alias'))}${th('pages', tx('ab.col.pages'), 'num')}<th scope="col" class="num">${tx('ab.col.views')}</th><th scope="col">${tx('ab.col.signin')}</th>${th('updated', tx('ab.col.updated'))}<th scope="col">${tx('ab.col.updated_by')}</th><th scope="col" class="num">${tx('ab.col.copies')}</th><th scope="col" class="ab-actions-col">${tx('ab.col.actions')}</th></tr></thead>
|
|
147
|
+
<thead><tr>${th('id', tx('ab.col.app'), 'num')}${th('name', tx('ab.col.name'))}${th('alias', tx('ab.col.alias'))}${th('pages', tx('ab.col.pages'), 'num')}<th scope="col" class="num col-low">${tx('ab.col.views')}</th><th scope="col">${tx('ab.col.signin')}</th>${th('updated', tx('ab.col.updated'))}<th scope="col" class="col-low">${tx('ab.col.updated_by')}</th><th scope="col" class="num col-low">${tx('ab.col.copies')}</th><th scope="col" class="ab-actions-col col-actions">${tx('ab.col.actions')}</th></tr></thead>
|
|
148
148
|
<tbody>${rows.map((a) => html`<tr data-filter-row>
|
|
149
149
|
<td class="num">${a.id}</td>
|
|
150
150
|
<td class="ab-name"><a href="${BASE}/apps/${a.id}">${appIcon(a)}<span>${a.name}</span></a>${a.locked_by ? html`<span class="ab-lock" role="img" aria-label="${lockedText(tx, a.locked_by)}" title="${lockedText(tx, a.locked_by)}">${icon('lock')}</span>` : ''}</td>
|
|
151
151
|
<td>${a.alias}${a.group_name ? html` <span class="tag">${a.group_name}</span>` : ''}</td>
|
|
152
152
|
<td class="num">${a.pages}</td>
|
|
153
|
-
<td class="num">${a.views}</td>
|
|
153
|
+
<td class="num col-low">${a.views}</td>
|
|
154
154
|
<td>${tx(a.authentication === 'none' ? 'ab.auth.none' : a.authentication === 'header' ? 'ab.auth.header' : a.authentication === 'database' ? 'ab.auth.database' : a.authentication === 'custom' ? 'ab.auth.custom' : 'ab.auth.app_users')}</td>
|
|
155
155
|
<td title="${a.updated_at ? new Date(a.updated_at).toISOString().slice(0, 16).replace('T', ' ') : ''}">${ago(a.updated_at, Date.now(), tx)}</td>
|
|
156
|
-
<td class="ab-by">${a.updated_by ?? ''}</td>
|
|
157
|
-
<td class="num">${a.copies ? html`<a href="${BASE}/apps/${a.id}/working-copies" aria-label="${tx('ab.copies_of', { copies: copiesText(tx, a.copies), name: a.name })}">${a.copies}</a>` : '0'}</td>
|
|
158
|
-
<td class="ab-actions">
|
|
156
|
+
<td class="ab-by col-low">${a.updated_by ?? ''}</td>
|
|
157
|
+
<td class="num col-low">${a.copies ? html`<a href="${BASE}/apps/${a.id}/working-copies" aria-label="${tx('ab.copies_of', { copies: copiesText(tx, a.copies), name: a.name })}">${a.copies}</a>` : '0'}</td>
|
|
158
|
+
<td class="ab-actions col-actions">
|
|
159
159
|
<a class="tb-btn" href="${BASE}/apps/${a.id}" title="${tx('ab.edit', { name: a.name })}">${icon('edit')}<span class="sr-only">${tx('ab.edit', { name: a.name })}</span></a>
|
|
160
160
|
<a class="tb-btn" href="/a/${a.alias}" target="_blank" rel="noopener" title="${tx('ab.run', { name: a.name })}">${icon('play')}<span class="sr-only">${tx('ab.run', { name: a.name })}</span></a>
|
|
161
161
|
</td></tr>`)}</tbody></table></div>`;
|
package/src/builder/i18n/de.ts
CHANGED
|
@@ -408,7 +408,6 @@ export const de: BuilderMessages = {
|
|
|
408
408
|
'pd.not_found': 'Komponente nicht gefunden.',
|
|
409
409
|
'pd.save_page': 'Seite speichern',
|
|
410
410
|
'pd.delete_page': 'Seite löschen',
|
|
411
|
-
'pd.delete_page_confirm': 'Seite {n} und alle ihre Komponenten löschen?',
|
|
412
411
|
'pd.filter_label': 'Eigenschaften filtern',
|
|
413
412
|
'pd.filter': 'Filtern',
|
|
414
413
|
'pd.help_hint': 'Wählen Sie eine Eigenschaft, um hier ihre Hilfe zu sehen.',
|
|
@@ -1427,7 +1426,6 @@ export const de: BuilderMessages = {
|
|
|
1427
1426
|
'st.ts_format': 'Datums- und Zeitformat',
|
|
1428
1427
|
'st.ts_format_ph': 'z. B. DD-MM-YYYY HH24:MI',
|
|
1429
1428
|
'st.save_settings': 'Einstellungen speichern',
|
|
1430
|
-
'st.delete_confirm': 'Anwendung {app} mit allen Seiten löschen?',
|
|
1431
1429
|
'st.delete': 'Anwendung löschen',
|
|
1432
1430
|
'st.error_fn': 'Funktion zur Fehlerbehandlung',
|
|
1433
1431
|
'st.error_fn_ph': 'z. B. sales.error_message',
|
|
@@ -1475,7 +1473,7 @@ export const de: BuilderMessages = {
|
|
|
1475
1473
|
'us.status': 'Status',
|
|
1476
1474
|
'us.none': 'keine',
|
|
1477
1475
|
'us.password': 'Passwort',
|
|
1478
|
-
'us.no_password': '
|
|
1476
|
+
'us.no_password': 'Kein Passwort',
|
|
1479
1477
|
'us.active': 'aktiv',
|
|
1480
1478
|
'us.inactive': 'inaktiv',
|
|
1481
1479
|
'us.last': 'zuletzt {at}',
|
|
@@ -1845,8 +1843,8 @@ export const de: BuilderMessages = {
|
|
|
1845
1843
|
'mfa.locked_out': 'Telefon, Schlüssel und Codes verloren? Bitten Sie einen Administrator, Ihre Zwei-Faktor-Anmeldung zurückzusetzen.',
|
|
1846
1844
|
'mfa.wrong': 'Dieser Code ist falsch oder wurde schon verwendet.',
|
|
1847
1845
|
'mfa.column': 'Zwei-Faktor',
|
|
1848
|
-
'mfa.on': '
|
|
1849
|
-
'mfa.off': '
|
|
1846
|
+
'mfa.on': 'An',
|
|
1847
|
+
'mfa.off': 'Aus',
|
|
1850
1848
|
'mfa.reset': 'Zurücksetzen',
|
|
1851
1849
|
'mfa.reason': 'Grund',
|
|
1852
1850
|
'mfa.reset_help': 'Entfernt die Apps und Wiederherstellungscodes von {user} und meldet die Person ab. Protokolliert; die Administratoren erhalten eine E-Mail.',
|
|
@@ -7538,4 +7536,19 @@ export const de: BuilderMessages = {
|
|
|
7538
7536
|
'wu.sec.instance': 'Instanz',
|
|
7539
7537
|
// ------------------------------------------------------------ Workspace home: needs attention (UX-13)
|
|
7540
7538
|
'wh.attention': 'Erfordert Aufmerksamkeit',
|
|
7539
|
+
// ------------------------------------------------------------ Delete with a typed confirmation (UX-6)
|
|
7540
|
+
'st.danger_zone': 'Gefahrenzone',
|
|
7541
|
+
'st.delete_text': 'Beim Löschen von {app} werden seine Seiten, gemeinsamen Komponenten, Arbeitskopien und auch seine Sicherungen entfernt; das lässt sich nicht rückgängig machen. Exportieren Sie die Anwendung vorher, wenn Sie sie noch brauchen könnten.',
|
|
7542
|
+
'st.delete_type': 'Geben Sie zur Bestätigung den Alias {alias} ein',
|
|
7543
|
+
'st.delete_mismatch': 'Geben Sie den Alias der Anwendung, {alias}, ein, um sie zu löschen.',
|
|
7544
|
+
'pd.delete_type': 'Geben Sie zur Bestätigung die Seitennummer {n} ein',
|
|
7545
|
+
'pd.delete_mismatch': 'Geben Sie die Seitennummer {n} ein, um die Seite zu löschen.',
|
|
7546
|
+
// ------------------------------------------------------------ Create application: name first, a sticky footer (UX-7)
|
|
7547
|
+
'aw.data_and_pages': 'Daten und Seiten',
|
|
7548
|
+
'aw.sign_in': 'Anmeldung',
|
|
7549
|
+
'aw.sum_on': '{n} Seiten auf {schema}',
|
|
7550
|
+
'aw.sum_on_1': '{n} Seite auf {schema}',
|
|
7551
|
+
'aw.sum_new': 'Eine neue Anwendung mit eigenem Schema',
|
|
7552
|
+
// ------------------------------------------------------------ A side drawer's Close (UX-8)
|
|
7553
|
+
'common.close': 'Schließen',
|
|
7541
7554
|
};
|