pgkiln 0.40.0 → 0.41.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/095_rest_catalogs.sql +59 -0
- package/db/migrations/096_developer_email.sql +15 -0
- package/docs/apex-feature-parity.md +12 -9
- package/docs/guide/03-builder.md +37 -1
- package/docs/guide/04-pages-and-regions.md +37 -4
- package/docs/guide/12-development.md +18 -1
- package/docs/guide/19-rest-data-sources.md +39 -1
- package/examples/hr/hr_59_gantt_editing.sql +21 -0
- package/examples/hr/hr_60_tree_charts.sql +46 -0
- package/package.json +1 -1
- package/public/app.css +44 -0
- package/public/app.js +126 -0
- package/public/builder.css +1 -1
- package/public/sw.js +5 -2
- package/src/app.ts +6 -0
- package/src/builder/find-workspace.ts +120 -0
- package/src/builder/home.ts +2 -0
- package/src/builder/mail.ts +39 -0
- package/src/builder/region-settings.ts +19 -1
- package/src/builder/rest-catalogs.ts +424 -0
- package/src/builder/routes.ts +32 -6
- package/src/builder/shared.ts +2 -0
- package/src/builder/workspace-transfer.ts +136 -0
- package/src/i18n/ar.ts +14 -0
- package/src/i18n/cs.ts +14 -0
- package/src/i18n/da.ts +14 -0
- package/src/i18n/de.ts +14 -0
- package/src/i18n/el.ts +14 -0
- package/src/i18n/es.ts +14 -0
- package/src/i18n/fi.ts +14 -0
- package/src/i18n/fr.ts +14 -0
- package/src/i18n/he.ts +14 -0
- package/src/i18n/it.ts +14 -0
- package/src/i18n/ja.ts +14 -0
- package/src/i18n/ko.ts +14 -0
- package/src/i18n/nb.ts +14 -0
- package/src/i18n/pl.ts +14 -0
- package/src/i18n/pt.ts +14 -0
- package/src/i18n/ru.ts +14 -0
- package/src/i18n/sv.ts +14 -0
- package/src/i18n/tr.ts +14 -0
- package/src/i18n/uk.ts +14 -0
- package/src/i18n/zh.ts +14 -0
- package/src/i18n.ts +28 -0
- package/src/restcatalog.ts +605 -0
- package/src/runtime/charts.ts +296 -12
- package/src/runtime/data-reporter.ts +48 -16
- package/src/runtime/gantt-move.ts +63 -0
- package/src/runtime/regions.ts +3 -0
- package/src/runtime/routes.ts +39 -0
- package/src/websources.ts +11 -3
- package/src/workspace-export.ts +240 -0
package/src/runtime/charts.ts
CHANGED
|
@@ -30,8 +30,8 @@ import { cell } from './report.ts';
|
|
|
30
30
|
// row, or one value column with a row per observation (the label repeats; the
|
|
31
31
|
// statistics are computed per label); a stock chart open, high, low and close.
|
|
32
32
|
|
|
33
|
-
export type ChartKind = 'bar' | 'column' | 'stacked' | 'line' | 'area' | 'combo' | 'scatter' | 'donut' | 'pie' | 'bubble' | 'gauge' | 'funnel' | 'radar' | 'gantt' | 'pyramid' | 'polar' | 'range' | 'boxplot' | 'stock';
|
|
34
|
-
export const CHART_KINDS: ChartKind[] = ['bar', 'column', 'stacked', 'line', 'area', 'combo', 'scatter', 'donut', 'pie', 'bubble', 'gauge', 'funnel', 'radar', 'gantt', 'pyramid', 'polar', 'range', 'boxplot', 'stock'];
|
|
33
|
+
export type ChartKind = 'bar' | 'column' | 'stacked' | 'line' | 'area' | 'combo' | 'scatter' | 'donut' | 'pie' | 'bubble' | 'gauge' | 'funnel' | 'radar' | 'gantt' | 'pyramid' | 'polar' | 'range' | 'boxplot' | 'stock' | 'treemap' | 'sunburst' | 'timeline';
|
|
34
|
+
export const CHART_KINDS: ChartKind[] = ['bar', 'column', 'stacked', 'line', 'area', 'combo', 'scatter', 'donut', 'pie', 'bubble', 'gauge', 'funnel', 'radar', 'gantt', 'pyramid', 'polar', 'range', 'boxplot', 'stock', 'treemap', 'sunburst', 'timeline'];
|
|
35
35
|
|
|
36
36
|
/** A gauge's scale and thresholds (config.gauge): warning above critical means low values are bad. */
|
|
37
37
|
export interface GaugeConfig {
|
|
@@ -51,6 +51,8 @@ export interface ChartOptions {
|
|
|
51
51
|
format?: (v: number) => string;
|
|
52
52
|
/** a Gantt chart's "today" line (milliseconds, wall clock as UTC); default now */
|
|
53
53
|
now?: number;
|
|
54
|
+
/** (sprint 69) an editable Gantt chart: its region, and the step of a drag (default: days when every date is midnight) */
|
|
55
|
+
edit?: { region: number; snap?: 'day' | 'hour' };
|
|
54
56
|
}
|
|
55
57
|
/** The interactive report's chart view has one series with text labels: no stacked, combo or scatter. */
|
|
56
58
|
export const REPORT_CHART_KINDS: ChartKind[] = ['bar', 'column', 'line', 'area', 'donut', 'pie'];
|
|
@@ -62,15 +64,15 @@ interface Series {
|
|
|
62
64
|
|
|
63
65
|
let link: ChartOptions['link'];
|
|
64
66
|
/** A mark: an <a> when the chart drills down (row i, series si), else `tag` (focusable when asked). */
|
|
65
|
-
function mark(tag: 'div' | 'span', i: number, si: number | null, cls: string, tip: string | null, inner: unknown, focusable = true, namedByContent = false): Raw {
|
|
67
|
+
function mark(tag: 'div' | 'span', i: number, si: number | null, cls: string, tip: string | null, inner: unknown, focusable = true, namedByContent = false, attrs: Raw | '' = ''): Raw {
|
|
66
68
|
const href = link?.(i, si) ?? null;
|
|
67
69
|
// a drill link with visible text is named by that text; elsewhere the tip is the name (role img: a labelled graphic)
|
|
68
70
|
const tipAttr = tip === null ? '' : href && namedByContent ? html` data-tip="${tip}"` : html` data-tip="${tip}" aria-label="${tip}"`;
|
|
69
|
-
if (href) return html`<a class="${cls} drill" ${href}${tipAttr}${focusable ? '' : raw(' tabindex="-1"')}>${inner}</a>`;
|
|
71
|
+
if (href) return html`<a class="${cls} drill" ${href}${tipAttr}${attrs}${focusable ? '' : raw(' tabindex="-1"')}>${inner}</a>`;
|
|
70
72
|
const role = tip === null ? '' : raw(' role="img"');
|
|
71
73
|
return tag === 'div'
|
|
72
|
-
? html`<div class="${cls}"${role}${tipAttr}${focusable ? raw(' tabindex="0"') : ''}>${inner}</div>`
|
|
73
|
-
: html`<span class="${cls}"${role}${tipAttr}${focusable ? raw(' tabindex="0"') : ''}>${inner}</span>`;
|
|
74
|
+
? html`<div class="${cls}"${role}${tipAttr}${attrs}${focusable ? raw(' tabindex="0"') : ''}>${inner}</div>`
|
|
75
|
+
: html`<span class="${cls}"${role}${tipAttr}${attrs}${focusable ? raw(' tabindex="0"') : ''}>${inner}</span>`;
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
const MAX_SERIES = 8;
|
|
@@ -98,6 +100,10 @@ let texts = {
|
|
|
98
100
|
noRange: 'A range chart needs a label column and two numeric columns: low and high.',
|
|
99
101
|
noBoxplot: 'A box plot needs a label column and either one numeric column (a row per value) or five: minimum, first quartile, median, third quartile and maximum.',
|
|
100
102
|
noStock: 'A stock chart needs a label column (the date) and four numeric columns: open, high, low and close.',
|
|
103
|
+
noTree: 'A treemap or sunburst needs a label column and a numeric column (optionally node_id and parent_id columns for a hierarchy).',
|
|
104
|
+
noTimeline: 'A timeline needs a label column and a start date (an end date and a series column are optional).',
|
|
105
|
+
total: 'Total',
|
|
106
|
+
ganttHint: 'Drag a task to move it, or its end to change its length. With the keyboard: the arrow keys move a task, Shift and the arrow keys change its end, Enter saves, Escape cancels.',
|
|
101
107
|
min: 'Minimum',
|
|
102
108
|
q1: 'First quartile',
|
|
103
109
|
median: 'Median',
|
|
@@ -810,7 +816,7 @@ function dateFormat(opts: Intl.DateTimeFormatOptions) {
|
|
|
810
816
|
}
|
|
811
817
|
}
|
|
812
818
|
|
|
813
|
-
function gantt(title: string, rows: unknown[][], fields: { name: string }[], now: number) {
|
|
819
|
+
function gantt(title: string, rows: unknown[][], fields: { name: string }[], now: number, edit?: ChartOptions['edit']) {
|
|
814
820
|
// label, start, end (empty or equal to the start: a milestone); optional columns by name:
|
|
815
821
|
// progress (0-100), task_id and depends_on (ids of the tasks it waits for: "3" or "3,4")
|
|
816
822
|
if (fields.length < 3) return html`<p class="empty">${texts.noGantt}</p>`;
|
|
@@ -862,16 +868,25 @@ function gantt(title: string, rows: unknown[][], fields: { name: string }[], now
|
|
|
862
868
|
(b) => html`<span class="gantt-arrow ${css.cls(`left:${pct(x(tasks[b].start))};top:${pct(((b + 0.5) / n) * 100)}`)}"></span>`,
|
|
863
869
|
);
|
|
864
870
|
|
|
871
|
+
// (sprint 69) editable: a task with a task_id carries its id and dates (wall clock ms) for app.js; bars get an end handle
|
|
872
|
+
const editable = !!edit && cId >= 0;
|
|
873
|
+
const snap = edit?.snap ?? (tasks.every((t) => t.start % DAY === 0 && t.end % DAY === 0) ? 'day' : 'hour');
|
|
874
|
+
const editAttrs = (t: (typeof tasks)[number]) => {
|
|
875
|
+
const id = editable ? cell(rows[t.i][cId]) : '';
|
|
876
|
+
// (draggable="false": a drill-down link would start the browser's own drag of a link)
|
|
877
|
+
return id ? html` data-task="${id}" data-label="${t.label}" data-start="${String(t.start)}" data-end="${String(t.end)}" draggable="false"` : '';
|
|
878
|
+
};
|
|
865
879
|
const lanes = tasks.map((t) => {
|
|
866
|
-
if (t.end === t.start) return html`<div class="gantt-lane">${mark('span', t.i, null, `gantt-milestone s1 ${css.cls(`left:${pct(x(t.start))}`)}`, tipOf(t), '')}</div>`;
|
|
880
|
+
if (t.end === t.start) return html`<div class="gantt-lane">${mark('span', t.i, null, `gantt-milestone s1 ${css.cls(`left:${pct(x(t.start))}`)}`, tipOf(t), '', true, false, editAttrs(t))}</div>`;
|
|
867
881
|
const geo = css.cls(`left:${pct(x(t.start))};width:${pct(x(t.end) - x(t.start))}`);
|
|
868
882
|
const done = t.progress === null ? '' : html`<span class="gantt-progress ${css.cls(`width:${pct(t.progress)}`)}"></span>`;
|
|
869
|
-
|
|
883
|
+
const handle = editAttrs(t) ? html`<span class="gantt-handle" aria-hidden="true"></span>` : '';
|
|
884
|
+
return html`<div class="gantt-lane">${mark('span', t.i, null, `gantt-bar s1${t.progress === null ? '' : ' partial'} ${geo}`, tipOf(t), html`${done}${handle}`, true, false, editAttrs(t))}</div>`;
|
|
870
885
|
});
|
|
871
886
|
const everyTick = Math.ceil(scale.ticks.length / 8);
|
|
872
887
|
const today = now >= scale.lo && now <= scale.hi ? html`<span class="gantt-today ${css.cls(`left:${pct(x(now))}`)}" title="${texts.today}"></span>` : '';
|
|
873
888
|
const head = (k: number) => fields[k]?.name ?? '';
|
|
874
|
-
return html`<div class="gantt">
|
|
889
|
+
return html`<div class="gantt${editable ? ' gantt-editable' : ''}"${editable ? html` data-gantt="${String(edit!.region)}" data-snap="${snap}" data-lo="${String(scale.lo)}" data-hi="${String(scale.hi)}"` : ''}>
|
|
875
890
|
<span class="gantt-corner">${head(0)}</span>
|
|
876
891
|
<div class="gantt-axis" aria-hidden="true">${scale.ticks.slice(0, -1).map(
|
|
877
892
|
// every other shown label is "odd": hidden on narrow screens (app.css)
|
|
@@ -885,6 +900,7 @@ function gantt(title: string, rows: unknown[][], fields: { name: string }[], now
|
|
|
885
900
|
${lanes}
|
|
886
901
|
</div>
|
|
887
902
|
</div>
|
|
903
|
+
${editable ? html`<p class="gantt-status" role="status" aria-live="polite"></p><p class="gantt-hint muted">${texts.ganttHint}</p>` : ''}
|
|
888
904
|
<details class="chart-data"><summary>${texts.table}</summary>
|
|
889
905
|
<div class="table-wrap"><table class="report">
|
|
890
906
|
<caption class="sr-only">${title}</caption>
|
|
@@ -896,6 +912,269 @@ function gantt(title: string, rows: unknown[][], fields: { name: string }[], now
|
|
|
896
912
|
</table></div></details>`;
|
|
897
913
|
}
|
|
898
914
|
|
|
915
|
+
// ---------------------------------------------------------------- treemap and sunburst (sprint 70)
|
|
916
|
+
// label, value; optional columns by name: node_id and parent_id (a node's parent; without
|
|
917
|
+
// node_id the label is the id). A node's size is its own value plus its children's.
|
|
918
|
+
interface TreeNode {
|
|
919
|
+
i: number;
|
|
920
|
+
id: string;
|
|
921
|
+
label: string;
|
|
922
|
+
own: number;
|
|
923
|
+
total: number;
|
|
924
|
+
parent: string | null;
|
|
925
|
+
children: TreeNode[];
|
|
926
|
+
depth: number;
|
|
927
|
+
}
|
|
928
|
+
const MAX_TREE_DEPTH = 4;
|
|
929
|
+
|
|
930
|
+
/** The nodes of a hierarchy chart, as a forest (cycles and unknown parents make roots). */
|
|
931
|
+
export function buildTree(rows: unknown[][], fields: { name: string }[]): TreeNode[] {
|
|
932
|
+
const col = (name: string) => fields.findIndex((f, i) => i > 1 && f.name.toLowerCase() === name);
|
|
933
|
+
const [cId, cParent] = [col('node_id'), col('parent_id')];
|
|
934
|
+
const nodes = rows.map((r, i): TreeNode => {
|
|
935
|
+
const v = Number(r[1]);
|
|
936
|
+
const label = cell(r[0]);
|
|
937
|
+
return { i, id: cId >= 0 ? cell(r[cId]) : label, label, own: Number.isFinite(v) && v > 0 ? v : 0, total: 0, parent: cParent >= 0 && r[cParent] !== null && r[cParent] !== '' ? cell(r[cParent]) : null, children: [], depth: 0 };
|
|
938
|
+
});
|
|
939
|
+
const byId = new Map<string, TreeNode>();
|
|
940
|
+
for (const n of nodes) if (!byId.has(n.id)) byId.set(n.id, n);
|
|
941
|
+
const roots: TreeNode[] = [];
|
|
942
|
+
// the parents given so far: a parent that is unknown, the node itself, or below it there makes a root
|
|
943
|
+
const given = new Map<TreeNode, TreeNode>();
|
|
944
|
+
for (const n of nodes) {
|
|
945
|
+
let p = n.parent !== null ? byId.get(n.parent) : undefined;
|
|
946
|
+
for (let a = p; a; a = given.get(a)) if (a === n) p = undefined;
|
|
947
|
+
if (p) {
|
|
948
|
+
given.set(n, p);
|
|
949
|
+
p.children.push(n);
|
|
950
|
+
} else roots.push(n);
|
|
951
|
+
}
|
|
952
|
+
const sum = (n: TreeNode, depth: number): number => {
|
|
953
|
+
n.depth = depth;
|
|
954
|
+
n.total = n.own + n.children.reduce((a, c) => a + sum(c, depth + 1), 0);
|
|
955
|
+
return n.total;
|
|
956
|
+
};
|
|
957
|
+
for (const r of roots) sum(r, 0);
|
|
958
|
+
return roots.filter((r) => r.total > 0);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
interface Rect { x: number; y: number; w: number; h: number }
|
|
962
|
+
/** Squarified treemap layout (Bruls, Huizing, van Wijk): rectangles close to squares, largest first. */
|
|
963
|
+
export function squarify<T extends { total: number }>(items: T[], box: Rect): (T & Rect)[] {
|
|
964
|
+
const out: (T & Rect)[] = [];
|
|
965
|
+
const total = items.reduce((a, n) => a + n.total, 0);
|
|
966
|
+
if (!total || box.w <= 0 || box.h <= 0) return out;
|
|
967
|
+
const scale = (box.w * box.h) / total;
|
|
968
|
+
let rest = items.filter((n) => n.total > 0).sort((a, b) => b.total - a.total).map((n) => ({ n, area: n.total * scale }));
|
|
969
|
+
let { x, y, w, h } = box;
|
|
970
|
+
const worst = (row: { area: number }[], side: number) => {
|
|
971
|
+
const s = row.reduce((a, r) => a + r.area, 0);
|
|
972
|
+
const max = Math.max(...row.map((r) => r.area));
|
|
973
|
+
const min = Math.min(...row.map((r) => r.area));
|
|
974
|
+
return Math.max((side * side * max) / (s * s), (s * s) / (side * side * min));
|
|
975
|
+
};
|
|
976
|
+
while (rest.length) {
|
|
977
|
+
const side = Math.min(w, h);
|
|
978
|
+
let k = 1;
|
|
979
|
+
while (k < rest.length && worst(rest.slice(0, k + 1), side) <= worst(rest.slice(0, k), side)) k++;
|
|
980
|
+
const row = rest.slice(0, k);
|
|
981
|
+
rest = rest.slice(k);
|
|
982
|
+
const s = row.reduce((a, r) => a + r.area, 0);
|
|
983
|
+
if (w >= h) {
|
|
984
|
+
const cw = rest.length ? s / h : w;
|
|
985
|
+
let yy = y;
|
|
986
|
+
for (const r of row) {
|
|
987
|
+
const rh = (r.area / s) * h;
|
|
988
|
+
out.push({ ...r.n, x, y: yy, w: cw, h: rh });
|
|
989
|
+
yy += rh;
|
|
990
|
+
}
|
|
991
|
+
x += cw;
|
|
992
|
+
w -= cw;
|
|
993
|
+
} else {
|
|
994
|
+
const rh = rest.length ? s / w : h;
|
|
995
|
+
let xx = x;
|
|
996
|
+
for (const r of row) {
|
|
997
|
+
const rw = (r.area / s) * w;
|
|
998
|
+
out.push({ ...r.n, x: xx, y, w: rw, h: rh });
|
|
999
|
+
xx += rw;
|
|
1000
|
+
}
|
|
1001
|
+
y += rh;
|
|
1002
|
+
h -= rh;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
return out;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function hierarchy(kind: 'treemap' | 'sunburst', title: string, rows: unknown[][], fields: { name: string }[]) {
|
|
1009
|
+
if (fields.length < 2) return html`<p class="empty">${texts.noTree}</p>`;
|
|
1010
|
+
let roots = buildTree(rows, fields);
|
|
1011
|
+
if (!roots.length) return html`<p class="empty">${texts.noData}</p>`;
|
|
1012
|
+
// one root with children: its children are the top level (its name is the chart's centre)
|
|
1013
|
+
const top = roots.length === 1 && roots[0].children.length ? roots[0] : null;
|
|
1014
|
+
if (top) roots = top.children.filter((c) => c.total > 0);
|
|
1015
|
+
const total = roots.reduce((a, r) => a + r.total, 0);
|
|
1016
|
+
const share = (v: number) => `${((v / total) * 100).toFixed(1)}%`;
|
|
1017
|
+
const path = (n: TreeNode, chain: string[]) => [...chain, n.label].join(' › ');
|
|
1018
|
+
const tip = (n: TreeNode, chain: string[]) => `${path(n, chain)}: ${fmt.format(n.total)} (${share(n.total)})`;
|
|
1019
|
+
const colour = (k: number) => `s${(k % MAX_SERIES) + 1}`;
|
|
1020
|
+
const body = kind === 'treemap' ? treemap(roots, colour, tip) : sunburst(roots, colour, tip, top ? top.label : texts.total, total);
|
|
1021
|
+
// the data table: every node with its parent and size (the accessible alternative)
|
|
1022
|
+
const flat: { n: TreeNode; parent: string }[] = [];
|
|
1023
|
+
const walk = (n: TreeNode, parent: string) => {
|
|
1024
|
+
flat.push({ n, parent });
|
|
1025
|
+
for (const c of n.children) walk(c, n.label);
|
|
1026
|
+
};
|
|
1027
|
+
for (const r of top ? [top] : roots) walk(r, '');
|
|
1028
|
+
return html`${body}
|
|
1029
|
+
<details class="chart-data"><summary>${texts.table}</summary>
|
|
1030
|
+
<div class="table-wrap"><table class="report">
|
|
1031
|
+
<caption class="sr-only">${title}</caption>
|
|
1032
|
+
<thead><tr><th scope="col">${fields[0].name}</th><th scope="col">parent</th><th scope="col" class="num">${fields[1].name}</th><th scope="col" class="num">${texts.total}</th></tr></thead>
|
|
1033
|
+
<tbody>${flat.map(({ n, parent }) => {
|
|
1034
|
+
const l = link?.(n.i, null);
|
|
1035
|
+
return html`<tr><th scope="row">${l ? html`<a ${l}>${n.label}</a>` : n.label}</th><td>${parent}</td><td class="num">${fmt.format(n.own)}</td><td class="num">${fmt.format(n.total)}</td></tr>`;
|
|
1036
|
+
})}</tbody>
|
|
1037
|
+
</table></div></details>`;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function treemap(roots: TreeNode[], colour: (k: number) => string, tip: (n: TreeNode, chain: string[]) => string) {
|
|
1041
|
+
// in units of a 16:10 area; percentages of it for the classes
|
|
1042
|
+
const W = 160;
|
|
1043
|
+
const H = 100;
|
|
1044
|
+
const geo = (b: Rect, outer: Rect) =>
|
|
1045
|
+
css.cls(`left:${pct(((b.x - outer.x) / outer.w) * 100)};top:${pct(((b.y - outer.y) / outer.h) * 100)};width:${pct((b.w / outer.w) * 100)};height:${pct((b.h / outer.h) * 100)}`);
|
|
1046
|
+
const area: Rect = { x: 0, y: 0, w: W, h: H };
|
|
1047
|
+
const groups = squarify(roots.map((r, k) => ({ ...r, k })), area);
|
|
1048
|
+
return html`<div class="treemap">${groups.map((g) => {
|
|
1049
|
+
const c = colour(g.k);
|
|
1050
|
+
const kids = g.children.filter((x) => x.total > 0);
|
|
1051
|
+
if (!kids.length) return mark('div', g.i, null, `tm-cell tm-leaf ${c} ${geo(g, area)}`, tip(g, []), html`<span class="tm-label">${g.label}</span><span class="tm-value">${fmt.format(g.total)}</span>`, true, true);
|
|
1052
|
+
// a title strip when the group is tall enough, the children below it
|
|
1053
|
+
const head = g.h > 14 ? Math.min(7, g.h * 0.2) : 0;
|
|
1054
|
+
const inner: Rect = { x: g.x, y: g.y + head, w: g.w, h: g.h - head };
|
|
1055
|
+
const cells = squarify(kids, inner);
|
|
1056
|
+
return html`<div class="tm-group ${c} ${geo(g, area)}">${head ? html`<span class="tm-title">${g.label}</span>` : ''}${cells.map((x) =>
|
|
1057
|
+
mark('div', x.i, null, `tm-cell ${geo(x, g)}`, tip(x, [g.label]), html`<span class="tm-label">${x.label}</span><span class="tm-value">${fmt.format(x.total)}</span>`, true, true))}</div>`;
|
|
1058
|
+
})}</div>`;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function sunburst(roots: TreeNode[], colour: (k: number) => string, tip: (n: TreeNode, chain: string[]) => string, centre: string, total: number) {
|
|
1062
|
+
// rings from radius 6 (the centre shows the total) to 20 in a 42 × 42 box
|
|
1063
|
+
const depth = Math.min(MAX_TREE_DEPTH, Math.max(...roots.map(function d(n: TreeNode): number {
|
|
1064
|
+
return 1 + Math.max(0, ...n.children.filter((c) => c.total > 0).map(d));
|
|
1065
|
+
})));
|
|
1066
|
+
const R0 = 6.5;
|
|
1067
|
+
const ring = (20 - R0) / depth;
|
|
1068
|
+
const pt = (r: number, a: number) => `${(21 + r * Math.sin(a)).toFixed(3)},${(21 - r * Math.cos(a)).toFixed(3)}`;
|
|
1069
|
+
const sector = (a0: number, a1: number, r1: number, r2: number) => {
|
|
1070
|
+
const sweep = Math.min(a1 - a0, 2 * Math.PI - 1e-4);
|
|
1071
|
+
const e = a0 + sweep;
|
|
1072
|
+
const large = sweep > Math.PI ? 1 : 0;
|
|
1073
|
+
return `M${pt(r1, a0)} A${r1},${r1} 0 ${large} 1 ${pt(r1, e)} L${pt(r2, e)} A${r2},${r2} 0 ${large} 0 ${pt(r2, a0)} Z`;
|
|
1074
|
+
};
|
|
1075
|
+
const arcs: Raw[] = [];
|
|
1076
|
+
const draw = (n: TreeNode, a0: number, a1: number, level: number, c: string, chain: string[]) => {
|
|
1077
|
+
const r1 = R0 + level * ring;
|
|
1078
|
+
let arc = html`<path class="sb ${c} d${String(level + 1)}" d="${sector(a0, a1, r1 + 0.15, r1 + ring)}"><title>${tip(n, chain)}</title></path>`;
|
|
1079
|
+
const h = link?.(n.i, null);
|
|
1080
|
+
if (h) arc = html`<a class="drill" ${h} tabindex="-1">${arc}</a>`;
|
|
1081
|
+
arcs.push(arc);
|
|
1082
|
+
if (level + 1 >= depth) return;
|
|
1083
|
+
let a = a0;
|
|
1084
|
+
for (const k of n.children.filter((x) => x.total > 0)) {
|
|
1085
|
+
const b = a + ((a1 - a0) * k.total) / n.total;
|
|
1086
|
+
draw(k, a, b, level + 1, c, [...chain, n.label]);
|
|
1087
|
+
a = b;
|
|
1088
|
+
}
|
|
1089
|
+
};
|
|
1090
|
+
let a = 0;
|
|
1091
|
+
roots.forEach((r, k) => {
|
|
1092
|
+
const b = a + (2 * Math.PI * r.total) / total;
|
|
1093
|
+
draw(r, a, b, 0, colour(k), []);
|
|
1094
|
+
a = b;
|
|
1095
|
+
});
|
|
1096
|
+
return html`<div class="chart-donut chart-sunburst">
|
|
1097
|
+
<div class="donut-figure">
|
|
1098
|
+
<svg viewBox="0 0 42 42" aria-hidden="true">${arcs}</svg>
|
|
1099
|
+
<div class="donut-center"><b>${compact.format(total)}</b><span>${centre}</span></div>
|
|
1100
|
+
</div>
|
|
1101
|
+
<ul class="chart-legend donut-legend">${roots.map((r, k) => {
|
|
1102
|
+
const inner = html`<span class="swatch ${colour(k)}"></span><span class="lg-label">${r.label}</span><span class="lg-value">${fmt.format(r.total)} · ${((r.total / total) * 100).toFixed(0)}%</span>`;
|
|
1103
|
+
const h = link?.(r.i, null);
|
|
1104
|
+
return html`<li data-tip="${tip(r, [])}">${h ? html`<a class="drill" ${h}>${inner}</a>` : inner}</li>`;
|
|
1105
|
+
})}</ul>
|
|
1106
|
+
</div>`;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// ---------------------------------------------------------------- timeline (sprint 70)
|
|
1110
|
+
// label, start, end (empty or equal: a point in time); optional column by name: series
|
|
1111
|
+
// (a lane per series). Items that overlap in a lane get rows of their own.
|
|
1112
|
+
function timeline(title: string, rows: unknown[][], fields: { name: string }[]) {
|
|
1113
|
+
if (fields.length < 2) return html`<p class="empty">${texts.noTimeline}</p>`;
|
|
1114
|
+
const cSeries = fields.findIndex((f, i) => i > 1 && f.name.toLowerCase() === 'series');
|
|
1115
|
+
const hasEnd = fields.length > 2 && cSeries !== 2;
|
|
1116
|
+
const items = rows
|
|
1117
|
+
.map((r, i) => {
|
|
1118
|
+
const start = wallClock(r[1]);
|
|
1119
|
+
const end = hasEnd ? wallClock(r[2]) : NaN;
|
|
1120
|
+
return { i, label: cell(r[0]), start, end: Number.isFinite(end) && end > start ? end : start, series: cSeries >= 0 ? cell(r[cSeries]) : '' };
|
|
1121
|
+
})
|
|
1122
|
+
.filter((t) => Number.isFinite(t.start))
|
|
1123
|
+
.sort((a, b) => a.start - b.start || a.i - b.i);
|
|
1124
|
+
if (!items.length) return html`<p class="empty">${texts.noTimeline}</p>`;
|
|
1125
|
+
const first = Math.min(...items.map((t) => t.start));
|
|
1126
|
+
const last = Math.max(...items.map((t) => t.end));
|
|
1127
|
+
const scale = timeScale(first, last + Math.max((last - first) * 0.03, HOUR));
|
|
1128
|
+
const span = scale.hi - scale.lo;
|
|
1129
|
+
const x = (t: number) => ((t - scale.lo) / span) * 100;
|
|
1130
|
+
const dateOnly = dateFormat({ dateStyle: 'medium' });
|
|
1131
|
+
const dateTime = dateFormat({ dateStyle: 'medium', timeStyle: 'short' });
|
|
1132
|
+
const when = (t: number) => (t % DAY ? dateTime.format(t) : dateOnly.format(t));
|
|
1133
|
+
const tickFmt = dateFormat(
|
|
1134
|
+
scale.unit === 'hour' ? { hour: '2-digit', minute: '2-digit' } : scale.unit === 'month' ? { month: 'short', year: '2-digit' } : scale.unit === 'year' ? { year: 'numeric' } : { day: 'numeric', month: 'short' },
|
|
1135
|
+
);
|
|
1136
|
+
const lanes = [...new Set(items.map((t) => t.series))];
|
|
1137
|
+
// rows within a lane: an item takes the first row free from its start (its label needs room too)
|
|
1138
|
+
const room = span * 0.16;
|
|
1139
|
+
const laid = lanes.map((name) => {
|
|
1140
|
+
const ends: number[] = [];
|
|
1141
|
+
const placed = items.filter((t) => t.series === name).map((t) => {
|
|
1142
|
+
let row = ends.findIndex((e) => e <= t.start);
|
|
1143
|
+
if (row < 0) row = ends.push(0) - 1;
|
|
1144
|
+
ends[row] = Math.max(t.end, t.start + room);
|
|
1145
|
+
return { t, row };
|
|
1146
|
+
});
|
|
1147
|
+
return { name, rows: Math.max(1, ends.length), placed };
|
|
1148
|
+
});
|
|
1149
|
+
const tipOf = (t: (typeof items)[number]) => `${t.series ? `${t.series} · ` : ''}${t.label}: ${t.end > t.start ? `${when(t.start)} – ${when(t.end)}` : when(t.start)}`;
|
|
1150
|
+
const everyTick = Math.ceil(scale.ticks.length / 8);
|
|
1151
|
+
return html`<div class="timeline">
|
|
1152
|
+
<div class="timeline-axis" aria-hidden="true">${scale.ticks.slice(0, -1).map(
|
|
1153
|
+
(t, k) => html`<span class="${k % everyTick ? 'skip ' : (k / everyTick) % 2 ? 'odd ' : ''}${css.cls(`left:${pct(x(t))}`)}">${tickFmt.format(t)}</span>`,
|
|
1154
|
+
)}</div>
|
|
1155
|
+
${laid.map((lane, k) => html`<div class="timeline-lane s${String((k % MAX_SERIES) + 1)}">
|
|
1156
|
+
${lane.name ? html`<span class="timeline-name">${lane.name}</span>` : ''}
|
|
1157
|
+
<div class="timeline-plot ${css.cls(`height:${(lane.rows * 1.9).toFixed(1)}rem`)}">
|
|
1158
|
+
${scale.ticks.map((t) => html`<span class="gantt-grid ${css.cls(`left:${pct(x(t))}`)}" aria-hidden="true"></span>`)}
|
|
1159
|
+
${lane.placed.map(({ t, row }) => {
|
|
1160
|
+
const top = css.cls(`top:${(row * 1.9 + 0.3).toFixed(1)}rem`);
|
|
1161
|
+
return t.end > t.start
|
|
1162
|
+
? mark('span', t.i, null, `tl-item ${top} ${css.cls(`left:${pct(x(t.start))};width:${pct(x(t.end) - x(t.start))}`)}`, tipOf(t), html`<span class="tl-label">${t.label}</span>`, true, true)
|
|
1163
|
+
: mark('span', t.i, null, `tl-point ${top} ${css.cls(`left:${pct(x(t.start))}`)}`, tipOf(t), html`<span class="tl-dot" aria-hidden="true"></span><span class="tl-label">${t.label}</span>`, true, true);
|
|
1164
|
+
})}
|
|
1165
|
+
</div></div>`)}
|
|
1166
|
+
</div>
|
|
1167
|
+
<details class="chart-data"><summary>${texts.table}</summary>
|
|
1168
|
+
<div class="table-wrap"><table class="report">
|
|
1169
|
+
<caption class="sr-only">${title}</caption>
|
|
1170
|
+
<thead><tr><th scope="col">${fields[0].name}</th>${cSeries >= 0 ? html`<th scope="col">${fields[cSeries].name}</th>` : ''}<th scope="col">${fields[1].name}</th>${hasEnd ? html`<th scope="col">${fields[2].name}</th>` : ''}</tr></thead>
|
|
1171
|
+
<tbody>${items.map((t) => {
|
|
1172
|
+
const l = link?.(t.i, null);
|
|
1173
|
+
return html`<tr><th scope="row">${l ? html`<a ${l}>${t.label}</a>` : t.label}</th>${cSeries >= 0 ? html`<td>${t.series}</td>` : ''}<td>${when(t.start)}</td>${hasEnd ? html`<td>${t.end > t.start ? when(t.end) : ''}</td>` : ''}</tr>`;
|
|
1174
|
+
})}</tbody>
|
|
1175
|
+
</table></div></details>`;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
899
1178
|
/** A range chart's legend: one entry per pair of series. */
|
|
900
1179
|
const rangeNames = (series: Series[]): Series[] =>
|
|
901
1180
|
Array.from({ length: Math.min(4, Math.floor(series.length / 2)) }, (_, k) => ({ name: `${series[2 * k].name} – ${series[2 * k + 1].name}`, values: [] }));
|
|
@@ -918,12 +1197,17 @@ export function renderChartBody(kind: ChartKind, title: string, rows: unknown[][
|
|
|
918
1197
|
...texts, table: t('chart.table'), other: t('chart.other'), label: t('chart.label'), noBubble: t('chart.no_bubble'), noRadar: t('chart.no_radar'),
|
|
919
1198
|
good: t('chart.status_good'), warning: t('chart.status_warning'), critical: t('chart.status_critical'), ofFirst: t('chart.of_first'), size: t('chart.size'),
|
|
920
1199
|
noGantt: t('chart.no_gantt'), noData: t('chart.no_data'), today: t('chart.today'),
|
|
921
|
-
noRange: t('chart.no_range'), noBoxplot: t('chart.no_boxplot'), noStock: t('chart.no_stock'),
|
|
1200
|
+
noRange: t('chart.no_range'), noBoxplot: t('chart.no_boxplot'), noStock: t('chart.no_stock'), ganttHint: t('chart.gantt_hint'), noTree: t('chart.no_tree'), noTimeline: t('chart.no_timeline'), total: t('chart.total'),
|
|
922
1201
|
min: t('chart.min'), q1: t('chart.q1'), median: t('chart.median'), q3: t('chart.q3'), max: t('chart.max'),
|
|
923
1202
|
};
|
|
924
1203
|
// a Gantt chart reads its date columns itself (its series are not numbers)
|
|
925
1204
|
if (kind === 'gantt')
|
|
926
|
-
return html`<figure class="chart chart-gantt" aria-label="${title}">${gantt(title, rows, fields, opts.now ?? Date.now())}</figure>`;
|
|
1205
|
+
return html`<figure class="chart chart-gantt" aria-label="${title}">${gantt(title, rows, fields, opts.now ?? Date.now(), opts.edit)}</figure>`;
|
|
1206
|
+
// (sprint 70) hierarchies and timelines read their columns by position and name, like a Gantt chart
|
|
1207
|
+
if (kind === 'treemap' || kind === 'sunburst')
|
|
1208
|
+
return html`<figure class="chart chart-${kind}" aria-label="${title}">${hierarchy(kind, title, rows, fields)}</figure>`;
|
|
1209
|
+
if (kind === 'timeline')
|
|
1210
|
+
return html`<figure class="chart chart-timeline" aria-label="${title}">${timeline(title, rows, fields)}</figure>`;
|
|
927
1211
|
const { labels, series } = parse(rows, fields, opts.hidden);
|
|
928
1212
|
if (!series.length) return html`<p class="empty">${texts.noSeries}</p>`;
|
|
929
1213
|
if (kind === 'boxplot') {
|
|
@@ -284,42 +284,74 @@ export async function renderDataReporter(ctx: PageContext, r: Region): Promise<R
|
|
|
284
284
|
return reportView(ctx, r, source, cols, def, saved, edited);
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
-
/**
|
|
287
|
+
/**
|
|
288
|
+
* The Data Reporter's welcome page (APEX 26.1): a greeting, the data sources as
|
|
289
|
+
* cards that start a report, a search over the reports, the user's own reports
|
|
290
|
+
* (latest changed first) and the ones others shared. config.welcome = false
|
|
291
|
+
* keeps the compact form (a list of sources and a Start button); welcome_title
|
|
292
|
+
* and welcome_text replace the greeting.
|
|
293
|
+
*/
|
|
288
294
|
async function home(ctx: PageContext, r: Region, sources: Source[]): Promise<Raw> {
|
|
289
295
|
const t = ctx.locale.t;
|
|
290
296
|
const c = ctx.client!;
|
|
291
297
|
const prefix = prefixOf(r);
|
|
298
|
+
const q = (ctx.params.get(`${prefix}q`) ?? '').trim().slice(0, 100);
|
|
292
299
|
const reports = (
|
|
293
300
|
await savepoint(c, () =>
|
|
294
|
-
c.query<Saved
|
|
301
|
+
c.query<Saved & { updated_at: string }>(
|
|
302
|
+
`select id, name, description, username, shared, own, definition, updated_at from meta.data_reports
|
|
303
|
+
where region_id = $1 and ($2 = '' or position(lower($2) in lower(name || ' ' || coalesce(description, ''))) > 0)
|
|
304
|
+
order by own desc, case when own then updated_at end desc, lower(name), id`, [r.id, q]),
|
|
295
305
|
)
|
|
296
306
|
).rows;
|
|
297
307
|
const labelOf = (id: string) => {
|
|
298
308
|
const s = sources.find((x) => x.id === id);
|
|
299
309
|
return s ? ctx.locale.tr(s.label || heading(s.table)) : id;
|
|
300
310
|
};
|
|
301
|
-
const list = (rows: Saved[]) =>
|
|
311
|
+
const list = (rows: (Saved & { updated_at: string })[]) =>
|
|
302
312
|
html`<ul class="reporter-list">${rows.map((x) => html`<li>
|
|
303
313
|
<a href="${url(ctx, r, new URLSearchParams([[`${prefix}open`, String(x.id)]]))}">${x.name}</a>
|
|
304
|
-
<span class="muted">${labelOf(str(x.definition?.source))}${x.own ? '' : html` · ${t('reporter.by', { user: x.username })}`}</span>${x.own && x.shared ? html` <span class="tag">${t('reporter.shared_tag')}</span>` : ''}
|
|
314
|
+
<span class="muted">${labelOf(str(x.definition?.source))}${x.own ? html` · ${t('reporter.changed', { date: String(x.updated_at).slice(0, 10) })}` : html` · ${t('reporter.by', { user: x.username })}`}</span>${x.definition?.chart ? html` <span class="tag">${t('reporter.chart_tag')}</span>` : ''}${x.own && x.shared ? html` <span class="tag">${t('reporter.shared_tag')}</span>` : ''}
|
|
305
315
|
${x.description ? html`<p class="muted reporter-desc">${x.description}</p>` : ''}
|
|
306
316
|
</li>`)}</ul>`;
|
|
307
317
|
const mine = reports.filter((x) => x.own);
|
|
308
318
|
const others = reports.filter((x) => !x.own);
|
|
309
|
-
const form = `drn${r.id}`;
|
|
310
319
|
const keep = [...ctx.params.entries()].filter(([k]) => !k.startsWith(prefix) && !['clear', 'cs'].includes(k));
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
320
|
+
const hidden = keep.map(([k, v]) => html`<input type="hidden" name="${k}" value="${v}">`);
|
|
321
|
+
const lists = html`${ctx.user !== 'nobody' ? html`<h3 class="reporter-h">${t('reporter.my_reports')}</h3>${mine.length ? list(mine) : html`<p class="muted">${q ? t('reporter.no_match') : t('reporter.no_reports')}</p>`}` : ''}
|
|
322
|
+
${others.length ? html`<h3 class="reporter-h">${t('reporter.shared_reports')}</h3>${list(others)}` : ''}`;
|
|
323
|
+
|
|
324
|
+
if (r.config.welcome === false) {
|
|
325
|
+
const form = `drn${r.id}`;
|
|
326
|
+
ctx.detached.push(html`<form id="${form}" method="get" action="${ctx.base}/${ctx.page.page_no}">${hidden}</form>`);
|
|
327
|
+
return html`<div class="reporter">
|
|
328
|
+
<div class="reporter-new">
|
|
329
|
+
<label class="label" for="${form}_src">${t('reporter.new')}</label>
|
|
330
|
+
<div class="reporter-row">
|
|
331
|
+
${sel(`${prefix}src`, form, t('reporter.source'), sources.map((s) => [s.id, ctx.locale.tr(s.label || heading(s.table))]), sources[0].id, `${form}_src`)}
|
|
332
|
+
<button class="btn btn-hot" form="${form}">${t('reporter.create')}</button>
|
|
333
|
+
</div>
|
|
334
|
+
${sources.some((s) => s.description) ? html`<dl class="reporter-sources">${sources.filter((s) => s.description).map((s) => html`<dt>${ctx.locale.tr(s.label || heading(s.table))}</dt><dd>${ctx.locale.tr(s.description!)}</dd>`)}</dl>` : ''}
|
|
318
335
|
</div>
|
|
319
|
-
${
|
|
320
|
-
</div
|
|
321
|
-
|
|
322
|
-
|
|
336
|
+
${lists}
|
|
337
|
+
</div>`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const title = typeof r.config.welcome_title === 'string' && r.config.welcome_title.trim() ? ctx.locale.tr(r.config.welcome_title) : t('reporter.welcome_title');
|
|
341
|
+
const text = typeof r.config.welcome_text === 'string' && r.config.welcome_text.trim() ? ctx.locale.tr(r.config.welcome_text) : t('reporter.welcome_text');
|
|
342
|
+
// the search: a GET form of its own (detached: the region may sit inside the page's form)
|
|
343
|
+
const search = `drq${r.id}`;
|
|
344
|
+
const searchable = q !== '' || reports.length > 6;
|
|
345
|
+
if (searchable) ctx.detached.push(html`<form id="${search}" method="get" action="${ctx.base}/${ctx.page.page_no}" role="search">${hidden}</form>`);
|
|
346
|
+
return html`<div class="reporter reporter-welcome">
|
|
347
|
+
<div class="reporter-hello"><h3 class="reporter-title">${title}</h3><p class="muted">${text}</p></div>
|
|
348
|
+
<h3 class="reporter-h">${t('reporter.new')}</h3>
|
|
349
|
+
<ul class="reporter-cards">${sources.map((s) => html`<li><a class="reporter-card" href="${url(ctx, r, new URLSearchParams([[`${prefix}src`, s.id]]))}">
|
|
350
|
+
<strong>${ctx.locale.tr(s.label || heading(s.table))}</strong>
|
|
351
|
+
${s.description ? html`<span class="muted">${ctx.locale.tr(s.description)}</span>` : ''}
|
|
352
|
+
<span class="reporter-card-meta">${t('reporter.columns_count', { n: s.columns.length })}</span></a></li>`)}</ul>
|
|
353
|
+
${searchable ? html`<div class="reporter-row reporter-search"><input type="search" name="${prefix}q" value="${q}" form="${search}" aria-label="${t('reporter.search')}" placeholder="${t('reporter.search')}"><button class="btn" form="${search}">${t('reporter.search')}</button></div>` : ''}
|
|
354
|
+
${lists}
|
|
323
355
|
</div>`;
|
|
324
356
|
}
|
|
325
357
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import pg from 'pg';
|
|
2
|
+
import { applyBinds, literal } from '../binds.ts';
|
|
3
|
+
import { savepoint } from '../db.ts';
|
|
4
|
+
import type { Region } from '../metadata.ts';
|
|
5
|
+
import { Forbidden, isAuthorized } from './authz.ts';
|
|
6
|
+
import { wallClock } from './charts.ts';
|
|
7
|
+
import { bindValues, stripSemicolon, toState, type PageContext } from './context.ts';
|
|
8
|
+
import { resolveRestRegion } from './rest-sources.ts';
|
|
9
|
+
|
|
10
|
+
// Gantt chart drag and drop (sprint 69; APEX: the Gantt chart's task
|
|
11
|
+
// dragging): a chart region of kind "gantt" with config.gantt_move (SQL, e.g.
|
|
12
|
+
// an UPDATE or a function call) lets users drag a task to new dates or drag
|
|
13
|
+
// its end. POST /a/<app>/<page>/chart/<region>/move (routes.ts) checks the
|
|
14
|
+
// CSRF token, page access and the region's visibility; moveGanttTask() then
|
|
15
|
+
// checks config.gantt_move_authz, the dates, and that the task is one of the
|
|
16
|
+
// rows the region's query shows this user (as the application's role, so row
|
|
17
|
+
// level security applies), and runs the SQL with :TASK_ID, :NEW_START and
|
|
18
|
+
// :NEW_END in a savepoint of the request's transaction.
|
|
19
|
+
|
|
20
|
+
/** YYYY-MM-DD or YYYY-MM-DDTHH:MM (a day or an hour of the chart's time axis). */
|
|
21
|
+
const WHEN = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?$/;
|
|
22
|
+
|
|
23
|
+
/** A target date's wall clock in ms, or NaN (also for impossible dates and years outside 1900–2200). */
|
|
24
|
+
export function targetTime(v: string): number {
|
|
25
|
+
const m = WHEN.exec(v);
|
|
26
|
+
if (!m || +m[1] < 1900 || +m[1] > 2200) return NaN;
|
|
27
|
+
const t = wallClock(v.replace('T', ' '));
|
|
28
|
+
// 2026-02-30 rolls over in Date.UTC: refuse what doesn't read back the same
|
|
29
|
+
const d = new Date(t);
|
|
30
|
+
return d.getUTCDate() === +m[3] && d.getUTCMonth() === +m[2] - 1 && (m[4] === undefined || (d.getUTCHours() === +m[4] && d.getUTCMinutes() === +m[5])) ? t : NaN;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** May this user drag the region's tasks? */
|
|
34
|
+
export async function ganttEditable(ctx: PageContext, r: Region) {
|
|
35
|
+
const cfg = r.config as Record<string, any>;
|
|
36
|
+
return cfg.kind === 'gantt' && typeof cfg.gantt_move === 'string' && cfg.gantt_move.trim() !== '' && (await isAuthorized(ctx, cfg.gantt_move_authz ?? null));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Give task `task` of a Gantt region the dates `start` and `end`. Throws Forbidden
|
|
41
|
+
* when dragging isn't allowed or the task isn't one this user sees, RangeError for
|
|
42
|
+
* dates that aren't valid. Returns the task's label (the query's first column).
|
|
43
|
+
*/
|
|
44
|
+
export async function moveGanttTask(ctx: PageContext, r: Region, task: string, start: string, end: string) {
|
|
45
|
+
const t = ctx.locale.t;
|
|
46
|
+
if (!(await ganttEditable(ctx, r))) throw new Forbidden(t('chart.gantt_cannot_move'));
|
|
47
|
+
const s = targetTime(start);
|
|
48
|
+
const e = targetTime(end);
|
|
49
|
+
if (!Number.isFinite(s) || !Number.isFinite(e) || e < s) throw new RangeError(t('chart.gantt_bad_dates'));
|
|
50
|
+
const c = ctx.client!;
|
|
51
|
+
await resolveRestRegion(ctx, r);
|
|
52
|
+
const src = stripSemicolon(applyBinds(r.source ?? '', bindValues(ctx)));
|
|
53
|
+
// the task_id column as the query names it (the chart finds it case-insensitively)
|
|
54
|
+
const shape = await savepoint(c, () => c.query(`select * from (\n${src}\n) "__q" limit 0`));
|
|
55
|
+
const idCol = shape.fields.find((f, i) => i > 2 && f.name.toLowerCase() === 'task_id');
|
|
56
|
+
if (!idCol) throw new Forbidden(t('chart.gantt_cannot_move'));
|
|
57
|
+
const res = await savepoint(c, () =>
|
|
58
|
+
c.query({ text: `select * from (\n${src}\n) "__q" where "__q".${pg.escapeIdentifier(idCol.name)}::text = ${literal(task)} limit 2`, rowMode: 'array' }));
|
|
59
|
+
if (res.rows.length !== 1) throw new Forbidden(t('chart.gantt_cannot_move'));
|
|
60
|
+
const sql = stripSemicolon(applyBinds((r.config as Record<string, any>).gantt_move, { ...bindValues(ctx), TASK_ID: task, NEW_START: start, NEW_END: end }));
|
|
61
|
+
await savepoint(c, () => c.query(sql));
|
|
62
|
+
return { label: toState(res.rows[0][0]) ?? task };
|
|
63
|
+
}
|
package/src/runtime/regions.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { QueryResult } from 'pg';
|
|
2
|
+
import { ganttEditable } from './gantt-move.ts';
|
|
2
3
|
import { applyBinds } from '../binds.ts';
|
|
3
4
|
import { savepoint } from '../db.ts';
|
|
4
5
|
import { esc, html, raw, type Raw } from '../html.ts';
|
|
@@ -145,6 +146,8 @@ async function renderChart(ctx: PageContext, r: Region) {
|
|
|
145
146
|
...chartFormat(ctx, r),
|
|
146
147
|
// the query's timestamps are in the session's time zone: so is "today"
|
|
147
148
|
...(kind === 'gantt' ? { now: wallClockIn(ctx.locale.timeZone ?? (await databaseTimeZone())) } : {}),
|
|
149
|
+
// (sprint 69) tasks this user may drag
|
|
150
|
+
...(kind === 'gantt' && (await ganttEditable(ctx, r)) ? { edit: { region: r.id, snap: r.config.gantt_snap === 'hour' || r.config.gantt_snap === 'day' ? r.config.gantt_snap : undefined } } : {}),
|
|
148
151
|
})}${firstRows(ctx, res)}`;
|
|
149
152
|
}
|
|
150
153
|
|
package/src/runtime/routes.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { cleanRichText } from '../richtext.ts';
|
|
|
30
30
|
import { applyUploads, fileRoutes, readMultipart, type Upload } from './files.ts';
|
|
31
31
|
import { renderRegion } from './regions.ts';
|
|
32
32
|
import { downloadHeaders, type Download } from './processes.ts';
|
|
33
|
+
import { moveGanttTask } from './gantt-move.ts';
|
|
33
34
|
import { moveCalendarEvent } from './calendar.ts';
|
|
34
35
|
import { openDownload, reportParams, normaliseReportParams, selectionOf } from './report.ts';
|
|
35
36
|
import { invalidatePage, lazyOf } from './region-cache.ts';
|
|
@@ -1038,6 +1039,44 @@ export async function runtimeRoutes(app: FastifyInstance) {
|
|
|
1038
1039
|
}
|
|
1039
1040
|
});
|
|
1040
1041
|
|
|
1042
|
+
// (sprint 69) Gantt drag and drop (gantt-move.ts): new dates for a task, answer with the re-rendered region.
|
|
1043
|
+
app.post('/a/:alias/:page/chart/:id/move', async (req: Req, reply) => {
|
|
1044
|
+
const ctx = await loadContext(req, reply, { json: true });
|
|
1045
|
+
if (!ctx) return;
|
|
1046
|
+
const body = req.body ?? {};
|
|
1047
|
+
if (body.__csrf !== ctx.session.csrf_token) return reply.code(403).send({ error: ctx.locale.t('error.session_reload') });
|
|
1048
|
+
ctx.params = new URLSearchParams(typeof body.__url_params === 'string' ? body.__url_params : '');
|
|
1049
|
+
const task = typeof body.task === 'string' ? body.task : '';
|
|
1050
|
+
const start = typeof body.start === 'string' ? body.start : '';
|
|
1051
|
+
const end = typeof body.end === 'string' ? body.end : '';
|
|
1052
|
+
try {
|
|
1053
|
+
const out = await appTx(txContext(ctx), async (c) => {
|
|
1054
|
+
ctx.client = c;
|
|
1055
|
+
await checkPageAccess(ctx);
|
|
1056
|
+
const vis = await computeVisibility(ctx);
|
|
1057
|
+
const r = ctx.page.regions.find((x) => x.id === Number(req.params.id) && x.type === 'chart');
|
|
1058
|
+
if (!r || !vis.regions.has(r.id) || !task || task.length > 200) throw new Forbidden(ctx.locale.t('chart.gantt_cannot_move'));
|
|
1059
|
+
const moved = await moveGanttTask(ctx, r, task, start, end);
|
|
1060
|
+
await computeVisibility(ctx);
|
|
1061
|
+
return {
|
|
1062
|
+
region: (await renderRegion(ctx, r)).toString(),
|
|
1063
|
+
css: ctx.css.text,
|
|
1064
|
+
message: ctx.locale.t('chart.gantt_moved', { task: moved.label, start: start.replace('T', ' '), end: end.replace('T', ' ') }),
|
|
1065
|
+
};
|
|
1066
|
+
});
|
|
1067
|
+
await saveState(ctx.session);
|
|
1068
|
+
await logActivity({ appId: ctx.app.id, pageNo: ctx.page.page_no, username: ctx.user, event: 'gantt_move', ip: ctx.ip, detail: `region ${req.params.id}, task ${task}: ${start} – ${end}` });
|
|
1069
|
+
return reply.send(out);
|
|
1070
|
+
} catch (e) {
|
|
1071
|
+
if (e instanceof Forbidden) {
|
|
1072
|
+
await logActivity({ appId: ctx.app.id, pageNo: ctx.page.page_no, username: ctx.user, event: 'forbidden', ip: ctx.ip, detail: `gantt move: region ${req.params.id}` });
|
|
1073
|
+
return reply.code(403).send({ error: e.message });
|
|
1074
|
+
}
|
|
1075
|
+
if (e instanceof RangeError) return reply.code(400).send({ error: e.message });
|
|
1076
|
+
return reply.code(400).send({ error: await publicError(ctx, e, 'gantt move') });
|
|
1077
|
+
}
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1041
1080
|
// ---------------------------------------------------------------- login / logout
|
|
1042
1081
|
const loginPage = async (app: App, locale: Locale, session: Session, next: string, error?: string) => {
|
|
1043
1082
|
const t = locale.t;
|