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.
Files changed (104) hide show
  1. package/SECURITY.md +7 -0
  2. package/db/migrations/119_workspace_home.sql +30 -0
  3. package/db/migrations/120_datasets.sql +87 -0
  4. package/db/migrations/121_ai_agents.sql +94 -0
  5. package/db/migrations/122_team_development.sql +186 -0
  6. package/db/migrations/123_workflow_outcomes.sql +407 -0
  7. package/db/migrations/124_component_details.sql +51 -0
  8. package/docs/apex-feature-parity.md +15 -12
  9. package/docs/guide/01-installation.md +9 -3
  10. package/docs/guide/03-builder.md +211 -11
  11. package/docs/guide/04-pages-and-regions.md +96 -22
  12. package/docs/guide/05-items.md +7 -1
  13. package/docs/guide/06-processing.md +123 -8
  14. package/docs/guide/07-dynamic-actions.md +34 -0
  15. package/docs/guide/09-reference.md +2 -1
  16. package/docs/guide/11-from-apex.md +2 -1
  17. package/docs/guide/12-development.md +70 -11
  18. package/docs/guide/20-ai-agents.md +4 -2
  19. package/examples/hr/README.md +2 -0
  20. package/examples/hr/hr_63_datasets.sql +29 -0
  21. package/examples/hr/hr_64_ai_agent.sql +97 -0
  22. package/examples/hr/hr_65_ask_reports.sql +21 -0
  23. package/examples/hr/hr_66_team_development.sql +58 -0
  24. package/examples/hr/hr_67_workflow_outcomes.sql +113 -0
  25. package/examples/hr/hr_68_component_details.sql +96 -0
  26. package/package.json +1 -1
  27. package/public/app.css +46 -0
  28. package/public/app.js +134 -20
  29. package/public/builder.css +116 -0
  30. package/public/builder.js +47 -0
  31. package/src/ai/chat.ts +18 -18
  32. package/src/ai/service.ts +35 -11
  33. package/src/ai/types.ts +7 -2
  34. package/src/app.ts +11 -1
  35. package/src/builder/advisor.ts +6 -3
  36. package/src/builder/ai-agents.ts +372 -0
  37. package/src/builder/ai-builder.ts +3 -3
  38. package/src/builder/ai.ts +13 -9
  39. package/src/builder/assistant.ts +22 -7
  40. package/src/builder/components.ts +38 -9
  41. package/src/builder/datasets.ts +472 -0
  42. package/src/builder/designer.ts +8 -3
  43. package/src/builder/feedback.ts +6 -1
  44. package/src/builder/home.ts +12 -7
  45. package/src/builder/instance.ts +2 -0
  46. package/src/builder/locks.ts +7 -1
  47. package/src/builder/region-help.ts +2 -2
  48. package/src/builder/reporter.ts +52 -3
  49. package/src/builder/routes.ts +7 -4
  50. package/src/builder/shared.ts +1 -1
  51. package/src/builder/subscriptions.ts +1 -0
  52. package/src/builder/team.ts +991 -0
  53. package/src/builder/ui.ts +8 -5
  54. package/src/builder/workflow-migrate.ts +210 -0
  55. package/src/builder/workflows.ts +29 -6
  56. package/src/builder/workspace-home.ts +285 -0
  57. package/src/builder/workspace-transfer.ts +1 -1
  58. package/src/builder/workspaces.ts +7 -4
  59. package/src/cli/replace.ts +7 -1
  60. package/src/datasets.ts +367 -0
  61. package/src/i18n/ar.ts +33 -0
  62. package/src/i18n/cs.ts +33 -0
  63. package/src/i18n/da.ts +33 -0
  64. package/src/i18n/de.ts +33 -0
  65. package/src/i18n/el.ts +33 -0
  66. package/src/i18n/es.ts +33 -0
  67. package/src/i18n/fi.ts +33 -0
  68. package/src/i18n/fr.ts +33 -0
  69. package/src/i18n/he.ts +33 -0
  70. package/src/i18n/it.ts +33 -0
  71. package/src/i18n/ja.ts +33 -0
  72. package/src/i18n/ko.ts +33 -0
  73. package/src/i18n/nb.ts +33 -0
  74. package/src/i18n/pl.ts +33 -0
  75. package/src/i18n/pt.ts +33 -0
  76. package/src/i18n/ru.ts +33 -0
  77. package/src/i18n/sv.ts +33 -0
  78. package/src/i18n/tr.ts +33 -0
  79. package/src/i18n/uk.ts +33 -0
  80. package/src/i18n/zh.ts +33 -0
  81. package/src/i18n.ts +66 -0
  82. package/src/instance.ts +3 -0
  83. package/src/metadata.ts +9 -3
  84. package/src/release-check.ts +139 -0
  85. package/src/reporting-app.ts +101 -0
  86. package/src/runtime/ai-agents.ts +365 -0
  87. package/src/runtime/ai-filter.ts +385 -107
  88. package/src/runtime/assistant.ts +64 -11
  89. package/src/runtime/context.ts +2 -0
  90. package/src/runtime/data-reporter.ts +199 -32
  91. package/src/runtime/ir-phrases.ts +812 -0
  92. package/src/runtime/ir-plan.ts +449 -0
  93. package/src/runtime/items.ts +1 -1
  94. package/src/runtime/regions.ts +6 -2
  95. package/src/runtime/render.ts +1 -1
  96. package/src/runtime/report.ts +1 -1
  97. package/src/runtime/tasks.ts +63 -12
  98. package/src/session.ts +2 -0
  99. package/src/subscriptions.ts +2 -1
  100. package/src/task-events.ts +232 -0
  101. package/src/team-samples.ts +74 -0
  102. package/src/team.ts +342 -0
  103. package/src/workflow.ts +135 -18
  104. 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
+ }
@@ -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>`;
@@ -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) and submit
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}">
@@ -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',
@@ -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.
@@ -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, '&quot;')}"`) : ''}>${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') : t(`tasks.outcome.${k.outcome ?? 'completed'}`);
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
- ${k.type === 'approval'
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
- const act = b.action as Action;
202
- if (b.__csrf !== ctx.session.csrf_token || !/^\d{1,18}$/.test(id) || !TASK_ACTIONS.includes(act)) return reply.redirect(next, 303);
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
- const done = (await c.query<{ r: { action_code: string | null; detail_pk: string | null; params: Record<string, unknown>; initiator: string } }>(
225
- 'select meta.complete_task($1, $2, $3) as r', [id, outcome, comment])).rows[0].r;
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
- await c.query(stripSemicolon(applyBinds(done.action_code, binds)));
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.__FLASH = t(`tasks.done.${act}`);
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
  }