pgkiln 0.50.0 → 0.52.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/SECURITY.md +7 -0
- package/db/migrations/119_workspace_home.sql +30 -0
- package/db/migrations/120_datasets.sql +87 -0
- package/db/migrations/121_ai_agents.sql +94 -0
- package/db/migrations/122_team_development.sql +186 -0
- package/db/migrations/123_workflow_outcomes.sql +407 -0
- package/db/migrations/124_component_details.sql +51 -0
- package/docs/apex-feature-parity.md +15 -12
- package/docs/guide/01-installation.md +9 -3
- package/docs/guide/03-builder.md +211 -11
- package/docs/guide/04-pages-and-regions.md +96 -22
- package/docs/guide/05-items.md +7 -1
- package/docs/guide/06-processing.md +123 -8
- package/docs/guide/07-dynamic-actions.md +34 -0
- package/docs/guide/09-reference.md +2 -1
- package/docs/guide/11-from-apex.md +2 -1
- package/docs/guide/12-development.md +70 -11
- package/docs/guide/20-ai-agents.md +4 -2
- package/examples/hr/README.md +2 -0
- package/examples/hr/hr_63_datasets.sql +29 -0
- package/examples/hr/hr_64_ai_agent.sql +97 -0
- package/examples/hr/hr_65_ask_reports.sql +21 -0
- package/examples/hr/hr_66_team_development.sql +58 -0
- package/examples/hr/hr_67_workflow_outcomes.sql +113 -0
- package/examples/hr/hr_68_component_details.sql +96 -0
- package/package.json +1 -1
- package/public/app.css +46 -0
- package/public/app.js +134 -20
- package/public/builder.css +116 -0
- package/public/builder.js +47 -0
- package/src/ai/chat.ts +18 -18
- package/src/ai/service.ts +35 -11
- package/src/ai/types.ts +7 -2
- package/src/app.ts +11 -1
- package/src/builder/advisor.ts +6 -3
- package/src/builder/ai-agents.ts +372 -0
- package/src/builder/ai-builder.ts +3 -3
- package/src/builder/ai.ts +13 -9
- package/src/builder/assistant.ts +22 -7
- package/src/builder/components.ts +38 -9
- package/src/builder/datasets.ts +472 -0
- package/src/builder/designer.ts +8 -3
- package/src/builder/feedback.ts +6 -1
- package/src/builder/home.ts +12 -7
- package/src/builder/instance.ts +2 -0
- package/src/builder/locks.ts +7 -1
- package/src/builder/region-help.ts +2 -2
- package/src/builder/reporter.ts +52 -3
- package/src/builder/routes.ts +7 -4
- package/src/builder/shared.ts +1 -1
- package/src/builder/subscriptions.ts +1 -0
- package/src/builder/team.ts +991 -0
- package/src/builder/ui.ts +8 -5
- package/src/builder/workflow-migrate.ts +210 -0
- package/src/builder/workflows.ts +29 -6
- package/src/builder/workspace-home.ts +285 -0
- package/src/builder/workspace-transfer.ts +1 -1
- package/src/builder/workspaces.ts +7 -4
- package/src/cli/replace.ts +7 -1
- package/src/datasets.ts +367 -0
- package/src/i18n/ar.ts +33 -0
- package/src/i18n/cs.ts +33 -0
- package/src/i18n/da.ts +33 -0
- package/src/i18n/de.ts +33 -0
- package/src/i18n/el.ts +33 -0
- package/src/i18n/es.ts +33 -0
- package/src/i18n/fi.ts +33 -0
- package/src/i18n/fr.ts +33 -0
- package/src/i18n/he.ts +33 -0
- package/src/i18n/it.ts +33 -0
- package/src/i18n/ja.ts +33 -0
- package/src/i18n/ko.ts +33 -0
- package/src/i18n/nb.ts +33 -0
- package/src/i18n/pl.ts +33 -0
- package/src/i18n/pt.ts +33 -0
- package/src/i18n/ru.ts +33 -0
- package/src/i18n/sv.ts +33 -0
- package/src/i18n/tr.ts +33 -0
- package/src/i18n/uk.ts +33 -0
- package/src/i18n/zh.ts +33 -0
- package/src/i18n.ts +66 -0
- package/src/instance.ts +3 -0
- package/src/metadata.ts +9 -3
- package/src/release-check.ts +139 -0
- package/src/reporting-app.ts +101 -0
- package/src/runtime/ai-agents.ts +365 -0
- package/src/runtime/ai-filter.ts +385 -107
- package/src/runtime/assistant.ts +64 -11
- package/src/runtime/context.ts +2 -0
- package/src/runtime/data-reporter.ts +199 -32
- package/src/runtime/ir-phrases.ts +812 -0
- package/src/runtime/ir-plan.ts +449 -0
- package/src/runtime/items.ts +1 -1
- package/src/runtime/regions.ts +6 -2
- package/src/runtime/render.ts +1 -1
- package/src/runtime/report.ts +1 -1
- package/src/runtime/tasks.ts +63 -12
- package/src/session.ts +2 -0
- package/src/subscriptions.ts +2 -1
- package/src/task-events.ts +232 -0
- package/src/team-samples.ts +74 -0
- package/src/team.ts +342 -0
- package/src/workflow.ts +135 -18
- package/src/workspace-export.ts +39 -4
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import { REPORT_CHART_KINDS } from './charts.ts';
|
|
2
|
+
import { ComputeError, computeNameOk, computeSql } from './compute.ts';
|
|
3
|
+
import { AGGREGATES, HIGHLIGHT_COLORS, MAX_COMPUTATIONS, OPERATORS } from './report.ts';
|
|
4
|
+
|
|
5
|
+
// (sprint 112) What "Ask in your own words" may do to an interactive report
|
|
6
|
+
// (APEX 26.1/26.2: AI interactive reports). An answer, from the AI service's
|
|
7
|
+
// structured output or from the built-in phrase parser (ir-phrases.ts), is a
|
|
8
|
+
// RawPlan: filters, highlights, a search, a sort, a control break,
|
|
9
|
+
// aggregates, group by, a pivot, a chart and computed columns. checkPlan()
|
|
10
|
+
// keeps only what the report allows (its shown columns, whitelisted
|
|
11
|
+
// operators, functions, chart types and colors, short values, computed
|
|
12
|
+
// expressions that parse with the same validator as Actions → Compute) and
|
|
13
|
+
// applyPlan() turns it into the report's ordinary URL parameters, the same
|
|
14
|
+
// ones the Actions menu sets. The report's own code builds the SQL from them.
|
|
15
|
+
//
|
|
16
|
+
// The settings in force are shown as chips (chipsOf): each one is a URL
|
|
17
|
+
// parameter value of the report, so a chip can be edited or removed
|
|
18
|
+
// (editChip, removeChip) and nothing else is stored.
|
|
19
|
+
|
|
20
|
+
export type Kind = 'number' | 'date' | 'text' | 'boolean';
|
|
21
|
+
|
|
22
|
+
/** A column the user may use: shown (not in "hidden"), by name, heading and kind; pos = ORDER BY position. */
|
|
23
|
+
export interface Column {
|
|
24
|
+
name: string;
|
|
25
|
+
label: string;
|
|
26
|
+
kind: Kind;
|
|
27
|
+
/** position in the report's query (ORDER BY n); 0 when not known */
|
|
28
|
+
pos: number;
|
|
29
|
+
/** a computed column (r<id>_c): other computed columns can't use it */
|
|
30
|
+
computed?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** An answer as it comes from the AI service or the parser, before it is checked. */
|
|
34
|
+
export interface RawPlan {
|
|
35
|
+
reset?: boolean;
|
|
36
|
+
filters?: { column: string; operator: string; value: string }[];
|
|
37
|
+
highlights?: { column: string; operator: string; value: string; color: string }[];
|
|
38
|
+
search?: string;
|
|
39
|
+
sort_column?: string;
|
|
40
|
+
sort_descending?: boolean;
|
|
41
|
+
control_break?: string;
|
|
42
|
+
aggregates?: { function: string; column: string }[];
|
|
43
|
+
group_by?: string[];
|
|
44
|
+
group_functions?: { function: string; column: string }[];
|
|
45
|
+
pivot?: { row: string; column: string; function: string; value: string } | null;
|
|
46
|
+
chart?: { type: string; label: string; function: string; value: string } | null;
|
|
47
|
+
computed_columns?: { name: string; expression: string }[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface Agg {
|
|
51
|
+
fn: string;
|
|
52
|
+
column: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A checked answer: only the report's columns, whitelisted operators and functions, valid expressions. */
|
|
56
|
+
export interface Plan {
|
|
57
|
+
reset: boolean;
|
|
58
|
+
filters: { column: string; operator: string; value: string }[];
|
|
59
|
+
highlights: { column: string; operator: string; value: string; color: string }[];
|
|
60
|
+
search: string;
|
|
61
|
+
sort: Column | null;
|
|
62
|
+
desc: boolean;
|
|
63
|
+
breakCol: string | null;
|
|
64
|
+
aggregates: Agg[];
|
|
65
|
+
groupBy: string[];
|
|
66
|
+
groupFunctions: Agg[];
|
|
67
|
+
pivot: { row: string; column: string; fn: string; value: string } | null;
|
|
68
|
+
chart: { kind: string; label: string; fn: string; value: string } | null;
|
|
69
|
+
computed: { name: string; expr: string }[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const MAX_FILTERS = 5;
|
|
73
|
+
const MAX_HIGHLIGHTS = 5;
|
|
74
|
+
const MAX_AGGREGATES = 5;
|
|
75
|
+
const MAX_GROUP_COLUMNS = 3;
|
|
76
|
+
const MAX_GROUP_FUNCTIONS = 6;
|
|
77
|
+
export const MAX_VALUE = 200;
|
|
78
|
+
|
|
79
|
+
const text = (v: unknown, max = MAX_VALUE) => (typeof v === 'string' ? v.replace(/[\r\n\0]+/g, ' ').trim().slice(0, max) : '');
|
|
80
|
+
|
|
81
|
+
/** Does the function suit the column (sum and avg need numbers)? */
|
|
82
|
+
const fnFits = (fn: string, col: Column | undefined) => !!col && Object.hasOwn(AGGREGATES, fn) && (!AGGREGATES[fn].numeric || col.kind === 'number');
|
|
83
|
+
|
|
84
|
+
/** A computed expression's kind, roughly: text when it joins or quotes text, else a number. */
|
|
85
|
+
const exprKind = (expr: string): Kind => (/\|\||'|\b(upper|lower|initcap|trim|substr|left|right|replace|concat)\s*\(/i.test(expr) ? 'text' : 'number');
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The answer checked against the report's shown columns. fieldCount is the
|
|
89
|
+
* number of columns of the report's query (hidden ones included), so a new
|
|
90
|
+
* computed column's sort position is known.
|
|
91
|
+
*/
|
|
92
|
+
export function checkPlan(json: unknown, columns: Column[], fieldCount = columns.reduce((n, c) => Math.max(n, c.pos), 0)): Plan {
|
|
93
|
+
const a = (json && typeof json === 'object' ? json : {}) as RawPlan;
|
|
94
|
+
const byName = new Map(columns.map((c) => [c.name, c]));
|
|
95
|
+
const list = <T>(v: unknown): T[] => (Array.isArray(v) ? v.filter((x) => x && typeof x === 'object') : []);
|
|
96
|
+
|
|
97
|
+
// computed columns first: the rest of the answer may use them
|
|
98
|
+
const source = columns.filter((c) => !c.computed).map((c) => c.name);
|
|
99
|
+
const computed: Plan['computed'] = [];
|
|
100
|
+
let next = fieldCount;
|
|
101
|
+
for (const c of list<{ name: unknown; expression: unknown }>(a.computed_columns).slice(0, MAX_COMPUTATIONS)) {
|
|
102
|
+
const name = text(c.name, 40);
|
|
103
|
+
const expr = text(c.expression, 500);
|
|
104
|
+
if (!name || !expr || !computeNameOk(name)) continue;
|
|
105
|
+
const taken = columns.find((x) => x.name.toLowerCase() === name.toLowerCase());
|
|
106
|
+
if (taken && !taken.computed) continue;
|
|
107
|
+
try {
|
|
108
|
+
computeSql(expr, source);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
if (e instanceof ComputeError) continue;
|
|
111
|
+
throw e;
|
|
112
|
+
}
|
|
113
|
+
computed.push({ name, expr });
|
|
114
|
+
// a replaced computed column moves to the end: its position isn't known, so it can't be sorted on here
|
|
115
|
+
byName.set(name, { name, label: name, kind: exprKind(expr), pos: taken ? 0 : ++next, computed: true });
|
|
116
|
+
}
|
|
117
|
+
const col = (v: unknown) => (typeof v === 'string' ? byName.get(v) : undefined);
|
|
118
|
+
|
|
119
|
+
const filters = list<{ column: unknown; operator: unknown; value: unknown }>(a.filters)
|
|
120
|
+
.filter((f) => col(f.column) && typeof f.operator === 'string' && Object.hasOwn(OPERATORS, f.operator) && typeof f.value === 'string')
|
|
121
|
+
.slice(0, MAX_FILTERS)
|
|
122
|
+
.map((f) => ({ column: f.column as string, operator: f.operator as string, value: OPERATORS[f.operator as string].noValue ? '' : text(f.value) }))
|
|
123
|
+
.filter((f) => OPERATORS[f.operator].noValue || f.value !== '');
|
|
124
|
+
const highlights = list<{ column: unknown; operator: unknown; value: unknown; color: unknown }>(a.highlights)
|
|
125
|
+
.filter((h) => col(h.column) && typeof h.operator === 'string' && Object.hasOwn(OPERATORS, h.operator) && typeof h.value === 'string')
|
|
126
|
+
.slice(0, MAX_HIGHLIGHTS)
|
|
127
|
+
.map((h) => ({
|
|
128
|
+
column: h.column as string, operator: h.operator as string, value: OPERATORS[h.operator as string].noValue ? '' : text(h.value),
|
|
129
|
+
color: typeof h.color === 'string' && HIGHLIGHT_COLORS.includes(h.color) ? h.color : 'yellow',
|
|
130
|
+
}))
|
|
131
|
+
.filter((h) => OPERATORS[h.operator].noValue || h.value !== '');
|
|
132
|
+
const sortCol = col(a.sort_column);
|
|
133
|
+
const aggs = (v: unknown, max: number) =>
|
|
134
|
+
list<{ function: unknown; column: unknown }>(v)
|
|
135
|
+
.filter((x) => typeof x.function === 'string' && fnFits(x.function, col(x.column)))
|
|
136
|
+
.map((x) => ({ fn: x.function as string, column: x.column as string }))
|
|
137
|
+
.filter((x, i, all) => all.findIndex((y) => y.fn === x.fn && y.column === x.column) === i)
|
|
138
|
+
.slice(0, max);
|
|
139
|
+
const groupBy = [...new Set((Array.isArray(a.group_by) ? a.group_by : []).filter((g) => col(g)) as string[])].slice(0, MAX_GROUP_COLUMNS);
|
|
140
|
+
const pv = a.pivot && typeof a.pivot === 'object' ? a.pivot : null;
|
|
141
|
+
const ch = a.chart && typeof a.chart === 'object' ? a.chart : null;
|
|
142
|
+
return {
|
|
143
|
+
reset: a.reset === true,
|
|
144
|
+
filters,
|
|
145
|
+
highlights,
|
|
146
|
+
search: text(a.search),
|
|
147
|
+
sort: sortCol && sortCol.pos > 0 ? sortCol : null,
|
|
148
|
+
desc: a.sort_descending === true,
|
|
149
|
+
breakCol: col(a.control_break)?.name ?? null,
|
|
150
|
+
aggregates: aggs(a.aggregates, MAX_AGGREGATES),
|
|
151
|
+
groupBy,
|
|
152
|
+
groupFunctions: groupBy.length ? aggs(a.group_functions, MAX_GROUP_FUNCTIONS) : [],
|
|
153
|
+
pivot: pv && col(pv.row) && col(pv.column) && pv.row !== pv.column && typeof pv.function === 'string' && fnFits(pv.function, col(pv.value))
|
|
154
|
+
? { row: pv.row, column: pv.column, fn: pv.function, value: pv.value }
|
|
155
|
+
: null,
|
|
156
|
+
chart: ch && typeof ch.type === 'string' && (REPORT_CHART_KINDS as string[]).includes(ch.type) && col(ch.label) && typeof ch.function === 'string' && fnFits(ch.function, col(ch.value))
|
|
157
|
+
? { kind: ch.type, label: ch.label, fn: ch.function, value: ch.value }
|
|
158
|
+
: null,
|
|
159
|
+
computed,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const planIsEmpty = (p: Plan) =>
|
|
164
|
+
!p.reset && !p.filters.length && !p.highlights.length && !p.search && !p.sort && !p.breakCol && !p.aggregates.length && !p.groupBy.length && !p.pivot && !p.chart && !p.computed.length;
|
|
165
|
+
|
|
166
|
+
/** The report's own settings (URL parameter suffixes) that a plan, a reset or the chips change. */
|
|
167
|
+
export const SETTING_KEYS = ['f', 'q', 'qc', 's', 'd', 'b', 'a', 'h', 'c', 'g', 'ga', 'pv', 'ch', 'v', 'x', 'bb', 'near'];
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Apply a checked plan to the page's URL parameters: a filter on a column
|
|
171
|
+
* replaces that column's filters; sort, control break, group by, pivot and
|
|
172
|
+
* chart replace theirs; highlights, aggregates and computed columns add up
|
|
173
|
+
* (a computed column of the same name is replaced). The page and keyset go.
|
|
174
|
+
*/
|
|
175
|
+
export function applyPlan(params: URLSearchParams, rid: number, plan: Plan) {
|
|
176
|
+
const k = (x: string) => `r${rid}_${x}`;
|
|
177
|
+
const replaceAll = (name: string, keep: (v: string) => boolean, add: string[]) => {
|
|
178
|
+
const rest = params.getAll(k(name)).filter(keep);
|
|
179
|
+
params.delete(k(name));
|
|
180
|
+
for (const v of [...rest, ...add]) params.append(k(name), v);
|
|
181
|
+
};
|
|
182
|
+
if (plan.reset) for (const x of SETTING_KEYS) params.delete(k(x));
|
|
183
|
+
if (plan.computed.length) {
|
|
184
|
+
const names = new Set(plan.computed.map((c) => c.name.toLowerCase()));
|
|
185
|
+
const all = [...params.getAll(k('c')).filter((v) => !names.has(v.split('|')[0].toLowerCase())), ...plan.computed.map((c) => `${c.name}|${c.expr}`)];
|
|
186
|
+
params.delete(k('c'));
|
|
187
|
+
for (const v of all.slice(-MAX_COMPUTATIONS)) params.append(k('c'), v);
|
|
188
|
+
}
|
|
189
|
+
if (plan.filters.length) {
|
|
190
|
+
const cols = new Set(plan.filters.map((f) => f.column));
|
|
191
|
+
replaceAll('f', (v) => !cols.has(v.split('|')[0]), plan.filters.map((f) => `${f.column}|${f.operator}|${f.value}`));
|
|
192
|
+
}
|
|
193
|
+
if (plan.highlights.length) {
|
|
194
|
+
const add = plan.highlights.map((h) => `${h.column}|${h.operator}|${h.color}|${h.value}`);
|
|
195
|
+
replaceAll('h', (v) => !add.includes(v), add);
|
|
196
|
+
}
|
|
197
|
+
if (plan.search) {
|
|
198
|
+
params.set(k('q'), plan.search);
|
|
199
|
+
params.delete(k('qc'));
|
|
200
|
+
}
|
|
201
|
+
if (plan.sort) {
|
|
202
|
+
params.set(k('s'), String(plan.sort.pos));
|
|
203
|
+
if (plan.desc) params.set(k('d'), 'desc');
|
|
204
|
+
else params.delete(k('d'));
|
|
205
|
+
}
|
|
206
|
+
if (plan.breakCol) params.set(k('b'), plan.breakCol);
|
|
207
|
+
if (plan.aggregates.length) {
|
|
208
|
+
const add = plan.aggregates.map((x) => `${x.fn}|${x.column}`);
|
|
209
|
+
replaceAll('a', (v) => !add.includes(v), add);
|
|
210
|
+
}
|
|
211
|
+
if (plan.groupBy.length) {
|
|
212
|
+
params.delete(k('g'));
|
|
213
|
+
params.delete(k('ga'));
|
|
214
|
+
for (const g of plan.groupBy) params.append(k('g'), g);
|
|
215
|
+
for (const x of plan.groupFunctions) params.append(k('ga'), `${x.fn}|${x.column}`);
|
|
216
|
+
params.set(k('v'), 'group');
|
|
217
|
+
}
|
|
218
|
+
if (plan.pivot) {
|
|
219
|
+
params.set(k('pv'), `${plan.pivot.row}|${plan.pivot.column}|${plan.pivot.fn}|${plan.pivot.value}`);
|
|
220
|
+
params.set(k('v'), 'pivot');
|
|
221
|
+
}
|
|
222
|
+
if (plan.chart) {
|
|
223
|
+
params.set(k('ch'), `${plan.chart.kind}|${plan.chart.label}|${plan.chart.fn}|${plan.chart.value}`);
|
|
224
|
+
params.set(k('v'), 'chart');
|
|
225
|
+
}
|
|
226
|
+
params.delete(k('p'));
|
|
227
|
+
params.delete(k('k'));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ------------------------------------------------------------------ chips
|
|
231
|
+
|
|
232
|
+
export type Chip =
|
|
233
|
+
| { kind: 'search'; key: string; value: string; column: string | null }
|
|
234
|
+
| { kind: 'filter'; key: string; raw: string; column: string; op: string; value: string }
|
|
235
|
+
| { kind: 'highlight'; key: string; raw: string; column: string; op: string; color: string; value: string }
|
|
236
|
+
| { kind: 'sort'; key: string; pos: number; desc: boolean }
|
|
237
|
+
| { kind: 'break'; key: string; column: string }
|
|
238
|
+
| { kind: 'aggregate'; key: string; raw: string; fn: string; column: string }
|
|
239
|
+
| { kind: 'compute'; key: string; raw: string; name: string; expr: string }
|
|
240
|
+
| { kind: 'group'; key: string; column: string }
|
|
241
|
+
| { kind: 'groupfn'; key: string; raw: string; fn: string; column: string }
|
|
242
|
+
| { kind: 'pivot'; key: string; row: string; column: string; fn: string; value: string }
|
|
243
|
+
| { kind: 'chart'; key: string; type: string; label: string; fn: string; value: string }
|
|
244
|
+
| { kind: 'hide'; key: string; column: string }
|
|
245
|
+
| { kind: 'area'; key: string }
|
|
246
|
+
| { kind: 'near'; key: string };
|
|
247
|
+
|
|
248
|
+
const MAX_CHIPS = 60;
|
|
249
|
+
|
|
250
|
+
/** The report's settings in the URL as chips (what reportState() reads, in the same limits). */
|
|
251
|
+
export function chipsOf(params: URLSearchParams, rid: number): Chip[] {
|
|
252
|
+
const k = (x: string) => `r${rid}_${x}`;
|
|
253
|
+
const out: Chip[] = [];
|
|
254
|
+
const q = (params.get(k('q')) ?? '').trim();
|
|
255
|
+
if (q) out.push({ kind: 'search', key: `q:${q}`, value: q, column: (params.get(k('qc')) ?? '').trim() || null });
|
|
256
|
+
for (const raw of params.getAll(k('f'))) {
|
|
257
|
+
const [column, op, ...rest] = raw.split('|');
|
|
258
|
+
if (column && Object.hasOwn(OPERATORS, op)) out.push({ kind: 'filter', key: `f:${raw}`, raw, column, op, value: rest.join('|') });
|
|
259
|
+
}
|
|
260
|
+
const s = parseInt(params.get(k('s')) ?? '', 10);
|
|
261
|
+
if (s > 0 && s <= 1000) out.push({ kind: 'sort', key: `s:${s}:${params.get(k('d')) === 'desc' ? 'd' : 'a'}`, pos: s, desc: params.get(k('d')) === 'desc' });
|
|
262
|
+
const b = params.get(k('b'));
|
|
263
|
+
if (b) out.push({ kind: 'break', key: `b:${b}`, column: b });
|
|
264
|
+
for (const raw of params.getAll(k('a')).slice(0, 20)) {
|
|
265
|
+
const [fn, column] = raw.split('|');
|
|
266
|
+
if (Object.hasOwn(AGGREGATES, fn) && column) out.push({ kind: 'aggregate', key: `a:${raw}`, raw, fn, column });
|
|
267
|
+
}
|
|
268
|
+
for (const raw of params.getAll(k('h')).slice(0, 10)) {
|
|
269
|
+
const [column, op, color, ...rest] = raw.split('|');
|
|
270
|
+
if (column && Object.hasOwn(OPERATORS, op) && HIGHLIGHT_COLORS.includes(color)) out.push({ kind: 'highlight', key: `h:${raw}`, raw, column, op, color, value: rest.join('|') });
|
|
271
|
+
}
|
|
272
|
+
for (const raw of params.getAll(k('c')).slice(0, MAX_COMPUTATIONS)) {
|
|
273
|
+
const [name, ...rest] = raw.split('|');
|
|
274
|
+
if (name && computeNameOk(name) && rest.length) out.push({ kind: 'compute', key: `c:${raw}`, raw, name, expr: rest.join('|') });
|
|
275
|
+
}
|
|
276
|
+
const groups = params.getAll(k('g')).filter(Boolean).slice(0, MAX_GROUP_COLUMNS);
|
|
277
|
+
for (const g of groups) out.push({ kind: 'group', key: `g:${g}`, column: g });
|
|
278
|
+
if (groups.length)
|
|
279
|
+
for (const raw of params.getAll(k('ga')).slice(0, MAX_GROUP_FUNCTIONS)) {
|
|
280
|
+
const [fn, column] = raw.split('|');
|
|
281
|
+
if (Object.hasOwn(AGGREGATES, fn) && column) out.push({ kind: 'groupfn', key: `ga:${raw}`, raw, fn, column });
|
|
282
|
+
}
|
|
283
|
+
const [row, column, fn, value] = (params.get(k('pv')) ?? '').split('|');
|
|
284
|
+
if (row && column && Object.hasOwn(AGGREGATES, fn) && value) out.push({ kind: 'pivot', key: `pv:${params.get(k('pv'))}`, row, column, fn, value });
|
|
285
|
+
const [type, label, cfn, cvalue] = (params.get(k('ch')) ?? '').split('|');
|
|
286
|
+
if ((REPORT_CHART_KINDS as string[]).includes(type) && label && Object.hasOwn(AGGREGATES, cfn) && cvalue) out.push({ kind: 'chart', key: `ch:${params.get(k('ch'))}`, type, label, fn: cfn, value: cvalue });
|
|
287
|
+
for (const x of params.getAll(k('x')).filter(Boolean).slice(0, 100)) out.push({ kind: 'hide', key: `x:${x}`, column: x });
|
|
288
|
+
if (params.get(k('bb'))) out.push({ kind: 'area', key: 'bb' });
|
|
289
|
+
if (params.get(k('near'))) out.push({ kind: 'near', key: 'near' });
|
|
290
|
+
return out.slice(0, MAX_CHIPS);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Take one value out of a repeated parameter. */
|
|
294
|
+
function dropValue(params: URLSearchParams, name: string, raw: string) {
|
|
295
|
+
const rest = params.getAll(name);
|
|
296
|
+
const i = rest.indexOf(raw);
|
|
297
|
+
if (i >= 0) rest.splice(i, 1);
|
|
298
|
+
params.delete(name);
|
|
299
|
+
for (const v of rest) params.append(name, v);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Replace one value of a repeated parameter in place (keeping the order). */
|
|
303
|
+
function swapValue(params: URLSearchParams, name: string, raw: string, next: string) {
|
|
304
|
+
const all = params.getAll(name);
|
|
305
|
+
const i = all.indexOf(raw);
|
|
306
|
+
if (i < 0) return;
|
|
307
|
+
all[i] = next;
|
|
308
|
+
params.delete(name);
|
|
309
|
+
for (const v of [...new Set(all)]) params.append(name, v);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const leaveView = (params: URLSearchParams, rid: number, view: string) => {
|
|
313
|
+
if (params.get(`r${rid}_v`) === view) params.delete(`r${rid}_v`);
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
export function removeChip(params: URLSearchParams, rid: number, chip: Chip) {
|
|
317
|
+
const k = (x: string) => `r${rid}_${x}`;
|
|
318
|
+
switch (chip.kind) {
|
|
319
|
+
case 'search': params.delete(k('q')); params.delete(k('qc')); break;
|
|
320
|
+
case 'filter': dropValue(params, k('f'), chip.raw); break;
|
|
321
|
+
case 'highlight': dropValue(params, k('h'), chip.raw); break;
|
|
322
|
+
case 'aggregate': dropValue(params, k('a'), chip.raw); break;
|
|
323
|
+
case 'compute': dropValue(params, k('c'), chip.raw); break;
|
|
324
|
+
case 'groupfn': dropValue(params, k('ga'), chip.raw); break;
|
|
325
|
+
case 'sort': params.delete(k('s')); params.delete(k('d')); break;
|
|
326
|
+
case 'break': params.delete(k('b')); break;
|
|
327
|
+
case 'group':
|
|
328
|
+
dropValue(params, k('g'), chip.column);
|
|
329
|
+
// the last group column takes its functions and the view with it
|
|
330
|
+
if (!params.getAll(k('g')).filter(Boolean).length) (params.delete(k('ga')), leaveView(params, rid, 'group'));
|
|
331
|
+
break;
|
|
332
|
+
case 'pivot': params.delete(k('pv')); leaveView(params, rid, 'pivot'); break;
|
|
333
|
+
case 'chart': params.delete(k('ch')); leaveView(params, rid, 'chart'); break;
|
|
334
|
+
case 'hide': dropValue(params, k('x'), chip.column); break;
|
|
335
|
+
case 'area': params.delete(k('bb')); break;
|
|
336
|
+
case 'near': params.delete(k('near')); break;
|
|
337
|
+
}
|
|
338
|
+
params.delete(k('p'));
|
|
339
|
+
params.delete(k('k'));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** A chip edit that isn't allowed: a tampered form (403), or a mistake the user can fix (a message). */
|
|
343
|
+
export class ChipRefused extends Error {
|
|
344
|
+
constructor(message: string, readonly tampered: boolean) {
|
|
345
|
+
super(message);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Change one chip with the edit form's fields (e<i>_a … e<i>_e as a, b, c, d,
|
|
351
|
+
* e). Every column must be one the user may use, every operator, function,
|
|
352
|
+
* color and chart type one of the report's own, and an expression must parse.
|
|
353
|
+
*/
|
|
354
|
+
export function editChip(params: URLSearchParams, rid: number, chip: Chip, f: Record<'a' | 'b' | 'c' | 'd' | 'e', string>, columns: Column[], needsNumber: (fn: string) => string) {
|
|
355
|
+
const k = (x: string) => `r${rid}_${x}`;
|
|
356
|
+
const byName = new Map(columns.map((c) => [c.name, c]));
|
|
357
|
+
const tampered = (what: string) => new ChipRefused(what, true);
|
|
358
|
+
const column = (v: string, sourceOnly = false) => {
|
|
359
|
+
const c = byName.get(v);
|
|
360
|
+
if (!c || (sourceOnly && c.computed)) throw tampered(`column ${v.slice(0, 63)}`);
|
|
361
|
+
return c;
|
|
362
|
+
};
|
|
363
|
+
const operator = (v: string) => {
|
|
364
|
+
if (!Object.hasOwn(OPERATORS, v)) throw tampered(`operator ${v.slice(0, 20)}`);
|
|
365
|
+
return v;
|
|
366
|
+
};
|
|
367
|
+
const fn = (v: string, c: Column) => {
|
|
368
|
+
if (!Object.hasOwn(AGGREGATES, v)) throw tampered(`function ${v.slice(0, 20)}`);
|
|
369
|
+
if (!fnFits(v, c)) throw new ChipRefused(needsNumber(v), false);
|
|
370
|
+
return v;
|
|
371
|
+
};
|
|
372
|
+
const value = (op: string, v: string) => (OPERATORS[op].noValue ? '' : text(v));
|
|
373
|
+
switch (chip.kind) {
|
|
374
|
+
case 'search': {
|
|
375
|
+
const v = text(f.a);
|
|
376
|
+
if (v) params.set(k('q'), v);
|
|
377
|
+
else params.delete(k('q'));
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
case 'filter': {
|
|
381
|
+
const op = operator(f.b);
|
|
382
|
+
swapValue(params, k('f'), chip.raw, `${column(f.a).name}|${op}|${value(op, f.c)}`);
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
case 'highlight': {
|
|
386
|
+
const op = operator(f.b);
|
|
387
|
+
if (!HIGHLIGHT_COLORS.includes(f.d)) throw tampered(`color ${f.d.slice(0, 20)}`);
|
|
388
|
+
swapValue(params, k('h'), chip.raw, `${column(f.a).name}|${op}|${f.d}|${value(op, f.c)}`);
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
case 'sort': {
|
|
392
|
+
const c = column(f.a);
|
|
393
|
+
if (!c.pos) throw new ChipRefused(c.name, false);
|
|
394
|
+
params.set(k('s'), String(c.pos));
|
|
395
|
+
if (f.b === 'desc') params.set(k('d'), 'desc');
|
|
396
|
+
else if (f.b === 'asc' || f.b === '') params.delete(k('d'));
|
|
397
|
+
else throw tampered(`direction ${f.b.slice(0, 20)}`);
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
case 'break': params.set(k('b'), column(f.a).name); break;
|
|
401
|
+
case 'aggregate': {
|
|
402
|
+
const c = column(f.b);
|
|
403
|
+
swapValue(params, k('a'), chip.raw, `${fn(f.a, c)}|${c.name}`);
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
case 'groupfn': {
|
|
407
|
+
const c = column(f.b);
|
|
408
|
+
swapValue(params, k('ga'), chip.raw, `${fn(f.a, c)}|${c.name}`);
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
case 'group': swapValue(params, k('g'), chip.column, column(f.a).name); break;
|
|
412
|
+
case 'pivot': {
|
|
413
|
+
const row = column(f.a).name;
|
|
414
|
+
const col = column(f.b).name;
|
|
415
|
+
if (row === col) throw new ChipRefused(row, false);
|
|
416
|
+
const v = column(f.d);
|
|
417
|
+
params.set(k('pv'), `${row}|${col}|${fn(f.c, v)}|${v.name}`);
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
case 'chart': {
|
|
421
|
+
if (!(REPORT_CHART_KINDS as string[]).includes(f.a)) throw tampered(`chart type ${f.a.slice(0, 20)}`);
|
|
422
|
+
const label = column(f.b).name;
|
|
423
|
+
const v = column(f.d);
|
|
424
|
+
params.set(k('ch'), `${f.a}|${label}|${fn(f.c, v)}|${v.name}`);
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
case 'compute': {
|
|
428
|
+
const name = text(f.a, 40);
|
|
429
|
+
const expr = text(f.b, 500);
|
|
430
|
+
if (!name || !computeNameOk(name)) throw new ChipRefused(name || '?', false);
|
|
431
|
+
const clash = columns.find((c) => !c.computed && c.name.toLowerCase() === name.toLowerCase());
|
|
432
|
+
if (clash) throw new ChipRefused(`There is already a column ${name}.`, false);
|
|
433
|
+
try {
|
|
434
|
+
computeSql(expr, columns.filter((c) => !c.computed).map((c) => c.name));
|
|
435
|
+
} catch (e) {
|
|
436
|
+
if (e instanceof ComputeError) throw new ChipRefused(`${name}: ${e.message}`, false);
|
|
437
|
+
throw e;
|
|
438
|
+
}
|
|
439
|
+
const others = params.getAll(k('c')).filter((v) => v !== chip.raw && v.split('|')[0].toLowerCase() === name.toLowerCase());
|
|
440
|
+
for (const o of others) dropValue(params, k('c'), o);
|
|
441
|
+
swapValue(params, k('c'), chip.raw, `${name}|${expr}`);
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
default:
|
|
445
|
+
throw tampered(`chip ${chip.kind} can't be edited`);
|
|
446
|
+
}
|
|
447
|
+
params.delete(k('p'));
|
|
448
|
+
params.delete(k('k'));
|
|
449
|
+
}
|
package/src/runtime/items.ts
CHANGED
|
@@ -326,7 +326,7 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
|
|
|
326
326
|
// a select list (the LOV's first max_rows rows) that works without JavaScript; app.js
|
|
327
327
|
// turns it into a field with a button that searches the whole LOV on the server
|
|
328
328
|
const t = ctx.locale.t;
|
|
329
|
-
control = html`<select id="${id}" name="${id}" data-popup-lov="${ctx.base}/${ctx.page.page_no}/lov/${id}/search" data-search-label="${t('lov.search')}" data-choose-label="${t('lov.choose')}" data-close-label="${t('lov.close')}" data-more-label="${t('lov.more')}" data-none-label="${t('lov.no_rows')}"${aria}>
|
|
329
|
+
control = html`<select id="${id}" name="${id}" data-popup-lov="${ctx.base}/${ctx.page.page_no}/lov/${id}/search" data-search-label="${t('lov.search')}" data-choose-label="${t('lov.choose')}" data-close-label="${t('lov.close')}" data-more-label="${t('lov.more')}" data-loading-label="${t('lov.loading')}" data-none-label="${t('lov.no_rows')}"${aria}>
|
|
330
330
|
<option value="">${item.config?.null_label ?? t('lov.none')}</option>
|
|
331
331
|
${options.map((o) => html`<option value="${o.value}"${o.value === value ? raw(' selected') : ''}>${o.display}</option>`)}
|
|
332
332
|
</select>`;
|
package/src/runtime/regions.ts
CHANGED
|
@@ -60,8 +60,9 @@ async function badgeOf(ctx: PageContext, b: Button): Promise<Raw | ''> {
|
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
62
|
* A menu button: a <details> dropdown (works without JavaScript) with links
|
|
63
|
-
* to pages (signed item values, only pages the user may open)
|
|
64
|
-
* requests (only those computeVisibility allowed)
|
|
63
|
+
* to pages (signed item values, only pages the user may open), submit
|
|
64
|
+
* requests (only those computeVisibility allowed) and (124) entries that run
|
|
65
|
+
* dynamic actions of event menu_entry.
|
|
65
66
|
*/
|
|
66
67
|
async function renderMenu(ctx: PageContext, b: Button, cls: string, badge: Raw | '') {
|
|
67
68
|
const entries = [];
|
|
@@ -74,6 +75,9 @@ async function renderMenu(ctx: PageContext, b: Button, cls: string, badge: Raw |
|
|
|
74
75
|
entries.push(html`<a ${linkAttrs(ctx, e.page, e.items ?? {})}${confirm}>${ic}${e.label}</a>`);
|
|
75
76
|
} else if (e.request && ctx.vis!.buttons.get(e.request)?.action === 'submit')
|
|
76
77
|
entries.push(html`<button type="submit" name="__request" value="${e.request}" data-button="${e.request}"${confirm}>${ic}${e.label}</button>`);
|
|
78
|
+
// (124) runs the dynamic actions of event menu_entry (app.js); nothing without JavaScript, like a "da" button
|
|
79
|
+
else if (e.action && /^[A-Z][A-Z0-9_]{0,59}$/.test(e.action))
|
|
80
|
+
entries.push(html`<button type="button" data-menu-entry="${e.action}"${confirm}>${ic}${e.label}</button>`);
|
|
77
81
|
}
|
|
78
82
|
if (!entries.length) return '';
|
|
79
83
|
return html`<details class="menu btn-menu" data-button="${b.name}">
|
package/src/runtime/render.ts
CHANGED
|
@@ -337,7 +337,7 @@ function formKeys(ctx: PageContext) {
|
|
|
337
337
|
const CLIENT_TEXTS = ['pwa.offline_banner', 'pwa.queued', 'pwa.queue_waiting', 'pwa.send_now', 'pwa.discard', 'pwa.status.waiting',
|
|
338
338
|
'pwa.status.signin', 'pwa.status.invalid', 'pwa.status.error', 'item.locate_error', 'item.address_none', 'item.address_error', 'item.scan_close', 'common.dismiss',
|
|
339
339
|
'crop.title', 'crop.apply', 'crop.skip', 'crop.help',
|
|
340
|
-
'push.turn_on', 'push.turn_off', 'push.on', 'push.off', 'push.blocked', 'push.unsupported', 'push.failed', 'page.unsaved_changes', 'page.link_copied',
|
|
340
|
+
'push.turn_on', 'push.turn_off', 'push.on', 'push.off', 'push.blocked', 'push.unsupported', 'push.failed', 'page.unsaved_changes', 'page.link_copied', 'da.wait',
|
|
341
341
|
'session.warning', 'session.stay', 'session.max', 'session.ended', 'login.sign_in_link',
|
|
342
342
|
// (sprint 76) a report column heading's menu
|
|
343
343
|
'report.sort_asc', 'report.sort_desc', 'report.hide', 'report.break', 'report.filter_values', 'report.no_values',
|
package/src/runtime/report.ts
CHANGED
|
@@ -1142,7 +1142,7 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
|
|
|
1142
1142
|
</div>`
|
|
1143
1143
|
: ''}
|
|
1144
1144
|
</div>
|
|
1145
|
-
${chips.length ? html`<div class="chips">${chips}</div>` : ''}`
|
|
1145
|
+
${chips.length && !ctx.askChips?.has(r.id) ? html`<div class="chips">${chips}</div>` : ''}`
|
|
1146
1146
|
: '';
|
|
1147
1147
|
|
|
1148
1148
|
// Keep the toolbar only when a search or filter may be the cause, so it can be removed.
|
package/src/runtime/tasks.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { appTx, savepoint } from '../db.ts';
|
|
|
4
4
|
import { html, raw, type Raw } from '../html.ts';
|
|
5
5
|
import type { Region } from '../metadata.ts';
|
|
6
6
|
import { logActivity, saveState } from '../session.ts';
|
|
7
|
+
import { runActions, taskBinds, type TaskAction } from '../task-events.ts';
|
|
7
8
|
import { pageAllowed } from './authz.ts';
|
|
8
9
|
import { publicError, stripSemicolon, type PageContext } from './context.ts';
|
|
9
10
|
import { appDateMask, appDateReader, dateField } from './items.ts';
|
|
@@ -27,9 +28,25 @@ import { loadContext, safeNext, txContext, type Req } from './routes.ts';
|
|
|
27
28
|
// /a/:alias/tasks/vacation through meta.set_vacation_rule / clear_vacation_rule.
|
|
28
29
|
// Expired tasks (the definition's expiration policy) show as done.
|
|
29
30
|
|
|
30
|
-
export const TASK_ACTIONS = ['claim', 'release', 'approve', 'reject', 'complete', 'delegate', 'cancel', 'comment'] as const;
|
|
31
|
+
export const TASK_ACTIONS = ['claim', 'release', 'approve', 'reject', 'complete', 'delegate', 'cancel', 'comment', 'outcome'] as const;
|
|
32
|
+
/** (123) a custom outcome's colour → the button's classes (a whitelist: the definition can't add others) */
|
|
33
|
+
const OUTCOME_CLASS: Record<string, string> = { default: '', primary: ' btn-hot', success: ' to-success', danger: ' to-danger', warning: ' to-warning' };
|
|
34
|
+
interface Outcome { name: string; label?: string; colour?: string }
|
|
31
35
|
type Action = (typeof TASK_ACTIONS)[number];
|
|
32
36
|
|
|
37
|
+
/** What meta.complete_task returns (123: with the outcome, its label and the fail events). */
|
|
38
|
+
interface CompleteResult {
|
|
39
|
+
action_code: string | null;
|
|
40
|
+
detail_pk: string | null;
|
|
41
|
+
params: Record<string, unknown>;
|
|
42
|
+
initiator: string;
|
|
43
|
+
outcome?: string;
|
|
44
|
+
label?: string;
|
|
45
|
+
subject?: string;
|
|
46
|
+
fail_events?: unknown[];
|
|
47
|
+
fail_recovery?: 'reopen' | 'complete';
|
|
48
|
+
}
|
|
49
|
+
|
|
33
50
|
interface TaskRow {
|
|
34
51
|
id: string;
|
|
35
52
|
definition: string;
|
|
@@ -54,6 +71,8 @@ interface TaskRow {
|
|
|
54
71
|
may_release: boolean;
|
|
55
72
|
may_delegate: boolean;
|
|
56
73
|
may_cancel: boolean;
|
|
74
|
+
/** (123) the definition's custom outcomes */
|
|
75
|
+
outcomes: Outcome[] | null;
|
|
57
76
|
}
|
|
58
77
|
|
|
59
78
|
export async function renderTasks(ctx: PageContext, r: Region): Promise<Raw> {
|
|
@@ -96,18 +115,23 @@ export async function renderTasks(ctx: PageContext, r: Region): Promise<Raw> {
|
|
|
96
115
|
ctx.detached.push(html`<form id="${f}" method="post" action="${action(k.id)}">${csrf}</form>`);
|
|
97
116
|
const button = (a: Action, label: string, cls = '', confirm?: string) =>
|
|
98
117
|
html`<button class="btn${cls}" form="${f}" name="action" value="${a}"${confirm ? raw(` data-confirm="${confirm.replace(/"/g, '"')}"`) : ''}>${label}</button>`;
|
|
118
|
+
// (123) custom outcomes: one button each, posting action=outcome:<name>
|
|
119
|
+
const custom = Array.isArray(k.outcomes) && k.outcomes.length ? k.outcomes.filter((o) => o && typeof o.name === 'string') : null;
|
|
99
120
|
const open = k.state === 'unassigned' || k.state === 'assigned';
|
|
100
121
|
const link = k.details_page && k.details_item && k.detail_pk !== null && (await pageAllowed(ctx, k.details_page))
|
|
101
122
|
? html`<a ${linkAttrs(ctx, k.details_page, { [k.details_item]: k.detail_pk })}>${k.subject}</a>`
|
|
102
123
|
: html`${k.subject}`;
|
|
103
124
|
const status = open
|
|
104
125
|
? k.state === 'assigned' ? t('tasks.assigned_to', { user: k.actual_owner ?? '' }) : t('tasks.unassigned')
|
|
105
|
-
: k.state === 'cancelled' ? t('tasks.cancelled') : k.state === 'expired' ? t('tasks.expired')
|
|
126
|
+
: k.state === 'cancelled' ? t('tasks.cancelled') : k.state === 'expired' ? t('tasks.expired')
|
|
127
|
+
: custom?.find((o) => o.name.toLowerCase() === k.outcome)?.label ?? (['approved', 'rejected', 'completed'].includes(k.outcome ?? 'completed') ? t(`tasks.outcome.${k.outcome ?? 'completed'}`) : String(k.outcome).toUpperCase());
|
|
106
128
|
const actions = open
|
|
107
129
|
? html`<div class="task-actions">
|
|
108
130
|
${k.may_act
|
|
109
131
|
? html`<input name="comment" form="${f}" maxlength="4000" placeholder="${t('tasks.comment_optional')}" aria-label="${t('tasks.comment')}">
|
|
110
|
-
${
|
|
132
|
+
${custom
|
|
133
|
+
? custom.map((o) => html`<button class="btn${OUTCOME_CLASS[o.colour ?? 'default'] ?? ''}" form="${f}" name="action" value="${`outcome:${o.name.toLowerCase()}`}">${o.label ?? o.name}</button>`)
|
|
134
|
+
: k.type === 'approval'
|
|
111
135
|
? html`${button('approve', t('tasks.approve'), ' btn-hot')}${button('reject', t('tasks.reject'), ' btn-danger')}`
|
|
112
136
|
: button('complete', t('tasks.complete'), ' btn-hot')}`
|
|
113
137
|
: ''}
|
|
@@ -198,9 +222,14 @@ export async function taskRoutes(app: FastifyInstance) {
|
|
|
198
222
|
const b = req.body ?? {};
|
|
199
223
|
const next = safeNext(ctx.app, b.next);
|
|
200
224
|
const id = String(req.params.id ?? '');
|
|
201
|
-
|
|
202
|
-
|
|
225
|
+
// (123) a custom outcome: "outcome:<name>" (the definition's outcomes are checked by meta.complete_task)
|
|
226
|
+
const custom = typeof b.action === 'string' && /^outcome:[a-z][a-z0-9_]{0,29}$/i.test(b.action) ? b.action.slice(8).toLowerCase() : null;
|
|
227
|
+
const act = (custom ? 'outcome' : b.action) as Action;
|
|
228
|
+
if (b.__csrf !== ctx.session.csrf_token || !/^\d{1,18}$/.test(id) || !TASK_ACTIONS.includes(act) || (act === 'outcome' && !custom)) return reply.redirect(next, 303);
|
|
203
229
|
const t = ctx.locale.t;
|
|
230
|
+
let failed: string | null = null;
|
|
231
|
+
let failedErr: unknown = null;
|
|
232
|
+
let label: string | null = null;
|
|
204
233
|
try {
|
|
205
234
|
await appTx(txContext(ctx), async (c) => {
|
|
206
235
|
ctx.client = c;
|
|
@@ -220,20 +249,42 @@ export async function taskRoutes(app: FastifyInstance) {
|
|
|
220
249
|
await c.query('select meta.add_task_comment($1, $2)', [id, (b.text ?? '').slice(0, 4000)]);
|
|
221
250
|
break;
|
|
222
251
|
default: {
|
|
223
|
-
const outcome = act === 'approve' ? 'approved' : act === 'reject' ? 'rejected' : 'completed';
|
|
224
|
-
|
|
225
|
-
|
|
252
|
+
const outcome = custom ?? (act === 'approve' ? 'approved' : act === 'reject' ? 'rejected' : 'completed');
|
|
253
|
+
// (123) a failing completion SQL with fail events or a recovery: back to one of these savepoints
|
|
254
|
+
await c.query('savepoint task_decision');
|
|
255
|
+
const done = (await c.query<{ r: CompleteResult }>('select meta.complete_task($1, $2, $3) as r', [id, outcome, comment])).rows[0].r;
|
|
256
|
+
label = done.label ?? null;
|
|
226
257
|
// the definition's action, as the application's role, in this transaction: an error undoes the decision
|
|
227
258
|
if (done.action_code?.trim()) {
|
|
228
259
|
const params = Object.fromEntries(Object.entries(done.params ?? {}).map(([k, v]) => [k.toUpperCase(), v === null || v === undefined ? null : typeof v === 'object' ? JSON.stringify(v) : String(v)]));
|
|
229
|
-
const binds = { ...params, TASK_ID: id, DETAIL_PK: done.detail_pk, OUTCOME: outcome.toUpperCase(), COMMENT: comment, APPROVER: ctx.user, INITIATOR: done.initiator };
|
|
230
|
-
|
|
260
|
+
const binds = { ...params, TASK_ID: id, DETAIL_PK: done.detail_pk, OUTCOME: (done.outcome ?? outcome).toUpperCase(), COMMENT: comment, APPROVER: ctx.user, INITIATOR: done.initiator };
|
|
261
|
+
const handled = (done.fail_events?.length ?? 0) > 0 || done.fail_recovery === 'complete';
|
|
262
|
+
if (!handled) await c.query(stripSemicolon(applyBinds(done.action_code, binds)));
|
|
263
|
+
else {
|
|
264
|
+
await c.query('savepoint task_action');
|
|
265
|
+
try {
|
|
266
|
+
await c.query(stripSemicolon(applyBinds(done.action_code, binds)));
|
|
267
|
+
await c.query('release savepoint task_action');
|
|
268
|
+
} catch (e) {
|
|
269
|
+
// (123) the Fail event: undo the SQL's effect (and, to reopen, the decision), log it, run the fail actions
|
|
270
|
+
failed = (e as Error).message;
|
|
271
|
+
failedErr = e;
|
|
272
|
+
await c.query(done.fail_recovery === 'complete' ? 'rollback to savepoint task_action' : 'rollback to savepoint task_decision');
|
|
273
|
+
await c.query('select meta.log_task_failure($1, $2)', [id, failed]);
|
|
274
|
+
await runActions(c, (done.fail_events ?? []) as TaskAction[], {
|
|
275
|
+
...taskBinds({ id, detail_pk: done.detail_pk, params: done.params, subject: done.subject ?? '', outcome: done.outcome ?? outcome, completed_by: ctx.user, initiator: done.initiator }),
|
|
276
|
+
EVENT: 'FAIL', ERROR: failed, COMMENT: comment,
|
|
277
|
+
}, done.detail_pk);
|
|
278
|
+
if (done.fail_recovery !== 'complete') return;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
231
281
|
}
|
|
232
282
|
}
|
|
233
283
|
}
|
|
234
284
|
});
|
|
235
|
-
logActivity({ appId: ctx.app.id, username: ctx.user, event: 'task', ip: ctx.ip, detail: `${act} ${id}` });
|
|
236
|
-
ctx.session.state.
|
|
285
|
+
logActivity({ appId: ctx.app.id, username: ctx.user, event: 'task', ip: ctx.ip, detail: `${custom ? `outcome ${custom}` : act} ${id}${failed ? ' (failed)' : ''}` });
|
|
286
|
+
if (failed !== null) ctx.session.state.__FLASH_ERROR = t('tasks.done.failed', { error: await publicError(ctx, failedErr, 'task') });
|
|
287
|
+
else ctx.session.state.__FLASH = act === 'outcome' ? t('tasks.done.outcome', { outcome: label ?? custom ?? '' }) : t(`tasks.done.${act}`);
|
|
237
288
|
} catch (e) {
|
|
238
289
|
ctx.session.state.__FLASH_ERROR = await publicError(ctx, e, 'task');
|
|
239
290
|
}
|
package/src/session.ts
CHANGED
|
@@ -16,6 +16,8 @@ export interface BuilderWorkspace {
|
|
|
16
16
|
/** (093) workspace preferences */
|
|
17
17
|
sql_workshop?: boolean;
|
|
18
18
|
rest_services?: boolean;
|
|
19
|
+
/** (119) what /builder opens: the App Builder (null, 'apps') or the Workspace home ('home') */
|
|
20
|
+
builder_home?: string | null;
|
|
19
21
|
/** (093) extension links, in the builder's Extensions menu */
|
|
20
22
|
links?: { label: string; url: string; description: string | null }[];
|
|
21
23
|
}
|