pgkiln 0.71.0 → 0.73.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/139_opentelemetry.sql +36 -0
- package/db/migrations/140_item_region_types.sql +27 -0
- package/docs/apex-feature-parity.md +86 -9
- package/docs/guide/01-installation.md +108 -0
- package/docs/guide/03-builder.md +9 -0
- package/docs/guide/04-pages-and-regions.md +41 -1
- package/docs/guide/05-items.md +43 -3
- package/docs/guide/09-reference.md +3 -0
- package/docs/guide/11-from-apex.md +3 -0
- package/docs/guide/12-development.md +23 -2
- package/examples/hr/hr_75_item_region_types.sql +81 -0
- package/package.json +1 -1
- package/public/app.css +48 -0
- package/public/app.js +427 -1
- package/public/builder.css +3 -0
- package/src/ai/service.ts +14 -0
- package/src/app.ts +10 -0
- package/src/builder/advisor.ts +5 -1
- package/src/builder/arrange.ts +7 -0
- package/src/builder/components.ts +5 -5
- package/src/builder/designer.ts +8 -2
- package/src/builder/home.ts +1 -0
- package/src/builder/i18n/de.ts +8 -0
- package/src/builder/i18n/en.ts +8 -0
- package/src/builder/i18n/es.ts +8 -0
- package/src/builder/i18n/fr.ts +8 -0
- package/src/builder/i18n/it.ts +8 -0
- package/src/builder/i18n/ja.ts +8 -0
- package/src/builder/i18n/ko.ts +8 -0
- package/src/builder/i18n/nl.ts +8 -0
- package/src/builder/i18n/pt.ts +8 -0
- package/src/builder/i18n/zh-cn.ts +8 -0
- package/src/builder/i18n/zh-tw.ts +8 -0
- package/src/builder/region-help.ts +8 -2
- package/src/builder/telemetry.ts +206 -0
- package/src/builder/workspaces.ts +2 -1
- package/src/i18n/ar.ts +13 -0
- package/src/i18n/cs.ts +13 -0
- package/src/i18n/da.ts +13 -0
- package/src/i18n/de.ts +13 -0
- package/src/i18n/el.ts +13 -0
- package/src/i18n/es.ts +13 -0
- package/src/i18n/fi.ts +13 -0
- package/src/i18n/fr.ts +13 -0
- package/src/i18n/he.ts +13 -0
- package/src/i18n/it.ts +13 -0
- package/src/i18n/ja.ts +13 -0
- package/src/i18n/ko.ts +13 -0
- package/src/i18n/nb.ts +13 -0
- package/src/i18n/pl.ts +13 -0
- package/src/i18n/pt.ts +13 -0
- package/src/i18n/ru.ts +13 -0
- package/src/i18n/sv.ts +13 -0
- package/src/i18n/tr.ts +13 -0
- package/src/i18n/uk.ts +13 -0
- package/src/i18n/zh.ts +13 -0
- package/src/i18n.ts +26 -0
- package/src/instance-checks.ts +11 -4
- package/src/layout.ts +2 -1
- package/src/mail/mail.ts +14 -0
- package/src/metadata.ts +5 -2
- package/src/runtime/ai-agents.ts +9 -0
- package/src/runtime/items.ts +32 -3
- package/src/runtime/page-regions.ts +50 -0
- package/src/runtime/region-cache.ts +1 -1
- package/src/runtime/regions.ts +11 -0
- package/src/runtime/render.ts +3 -17
- package/src/runtime/report.ts +55 -3
- package/src/runtime/routes.ts +3 -0
- package/src/session.ts +26 -0
- package/src/telemetry/browser.ts +91 -0
- package/src/telemetry/config.ts +342 -0
- package/src/telemetry/export.ts +484 -0
- package/src/telemetry/http.ts +126 -0
- package/src/telemetry/trace.ts +456 -0
- package/src/webclient.ts +25 -0
- package/src/workflow.ts +14 -7
package/public/app.js
CHANGED
|
@@ -264,6 +264,8 @@ const pgkilnUnsaved = (() => {
|
|
|
264
264
|
if (el.closest('[data-range]')) return els.some((e) => e.value) ? els.slice(0, 2).map((e) => e.value).join(':') : '';
|
|
265
265
|
if (el.type === 'radio') return (els.find((e) => e.checked) || {}).value || '';
|
|
266
266
|
if (el.type === 'checkbox') return el.checked ? 'true' : 'false';
|
|
267
|
+
// (140) a multi-value list (multiselect, shuttle, select many): its values colon-separated, in order
|
|
268
|
+
if (el.tagName === 'SELECT' && el.multiple) return [...el.selectedOptions].map((o) => o.value).join(':');
|
|
267
269
|
return el.value;
|
|
268
270
|
}
|
|
269
271
|
|
|
@@ -281,7 +283,10 @@ const pgkilnUnsaved = (() => {
|
|
|
281
283
|
els.slice(0, 2).forEach((e, i) => (e.value = parts[i] || ''));
|
|
282
284
|
} else if (el.type === 'radio') els.forEach((e) => (e.checked = e.value === value));
|
|
283
285
|
else if (el.type === 'checkbox') el.checked = ['true', 't', 'on', '1', 'yes', 'y'].includes(String(value).toLowerCase());
|
|
284
|
-
else el.
|
|
286
|
+
else if (el.tagName === 'SELECT' && el.multiple) {
|
|
287
|
+
const values = String(value ?? '').split(':').filter(Boolean);
|
|
288
|
+
for (const o of el.options) o.selected = values.includes(o.value);
|
|
289
|
+
} else el.value = value ?? '';
|
|
285
290
|
if (depth < 5) {
|
|
286
291
|
depth++;
|
|
287
292
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
@@ -2162,6 +2167,377 @@ function popupLov(sel) {
|
|
|
2162
2167
|
});
|
|
2163
2168
|
}
|
|
2164
2169
|
|
|
2170
|
+
// (140; APEX: Shuttle) Two lists, available and chosen, with buttons to move one or all values across and to
|
|
2171
|
+
// order the chosen ones. The item's own <select multiple> (it works without JavaScript) stays in the form, hidden:
|
|
2172
|
+
// its selected options are the value, in the order of the chosen list (they come first in it).
|
|
2173
|
+
function enhanceShuttles(root) {
|
|
2174
|
+
const boxes = root.matches?.('[data-shuttle]') ? [root] : [...(root.querySelectorAll?.('[data-shuttle]') ?? [])];
|
|
2175
|
+
boxes.forEach(shuttle);
|
|
2176
|
+
}
|
|
2177
|
+
document.addEventListener('pgkiln:replaced', (e) => e.detail && enhanceShuttles(e.detail));
|
|
2178
|
+
enhanceShuttles(document);
|
|
2179
|
+
function shuttle(box) {
|
|
2180
|
+
const sel = box.querySelector('select[multiple]');
|
|
2181
|
+
if (!sel || box.dataset.enhanced) return;
|
|
2182
|
+
box.dataset.enhanced = '1';
|
|
2183
|
+
const d = box.dataset;
|
|
2184
|
+
const id = sel.id;
|
|
2185
|
+
const label = document.querySelector(`label[for="${CSS.escape(id)}"]`);
|
|
2186
|
+
const name = (label?.textContent || sel.name).replace(/\*\s*$/, '').trim();
|
|
2187
|
+
const el = (tag, cls) => {
|
|
2188
|
+
const x = document.createElement(tag);
|
|
2189
|
+
if (cls) x.className = cls;
|
|
2190
|
+
return x;
|
|
2191
|
+
};
|
|
2192
|
+
const list = (suffix, text) => {
|
|
2193
|
+
const l = el('select', 'shuttle-list');
|
|
2194
|
+
l.multiple = true;
|
|
2195
|
+
l.id = `${id}_${suffix}`;
|
|
2196
|
+
l.size = Math.max(sel.size || 6, 6);
|
|
2197
|
+
l.setAttribute('aria-label', `${name}: ${text}`);
|
|
2198
|
+
for (const a of ['aria-describedby', 'aria-invalid']) if (sel.hasAttribute(a)) l.setAttribute(a, sel.getAttribute(a));
|
|
2199
|
+
return l;
|
|
2200
|
+
};
|
|
2201
|
+
const avail = list('available', d.availableLabel || 'Available');
|
|
2202
|
+
const chosen = list('chosen', d.chosenLabel || 'Selected');
|
|
2203
|
+
const filter = el('input', 'shuttle-filter');
|
|
2204
|
+
filter.type = 'search';
|
|
2205
|
+
filter.id = `${id}_filter`;
|
|
2206
|
+
filter.autocomplete = 'off';
|
|
2207
|
+
filter.placeholder = d.filterLabel || 'Filter';
|
|
2208
|
+
filter.setAttribute('aria-label', `${name}: ${d.filterLabel || 'Filter'}`);
|
|
2209
|
+
filter.setAttribute('aria-controls', avail.id);
|
|
2210
|
+
const button = (cmd, text, glyph) => {
|
|
2211
|
+
const b = el('button', 'btn btn-sm shuttle-btn');
|
|
2212
|
+
b.type = 'button';
|
|
2213
|
+
b.dataset.cmd = cmd;
|
|
2214
|
+
b.title = text;
|
|
2215
|
+
b.setAttribute('aria-label', text);
|
|
2216
|
+
const g = el('span');
|
|
2217
|
+
g.setAttribute('aria-hidden', 'true');
|
|
2218
|
+
g.textContent = glyph;
|
|
2219
|
+
b.append(g);
|
|
2220
|
+
return b;
|
|
2221
|
+
};
|
|
2222
|
+
const moves = el('div', 'shuttle-moves');
|
|
2223
|
+
moves.setAttribute('role', 'group');
|
|
2224
|
+
moves.append(button('all', d.moveAllLabel || 'Move all', '»'), button('move', d.moveLabel || 'Move', '›'), button('remove', d.removeLabel || 'Remove', '‹'), button('none', d.removeAllLabel || 'Remove all', '«'));
|
|
2225
|
+
const order = el('div', 'shuttle-order');
|
|
2226
|
+
order.setAttribute('role', 'group');
|
|
2227
|
+
order.append(button('top', d.topLabel || 'Move to top', '⤒'), button('up', d.upLabel || 'Move up', '↑'), button('down', d.downLabel || 'Move down', '↓'), button('bottom', d.bottomLabel || 'Move to bottom', '⤓'));
|
|
2228
|
+
const left = el('div', 'shuttle-col');
|
|
2229
|
+
left.append(filter, avail);
|
|
2230
|
+
const right = el('div', 'shuttle-col');
|
|
2231
|
+
right.append(chosen);
|
|
2232
|
+
const ui = el('div', 'shuttle-ui');
|
|
2233
|
+
ui.append(left, moves, right, order);
|
|
2234
|
+
sel.hidden = true;
|
|
2235
|
+
box.append(ui);
|
|
2236
|
+
if (label) label.htmlFor = avail.id;
|
|
2237
|
+
|
|
2238
|
+
const src = new Map(); // a shown option → the item's option
|
|
2239
|
+
const lovIndex = (o) => Number(o.dataset.i ?? 0);
|
|
2240
|
+
// the item's options: the chosen ones first (in their order), then the others in the list of values' order
|
|
2241
|
+
const arrange = (picked) => {
|
|
2242
|
+
const rest = [...sel.options].filter((o) => !picked.includes(o)).sort((a, b) => lovIndex(a) - lovIndex(b));
|
|
2243
|
+
for (const o of sel.options) o.selected = picked.includes(o);
|
|
2244
|
+
sel.append(...picked, ...rest);
|
|
2245
|
+
};
|
|
2246
|
+
const picked = () => [...sel.options].filter((o) => o.selected);
|
|
2247
|
+
const draw = (keep = new Set()) => {
|
|
2248
|
+
const q = filter.value.trim().toLowerCase();
|
|
2249
|
+
const fill = (l, opts) => {
|
|
2250
|
+
l.replaceChildren(
|
|
2251
|
+
...opts.map((o) => {
|
|
2252
|
+
const x = new Option(o.text, o.value);
|
|
2253
|
+
x.selected = keep.has(o);
|
|
2254
|
+
src.set(x, o);
|
|
2255
|
+
return x;
|
|
2256
|
+
}),
|
|
2257
|
+
);
|
|
2258
|
+
};
|
|
2259
|
+
src.clear();
|
|
2260
|
+
fill(avail, [...sel.options].filter((o) => !o.selected && (!q || o.text.toLowerCase().includes(q))));
|
|
2261
|
+
fill(chosen, picked());
|
|
2262
|
+
};
|
|
2263
|
+
let own = false;
|
|
2264
|
+
const changed = (keep) => {
|
|
2265
|
+
draw(keep);
|
|
2266
|
+
own = true;
|
|
2267
|
+
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
|
2268
|
+
own = false;
|
|
2269
|
+
};
|
|
2270
|
+
const marked = (l) => [...l.selectedOptions].map((x) => src.get(x)).filter(Boolean);
|
|
2271
|
+
const run = (cmd) => {
|
|
2272
|
+
let now = picked();
|
|
2273
|
+
let keep = new Set();
|
|
2274
|
+
if (cmd === 'move' || cmd === 'all') {
|
|
2275
|
+
const add = cmd === 'all' ? [...avail.options].map((x) => src.get(x)) : marked(avail);
|
|
2276
|
+
if (!add.length) return;
|
|
2277
|
+
now = [...now, ...add];
|
|
2278
|
+
keep = new Set(add);
|
|
2279
|
+
} else if (cmd === 'remove' || cmd === 'none') {
|
|
2280
|
+
const drop = cmd === 'none' ? now : marked(chosen);
|
|
2281
|
+
if (!drop.length) return;
|
|
2282
|
+
now = now.filter((o) => !drop.includes(o));
|
|
2283
|
+
keep = new Set(drop);
|
|
2284
|
+
} else {
|
|
2285
|
+
const m = marked(chosen);
|
|
2286
|
+
if (!m.length) return;
|
|
2287
|
+
keep = new Set(m);
|
|
2288
|
+
const others = now.filter((o) => !keep.has(o));
|
|
2289
|
+
if (cmd === 'top') now = [...m, ...others];
|
|
2290
|
+
else if (cmd === 'bottom') now = [...others, ...m];
|
|
2291
|
+
else {
|
|
2292
|
+
const step = cmd === 'up' ? -1 : 1;
|
|
2293
|
+
const idx = (cmd === 'up' ? m : [...m].reverse()).map((o) => now.indexOf(o));
|
|
2294
|
+
for (const i of idx) {
|
|
2295
|
+
const j = i + step;
|
|
2296
|
+
if (j < 0 || j >= now.length || keep.has(now[j])) continue;
|
|
2297
|
+
[now[i], now[j]] = [now[j], now[i]];
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
arrange(now);
|
|
2302
|
+
changed(keep);
|
|
2303
|
+
};
|
|
2304
|
+
ui.addEventListener('click', (e) => {
|
|
2305
|
+
const b = e.target.closest('button[data-cmd]');
|
|
2306
|
+
if (b) run(b.dataset.cmd);
|
|
2307
|
+
});
|
|
2308
|
+
avail.addEventListener('dblclick', () => run('move'));
|
|
2309
|
+
chosen.addEventListener('dblclick', () => run('remove'));
|
|
2310
|
+
// the keyboard: Enter moves the marked values across; in the chosen list Alt+arrows order them
|
|
2311
|
+
avail.addEventListener('keydown', (e) => {
|
|
2312
|
+
if (e.key === 'Enter') {
|
|
2313
|
+
e.preventDefault();
|
|
2314
|
+
run('move');
|
|
2315
|
+
}
|
|
2316
|
+
});
|
|
2317
|
+
chosen.addEventListener('keydown', (e) => {
|
|
2318
|
+
if (e.key === 'Enter' || e.key === 'Delete') {
|
|
2319
|
+
e.preventDefault();
|
|
2320
|
+
run('remove');
|
|
2321
|
+
} else if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
|
2322
|
+
e.preventDefault();
|
|
2323
|
+
run(e.key === 'ArrowUp' ? (e.shiftKey ? 'top' : 'up') : e.shiftKey ? 'bottom' : 'down');
|
|
2324
|
+
}
|
|
2325
|
+
});
|
|
2326
|
+
// the lists' own change events are not the item's
|
|
2327
|
+
for (const l of [avail, chosen]) l.addEventListener('change', (e) => e.stopPropagation());
|
|
2328
|
+
filter.addEventListener('input', () => draw(new Set(marked(avail))));
|
|
2329
|
+
filter.addEventListener('change', (e) => e.stopPropagation());
|
|
2330
|
+
filter.addEventListener('keydown', (e) => {
|
|
2331
|
+
if (e.key === 'Enter') e.preventDefault();
|
|
2332
|
+
});
|
|
2333
|
+
// a dynamic action (Set value, Clear) changed the item
|
|
2334
|
+
sel.addEventListener('change', () => {
|
|
2335
|
+
if (!own) draw();
|
|
2336
|
+
});
|
|
2337
|
+
arrange(picked());
|
|
2338
|
+
draw();
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
// (140; APEX 24.2: Select One, Select Many) A search field over the item's list of values: typing filters a list
|
|
2342
|
+
// box below it (arrow keys, Enter, Escape); Select Many shows the chosen values as chips with a remove button.
|
|
2343
|
+
// The item's <select> (single or multiple; it works without JavaScript) stays in the form, hidden, as the value.
|
|
2344
|
+
function enhanceSelectPicks(root) {
|
|
2345
|
+
const boxes = root.matches?.('[data-select-pick]') ? [root] : [...(root.querySelectorAll?.('[data-select-pick]') ?? [])];
|
|
2346
|
+
boxes.forEach(selectPick);
|
|
2347
|
+
}
|
|
2348
|
+
let selectPickSeq = 0;
|
|
2349
|
+
document.addEventListener('pgkiln:replaced', (e) => e.detail && enhanceSelectPicks(e.detail));
|
|
2350
|
+
enhanceSelectPicks(document);
|
|
2351
|
+
function selectPick(box) {
|
|
2352
|
+
const sel = box.querySelector('select');
|
|
2353
|
+
if (!sel || box.dataset.enhanced) return;
|
|
2354
|
+
box.dataset.enhanced = '1';
|
|
2355
|
+
const d = box.dataset;
|
|
2356
|
+
const many = sel.multiple;
|
|
2357
|
+
const id = sel.id;
|
|
2358
|
+
const label = document.querySelector(`label[for="${CSS.escape(id)}"]`);
|
|
2359
|
+
const input = document.createElement('input');
|
|
2360
|
+
input.type = 'text';
|
|
2361
|
+
input.id = `${id}_search`;
|
|
2362
|
+
input.className = 'select-pick-input';
|
|
2363
|
+
input.autocomplete = 'off';
|
|
2364
|
+
input.spellcheck = false;
|
|
2365
|
+
input.placeholder = d.searchLabel || 'Search…';
|
|
2366
|
+
input.setAttribute('role', 'combobox');
|
|
2367
|
+
input.setAttribute('aria-autocomplete', 'list');
|
|
2368
|
+
input.setAttribute('aria-expanded', 'false');
|
|
2369
|
+
for (const a of ['aria-describedby', 'aria-invalid', 'aria-required']) if (sel.hasAttribute(a)) input.setAttribute(a, sel.getAttribute(a));
|
|
2370
|
+
const listId = `${id}_listbox_${++selectPickSeq}`;
|
|
2371
|
+
input.setAttribute('aria-controls', listId);
|
|
2372
|
+
const list = document.createElement('ul');
|
|
2373
|
+
list.id = listId;
|
|
2374
|
+
list.className = 'select-pick-list';
|
|
2375
|
+
list.setAttribute('role', 'listbox');
|
|
2376
|
+
if (label) list.setAttribute('aria-label', label.textContent.replace(/\*\s*$/, '').trim());
|
|
2377
|
+
if (many) list.setAttribute('aria-multiselectable', 'true');
|
|
2378
|
+
list.hidden = true;
|
|
2379
|
+
const chips = document.createElement('ul');
|
|
2380
|
+
chips.className = 'tag-list select-pick-chips';
|
|
2381
|
+
const ui = document.createElement('div');
|
|
2382
|
+
ui.className = 'select-pick-ui';
|
|
2383
|
+
if (many) ui.append(chips);
|
|
2384
|
+
ui.append(input, list);
|
|
2385
|
+
sel.hidden = true;
|
|
2386
|
+
box.append(ui);
|
|
2387
|
+
if (label) label.htmlFor = input.id;
|
|
2388
|
+
const status = document.createElement('span');
|
|
2389
|
+
status.className = 'sr-only';
|
|
2390
|
+
status.setAttribute('aria-live', 'polite');
|
|
2391
|
+
ui.append(status);
|
|
2392
|
+
|
|
2393
|
+
let active = -1;
|
|
2394
|
+
let shown = [];
|
|
2395
|
+
const options = () => [...sel.options];
|
|
2396
|
+
const textOf = (o) => o.text;
|
|
2397
|
+
const syncInput = () => {
|
|
2398
|
+
if (!many) input.value = sel.value ? (sel.selectedOptions[0]?.text ?? '') : '';
|
|
2399
|
+
};
|
|
2400
|
+
const drawChips = () => {
|
|
2401
|
+
if (!many) return;
|
|
2402
|
+
chips.replaceChildren(
|
|
2403
|
+
...[...sel.selectedOptions].map((o) => {
|
|
2404
|
+
const li = document.createElement('li');
|
|
2405
|
+
li.className = 'tag';
|
|
2406
|
+
li.append(o.text);
|
|
2407
|
+
const x = document.createElement('button');
|
|
2408
|
+
x.type = 'button';
|
|
2409
|
+
x.className = 'tag-remove';
|
|
2410
|
+
x.textContent = '×';
|
|
2411
|
+
x.setAttribute('aria-label', (d.removeLabel || 'Remove {value}').replace('{value}', o.text));
|
|
2412
|
+
x.addEventListener('click', () => {
|
|
2413
|
+
o.selected = false;
|
|
2414
|
+
changed();
|
|
2415
|
+
input.focus();
|
|
2416
|
+
});
|
|
2417
|
+
li.append(x);
|
|
2418
|
+
return li;
|
|
2419
|
+
}),
|
|
2420
|
+
);
|
|
2421
|
+
};
|
|
2422
|
+
let own = false;
|
|
2423
|
+
const changed = () => {
|
|
2424
|
+
drawChips();
|
|
2425
|
+
syncInput();
|
|
2426
|
+
if (!list.hidden) draw();
|
|
2427
|
+
own = true;
|
|
2428
|
+
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
|
2429
|
+
own = false;
|
|
2430
|
+
};
|
|
2431
|
+
const setActive = (i) => {
|
|
2432
|
+
active = i;
|
|
2433
|
+
[...list.children].forEach((li, n) => li.classList.toggle('is-active', n === i));
|
|
2434
|
+
const li = list.children[i];
|
|
2435
|
+
if (li && li.id) {
|
|
2436
|
+
input.setAttribute('aria-activedescendant', li.id);
|
|
2437
|
+
li.scrollIntoView?.({ block: 'nearest' });
|
|
2438
|
+
} else input.removeAttribute('aria-activedescendant');
|
|
2439
|
+
};
|
|
2440
|
+
const draw = () => {
|
|
2441
|
+
const q = (many || input.dataset.typed ? input.value : '').trim().toLowerCase();
|
|
2442
|
+
shown = options().filter((o) => (many ? o.value !== '' : true) && (!q || textOf(o).toLowerCase().includes(q)));
|
|
2443
|
+
list.replaceChildren(
|
|
2444
|
+
...shown.map((o, n) => {
|
|
2445
|
+
const li = document.createElement('li');
|
|
2446
|
+
li.id = `${listId}_${n}`;
|
|
2447
|
+
li.setAttribute('role', 'option');
|
|
2448
|
+
li.setAttribute('aria-selected', String(o.selected && (many || o.value !== '')));
|
|
2449
|
+
li.textContent = textOf(o);
|
|
2450
|
+
li.addEventListener('mousedown', (e) => e.preventDefault()); // keep the focus in the field
|
|
2451
|
+
li.addEventListener('click', () => pick(n));
|
|
2452
|
+
return li;
|
|
2453
|
+
}),
|
|
2454
|
+
);
|
|
2455
|
+
if (!shown.length) {
|
|
2456
|
+
const li = document.createElement('li');
|
|
2457
|
+
li.className = 'select-pick-none';
|
|
2458
|
+
li.textContent = d.noneLabel || 'Nothing found.';
|
|
2459
|
+
list.append(li);
|
|
2460
|
+
}
|
|
2461
|
+
status.textContent = shown.length ? '' : d.noneLabel || 'Nothing found.';
|
|
2462
|
+
setActive(shown.length ? Math.min(Math.max(active, 0), shown.length - 1) : -1);
|
|
2463
|
+
};
|
|
2464
|
+
const open = () => {
|
|
2465
|
+
if (!list.hidden) return;
|
|
2466
|
+
list.hidden = false;
|
|
2467
|
+
input.setAttribute('aria-expanded', 'true');
|
|
2468
|
+
active = many ? 0 : Math.max(0, options().findIndex((o) => o.selected));
|
|
2469
|
+
draw();
|
|
2470
|
+
};
|
|
2471
|
+
const close = () => {
|
|
2472
|
+
list.hidden = true;
|
|
2473
|
+
input.setAttribute('aria-expanded', 'false');
|
|
2474
|
+
input.removeAttribute('aria-activedescendant');
|
|
2475
|
+
delete input.dataset.typed;
|
|
2476
|
+
if (many) input.value = '';
|
|
2477
|
+
else syncInput();
|
|
2478
|
+
};
|
|
2479
|
+
const pick = (n) => {
|
|
2480
|
+
const o = shown[n];
|
|
2481
|
+
if (!o) return;
|
|
2482
|
+
if (many) {
|
|
2483
|
+
o.selected = !o.selected;
|
|
2484
|
+
input.value = '';
|
|
2485
|
+
active = n;
|
|
2486
|
+
changed();
|
|
2487
|
+
} else {
|
|
2488
|
+
sel.value = o.value;
|
|
2489
|
+
close();
|
|
2490
|
+
changed();
|
|
2491
|
+
}
|
|
2492
|
+
};
|
|
2493
|
+
input.addEventListener('focus', () => {
|
|
2494
|
+
if (!many) input.select?.();
|
|
2495
|
+
});
|
|
2496
|
+
input.addEventListener('click', open);
|
|
2497
|
+
input.addEventListener('input', () => {
|
|
2498
|
+
input.dataset.typed = '1';
|
|
2499
|
+
if (list.hidden) open();
|
|
2500
|
+
active = 0;
|
|
2501
|
+
draw();
|
|
2502
|
+
});
|
|
2503
|
+
input.addEventListener('change', (e) => e.stopPropagation()); // the item's value is the select's
|
|
2504
|
+
input.addEventListener('keydown', (e) => {
|
|
2505
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
2506
|
+
e.preventDefault();
|
|
2507
|
+
if (list.hidden) return open();
|
|
2508
|
+
if (shown.length) setActive((active + (e.key === 'ArrowDown' ? 1 : -1) + shown.length) % shown.length);
|
|
2509
|
+
} else if (e.key === 'Enter') {
|
|
2510
|
+
if (list.hidden) return;
|
|
2511
|
+
e.preventDefault();
|
|
2512
|
+
if (active >= 0) pick(active);
|
|
2513
|
+
} else if (e.key === 'Escape') {
|
|
2514
|
+
if (list.hidden) return;
|
|
2515
|
+
e.preventDefault();
|
|
2516
|
+
close();
|
|
2517
|
+
} else if (e.key === 'Backspace' && many && !input.value && sel.selectedOptions.length) {
|
|
2518
|
+
sel.selectedOptions[sel.selectedOptions.length - 1].selected = false;
|
|
2519
|
+
changed();
|
|
2520
|
+
} else if (e.key === 'Tab') close();
|
|
2521
|
+
});
|
|
2522
|
+
input.addEventListener('blur', () => {
|
|
2523
|
+
if (list.hidden) return;
|
|
2524
|
+
// Select One: a field emptied by the user chooses no value
|
|
2525
|
+
if (!many && input.dataset.typed && !input.value.trim() && [...sel.options].some((o) => o.value === '')) {
|
|
2526
|
+
sel.value = '';
|
|
2527
|
+
close();
|
|
2528
|
+
changed();
|
|
2529
|
+
} else close();
|
|
2530
|
+
});
|
|
2531
|
+
// a dynamic action (Set value, Clear) changed the item
|
|
2532
|
+
sel.addEventListener('change', () => {
|
|
2533
|
+
if (own) return;
|
|
2534
|
+
drawChips();
|
|
2535
|
+
if (document.activeElement !== input) syncInput();
|
|
2536
|
+
});
|
|
2537
|
+
drawChips();
|
|
2538
|
+
syncInput();
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2165
2541
|
// Builder: role suggestions. Clicking a role adds it to the roles field of
|
|
2166
2542
|
// the same form; in a form with an application select, only that app's
|
|
2167
2543
|
// suggestions are shown.
|
|
@@ -4362,3 +4738,53 @@ document.addEventListener('change', (e) => {
|
|
|
4362
4738
|
});
|
|
4363
4739
|
}
|
|
4364
4740
|
})();
|
|
4741
|
+
|
|
4742
|
+
// (sprint 139, AP-6) Browser spans: a page whose trace goes to an OpenTelemetry collector carries a
|
|
4743
|
+
// signed <meta name="pgkiln-trace">. Its load timings and its Ajax calls to the same application are
|
|
4744
|
+
// reported to pgkiln (POST /a/<alias>/telemetry), which adds them to the page's trace; the browser
|
|
4745
|
+
// never sees the collector. Nothing happens on pages without the meta.
|
|
4746
|
+
(() => {
|
|
4747
|
+
const meta = document.querySelector('meta[name="pgkiln-trace"]');
|
|
4748
|
+
const m = /^\/a\/[^/]+\//.exec(location.pathname);
|
|
4749
|
+
if (!meta || !m || !window.fetch || !window.performance) return;
|
|
4750
|
+
const token = meta.content;
|
|
4751
|
+
const url = `${m[0]}telemetry`;
|
|
4752
|
+
const origin = performance.timeOrigin || Date.now() - performance.now();
|
|
4753
|
+
let queue = [];
|
|
4754
|
+
const send = () => {
|
|
4755
|
+
if (!queue.length) return;
|
|
4756
|
+
const body = JSON.stringify({ t: token, spans: queue.splice(0, 50) });
|
|
4757
|
+
const blob = new Blob([body], { type: 'application/json' });
|
|
4758
|
+
if (!(navigator.sendBeacon && navigator.sendBeacon(url, blob)))
|
|
4759
|
+
realFetch(url, { method: 'POST', body, headers: { 'content-type': 'application/json' }, keepalive: true, credentials: 'same-origin' }).catch(() => {});
|
|
4760
|
+
};
|
|
4761
|
+
const realFetch = window.fetch.bind(window);
|
|
4762
|
+
window.fetch = function (input, init) {
|
|
4763
|
+
const t0 = performance.now();
|
|
4764
|
+
let path = '';
|
|
4765
|
+
try {
|
|
4766
|
+
const u = new URL(typeof input === 'string' ? input : input instanceof Request ? input.url : String(input), location.href);
|
|
4767
|
+
if (u.origin === location.origin && u.pathname.startsWith(m[0]) && u.pathname !== url) path = u.pathname + u.search;
|
|
4768
|
+
} catch {
|
|
4769
|
+
// not a URL we report
|
|
4770
|
+
}
|
|
4771
|
+
const p = realFetch(input, init);
|
|
4772
|
+
if (path) {
|
|
4773
|
+
const method = String((init && init.method) || (input instanceof Request ? input.method : 'GET')).toUpperCase();
|
|
4774
|
+
const done = (status) => {
|
|
4775
|
+
queue.push({ k: 'ajax', path, method, status, start: origin + t0, dur: performance.now() - t0 });
|
|
4776
|
+
if (queue.length >= 20) send();
|
|
4777
|
+
};
|
|
4778
|
+
p.then((r) => done(r.status), () => done(0));
|
|
4779
|
+
}
|
|
4780
|
+
return p;
|
|
4781
|
+
};
|
|
4782
|
+
window.addEventListener('load', () => setTimeout(() => {
|
|
4783
|
+
const n = performance.getEntriesByType('navigation')[0];
|
|
4784
|
+
if (n) queue.push({ k: 'load', start: origin + n.startTime, dur: (n.loadEventEnd || performance.now()) - n.startTime,
|
|
4785
|
+
t: { ttfb: n.responseStart - n.startTime, dom_interactive: n.domInteractive - n.startTime, dom_complete: n.domComplete - n.startTime, load: n.loadEventEnd - n.startTime } });
|
|
4786
|
+
send();
|
|
4787
|
+
}, 0));
|
|
4788
|
+
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') send(); });
|
|
4789
|
+
window.addEventListener('pagehide', send);
|
|
4790
|
+
})();
|
package/public/builder.css
CHANGED
|
@@ -1645,3 +1645,6 @@ a.cp-type:hover, a.cp-type:focus-visible { border-color: var(--accent); }
|
|
|
1645
1645
|
.util-icons code { font-size: 0.75rem; overflow-wrap: anywhere; }
|
|
1646
1646
|
.ui-defaults :is(input, select, textarea) { width: 100%; min-width: 7rem; }
|
|
1647
1647
|
.util-upgrade + .util-upgrade { margin-top: 1rem; }
|
|
1648
|
+
|
|
1649
|
+
/* (sprint 139, AP-6) OpenTelemetry */
|
|
1650
|
+
.otel-problem { color: var(--danger); overflow-wrap: anywhere; }
|
package/src/ai/service.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { decryptSecret } from '../secrets.ts';
|
|
|
3
3
|
import { ClaudeProvider } from './anthropic.ts';
|
|
4
4
|
import { OpenAiProvider } from './openai.ts';
|
|
5
5
|
import { AiError, type AiProvider, type GenerateRequest, type GenerateResult, type ProviderName } from './types.ts';
|
|
6
|
+
import { count, recordSpan } from '../telemetry/trace.ts';
|
|
6
7
|
|
|
7
8
|
// AI services (migration 060; Gemini and OpenAI-compatible endpoints: 121): an administrator configures them in the
|
|
8
9
|
// builder (Workspace utilities → AI services) and allows each application
|
|
@@ -115,6 +116,19 @@ export interface Usage {
|
|
|
115
116
|
}
|
|
116
117
|
|
|
117
118
|
export async function logUsage(info: Pick<CallInfo, 'appId' | 'pageNo' | 'user' | 'source'>, u: Usage) {
|
|
119
|
+
// (sprint 139, AP-6) the call as a span of the current trace (timed by the caller), tokens as metrics
|
|
120
|
+
if (u.status !== 'limited')
|
|
121
|
+
recordSpan(`AI ${u.service.provider} ${u.model}`.slice(0, 120), {
|
|
122
|
+
ms: u.ms,
|
|
123
|
+
attrs: {
|
|
124
|
+
'gen_ai.system': u.service.provider, 'gen_ai.request.model': u.model, 'gen_ai.operation.name': info.source === 'embedding' ? 'embeddings' : 'chat',
|
|
125
|
+
'gen_ai.usage.input_tokens': u.inputTokens, 'gen_ai.usage.output_tokens': u.outputTokens, 'pgkiln.ai.service': u.service.name, 'pgkiln.ai.source': info.source,
|
|
126
|
+
},
|
|
127
|
+
error: u.status === 'ok' ? null : `${u.status}${u.message ? `: ${u.message}` : ''}`,
|
|
128
|
+
});
|
|
129
|
+
count('pgkiln.ai.requests', { provider: u.service.provider, status: u.status });
|
|
130
|
+
count('pgkiln.ai.tokens', { provider: u.service.provider, model: u.model.slice(0, 100), type: 'input' }, u.inputTokens);
|
|
131
|
+
count('pgkiln.ai.tokens', { provider: u.service.provider, model: u.model.slice(0, 100), type: 'output' }, u.outputTokens);
|
|
118
132
|
try {
|
|
119
133
|
await owner.query(
|
|
120
134
|
`insert into meta.ai_usage (app_id, page_no, username, service_id, service, provider, model, source, input_tokens, output_tokens, duration_ms, status, message)
|
package/src/app.ts
CHANGED
|
@@ -97,6 +97,10 @@ import { mailQueueRoutes } from './builder/mail-queue.ts';
|
|
|
97
97
|
import { networkAccessRoutes } from './builder/network-access.ts';
|
|
98
98
|
import { workspaceMailRoutes } from './builder/workspace-mail.ts';
|
|
99
99
|
import { runtimeScope } from './db.ts';
|
|
100
|
+
// (sprint 139, AP-6) OpenTelemetry: a trace per request, /metrics, the browser relay
|
|
101
|
+
import { startTelemetry } from './telemetry/export.ts';
|
|
102
|
+
import { telemetryHooks, telemetryRoutes } from './telemetry/http.ts';
|
|
103
|
+
import { telemetryBuilderRoutes } from './builder/telemetry.ts';
|
|
100
104
|
|
|
101
105
|
export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
102
106
|
// a database older than the code: apply the migrations (MIGRATE_ON_START=true)
|
|
@@ -117,6 +121,9 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
117
121
|
trustProxy: trustProxySetting(process.env.TRUST_PROXY),
|
|
118
122
|
});
|
|
119
123
|
securityHeaders(app);
|
|
124
|
+
// (sprint 139) before the workspace scope below, so the request's trace encloses everything
|
|
125
|
+
if (!pending.length) await startTelemetry();
|
|
126
|
+
telemetryHooks(app);
|
|
120
127
|
if (pending.length) {
|
|
121
128
|
// checked again (at most every 5 s) until someone migrates; then requests go through
|
|
122
129
|
let checkedAt = Date.now();
|
|
@@ -279,6 +286,9 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
279
286
|
await app.register(dependencyRoutes);
|
|
280
287
|
await app.register(upgradeRoutes);
|
|
281
288
|
await app.register(uiDefaultRoutes);
|
|
289
|
+
// (sprint 139, AP-6) OpenTelemetry: Workspace utilities → OpenTelemetry, a workspace's own, /metrics, the browser relay
|
|
290
|
+
await app.register(telemetryRoutes);
|
|
291
|
+
await app.register(telemetryBuilderRoutes);
|
|
282
292
|
app.get('/', async (_req, reply) => reply.redirect('/builder/home'));
|
|
283
293
|
return app;
|
|
284
294
|
}
|
package/src/builder/advisor.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { configColumns, searchConfigProblems, textSearchConfig, tsvectorSql } fr
|
|
|
15
15
|
import { COMPONENTS } from './components.ts';
|
|
16
16
|
import { DISTANCES, PGVECTOR_MISSING, vectorColumnDims, vectorSchema, type Distance } from '../vector.ts';
|
|
17
17
|
import { appEntries, type Entry } from './search.ts';
|
|
18
|
+
import { LOV_CHECKED } from '../runtime/items.ts';
|
|
18
19
|
import { appHeader, BASE, developer, region, send, shell, type Req } from './ui.ts';
|
|
19
20
|
|
|
20
21
|
// App Builder → Advisor (APEX: Advisor): checks an application without
|
|
@@ -48,7 +49,7 @@ export function sqlFields(kind: string, row: any): { name: string; shape: SqlSha
|
|
|
48
49
|
switch (kind) {
|
|
49
50
|
case 'region':
|
|
50
51
|
return [
|
|
51
|
-
...(['report', 'grid', 'chart', 'cards', 'calendar', 'map', 'tree', 'dynamic'].includes(row.type) && row.source?.trim() && !row.rest_source ? [{ name: 'source', shape: 'select' as const }] : []),
|
|
52
|
+
...(['report', 'classic_report', 'grid', 'chart', 'cards', 'calendar', 'map', 'tree', 'dynamic'].includes(row.type) && row.source?.trim() && !row.rest_source ? [{ name: 'source', shape: 'select' as const }] : []),
|
|
52
53
|
{ name: 'condition', shape: 'boolean' },
|
|
53
54
|
];
|
|
54
55
|
case 'item':
|
|
@@ -428,6 +429,9 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
|
|
|
428
429
|
if (kind === 'region' && row.type === 'grid' && !all.some((x) => x.kind === 'process' && x.row.type === 'grid_dml' && x.row.region_id === row.id))
|
|
429
430
|
missing(e, 'Source', 'No grid_dml process saves this grid, so it is read-only.', 'info');
|
|
430
431
|
if (kind === 'region' && row.type === 'form' && row.pk_item && !items.has(String(row.pk_item).toUpperCase())) missing(e, 'Primary key item', `Item ${row.pk_item} doesn't exist.`);
|
|
432
|
+
// (140) a list item (select list, radio group, shuttle, select one or many …) shows no choices without a list of values
|
|
433
|
+
if (kind === 'item' && LOV_CHECKED.has(row.type) && !String(row.lov ?? '').trim())
|
|
434
|
+
missing(e, 'List of values', `A ${row.type} item needs a list of values: it has no choices, and no value passes the check on submit.`, 'warning');
|
|
431
435
|
// (078) a URL region: a literal origin, valid attributes
|
|
432
436
|
if (kind === 'region' && row.type === 'url') {
|
|
433
437
|
const target = urlTarget(row.source);
|
package/src/builder/arrange.ts
CHANGED
|
@@ -45,6 +45,7 @@ export const REGION_LABELS: Record<string, [string, string]> = {
|
|
|
45
45
|
workflows: ['Workflow Console', 'activity'], map: ['Map', 'map'], tree: ['Tree', 'org'], static: ['Static Content', 'region'], dynamic: ['Dynamic Content', 'code'],
|
|
46
46
|
template_component: ['Template Component', 'layers'], list: ['List', 'list'], data_reporter: ['Data Reporter', 'table'], form_designer: ['Form Designer', 'file'],
|
|
47
47
|
ai_assistant: ['AI Assistant', 'bolt'], url: ['URL', 'link'], search: ['Search', 'search'], login: ['Login', 'lock'], plugin: ['Plug-in', 'layers'],
|
|
48
|
+
classic_report: ['Classic Report', 'table'], breadcrumb: ['Breadcrumb', 'chevron'], help_text: ['Help Text', 'help'],
|
|
48
49
|
};
|
|
49
50
|
export const ITEM_LABELS: Record<string, [string, string]> = {
|
|
50
51
|
text: ['Text Field', 'item'], textarea: ['Text Area', 'item'], number: ['Number Field', 'item'], date: ['Date Picker', 'calendar'],
|
|
@@ -53,6 +54,7 @@ export const ITEM_LABELS: Record<string, [string, string]> = {
|
|
|
53
54
|
email: ['E-mail', 'inbox'], tel: ['Phone Number', 'item'], url: ['URL', 'item'], color: ['Color Picker', 'item'], file: ['File Upload', 'upload'],
|
|
54
55
|
location: ['Location', 'map'], richtext: ['Rich Text Editor', 'edit'], markdown: ['Markdown Editor', 'code'], rating: ['Star Rating', 'check'],
|
|
55
56
|
combobox: ['Combobox (Tags)', 'list'], daterange: ['Date Range', 'calendar'], qrcode: ['QR Code', 'scan'], image: ['Display Image', 'file'], address: ['Geocoded Address', 'map'], hidden: ['Hidden', 'item'], display: ['Display Only', 'file'], password: ['Password', 'key'],
|
|
57
|
+
shuttle: ['Shuttle', 'arrow-right'], select_one: ['Select One', 'search'], select_many: ['Select Many', 'check'],
|
|
56
58
|
};
|
|
57
59
|
export const BUTTON_LABELS: Record<string, [string, string]> = {
|
|
58
60
|
submit: ['Submit Page', 'button'], redirect: ['Redirect to Page', 'button'], da: ['Dynamic Action', 'bolt'], document: ['Download Document', 'download'],
|
|
@@ -212,6 +214,11 @@ export async function createComponent(c: Q, pid: number, kind: LayoutKind, type:
|
|
|
212
214
|
if (!REGION_TYPES().includes(type)) throw new LayoutError(400, 'Unknown region type.');
|
|
213
215
|
id = (await c.query(`insert into meta.region (page_id, seq, title, type, source) values ($1, 999999, $2, $3, $4) returning id`,
|
|
214
216
|
[pid, `New ${REGION_LABELS[type]?.[0].toLowerCase() ?? type}`, type, NEW_REGION_SOURCE[type] ?? null])).rows[0].id;
|
|
217
|
+
// (140) a breadcrumb goes in the breadcrumb bar, without a frame of its own (unless it is dropped elsewhere)
|
|
218
|
+
if (type === 'breadcrumb') {
|
|
219
|
+
await c.query(`update meta.region set template = 'plain', title = 'Breadcrumb' where id = $1`, [id]);
|
|
220
|
+
if (!o.position && !o.before) o = { ...o, position: 'breadcrumb' };
|
|
221
|
+
}
|
|
215
222
|
} else {
|
|
216
223
|
const region = await targetRegion(c, pid, o.region ?? '');
|
|
217
224
|
if (kind === 'item') {
|
|
@@ -116,9 +116,9 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
116
116
|
{ name: 'title', common: true, label: 'Title', kind: 'text', group: 'Identification' },
|
|
117
117
|
{ name: 'static_id', label: 'Static ID', kind: 'text', group: 'Identification',
|
|
118
118
|
help: 'Optional, unique on the page: lower case letters, digits, _ and -, starting with a letter. Names the region in exported files (so renaming it keeps them) and is data-static-id on the page, for CSS and JavaScript.' },
|
|
119
|
-
{ name: 'type', common: true, label: 'Type', kind: 'select', options: ['report', 'grid', 'form', 'login', 'chart', 'cards', 'calendar', 'facets', 'smart_filters', 'display_selector', 'tasks', 'workflows', 'map', 'tree', 'template_component', 'list', 'static', 'dynamic', 'data_reporter', 'form_designer', 'ai_assistant', 'plugin', 'url', 'search'], group: 'Identification' },
|
|
119
|
+
{ name: 'type', common: true, label: 'Type', kind: 'select', options: ['report', 'grid', 'form', 'login', 'chart', 'cards', 'calendar', 'facets', 'smart_filters', 'display_selector', 'tasks', 'workflows', 'map', 'tree', 'template_component', 'list', 'static', 'dynamic', 'data_reporter', 'form_designer', 'ai_assistant', 'plugin', 'url', 'search', 'classic_report', 'breadcrumb', 'help_text'], group: 'Identification' },
|
|
120
120
|
{ name: 'source', common: true, label: 'Source', kind: 'code', wide: true, group: 'Source',
|
|
121
|
-
help: 'report/grid: a SELECT (use :ITEM binds) · chart: label column + one numeric column per series · cards: title, subtitle, body, badge, icon · calendar: start_date, end_date, title · map: lat and lng (or location "lat,lng"), title, body, geojson · tree: id, parent_id, label, icon · template_component: any SELECT (its columns are #COLUMN# in the template), or empty for one instance · dynamic: a SELECT returning HTML (escape with meta.html_escape) · static: HTML with &ITEM. substitutions · url: the page to show, https://host/path?q=&P1_ITEM. (the host is written out; items are URL-encoded) or a path on this server (/a/other/1).' },
|
|
121
|
+
help: 'report/grid/classic_report: a SELECT (use :ITEM binds) · chart: label column + one numeric column per series · cards: title, subtitle, body, badge, icon · calendar: start_date, end_date, title · map: lat and lng (or location "lat,lng"), title, body, geojson · tree: id, parent_id, label, icon · template_component: any SELECT (its columns are #COLUMN# in the template), or empty for one instance · dynamic: a SELECT returning HTML (escape with meta.html_escape) · static: HTML with &ITEM. substitutions · url: the page to show, https://host/path?q=&P1_ITEM. (the host is written out; items are URL-encoded) or a path on this server (/a/other/1).' },
|
|
122
122
|
{ name: 'rest_source', label: 'REST data source', kind: 'rest_source', group: 'Source',
|
|
123
123
|
help: 'Read the rows of a REST data source (Shared Components) instead of a table: the source above is then optional SQL over them, e.g. select * from rest where price > 10. Parameters: {"rest_params": {"city": "&P1_CITY."}} in the attributes.' },
|
|
124
124
|
{ name: 'table_name', common: true, label: 'Table (form, grid)', kind: 'text', help: 'e.g. sales.orders', group: 'Source' },
|
|
@@ -149,13 +149,13 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
149
149
|
validate: (v) => formatSettingsProblem(v.config),
|
|
150
150
|
fields: [
|
|
151
151
|
{ name: 'name', common: true, label: 'Name', kind: 'upper', help: 'Referenced in SQL as :NAME, e.g. P3_NAME', group: 'Identification' },
|
|
152
|
-
{ name: 'type', common: true, label: 'Type', kind: 'select', options: ['text', 'textarea', 'number', 'date', 'datetime', 'select', 'popup_lov', 'radio', 'checkbox', 'switch', 'checkbox_group', 'multiselect', 'email', 'tel', 'url', 'color', 'file', 'location', 'richtext', 'markdown', 'rating', 'combobox', 'daterange', 'qrcode', 'image', 'address', 'hidden', 'display', 'password', 'plugin'], group: 'Identification' },
|
|
152
|
+
{ name: 'type', common: true, label: 'Type', kind: 'select', options: ['text', 'textarea', 'number', 'date', 'datetime', 'select', 'popup_lov', 'radio', 'checkbox', 'switch', 'checkbox_group', 'multiselect', 'email', 'tel', 'url', 'color', 'file', 'location', 'richtext', 'markdown', 'rating', 'combobox', 'daterange', 'qrcode', 'image', 'address', 'hidden', 'display', 'password', 'plugin', 'shuttle', 'select_one', 'select_many'], group: 'Identification' },
|
|
153
153
|
{ name: 'label', common: true, label: 'Label', kind: 'text', group: 'Identification' },
|
|
154
154
|
{ name: 'region_id', common: true, label: 'Region', kind: 'region', group: 'Layout' },
|
|
155
155
|
{ name: 'seq', label: 'Sequence', kind: 'int', group: 'Layout' },
|
|
156
156
|
{ name: 'source_column', common: true, label: 'Source column (form)', kind: 'text', group: 'Source' },
|
|
157
157
|
{ name: 'default_value', common: true, label: 'Default value', kind: 'text', group: 'Source' },
|
|
158
|
-
{ name: 'lov', common: true, label: 'List of values', kind: 'code', wide: true, group: 'List of values', help: 'select display, return from … · STATIC:Yes;Y,No;N · LOV:NAME (shared) · checkbox_group/multiselect store values colon-separated: string_to_array(:P1_X, \':\')' },
|
|
158
|
+
{ name: 'lov', common: true, label: 'List of values', kind: 'code', wide: true, group: 'List of values', help: 'select display, return from … · STATIC:Yes;Y,No;N · LOV:NAME (shared) · checkbox_group/multiselect/shuttle/select_many store values colon-separated: string_to_array(:P1_X, \':\')' },
|
|
159
159
|
{ name: 'template_options', label: 'Template options', kind: 'options', choices: ITEM_OPTIONS, group: 'Appearance',
|
|
160
160
|
help: 'CSS classes from a fixed list, added to the item (APEX: Template Options).' },
|
|
161
161
|
{ name: 'required', common: true, label: 'Value required', kind: 'bool', group: 'Validation' },
|
|
@@ -163,7 +163,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
163
163
|
{ name: 'readonly_condition', label: 'Read-only condition (SQL)', kind: 'code', group: 'Security', help: 'When true the item is shown read-only and ignored on submit.' },
|
|
164
164
|
{ name: 'authz', label: 'Authorization', kind: 'authz', group: 'Security', help: AUTHZ_HELP },
|
|
165
165
|
buildOption('Security'),
|
|
166
|
-
{ name: 'config', label: 'Attributes (JSON)', kind: 'json', group: 'Attributes', help: '{"submit_on_change":true,"null_label":"- All -","cascade_parents":"P3_DEPTNO","wide":true}. {"warn_unsaved":false}: a change of this item doesn\'t count for the page\'s "Warn on unsaved changes". Lists of values read at most {"max_rows":5000} rows. File items: {"filename_column":"photo_name","mime_column":"photo_mime","size_column":"photo_size","updated_column":"photo_updated","accept":"image/*,.pdf","max_mb":2,"capture":"environment","max_px":1600} (capture opens the camera on phones; max_px makes photos smaller before upload). {"crop":"1:1"} lets the user crop a picture before upload (free, 1:1, 4:3, 3:4, 16:9, 3:2, 2:3). Several files: {"multiple":true,"max_files":5,"table":"doc.attachment","parent_column":"ticket_id","key_column":"id"} with the content column as source (one row per file). Text items: {"scan":true} adds a barcode/QR scan button where the browser can read codes. Location items hold "lat,lng" with a "Use my location" button. Rich text: sanitised HTML; markdown: Markdown text ({"rows":10}). Rating: {"max":5} stars, stored as 1..max. Combobox: free text with list-of-values suggestions, values colon-separated ({"multiple":false} for one value). Date range: "from:to" (ISO dates; split_part(:P1_X, \':\', 1)). QR code: shows the value as a QR code ({"ecc":"M","size":200,"show_value":true}). Password: {"reveal":true} adds a show/hide button. Image (display image): the value is an https URL, a path or a data:image URL; or {"sql":"select photo, photo_mime from hr.emp where id = :P3_ID"} shows the first column of the first row (a BLOB); {"alt":"Photo of &P3_NAME.","width":160,"height":160}. Address: {"coordinates_item":"P3_LOCATION","country":"nl"} (a "Check address" button where the installation has a geocoder, GEOCODER_URL; the chosen address\'s position goes into the item as "lat,lng"). Any item: {"persist":"user"} keeps its value per user across sessions (a user preference named like the item). Number and display items: {"format_mask":"999G999G990D00"} shows the number in the language\'s notation (FML999G990D00 adds the currency) and reads it back on submit. · plugin: {"plugin":"name","attributes":{"NAME":"value"}} (an item plug-in: a text field its JavaScript enhances)' },
|
|
166
|
+
{ name: 'config', label: 'Attributes (JSON)', kind: 'json', group: 'Attributes', help: '{"submit_on_change":true,"null_label":"- All -","cascade_parents":"P3_DEPTNO","wide":true}. {"warn_unsaved":false}: a change of this item doesn\'t count for the page\'s "Warn on unsaved changes". Lists of values read at most {"max_rows":5000} rows. File items: {"filename_column":"photo_name","mime_column":"photo_mime","size_column":"photo_size","updated_column":"photo_updated","accept":"image/*,.pdf","max_mb":2,"capture":"environment","max_px":1600} (capture opens the camera on phones; max_px makes photos smaller before upload). {"crop":"1:1"} lets the user crop a picture before upload (free, 1:1, 4:3, 3:4, 16:9, 3:2, 2:3). Several files: {"multiple":true,"max_files":5,"table":"doc.attachment","parent_column":"ticket_id","key_column":"id"} with the content column as source (one row per file). Text items: {"scan":true} adds a barcode/QR scan button where the browser can read codes. Location items hold "lat,lng" with a "Use my location" button. Rich text: sanitised HTML; markdown: Markdown text ({"rows":10}). Rating: {"max":5} stars, stored as 1..max. Combobox: free text with list-of-values suggestions, values colon-separated ({"multiple":false} for one value). Shuttle: two lists (available, chosen) with move and order buttons; the chosen values colon-separated in their order. Select One / Select Many: a search field over the list of values (Select Many: chosen values as chips, colon-separated); {"null_label":"- None -"} or false for Select One. Date range: "from:to" (ISO dates; split_part(:P1_X, \':\', 1)). QR code: shows the value as a QR code ({"ecc":"M","size":200,"show_value":true}). Password: {"reveal":true} adds a show/hide button. Image (display image): the value is an https URL, a path or a data:image URL; or {"sql":"select photo, photo_mime from hr.emp where id = :P3_ID"} shows the first column of the first row (a BLOB); {"alt":"Photo of &P3_NAME.","width":160,"height":160}. Address: {"coordinates_item":"P3_LOCATION","country":"nl"} (a "Check address" button where the installation has a geocoder, GEOCODER_URL; the chosen address\'s position goes into the item as "lat,lng"). Any item: {"persist":"user"} keeps its value per user across sessions (a user preference named like the item). Number and display items: {"format_mask":"999G999G990D00"} shows the number in the language\'s notation (FML999G990D00 adds the currency) and reads it back on submit. · plugin: {"plugin":"name","attributes":{"NAME":"value"}} (an item plug-in: a text field its JavaScript enhances)' },
|
|
167
167
|
],
|
|
168
168
|
},
|
|
169
169
|
button: {
|