pgkiln 0.45.0 → 0.46.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/106_form_designer.sql +102 -0
- package/db/migrations/107_app_version.sql +9 -0
- package/docs/apex-feature-parity.md +6 -3
- package/docs/guide/03-builder.md +36 -12
- package/docs/guide/04-pages-and-regions.md +74 -2
- package/docs/guide/05-items.md +14 -2
- package/docs/guide/09-reference.md +1 -1
- package/docs/guide/11-from-apex.md +2 -1
- package/docs/guide/12-development.md +6 -1
- package/examples/hr/hr_61_form_designer.sql +80 -0
- package/package.json +1 -1
- package/public/app.css +62 -2
- package/public/app.js +244 -35
- package/public/builder.css +21 -1
- package/public/builder.js +67 -0
- package/public/sw.js +2 -1
- package/src/app.ts +9 -1
- package/src/builder/appwizard.ts +22 -6
- package/src/builder/arrange.ts +33 -12
- package/src/builder/code-editor.ts +1 -1
- package/src/builder/components.ts +1 -1
- package/src/builder/designer.ts +44 -17
- package/src/builder/errors.ts +65 -0
- package/src/builder/form-designer.ts +229 -0
- package/src/builder/locks.ts +6 -0
- package/src/builder/newapp.ts +2 -1
- package/src/builder/object-browser.ts +81 -18
- package/src/builder/region-help.ts +2 -0
- package/src/builder/region-settings.ts +2 -0
- package/src/builder/reporter.ts +1 -1
- package/src/builder/routes.ts +7 -5
- package/src/builder/search.ts +3 -3
- package/src/builder/shared.ts +2 -2
- package/src/builder/ui.ts +2 -0
- package/src/cli/replace.ts +10 -0
- package/src/i18n/ar.ts +62 -0
- package/src/i18n/cs.ts +62 -0
- package/src/i18n/da.ts +62 -0
- package/src/i18n/de.ts +62 -0
- package/src/i18n/el.ts +62 -0
- package/src/i18n/es.ts +62 -0
- package/src/i18n/fi.ts +62 -0
- package/src/i18n/fr.ts +62 -0
- package/src/i18n/he.ts +62 -0
- package/src/i18n/it.ts +62 -0
- package/src/i18n/ja.ts +62 -0
- package/src/i18n/ko.ts +62 -0
- package/src/i18n/nb.ts +62 -0
- package/src/i18n/pl.ts +62 -0
- package/src/i18n/pt.ts +62 -0
- package/src/i18n/ru.ts +62 -0
- package/src/i18n/sv.ts +62 -0
- package/src/i18n/tr.ts +62 -0
- package/src/i18n/uk.ts +62 -0
- package/src/i18n/zh.ts +62 -0
- package/src/i18n.ts +124 -0
- package/src/metadata.ts +4 -2
- package/src/runtime/devbar.ts +1 -1
- package/src/runtime/facet-state.ts +10 -5
- package/src/runtime/facets.ts +29 -5
- package/src/runtime/form-designer.ts +676 -0
- package/src/runtime/grid.ts +15 -8
- package/src/runtime/items.ts +33 -21
- package/src/runtime/lists.ts +1 -1
- package/src/runtime/pdf.ts +2 -1
- package/src/runtime/regions.ts +4 -0
- package/src/runtime/render.ts +7 -3
- package/src/runtime/report.ts +2 -2
- package/src/runtime/routes.ts +3 -1
- package/src/runtime/smart-filters.ts +2 -1
- package/src/runtime/tasks.ts +6 -3
package/public/app.css
CHANGED
|
@@ -1152,6 +1152,13 @@ td[data-add]:hover > .cal-add, .cal-add:focus-visible { opacity: 1; }
|
|
|
1152
1152
|
.facet { border: 0; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.3rem; }
|
|
1153
1153
|
.facet legend { font-weight: 650; font-size: 0.85rem; margin-bottom: 0.3rem; padding: 0; display: flex; gap: 0.5rem; }
|
|
1154
1154
|
.facet-clear { font-weight: 400; font-size: 0.8rem; }
|
|
1155
|
+
.facet:has(> .facet-menu) { position: relative; }
|
|
1156
|
+
.facet:has(> .facet-menu) > legend { padding-right: 1.6rem; }
|
|
1157
|
+
.facet-menu { position: absolute; top: 0; right: 0; }
|
|
1158
|
+
.facet-menu > summary { cursor: pointer; padding: 0 0.35rem; border-radius: 4px; color: var(--muted); }
|
|
1159
|
+
.facet-menu > summary:hover, .facet-menu[open] > summary { background: var(--surface-3); color: inherit; }
|
|
1160
|
+
.facet-menu .menu-panel { left: auto; right: 0; min-width: 11rem; }
|
|
1161
|
+
.facet-menu .menu-links a[aria-current] { font-weight: 650; }
|
|
1155
1162
|
.facet-value { font-weight: 400; font-size: 0.9rem; }
|
|
1156
1163
|
/* (sprint 65) a facet's chart of its counts, collapsed under the legend */
|
|
1157
1164
|
.facet-chart { margin: 0.1rem 0 0.4rem; }
|
|
@@ -1965,9 +1972,25 @@ a.search-hit:hover, a.search-hit:focus-visible { background: var(--accent-soft);
|
|
|
1965
1972
|
.th-menu-values li.muted { padding: 0.35rem 0.9rem; }
|
|
1966
1973
|
.js th[data-col] > a { cursor: pointer; }
|
|
1967
1974
|
|
|
1968
|
-
/* (sprint 76) a date item in the application's date format
|
|
1975
|
+
/* (sprint 76) a date item in the application's date format */
|
|
1969
1976
|
.date-field { position: relative; }
|
|
1970
|
-
|
|
1977
|
+
/* (sprint 84; APEX: the date picker) a month of days under a date field (app.js) */
|
|
1978
|
+
.date-picker { position: absolute; top: calc(100% + 4px); left: 0; z-index: 60; background: var(--surface); color: var(--text);
|
|
1979
|
+
border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgb(16 24 40 / 18%); padding: 0.5rem; min-width: 17rem; }
|
|
1980
|
+
.date-picker-head { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.35rem; }
|
|
1981
|
+
.date-picker-title { font-weight: 650; text-transform: capitalize; }
|
|
1982
|
+
.date-picker-grid { border-collapse: collapse; width: 100%; }
|
|
1983
|
+
.date-picker-grid th { font-size: 0.75rem; font-weight: 600; color: var(--muted); padding: 0.2rem 0; text-align: center; }
|
|
1984
|
+
.date-picker-grid td { padding: 1px; text-align: center; }
|
|
1985
|
+
.date-picker-grid button { width: 2.2rem; height: 2.1rem; border: 0; border-radius: 6px; background: none; color: inherit; font: inherit; cursor: pointer; }
|
|
1986
|
+
.date-picker-grid button:hover { background: var(--surface-3); }
|
|
1987
|
+
.date-picker-grid button.date-other { color: var(--muted); }
|
|
1988
|
+
.date-picker-grid button.date-today { box-shadow: inset 0 0 0 1px var(--accent); }
|
|
1989
|
+
.date-picker-grid button.date-selected { background: var(--accent); color: var(--accent-text); }
|
|
1990
|
+
.date-picker-grid button:disabled { opacity: 0.35; cursor: not-allowed; }
|
|
1991
|
+
.date-picker-grid button:focus-visible { outline: 2px solid var(--accent); outline-offset: 0; }
|
|
1992
|
+
.date-picker-foot { display: flex; justify-content: space-between; margin-top: 0.4rem; }
|
|
1993
|
+
.js .grid-table tbody td[data-col]:not(:focus-within) .date-field > .btn { visibility: hidden; }
|
|
1971
1994
|
|
|
1972
1995
|
/* (sprint 77) display positions: banner, breadcrumb bar, a side column, footer; button positions in a region */
|
|
1973
1996
|
.t-pos-banner, .t-pos-breadcrumb { margin-bottom: 1rem; }
|
|
@@ -1982,3 +2005,40 @@ a.search-hit:hover, a.search-hit:focus-visible { background: var(--accent-soft);
|
|
|
1982
2005
|
@media (max-width: 900px) { .t-columns-left, .t-columns-right { grid-template-columns: 1fr; } .t-columns-right > .t-side { order: 0; } }
|
|
1983
2006
|
.region-footer-split { display: flex; justify-content: space-between; gap: 0.5rem; flex-wrap: wrap; }
|
|
1984
2007
|
.region-footer-split > div { display: flex; gap: 0.5rem; }
|
|
2008
|
+
|
|
2009
|
+
/* (sprint 85) the Form Designer region (form-designer.ts): forms as cards with a Design link, the designer's field table, a row's form */
|
|
2010
|
+
.fd-cards > li { display: flex; flex-direction: column; gap: 0.25rem; }
|
|
2011
|
+
.fd-design-link { font-size: 0.85rem; display: inline-flex; align-items: center; gap: 0.3rem; }
|
|
2012
|
+
.fd-fields input.fd-seq { width: 4.5rem; }
|
|
2013
|
+
.fd-fields td select { min-width: 8rem; }
|
|
2014
|
+
.fd-fields td input:not([type=checkbox]):not(.fd-seq) { min-width: 6rem; width: 100%; }
|
|
2015
|
+
.fd-form { margin: 0.75rem 0; }
|
|
2016
|
+
.fd-radios { border: 0; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.25rem; }
|
|
2017
|
+
.fd-new { margin-top: 1rem; }
|
|
2018
|
+
|
|
2019
|
+
/* (sprint 88, GEN-4; APEX: the Cards list template and the hero region) a list's cards each get a colour band
|
|
2020
|
+
and their icon in a round badge, in the chart palette's order; the hero sits on the page background */
|
|
2021
|
+
.list-cards > li { --card-color: var(--series-1); }
|
|
2022
|
+
.list-cards > li:nth-child(8n+2) { --card-color: var(--series-2); }
|
|
2023
|
+
.list-cards > li:nth-child(8n+3) { --card-color: var(--series-3); }
|
|
2024
|
+
.list-cards > li:nth-child(8n+4) { --card-color: var(--series-4); }
|
|
2025
|
+
.list-cards > li:nth-child(8n+5) { --card-color: var(--series-5); }
|
|
2026
|
+
.list-cards > li:nth-child(8n+6) { --card-color: var(--series-6); }
|
|
2027
|
+
.list-cards > li:nth-child(8n+7) { --card-color: var(--series-7); }
|
|
2028
|
+
.list-cards > li:nth-child(8n+8) { --card-color: var(--series-8); }
|
|
2029
|
+
.list-card { border-top: 4px solid var(--card-color); }
|
|
2030
|
+
.list-card-icon { flex: none; display: grid; place-items: center; width: 2.5rem; height: 2.5rem; border-radius: 50%;
|
|
2031
|
+
background: color-mix(in srgb, var(--card-color) 16%, transparent); color: var(--card-color); }
|
|
2032
|
+
.list-card-icon .icon { width: 1.3rem; height: 1.3rem; }
|
|
2033
|
+
.list-card-head { gap: 0.75rem; }
|
|
2034
|
+
.region-hero { background: transparent; padding-left: 0; padding-right: 0; }
|
|
2035
|
+
|
|
2036
|
+
/* (sprint 88, GEN-4; APEX: the page footer) the application's version and Back to top, under the content */
|
|
2037
|
+
.t-footer { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 0.5rem 1rem;
|
|
2038
|
+
margin: 0 1.5rem 1rem; max-width: calc(1500px - 3rem); padding: 0.75rem 0 0.25rem; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.85rem; }
|
|
2039
|
+
@media (max-width: 640px) { .t-footer { margin: 0 0.75rem 1rem; } }
|
|
2040
|
+
.t-footer .t-top { margin-left: auto; display: inline-flex; align-items: center; gap: 0.3rem; color: inherit; }
|
|
2041
|
+
.t-footer .t-top:hover, .t-footer .t-top:focus-visible { color: var(--accent); }
|
|
2042
|
+
.t-footer .icon { width: 1rem; height: 1rem; }
|
|
2043
|
+
@media print { .t-footer { display: none; } }
|
|
2044
|
+
.list-card:hover { border-top-color: var(--card-color); }
|
package/public/app.js
CHANGED
|
@@ -4,6 +4,23 @@
|
|
|
4
4
|
// inline scripts.
|
|
5
5
|
document.documentElement.classList.add('js');
|
|
6
6
|
|
|
7
|
+
// (sprint 86) Values a browser kept under pgkiln's old name (pgapex.*, pgapex-push:*) move to
|
|
8
|
+
// pgkiln.* / pgkiln-push:* once: the navigation drawer, the builder's tabs and panes, the region
|
|
9
|
+
// display selector's tab, a device's push notifications. A value already kept under the new name wins.
|
|
10
|
+
for (const store of ['localStorage', 'sessionStorage']) {
|
|
11
|
+
try {
|
|
12
|
+
const s = window[store];
|
|
13
|
+
for (const k of Object.keys(s)) {
|
|
14
|
+
if (!/^pgapex[.-]/.test(k)) continue;
|
|
15
|
+
const neu = `pgkiln${k.slice(6)}`;
|
|
16
|
+
if (s.getItem(neu) === null) s.setItem(neu, s.getItem(k));
|
|
17
|
+
s.removeItem(k);
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
/* storage off (private mode, a blocked cookie setting): nothing kept, nothing to move */
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
7
24
|
// Push notifications (migration 074): the page of an app with notifications on carries
|
|
8
25
|
// the app's public key (data-push) and the user (data-push-user). Subscribing needs a
|
|
9
26
|
// click (My account → Notifications, or the dynamic action push_subscribe); the
|
|
@@ -3492,23 +3509,10 @@ function areaFilter(map, f) {
|
|
|
3492
3509
|
draw();
|
|
3493
3510
|
}
|
|
3494
3511
|
|
|
3495
|
-
// Date range: the end date can't be before the start date
|
|
3496
|
-
function range(box) {
|
|
3497
|
-
const [from, to] = box.querySelectorAll('input[type=date]');
|
|
3498
|
-
if (!from || !to) return;
|
|
3499
|
-
const limit = () => {
|
|
3500
|
-
to.min = from.value;
|
|
3501
|
-
from.max = to.value;
|
|
3502
|
-
};
|
|
3503
|
-
from.addEventListener('change', limit);
|
|
3504
|
-
to.addEventListener('change', limit);
|
|
3505
|
-
}
|
|
3506
|
-
|
|
3507
3512
|
function enhance(root) {
|
|
3508
3513
|
root.querySelectorAll('[data-richtext]').forEach(richText);
|
|
3509
3514
|
root.querySelectorAll('[data-markdown]').forEach(markdown);
|
|
3510
3515
|
root.querySelectorAll('[data-tags]').forEach(tags);
|
|
3511
|
-
root.querySelectorAll('[data-range]').forEach(range);
|
|
3512
3516
|
root.querySelectorAll('[data-reveal]').forEach((b) => (b.hidden = false));
|
|
3513
3517
|
}
|
|
3514
3518
|
enhance(document);
|
|
@@ -3931,11 +3935,42 @@ document.addEventListener('change', (e) => {
|
|
|
3931
3935
|
if (e.target.matches?.('select[data-go]')) e.target.form?.submit();
|
|
3932
3936
|
});
|
|
3933
3937
|
|
|
3934
|
-
// (sprint 76) A date
|
|
3935
|
-
// picker
|
|
3938
|
+
// (sprint 76, 84; APEX: the date picker) A date field in a date mask (items.ts dateField): its calendar button
|
|
3939
|
+
// opens pgkiln's date picker, a month of days under the field; the chosen date is written into the text field
|
|
3940
|
+
// in the mask. Keys: arrows (a day, a week), Page Up/Down (a month; with Shift a year), Home/End (the week),
|
|
3941
|
+
// Enter or Space (choose), Escape (close).
|
|
3936
3942
|
(() => {
|
|
3943
|
+
const meta = (() => { try { return JSON.parse(document.getElementById('pgkiln-meta')?.textContent || '{}'); } catch { return {}; } })();
|
|
3944
|
+
const t = (k, d) => (meta.texts && meta.texts[k]) || d;
|
|
3945
|
+
const lang = document.documentElement.lang || 'en';
|
|
3937
3946
|
const pad = (n) => String(n).padStart(2, '0');
|
|
3938
|
-
const
|
|
3947
|
+
const isoOf = (d) => `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
|
|
3948
|
+
const dateOf = (iso) => { const [y, m, d] = iso.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d)); };
|
|
3949
|
+
const addDays = (iso, n) => { const d = dateOf(iso); d.setUTCDate(d.getUTCDate() + n); return isoOf(d); };
|
|
3950
|
+
const addMonths = (iso, n) => {
|
|
3951
|
+
const d = dateOf(iso);
|
|
3952
|
+
const day = d.getUTCDate();
|
|
3953
|
+
d.setUTCDate(1);
|
|
3954
|
+
d.setUTCMonth(d.getUTCMonth() + n);
|
|
3955
|
+
const last = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate();
|
|
3956
|
+
d.setUTCDate(Math.min(day, last));
|
|
3957
|
+
return isoOf(d);
|
|
3958
|
+
};
|
|
3959
|
+
const today = () => { const n = new Date(); return `${n.getFullYear()}-${pad(n.getMonth() + 1)}-${pad(n.getDate())}`; };
|
|
3960
|
+
// the week starts on the language's first day (Monday where the browser doesn't say)
|
|
3961
|
+
const weekStart = (() => {
|
|
3962
|
+
try {
|
|
3963
|
+
const l = new Intl.Locale(lang);
|
|
3964
|
+
const w = (l.getWeekInfo && l.getWeekInfo()) || l.weekInfo;
|
|
3965
|
+
if (w && w.firstDay) return w.firstDay % 7;
|
|
3966
|
+
} catch {}
|
|
3967
|
+
return /^en-US$|^(ja|ko|zh|he|ar|pt-BR)/i.test(lang) ? 0 : 1;
|
|
3968
|
+
})();
|
|
3969
|
+
const fmt = (o) => { try { return new Intl.DateTimeFormat(lang, { ...o, timeZone: 'UTC' }); } catch { return new Intl.DateTimeFormat('en', { ...o, timeZone: 'UTC' }); } };
|
|
3970
|
+
const dayShort = fmt({ weekday: 'short' }), dayLong = fmt({ weekday: 'long' }), full = fmt({ dateStyle: 'full' }), title = fmt({ month: 'long', year: 'numeric' });
|
|
3971
|
+
|
|
3972
|
+
const TOKEN = /"[^"]*"|YYYY|YY|MONTH|MON|MM|DD|DAY|DY/gi;
|
|
3973
|
+
const monthsOf = (field) => { try { return JSON.parse(field.dataset.months || '{}'); } catch { return {}; } };
|
|
3939
3974
|
const format = (iso, mask, months) => {
|
|
3940
3975
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
|
3941
3976
|
if (!m) return '';
|
|
@@ -3948,31 +3983,205 @@ document.addEventListener('change', (e) => {
|
|
|
3948
3983
|
if (up === 'YY') return pad(y % 100);
|
|
3949
3984
|
if (up === 'MM') return pad(mo);
|
|
3950
3985
|
if (up === 'DD') return pad(d);
|
|
3951
|
-
if (up === 'MONTH') return cased(months.long[mo - 1]);
|
|
3952
|
-
if (up === 'MON') return cased(months.short[mo - 1]);
|
|
3986
|
+
if (up === 'MONTH') return cased((months.long || [])[mo - 1] || '');
|
|
3987
|
+
if (up === 'MON') return cased((months.short || [])[mo - 1] || '');
|
|
3988
|
+
if (up === 'DAY') return cased(dayLong.format(dateOf(iso)));
|
|
3989
|
+
if (up === 'DY') return cased(dayShort.format(dateOf(iso)));
|
|
3953
3990
|
return tok;
|
|
3954
3991
|
});
|
|
3955
3992
|
};
|
|
3993
|
+
// the text read back in the mask (as format.ts parseDateMask does), or as YYYY-MM-DD; null when it isn't a date
|
|
3994
|
+
const parse = (text, mask, months) => {
|
|
3995
|
+
const v = (text || '').trim();
|
|
3996
|
+
let y, mo, d;
|
|
3997
|
+
const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v);
|
|
3998
|
+
if (iso) [y, mo, d] = iso.slice(1).map(Number);
|
|
3999
|
+
else {
|
|
4000
|
+
const esc = (x) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
4001
|
+
const names = (list) => `(${(list || []).map(esc).join('|') || '\\p{L}+'})`;
|
|
4002
|
+
const groups = [];
|
|
4003
|
+
let last = 0, re = '';
|
|
4004
|
+
for (const m of mask.matchAll(TOKEN)) {
|
|
4005
|
+
re += esc(mask.slice(last, m.index));
|
|
4006
|
+
last = m.index + m[0].length;
|
|
4007
|
+
if (m[0].startsWith('"')) { re += esc(m[0].slice(1, -1)); continue; }
|
|
4008
|
+
const up = m[0].toUpperCase();
|
|
4009
|
+
re += { YYYY: '(\\d{4})', YY: '(\\d{2})', MM: '(\\d{1,2})', DD: '(\\d{1,2})', MONTH: names(months.long), MON: names(months.short), DAY: '(\\p{L}+)', DY: '(\\p{L}+)' }[up];
|
|
4010
|
+
groups.push(up);
|
|
4011
|
+
}
|
|
4012
|
+
re += esc(mask.slice(last));
|
|
4013
|
+
const m = new RegExp(`^${re}$`, 'iu').exec(v);
|
|
4014
|
+
if (!m) return null;
|
|
4015
|
+
groups.forEach((g, i) => {
|
|
4016
|
+
const x = m[i + 1];
|
|
4017
|
+
if (g === 'YYYY') y = Number(x);
|
|
4018
|
+
else if (g === 'YY') y = 2000 + Number(x);
|
|
4019
|
+
else if (g === 'MM') mo = Number(x);
|
|
4020
|
+
else if (g === 'DD') d = Number(x);
|
|
4021
|
+
else if (g === 'MONTH' || g === 'MON') mo = ((g === 'MONTH' ? months.long : months.short) || []).findIndex((n) => n.toLowerCase() === x.toLowerCase()) + 1;
|
|
4022
|
+
});
|
|
4023
|
+
}
|
|
4024
|
+
if (!y || !mo || !d) return null;
|
|
4025
|
+
const out = `${String(y).padStart(4, '0')}-${pad(mo)}-${pad(d)}`;
|
|
4026
|
+
return isoOf(dateOf(out)) === out ? out : null;
|
|
4027
|
+
};
|
|
4028
|
+
|
|
4029
|
+
let open = null; // { box, field, btn, focus, view }
|
|
4030
|
+
function close(back) {
|
|
4031
|
+
if (!open) return;
|
|
4032
|
+
const { box, btn } = open;
|
|
4033
|
+
open = null;
|
|
4034
|
+
box.remove();
|
|
4035
|
+
btn.setAttribute('aria-expanded', 'false');
|
|
4036
|
+
if (back) btn.focus();
|
|
4037
|
+
}
|
|
4038
|
+
function choose(iso) {
|
|
4039
|
+
const { field } = open;
|
|
4040
|
+
field.value = iso ? format(iso, field.dataset.dateMask || 'YYYY-MM-DD', monthsOf(field)) : '';
|
|
4041
|
+
close(false);
|
|
4042
|
+
field.dispatchEvent(new Event('input', { bubbles: true }));
|
|
4043
|
+
field.dispatchEvent(new Event('change', { bubbles: true }));
|
|
4044
|
+
field.focus();
|
|
4045
|
+
}
|
|
4046
|
+
// a date range's to is on or after its from (and the from on or before the to)
|
|
4047
|
+
const allowed = (iso) => {
|
|
4048
|
+
const { field } = open;
|
|
4049
|
+
if (!field.closest('.date-range')) return true;
|
|
4050
|
+
const isTo = field.id.endsWith('_TO');
|
|
4051
|
+
const other = document.getElementById(isTo ? field.id.slice(0, -3) : `${field.id}_TO`);
|
|
4052
|
+
const bound = other ? parse(other.value, other.dataset.dateMask || 'YYYY-MM-DD', monthsOf(other)) : null;
|
|
4053
|
+
return !bound || (isTo ? iso >= bound : iso <= bound);
|
|
4054
|
+
};
|
|
4055
|
+
function render() {
|
|
4056
|
+
const { box, field } = open;
|
|
4057
|
+
const sel = parse(field.value, field.dataset.dateMask || 'YYYY-MM-DD', monthsOf(field));
|
|
4058
|
+
const now = today();
|
|
4059
|
+
const view = open.view;
|
|
4060
|
+
const first = dateOf(`${view.slice(0, 7)}-01`);
|
|
4061
|
+
const start = addDays(isoOf(first), -((first.getUTCDay() - weekStart + 7) % 7));
|
|
4062
|
+
box.textContent = '';
|
|
4063
|
+
const head = document.createElement('div');
|
|
4064
|
+
head.className = 'date-picker-head';
|
|
4065
|
+
const nav = (label, n, glyph) => {
|
|
4066
|
+
const b = document.createElement('button');
|
|
4067
|
+
b.type = 'button';
|
|
4068
|
+
b.className = 'btn btn-sm';
|
|
4069
|
+
b.setAttribute('aria-label', label);
|
|
4070
|
+
b.textContent = glyph;
|
|
4071
|
+
b.addEventListener('click', () => { open.focus = addMonths(open.focus, n); open.view = open.focus; render(); });
|
|
4072
|
+
return b;
|
|
4073
|
+
};
|
|
4074
|
+
const caption = document.createElement('div');
|
|
4075
|
+
caption.className = 'date-picker-title';
|
|
4076
|
+
caption.setAttribute('aria-live', 'polite');
|
|
4077
|
+
caption.id = `${field.id}_dp_title`;
|
|
4078
|
+
caption.textContent = title.format(first);
|
|
4079
|
+
head.append(nav(t('calendar.previous', 'Previous month'), -1, '‹'), caption, nav(t('calendar.next', 'Next month'), 1, '›'));
|
|
4080
|
+
const table = document.createElement('table');
|
|
4081
|
+
table.className = 'date-picker-grid';
|
|
4082
|
+
table.setAttribute('role', 'grid');
|
|
4083
|
+
table.setAttribute('aria-labelledby', caption.id);
|
|
4084
|
+
const hr = table.createTHead().insertRow();
|
|
4085
|
+
for (let i = 0; i < 7; i++) {
|
|
4086
|
+
const th = document.createElement('th');
|
|
4087
|
+
const day = addDays(start, i);
|
|
4088
|
+
th.scope = 'col';
|
|
4089
|
+
th.abbr = dayLong.format(dateOf(day));
|
|
4090
|
+
th.textContent = dayShort.format(dateOf(day));
|
|
4091
|
+
hr.append(th);
|
|
4092
|
+
}
|
|
4093
|
+
const body = table.createTBody();
|
|
4094
|
+
for (let w = 0; w < 6; w++) {
|
|
4095
|
+
const tr = body.insertRow();
|
|
4096
|
+
for (let i = 0; i < 7; i++) {
|
|
4097
|
+
const iso = addDays(start, w * 7 + i);
|
|
4098
|
+
const td = tr.insertCell();
|
|
4099
|
+
const b = document.createElement('button');
|
|
4100
|
+
b.type = 'button';
|
|
4101
|
+
b.dataset.iso = iso;
|
|
4102
|
+
b.textContent = String(Number(iso.slice(8)));
|
|
4103
|
+
b.setAttribute('aria-label', full.format(dateOf(iso)));
|
|
4104
|
+
b.tabIndex = iso === open.focus ? 0 : -1;
|
|
4105
|
+
if (iso.slice(0, 7) !== view.slice(0, 7)) b.classList.add('date-other');
|
|
4106
|
+
if (iso === now) { b.classList.add('date-today'); b.setAttribute('aria-current', 'date'); }
|
|
4107
|
+
if (iso === sel) { b.classList.add('date-selected'); b.setAttribute('aria-pressed', 'true'); }
|
|
4108
|
+
if (!allowed(iso)) b.disabled = true;
|
|
4109
|
+
td.append(b);
|
|
4110
|
+
}
|
|
4111
|
+
}
|
|
4112
|
+
const foot = document.createElement('div');
|
|
4113
|
+
foot.className = 'date-picker-foot';
|
|
4114
|
+
const act = (label, fn) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'btn btn-sm'; b.textContent = label; b.addEventListener('click', fn); return b; };
|
|
4115
|
+
foot.append(act(t('calendar.today', 'Today'), () => (allowed(now) ? choose(now) : null)), act(t('facets.clear_short', 'Clear'), () => choose('')));
|
|
4116
|
+
box.append(head, table, foot);
|
|
4117
|
+
box.querySelector('button[tabindex="0"]')?.focus({ preventScroll: true });
|
|
4118
|
+
}
|
|
4119
|
+
// fixed under the field (above it when there is no room), so a scrolling table or grid cell doesn't clip it
|
|
4120
|
+
function place() {
|
|
4121
|
+
if (!open) return;
|
|
4122
|
+
const { box, field } = open;
|
|
4123
|
+
const r = field.getBoundingClientRect();
|
|
4124
|
+
const h = box.offsetHeight, w = box.offsetWidth;
|
|
4125
|
+
box.style.position = 'fixed';
|
|
4126
|
+
box.style.top = `${Math.round(r.bottom + 4 + h > window.innerHeight - 8 && r.top - h - 4 > 8 ? r.top - h - 4 : r.bottom + 4)}px`;
|
|
4127
|
+
box.style.left = `${Math.round(Math.max(8, Math.min(r.left, window.innerWidth - w - 8)))}px`;
|
|
4128
|
+
}
|
|
4129
|
+
window.addEventListener('scroll', place, { passive: true, capture: true });
|
|
4130
|
+
window.addEventListener('resize', place);
|
|
4131
|
+
function show(field, btn) {
|
|
4132
|
+
close(false);
|
|
4133
|
+
const box = document.createElement('div');
|
|
4134
|
+
box.className = 'date-picker';
|
|
4135
|
+
box.setAttribute('role', 'dialog');
|
|
4136
|
+
box.setAttribute('aria-label', t('item.choose_date', 'Choose a date'));
|
|
4137
|
+
const sel = parse(field.value, field.dataset.dateMask || 'YYYY-MM-DD', monthsOf(field));
|
|
4138
|
+
open = { box, field, btn, focus: sel || today(), view: sel || today() };
|
|
4139
|
+
btn.setAttribute('aria-expanded', 'true');
|
|
4140
|
+
(field.closest('.date-field') || field.parentElement).append(box);
|
|
4141
|
+
render();
|
|
4142
|
+
place();
|
|
4143
|
+
box.addEventListener('keydown', (e) => {
|
|
4144
|
+
const day = e.target.dataset?.iso;
|
|
4145
|
+
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(true); return; }
|
|
4146
|
+
if (!day) return;
|
|
4147
|
+
const step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }[e.key];
|
|
4148
|
+
let next = null;
|
|
4149
|
+
if (step) next = addDays(day, step);
|
|
4150
|
+
else if (e.key === 'PageUp' || e.key === 'PageDown') next = addMonths(day, (e.key === 'PageUp' ? -1 : 1) * (e.shiftKey ? 12 : 1));
|
|
4151
|
+
else if (e.key === 'Home') next = addDays(day, -((dateOf(day).getUTCDay() - weekStart + 7) % 7));
|
|
4152
|
+
else if (e.key === 'End') next = addDays(day, 6 - ((dateOf(day).getUTCDay() - weekStart + 7) % 7));
|
|
4153
|
+
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (allowed(day)) choose(day); return; }
|
|
4154
|
+
if (!next) return;
|
|
4155
|
+
e.preventDefault();
|
|
4156
|
+
open.focus = next;
|
|
4157
|
+
if (next.slice(0, 7) !== open.view.slice(0, 7)) { open.view = next; render(); }
|
|
4158
|
+
else {
|
|
4159
|
+
box.querySelectorAll('button[data-iso]').forEach((b) => (b.tabIndex = b.dataset.iso === next ? 0 : -1));
|
|
4160
|
+
box.querySelector(`button[data-iso="${next}"]`)?.focus({ preventScroll: true });
|
|
4161
|
+
}
|
|
4162
|
+
});
|
|
4163
|
+
box.addEventListener('click', (e) => {
|
|
4164
|
+
const b = e.target.closest('button[data-iso]');
|
|
4165
|
+
if (b && !b.disabled) choose(b.dataset.iso);
|
|
4166
|
+
});
|
|
4167
|
+
}
|
|
3956
4168
|
document.addEventListener('click', (e) => {
|
|
3957
4169
|
const btn = e.target.closest?.('[data-date-pick]');
|
|
3958
|
-
if (
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
4170
|
+
if (btn) {
|
|
4171
|
+
const field = document.getElementById(btn.dataset.datePick);
|
|
4172
|
+
if (!field) return;
|
|
4173
|
+
e.preventDefault();
|
|
4174
|
+
if (open && open.btn === btn) close(true);
|
|
4175
|
+
else show(field, btn);
|
|
4176
|
+
return;
|
|
3965
4177
|
}
|
|
4178
|
+
if (open && !e.target.closest?.('.date-picker')) close(false);
|
|
3966
4179
|
});
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
if (
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
try { months = JSON.parse(text.dataset.months || '{}'); } catch {}
|
|
3974
|
-
text.value = format(native.value, text.dataset.dateMask || 'YYYY-MM-DD', months);
|
|
3975
|
-
text.dispatchEvent(new Event('change', { bubbles: true }));
|
|
3976
|
-
text.focus();
|
|
4180
|
+
// Alt+Down in the field opens the picker (as a native date field does)
|
|
4181
|
+
document.addEventListener('keydown', (e) => {
|
|
4182
|
+
if (e.altKey && e.key === 'ArrowDown' && e.target.matches?.('input[data-date-mask]')) {
|
|
4183
|
+
const btn = e.target.parentElement?.querySelector('[data-date-pick]');
|
|
4184
|
+
if (btn) { e.preventDefault(); show(e.target, btn); }
|
|
4185
|
+
}
|
|
3977
4186
|
});
|
|
3978
4187
|
})();
|
package/public/builder.css
CHANGED
|
@@ -368,7 +368,7 @@ a.pd-node:hover { background: var(--surface-3); }
|
|
|
368
368
|
.js .pd-gal { cursor: grab; }
|
|
369
369
|
.pd-gal:hover { background: var(--surface-3); }
|
|
370
370
|
.pd-gal .icon { width: 22px; height: 22px; color: var(--muted); stroke-width: 1.5; }
|
|
371
|
-
.pd-gal span { min-width: 0; overflow
|
|
371
|
+
.pd-gal span { min-width: 0; overflow-wrap: anywhere; line-height: 1.2; }
|
|
372
372
|
|
|
373
373
|
/* ------------------------------------------------------------ help and search tabs */
|
|
374
374
|
.pd-help, .pd-search { padding: 12px 16px; max-width: 60rem; }
|
|
@@ -1012,3 +1012,23 @@ details.prop-group-more[open] > summary { margin-bottom: 8px; }
|
|
|
1012
1012
|
.pd-goto-no input { width: 5.5em; font-variant-numeric: tabular-nums; }
|
|
1013
1013
|
/* on narrower screens the previous / next arrows and the page list do it */
|
|
1014
1014
|
@media (max-width: 1279px) { .pd-goto-no { display: none; } }
|
|
1015
|
+
|
|
1016
|
+
/* (sprint 89, SQL-4) the Object Browser: a table's tabs on one row with More, and the Columns toolbar */
|
|
1017
|
+
.ob-tabs { flex-wrap: nowrap; align-items: stretch; }
|
|
1018
|
+
.ob-tabs > a { white-space: nowrap; }
|
|
1019
|
+
.ob-more { position: relative; }
|
|
1020
|
+
.ob-more > summary { display: flex; align-items: center; gap: 0.25rem; height: 100%; padding: 0.5rem 0.85rem; color: var(--muted); cursor: pointer; white-space: nowrap; }
|
|
1021
|
+
.ob-more > summary:hover, .ob-more[open] > summary { color: var(--text); }
|
|
1022
|
+
.ob-more .menu-panel { right: 0; left: auto; min-width: 12rem; }
|
|
1023
|
+
.ob-more .icon { width: 0.9rem; height: 0.9rem; }
|
|
1024
|
+
.ob-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; }
|
|
1025
|
+
.ob-tool { position: relative; }
|
|
1026
|
+
.ob-tool > summary { list-style: none; }
|
|
1027
|
+
.ob-tool .menu-panel { left: 0; min-width: 18rem; padding: 0.75rem; }
|
|
1028
|
+
.ob-tool .ob-action { flex-direction: column; align-items: stretch; }
|
|
1029
|
+
.ob-tool .ob-action input:not([type=checkbox]), .ob-tool .ob-action select { width: 100%; }
|
|
1030
|
+
.ob-toolbar-note { font-size: 0.85rem; }
|
|
1031
|
+
@media (max-width: 640px) { .ob-tabs { overflow-x: auto; } .ob-tool .menu-panel { min-width: min(18rem, 90vw); } }
|
|
1032
|
+
|
|
1033
|
+
/* (sprint 90, PD-4) a commented-out component in the tree (APEX: Comment Out): struck through and muted */
|
|
1034
|
+
.pd-node[data-commented] .pd-label { text-decoration: line-through; color: var(--muted); }
|
package/public/builder.js
CHANGED
|
@@ -270,6 +270,11 @@
|
|
|
270
270
|
list.push(['Duplicate', () => submit(`${base}/layout/duplicate`, { kind, id })], ['Copy to other page…', copyDialog]);
|
|
271
271
|
if (kind !== 'region') list.push(['Create dynamic action', go('new=dynamic_action')]);
|
|
272
272
|
if (node.classList.contains('has-kids')) list.push(null, ['Expand all below', () => setAll(node, true)], ['Collapse all below', () => setAll(node, false)]);
|
|
273
|
+
// (sprint 90; APEX: Comment Out) leave a component out of the page without deleting it (build option COMMENTED_OUT)
|
|
274
|
+
if (['region', 'item', 'button', 'dynamic_action', 'validation', 'process', 'computation', 'branch'].includes(kind))
|
|
275
|
+
list.push(null, node.hasAttribute('data-commented')
|
|
276
|
+
? ['Uncomment', () => submit(`${base}/layout/comment`, { kind, id, on: 'false' })]
|
|
277
|
+
: ['Comment out', () => submit(`${base}/layout/comment`, { kind, id, on: 'true' })]);
|
|
273
278
|
list.push(null, ['Delete', () => submit(`${base}/c/${kind}/${id}/delete`, {}, `Delete ${kind} ${label}?`), 'danger']);
|
|
274
279
|
return list;
|
|
275
280
|
};
|
|
@@ -1167,3 +1172,65 @@ document.querySelectorAll('input[data-filter-tree]').forEach((el) => {
|
|
|
1167
1172
|
});
|
|
1168
1173
|
});
|
|
1169
1174
|
});
|
|
1175
|
+
|
|
1176
|
+
// (sprint 87) The create application wizard:
|
|
1177
|
+
// - an alias follows its name (data-derive-from) until the developer types in it, like aliasFor() on the server;
|
|
1178
|
+
// - choosing a schema under Data reloads the Pages list (and the proposed name and alias, while untouched)
|
|
1179
|
+
// without leaving the page; without JavaScript, "Show its tables" does the same with a reload;
|
|
1180
|
+
// - First user and Password are hidden while Authentication is None (the server ignores them then).
|
|
1181
|
+
(() => {
|
|
1182
|
+
const slug = (v) => {
|
|
1183
|
+
let a = v.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
|
|
1184
|
+
if (a && !/^[a-z]/.test(a)) a = `app-${a}`;
|
|
1185
|
+
return a;
|
|
1186
|
+
};
|
|
1187
|
+
document.querySelectorAll('input[data-derive-from]').forEach((alias) => {
|
|
1188
|
+
const name = document.getElementById(alias.dataset.deriveFrom);
|
|
1189
|
+
if (!name) return;
|
|
1190
|
+
alias.addEventListener('input', () => alias.setAttribute('data-edited', ''));
|
|
1191
|
+
name.addEventListener('input', () => {
|
|
1192
|
+
if (!alias.hasAttribute('data-edited')) alias.value = slug(name.value);
|
|
1193
|
+
});
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
document.querySelectorAll('select[name="authentication"]').forEach((auth) => {
|
|
1197
|
+
const form = auth.form;
|
|
1198
|
+
const fields = ['admin_user', 'admin_password'].map((n) => form?.elements.namedItem(n)?.closest?.('.field')).filter(Boolean);
|
|
1199
|
+
if (!fields.length) return;
|
|
1200
|
+
const sync = () => fields.forEach((f) => (f.hidden = auth.value === 'none'));
|
|
1201
|
+
auth.addEventListener('change', sync);
|
|
1202
|
+
sync();
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
const picker = document.querySelector('form.create-schema select[name="schema"]');
|
|
1206
|
+
const pages = document.getElementById('aw-pages');
|
|
1207
|
+
const hidden = document.getElementById('aw-schema-fields');
|
|
1208
|
+
if (!picker || !pages || !hidden) return;
|
|
1209
|
+
const name = document.getElementById('f_name');
|
|
1210
|
+
let nameTouched = false;
|
|
1211
|
+
name?.addEventListener('input', () => (nameTouched = true));
|
|
1212
|
+
picker.addEventListener('change', async () => {
|
|
1213
|
+
const url = `${picker.form.action}?schema=${encodeURIComponent(picker.value)}`;
|
|
1214
|
+
pages.setAttribute('aria-busy', 'true');
|
|
1215
|
+
try {
|
|
1216
|
+
const res = await fetch(url, { headers: { accept: 'text/html' }, credentials: 'same-origin' });
|
|
1217
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
1218
|
+
const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
|
|
1219
|
+
const nextPages = doc.getElementById('aw-pages');
|
|
1220
|
+
const nextHidden = doc.getElementById('aw-schema-fields');
|
|
1221
|
+
if (!nextPages || !nextHidden) throw new Error('unexpected page');
|
|
1222
|
+
pages.replaceWith(nextPages);
|
|
1223
|
+
hidden.replaceWith(nextHidden);
|
|
1224
|
+
// the proposals for the new schema, unless the developer already typed a name or alias
|
|
1225
|
+
const alias = document.querySelector('input[data-derive-from]');
|
|
1226
|
+
if (!nameTouched && name) name.value = doc.getElementById('f_name')?.value ?? name.value;
|
|
1227
|
+
if (alias && !alias.hasAttribute('data-edited')) alias.value = doc.getElementById('f_alias')?.value ?? alias.value;
|
|
1228
|
+
history.replaceState(null, '', url);
|
|
1229
|
+
document.getElementById('aw-pages')?.removeAttribute('aria-busy');
|
|
1230
|
+
} catch {
|
|
1231
|
+
picker.form.submit();
|
|
1232
|
+
}
|
|
1233
|
+
});
|
|
1234
|
+
// the button is for pages without JavaScript
|
|
1235
|
+
picker.form.querySelector('button')?.setAttribute('hidden', '');
|
|
1236
|
+
})();
|
package/public/sw.js
CHANGED
|
@@ -15,11 +15,12 @@ self.addEventListener('install', (event) => {
|
|
|
15
15
|
event.waitUntil(caches.open(STATIC).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
|
|
16
16
|
});
|
|
17
17
|
|
|
18
|
+
// older versions' static files, and (sprint 86) the caches of pgkiln's old name (pgapex-*)
|
|
18
19
|
self.addEventListener('activate', (event) => {
|
|
19
20
|
event.waitUntil(
|
|
20
21
|
caches
|
|
21
22
|
.keys()
|
|
22
|
-
.then((keys) => Promise.all(keys.filter((k) => k.startsWith('pgkiln-static-') && k !== STATIC).map((k) => caches.delete(k))))
|
|
23
|
+
.then((keys) => Promise.all(keys.filter((k) => (k.startsWith('pgkiln-static-') && k !== STATIC) || k.startsWith('pgapex-')).map((k) => caches.delete(k))))
|
|
23
24
|
.then(() => self.clients.claim()),
|
|
24
25
|
);
|
|
25
26
|
});
|
package/src/app.ts
CHANGED
|
@@ -49,6 +49,9 @@ import { accountRoutes } from './runtime/account.ts';
|
|
|
49
49
|
import { taskRoutes } from './runtime/tasks.ts';
|
|
50
50
|
import { dataReporterRoutes } from './runtime/data-reporter.ts';
|
|
51
51
|
import { reporterBuilderRoutes } from './builder/reporter.ts';
|
|
52
|
+
import { formDesignerRoutes } from './runtime/form-designer.ts';
|
|
53
|
+
import { formDesignerBuilderRoutes } from './builder/form-designer.ts';
|
|
54
|
+
import { builderErrorPages, builderServerError } from './builder/errors.ts';
|
|
52
55
|
import { workflowRoutes } from './runtime/workflows.ts';
|
|
53
56
|
import { pwaRoutes } from './runtime/pwa.ts';
|
|
54
57
|
import { pushRoutes } from './runtime/push.ts';
|
|
@@ -138,10 +141,13 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
138
141
|
});
|
|
139
142
|
// An id in the URL that isn't a number (or is too big) fails in PostgreSQL:
|
|
140
143
|
// that's a page that doesn't exist, not a server error.
|
|
141
|
-
app.setErrorHandler((err: Error & { code?: string },
|
|
144
|
+
app.setErrorHandler((err: Error & { code?: string }, req, reply) => {
|
|
142
145
|
if (err.code === '22P02' || err.code === '22003') return reply.code(404).type('text/plain').send('Not found');
|
|
146
|
+
// (sprint 86) a browser on a builder page gets an HTML error page
|
|
147
|
+
if (builderServerError(req, reply, err)) return reply;
|
|
143
148
|
return reply.send(err);
|
|
144
149
|
});
|
|
150
|
+
builderErrorPages(app);
|
|
145
151
|
await app.register(cookie);
|
|
146
152
|
await app.register(formbody, { bodyLimit: 5 * 1024 * 1024 });
|
|
147
153
|
// file upload items; oversized files are cut off and reported, not thrown
|
|
@@ -223,6 +229,8 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
223
229
|
await app.register(codeEditorRoutes);
|
|
224
230
|
await app.register(dataReporterRoutes);
|
|
225
231
|
await app.register(reporterBuilderRoutes);
|
|
232
|
+
await app.register(formDesignerRoutes);
|
|
233
|
+
await app.register(formDesignerBuilderRoutes);
|
|
226
234
|
await app.register(sampleDataRoutes);
|
|
227
235
|
await app.register(assistantRoutes);
|
|
228
236
|
await app.register(aiFilterRoutes);
|
package/src/builder/appwizard.ts
CHANGED
|
@@ -50,6 +50,9 @@ interface Relation {
|
|
|
50
50
|
calendar: boolean;
|
|
51
51
|
/** (sprint 73) a table with a foreign key to this one, for master-detail */
|
|
52
52
|
detail: boolean;
|
|
53
|
+
/** (sprint 87) row level security is on, with this many policies */
|
|
54
|
+
rls: boolean;
|
|
55
|
+
policies: number;
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
/** The tables, partitioned tables, views and materialized views of a schema (not partitions). */
|
|
@@ -59,7 +62,8 @@ export async function schemaRelations(schema: string): Promise<Relation[]> {
|
|
|
59
62
|
`select c.relname, meta.wizard_table_label(c.oid) as label, case when c.relkind in ('v', 'm') then 'view' else 'table' end as kind,
|
|
60
63
|
meta.wizard_pk(c.oid) as pk, case when c.reltuples >= 0 then c.reltuples::bigint end as rows,
|
|
61
64
|
meta.wizard_defaults('calendar', c.oid) ->> 'start' is not null as calendar,
|
|
62
|
-
meta.wizard_pk(c.oid) is not null and meta.wizard_defaults('master_detail', c.oid) ->> 'detail' is not null as detail
|
|
65
|
+
meta.wizard_pk(c.oid) is not null and meta.wizard_defaults('master_detail', c.oid) ->> 'detail' is not null as detail,
|
|
66
|
+
c.relrowsecurity as rls, (select count(*)::int from pg_policy p where p.polrelid = c.oid) as policies
|
|
63
67
|
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
64
68
|
where n.nspname = $1 and c.relkind in ('r', 'p', 'v', 'm') and not c.relispartition
|
|
65
69
|
order by c.relkind in ('v', 'm'), c.relname`,
|
|
@@ -311,6 +315,16 @@ const SOURCES: [string, string, string, string][] = [
|
|
|
311
315
|
['gallery', 'grid', 'From the App Gallery', 'A starter application'],
|
|
312
316
|
];
|
|
313
317
|
|
|
318
|
+
/**
|
|
319
|
+
* (sprint 87) A table's row level security, before the application is created: on with its number
|
|
320
|
+
* of policies, or on without any (the application's role then sees no rows unless it owns the table).
|
|
321
|
+
*/
|
|
322
|
+
const rlsBadge = (r: Relation) =>
|
|
323
|
+
!r.rls ? ''
|
|
324
|
+
: r.policies > 0
|
|
325
|
+
? html` <span class="tag aw-rls" title="Row level security is on (${r.policies} ${r.policies === 1 ? 'policy' : 'policies'}): each user sees only the rows the policies allow">${icon('shield')} RLS · ${r.policies}</span>`
|
|
326
|
+
: html` <span class="tag tag-error aw-rls" title="Row level security is on without policies: the application's role sees no rows (unless it owns the table or bypasses row level security)">${icon('shield')} RLS, no policies</span>`;
|
|
327
|
+
|
|
314
328
|
async function createPage(s: Session, reply: FastifyReply, schema: string, b: Body | null, message: Raw | '' = '') {
|
|
315
329
|
const schemas = await appSchemas();
|
|
316
330
|
const known = schemas.includes(schema);
|
|
@@ -334,8 +348,8 @@ async function createPage(s: Session, reply: FastifyReply, schema: string, b: Bo
|
|
|
334
348
|
'The application\'s parsing schema. Choose a schema to get a page per table and view; a new schema is named after the alias.')}</div>
|
|
335
349
|
<div class="buttons"><button class="btn">${icon('search')} Show its tables</button></div></form>`)}
|
|
336
350
|
<form method="post" action="${BASE}/apps" data-progress="Creating the application and its pages…">${csrf(s)}
|
|
337
|
-
<input type="hidden" name="
|
|
338
|
-
${known ? html`<input type="hidden" name="tables" value="1">` : ''}
|
|
351
|
+
<input type="hidden" name="features" value="1">
|
|
352
|
+
<div id="aw-schema-fields"><input type="hidden" name="schema" value="${known ? schema : ''}">${known ? html`<input type="hidden" name="tables" value="1">` : ''}</div>
|
|
339
353
|
${region('Application', html`<div class="form-grid">
|
|
340
354
|
<div class="field"><label class="label" for="f_name">Name</label>
|
|
341
355
|
<input id="f_name" name="name" value="${v('name', suggested)}" required data-select-once></div>
|
|
@@ -354,14 +368,16 @@ async function createPage(s: Session, reply: FastifyReply, schema: string, b: Bo
|
|
|
354
368
|
? relations.map((r, i) => html`<li><input type="hidden" name="t_${i}" value="${r.relname}">
|
|
355
369
|
<label class="aw-page-kind" for="f_k_${i}">${icon(r.kind === 'view' ? 'eye' : 'table')} ${r.label}</label>
|
|
356
370
|
<select id="f_k_${i}" name="k_${i}">${kindsFor(r).map((k) => html`<option value="${k}"${k === kindOf(r, i) ? raw(' selected') : ''}>${PAGE_KINDS[k]}</option>`)}</select>
|
|
357
|
-
<small class="muted"><code>${r.relname}</code> · ${r.kind === 'view' ? 'view' : rows(r)}${r.kind === 'table' && !r.pk ? ', no single-column key: no form' : ''}${Number(r.rows ?? 0) > LARGE_TABLE ? ': a paged grid' : ''}</small
|
|
371
|
+
<small class="muted"><code>${r.relname}</code> · ${r.kind === 'view' ? 'view' : rows(r)}${r.kind === 'table' && !r.pk ? ', no single-column key: no form' : ''}${Number(r.rows ?? 0) > LARGE_TABLE ? ': a paged grid' : ''}</small>${rlsBadge(r)}</li>`)
|
|
358
372
|
: html`<li class="muted">${schema} has no tables or views.</li>`
|
|
359
373
|
: html`<li class="muted">Choose a schema under Data for a page per table, or add pages with the page wizards later.</li>`}
|
|
360
374
|
</ul>
|
|
361
|
-
${known ? check('chart', 'Dashboard: a chart per table with the number of rows per parent row (a foreign key) or per value of a column whose values repeat', b ? b.chart === 'true' : true) : ''}
|
|
375
|
+
${known ? check('chart', 'Dashboard: a chart per table with the number of rows per parent row (a foreign key) or per value of a column whose values repeat', b ? b.chart === 'true' : true) : ''}`, '', 'aw-pages')}
|
|
362
376
|
${region('Features', html`<div class="aw-features">${FEATURES.map(([k, label, help]) => check(`f_${k}`, label, features[k], help))}</div>`)}
|
|
363
377
|
${region('Settings', html`<div class="form-grid">
|
|
364
|
-
|
|
378
|
+
<div class="field"><label class="label" for="f_alias">Alias (URL)</label>
|
|
379
|
+
<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') : ''}>
|
|
380
|
+
<small class="help">lowercase, e.g. inventory → /a/inventory; follows the name until you change it</small></div>
|
|
365
381
|
${select('authentication', 'Authentication', v('authentication', 'app_users'), [['app_users', 'App users (login page)'], ['none', 'None (public)']])}
|
|
366
382
|
${input('admin_user', 'First user', v('admin_user', ''), { placeholder: 'e.g. your name', help: 'Gets the admin role. An existing account in Users is reused.' })}
|
|
367
383
|
${input('admin_password', 'Password', '', { type: 'password', auto: 'new-password', help: 'For a new account; at least 8 characters.' })}
|