pgkiln 0.44.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/102_app_computations.sql +345 -0
- package/db/migrations/103_shortcuts.sql +341 -0
- package/db/migrations/104_report_queries.sql +413 -0
- package/db/migrations/105_component_settings_groups.sql +367 -0
- package/db/migrations/106_form_designer.sql +102 -0
- package/db/migrations/107_app_version.sql +9 -0
- package/docs/apex-feature-parity.md +15 -12
- package/docs/guide/02-concepts.md +24 -0
- package/docs/guide/03-builder.md +69 -13
- package/docs/guide/04-pages-and-regions.md +84 -3
- package/docs/guide/05-items.md +14 -2
- package/docs/guide/06-processing.md +22 -0
- package/docs/guide/09-reference.md +14 -1
- package/docs/guide/11-from-apex.md +2 -1
- package/docs/guide/12-development.md +8 -2
- package/docs/guide/16-files.md +23 -0
- package/docs/guide/18-cli.md +4 -0
- package/examples/hr/hr_61_form_designer.sql +80 -0
- package/package.json +1 -1
- package/public/app.css +79 -2
- package/public/app.js +307 -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/appfiles.ts +18 -2
- package/src/builder/advisor.ts +7 -2
- package/src/builder/appwizard.ts +22 -6
- package/src/builder/arrange.ts +33 -12
- package/src/builder/code-editor.ts +7 -2
- package/src/builder/components.ts +86 -4
- 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/forms.ts +1 -1
- 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 +41 -8
- package/src/builder/subscriptions.ts +30 -4
- package/src/builder/ui.ts +2 -0
- package/src/cli/replace.ts +16 -2
- 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 +18 -3
- package/src/runtime/component-settings.ts +42 -0
- package/src/runtime/context.ts +4 -0
- package/src/runtime/devbar.ts +1 -1
- package/src/runtime/documents.ts +29 -3
- package/src/runtime/engine.ts +2 -2
- 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 +36 -11
- package/src/runtime/items.ts +47 -32
- package/src/runtime/lists.ts +1 -1
- package/src/runtime/logic.ts +12 -7
- package/src/runtime/pdf.ts +2 -1
- package/src/runtime/regions.ts +8 -3
- package/src/runtime/render.ts +8 -3
- package/src/runtime/report.ts +4 -3
- package/src/runtime/routes.ts +7 -3
- package/src/runtime/shortcuts.ts +43 -0
- package/src/runtime/smart-filters.ts +2 -1
- package/src/runtime/tasks.ts +6 -3
- package/src/security.ts +2 -1
- package/src/subscriptions.ts +67 -0
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
|
|
@@ -1666,6 +1683,69 @@ const pgkilnUnsaved = (() => {
|
|
|
1666
1683
|
data[i].forEach((v, j) => cells[c0 + j] && setCell(cells[c0 + j], v));
|
|
1667
1684
|
}
|
|
1668
1685
|
});
|
|
1686
|
+
|
|
1687
|
+
// ------------------------------------------------------------ cell navigation and edit mode (APEX)
|
|
1688
|
+
// An editable cell reads as text (app.css) until it is edited: a click in it, Enter or F2 or typing
|
|
1689
|
+
// edits it. On a cell the arrow keys move to the next one; while editing, Escape goes back to the
|
|
1690
|
+
// cell and Enter to the cell below. Tab moves through the cells' controls as usual.
|
|
1691
|
+
const cellSel = '[data-grid] table.grid-table > tbody:not(.grid-template) > tr > td[data-col]';
|
|
1692
|
+
function focusCell(td, edit) {
|
|
1693
|
+
if (!td) return;
|
|
1694
|
+
const el = control(td);
|
|
1695
|
+
if (edit && el && !el.disabled) {
|
|
1696
|
+
el.focus();
|
|
1697
|
+
if (el.type !== 'checkbox' && el.tagName !== 'SELECT') el.select?.();
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
if (!td.hasAttribute('tabindex')) td.tabIndex = -1;
|
|
1701
|
+
td.focus();
|
|
1702
|
+
}
|
|
1703
|
+
function neighbour(td, dr, dc) {
|
|
1704
|
+
const rows = rowsOf(td.closest('[data-grid]'));
|
|
1705
|
+
const { r, c } = at(td);
|
|
1706
|
+
return rows[r + dr] ? shownCells(rows[r + dr])[c + dc] : undefined;
|
|
1707
|
+
}
|
|
1708
|
+
const MOVES = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1] };
|
|
1709
|
+
document.addEventListener('keydown', (e) => {
|
|
1710
|
+
if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey) return;
|
|
1711
|
+
const t = e.target;
|
|
1712
|
+
if (t.matches?.(cellSel)) {
|
|
1713
|
+
const el = control(t);
|
|
1714
|
+
if (MOVES[e.key]) {
|
|
1715
|
+
e.preventDefault();
|
|
1716
|
+
focusCell(neighbour(t, ...MOVES[e.key]), false);
|
|
1717
|
+
} else if (el && (e.key === 'Enter' || e.key === 'F2')) {
|
|
1718
|
+
e.preventDefault();
|
|
1719
|
+
focusCell(t, true);
|
|
1720
|
+
} else if (el?.type === 'checkbox' && e.key === ' ') {
|
|
1721
|
+
e.preventDefault();
|
|
1722
|
+
el.click();
|
|
1723
|
+
} else if (el && e.key.length === 1) focusCell(t, true); // the key types into the control
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
const td = t.closest?.(cellSel);
|
|
1727
|
+
if (!td || !t.matches('input, select')) return;
|
|
1728
|
+
if (e.key === 'Escape') focusCell(td, false);
|
|
1729
|
+
else if (e.key === 'Enter' && !e.shiftKey) {
|
|
1730
|
+
e.preventDefault();
|
|
1731
|
+
const below = neighbour(td, 1, 0);
|
|
1732
|
+
focusCell(below ?? td, !!below);
|
|
1733
|
+
} else if (e.key === 'F2') focusCell(td, false);
|
|
1734
|
+
});
|
|
1735
|
+
// a click in a cell beside its control edits it
|
|
1736
|
+
document.addEventListener('click', (e) => {
|
|
1737
|
+
if (!e.shiftKey && e.target.matches?.(cellSel)) focusCell(e.target, true);
|
|
1738
|
+
});
|
|
1739
|
+
// an edited value shows as itself (a list's display value)
|
|
1740
|
+
const syncText = (e) => {
|
|
1741
|
+
const span = e.target.closest?.('td[data-col]')?.querySelector(':scope > .grid-text');
|
|
1742
|
+
if (!span) return;
|
|
1743
|
+
const el = e.target;
|
|
1744
|
+
if (el.tagName === 'SELECT') span.textContent = el.selectedIndex > 0 ? el.options[el.selectedIndex].text : '';
|
|
1745
|
+
else span.remove();
|
|
1746
|
+
};
|
|
1747
|
+
document.addEventListener('input', syncText);
|
|
1748
|
+
document.addEventListener('change', syncText);
|
|
1669
1749
|
})();
|
|
1670
1750
|
|
|
1671
1751
|
// Faceted search: apply on change. A range facet's own from/to: typing picks
|
|
@@ -3429,23 +3509,10 @@ function areaFilter(map, f) {
|
|
|
3429
3509
|
draw();
|
|
3430
3510
|
}
|
|
3431
3511
|
|
|
3432
|
-
// Date range: the end date can't be before the start date
|
|
3433
|
-
function range(box) {
|
|
3434
|
-
const [from, to] = box.querySelectorAll('input[type=date]');
|
|
3435
|
-
if (!from || !to) return;
|
|
3436
|
-
const limit = () => {
|
|
3437
|
-
to.min = from.value;
|
|
3438
|
-
from.max = to.value;
|
|
3439
|
-
};
|
|
3440
|
-
from.addEventListener('change', limit);
|
|
3441
|
-
to.addEventListener('change', limit);
|
|
3442
|
-
}
|
|
3443
|
-
|
|
3444
3512
|
function enhance(root) {
|
|
3445
3513
|
root.querySelectorAll('[data-richtext]').forEach(richText);
|
|
3446
3514
|
root.querySelectorAll('[data-markdown]').forEach(markdown);
|
|
3447
3515
|
root.querySelectorAll('[data-tags]').forEach(tags);
|
|
3448
|
-
root.querySelectorAll('[data-range]').forEach(range);
|
|
3449
3516
|
root.querySelectorAll('[data-reveal]').forEach((b) => (b.hidden = false));
|
|
3450
3517
|
}
|
|
3451
3518
|
enhance(document);
|
|
@@ -3868,11 +3935,42 @@ document.addEventListener('change', (e) => {
|
|
|
3868
3935
|
if (e.target.matches?.('select[data-go]')) e.target.form?.submit();
|
|
3869
3936
|
});
|
|
3870
3937
|
|
|
3871
|
-
// (sprint 76) A date
|
|
3872
|
-
// 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).
|
|
3873
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';
|
|
3874
3946
|
const pad = (n) => String(n).padStart(2, '0');
|
|
3875
|
-
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 {}; } };
|
|
3876
3974
|
const format = (iso, mask, months) => {
|
|
3877
3975
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
|
3878
3976
|
if (!m) return '';
|
|
@@ -3885,31 +3983,205 @@ document.addEventListener('change', (e) => {
|
|
|
3885
3983
|
if (up === 'YY') return pad(y % 100);
|
|
3886
3984
|
if (up === 'MM') return pad(mo);
|
|
3887
3985
|
if (up === 'DD') return pad(d);
|
|
3888
|
-
if (up === 'MONTH') return cased(months.long[mo - 1]);
|
|
3889
|
-
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)));
|
|
3890
3990
|
return tok;
|
|
3891
3991
|
});
|
|
3892
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
|
+
}
|
|
3893
4168
|
document.addEventListener('click', (e) => {
|
|
3894
4169
|
const btn = e.target.closest?.('[data-date-pick]');
|
|
3895
|
-
if (
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
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;
|
|
3902
4177
|
}
|
|
4178
|
+
if (open && !e.target.closest?.('.date-picker')) close(false);
|
|
3903
4179
|
});
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
if (
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
try { months = JSON.parse(text.dataset.months || '{}'); } catch {}
|
|
3911
|
-
text.value = format(native.value, text.dataset.dateMask || 'YYYY-MM-DD', months);
|
|
3912
|
-
text.dispatchEvent(new Event('change', { bubbles: true }));
|
|
3913
|
-
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
|
+
}
|
|
3914
4186
|
});
|
|
3915
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/appfiles.ts
CHANGED
|
@@ -116,6 +116,10 @@ const CODE: Record<string, Record<string, string>> = {
|
|
|
116
116
|
computation: { expression: 'sql', condition_expr: 'sql' },
|
|
117
117
|
branch: { condition_expr: 'sql' },
|
|
118
118
|
app_process: { code: 'sql' },
|
|
119
|
+
app_computation: { expression: 'sql', condition_expr: 'sql' },
|
|
120
|
+
// (103) a shortcut's value: text, HTML or PL/pgSQL (see codeExt)
|
|
121
|
+
shortcut: { value: 'txt' },
|
|
122
|
+
report_query: { query: 'sql' },
|
|
119
123
|
authz_scheme: { value: 'sql' },
|
|
120
124
|
lov: { query: 'sql' },
|
|
121
125
|
automation: { query: 'sql', code: 'sql' },
|
|
@@ -134,7 +138,9 @@ const CODE: Record<string, Record<string, string>> = {
|
|
|
134
138
|
};
|
|
135
139
|
const isLong = (v: unknown): v is string => typeof v === 'string' && (v.includes('\n') || v.length > 60);
|
|
136
140
|
const codeExt = (table: string, column: string, row: any) =>
|
|
137
|
-
table === 'region' && column === 'source' && row.type === 'static' ? 'html'
|
|
141
|
+
table === 'region' && column === 'source' && row.type === 'static' ? 'html'
|
|
142
|
+
: table === 'shortcut' ? (row.type === 'html' ? 'html' : row.type === 'function_body' ? 'sql' : 'txt')
|
|
143
|
+
: CODE[table][column];
|
|
138
144
|
|
|
139
145
|
export type Style = 'json' | 'text';
|
|
140
146
|
|
|
@@ -191,6 +197,11 @@ const NAMED: [section: string, dir: string, table: string][] = [
|
|
|
191
197
|
// (086) application settings
|
|
192
198
|
['app_settings', 'shared/app-settings', 'app_setting'],
|
|
193
199
|
['page_groups', 'shared/page-groups', 'page_group'],
|
|
200
|
+
// (103) shortcuts, (104) report queries
|
|
201
|
+
['shortcuts', 'shared/shortcuts', 'shortcut'],
|
|
202
|
+
['report_queries', 'shared/report-queries', 'report_query'],
|
|
203
|
+
// (105) component groups
|
|
204
|
+
['component_groups', 'shared/component-groups', 'component_group'],
|
|
194
205
|
];
|
|
195
206
|
|
|
196
207
|
/** Components of a page: [array in the document, directory, table, key source]. */
|
|
@@ -214,10 +225,11 @@ const SINGLE: [section: string, path: string, sort: string[]][] = [
|
|
|
214
225
|
];
|
|
215
226
|
|
|
216
227
|
const ACTIONS_DIR = 'shared/automation-actions';
|
|
228
|
+
const APP_COMPUTATIONS_DIR = 'shared/app-computations';
|
|
217
229
|
const STATIC_DIR = 'static';
|
|
218
230
|
const STATIC_INDEX = `${STATIC_DIR}/files.json`;
|
|
219
231
|
|
|
220
|
-
const KNOWN = new Set(['format', 'app', 'static_files', 'app_processes', 'translations', 'nav', 'list_entries', 'automation_actions', 'pages', ...NAMED.map((n) => n[0]), ...SINGLE.map((s) => s[0])]);
|
|
232
|
+
const KNOWN = new Set(['format', 'app', 'static_files', 'app_processes', 'app_computations', 'translations', 'nav', 'list_entries', 'automation_actions', 'pages', ...NAMED.map((n) => n[0]), ...SINGLE.map((s) => s[0])]);
|
|
221
233
|
|
|
222
234
|
const byColumns = (cols: string[]) => (a: any, b: any) => {
|
|
223
235
|
for (const c of cols) {
|
|
@@ -258,6 +270,9 @@ export function docToFiles(doc: Doc, style: Style = 'json'): FileMap {
|
|
|
258
270
|
|
|
259
271
|
const procs: any[] = doc.app_processes ?? [];
|
|
260
272
|
uniqueKeys(procs, (r) => r.name, 'process').forEach((k, i) => w.record('shared/app-processes', `${seqPrefix(procs[i].seq)}-${k}`, 'app_process', procs[i]));
|
|
273
|
+
// (102) application computations, by sequence, item and point
|
|
274
|
+
const comps: any[] = doc.app_computations ?? [];
|
|
275
|
+
uniqueKeys(comps, (r) => `${r.item_name ?? ''} ${r.point ?? ''}`, 'computation').forEach((k, i) => w.record(APP_COMPUTATIONS_DIR, `${seqPrefix(comps[i].seq)}-${k}`, 'app_computation', comps[i]));
|
|
261
276
|
|
|
262
277
|
for (const [section, path, sort] of SINGLE) w.json(path, [...(doc[section] ?? [])].sort(byColumns(sort)));
|
|
263
278
|
|
|
@@ -453,6 +468,7 @@ export function filesToDoc(files: FileMap): Doc {
|
|
|
453
468
|
});
|
|
454
469
|
}
|
|
455
470
|
doc.app_processes = readRecords(files, 'shared/app-processes').sort(SEQ_SORT).map(mergeCode);
|
|
471
|
+
doc.app_computations = readRecords(files, APP_COMPUTATIONS_DIR).sort(SEQ_SORT).map(mergeCode);
|
|
456
472
|
for (const [section, path] of SINGLE) doc[section] = read(path);
|
|
457
473
|
doc.translations = [...files.keys()]
|
|
458
474
|
.filter((p) => /^globalization\/translations\/[^/]+\.(json|yaml)$/.test(p))
|