pgkiln 0.72.0 → 0.74.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/140_item_region_types.sql +27 -0
- package/db/migrations/141_workspace_resources.sql +115 -0
- package/docs/apex-feature-parity.md +91 -12
- package/docs/guide/01-installation.md +1 -0
- package/docs/guide/03-builder.md +47 -4
- package/docs/guide/04-pages-and-regions.md +41 -1
- package/docs/guide/05-items.md +43 -3
- package/docs/guide/11-from-apex.md +3 -0
- package/docs/guide/12-development.md +25 -3
- package/docs/guide/16-files.md +29 -0
- package/docs/guide/19-rest-data-sources.md +41 -3
- package/examples/hr/hr_75_item_region_types.sql +81 -0
- package/examples/hr/hr_76_remote_servers.sql +48 -0
- package/package.json +1 -1
- package/public/app.css +48 -0
- package/public/app.js +377 -1
- package/src/ai/service.ts +10 -1
- package/src/app.ts +2 -0
- package/src/builder/advisor.ts +18 -2
- package/src/builder/ai.ts +29 -3
- package/src/builder/arrange.ts +7 -0
- package/src/builder/components.ts +5 -5
- package/src/builder/designer.ts +9 -3
- package/src/builder/home.ts +3 -0
- package/src/builder/i18n/de.ts +6 -0
- package/src/builder/i18n/en.ts +6 -0
- package/src/builder/i18n/es.ts +6 -0
- package/src/builder/i18n/fr.ts +6 -0
- package/src/builder/i18n/it.ts +6 -0
- package/src/builder/i18n/ja.ts +6 -0
- package/src/builder/i18n/ko.ts +6 -0
- package/src/builder/i18n/nl.ts +6 -0
- package/src/builder/i18n/pt.ts +6 -0
- package/src/builder/i18n/zh-cn.ts +6 -0
- package/src/builder/i18n/zh-tw.ts +6 -0
- package/src/builder/network-access.ts +33 -0
- package/src/builder/region-help.ts +8 -2
- package/src/builder/rest-catalogs.ts +22 -14
- package/src/builder/routes.ts +5 -1
- package/src/builder/static-files.ts +14 -4
- package/src/builder/websources.ts +12 -6
- package/src/builder/workflows.ts +2 -1
- package/src/builder/workspace-resources.ts +528 -0
- 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/metadata.ts +22 -5
- package/src/remote-servers.ts +133 -0
- package/src/restcatalog.ts +21 -10
- package/src/restsync.ts +1 -1
- package/src/runtime/context.ts +2 -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/static-files.ts +46 -17
- package/src/runtime/template-components.ts +2 -1
- package/src/websources.ts +43 -18
- package/src/workspace-export.ts +80 -1
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.
|
package/src/ai/service.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { owner } from '../db.ts';
|
|
2
2
|
import { decryptSecret } from '../secrets.ts';
|
|
3
|
+
import { aiServerUrl } from '../remote-servers.ts';
|
|
3
4
|
import { ClaudeProvider } from './anthropic.ts';
|
|
4
5
|
import { OpenAiProvider } from './openai.ts';
|
|
5
6
|
import { AiError, type AiProvider, type GenerateRequest, type GenerateResult, type ProviderName } from './types.ts';
|
|
@@ -27,6 +28,9 @@ export interface AiService {
|
|
|
27
28
|
base_url: string | null;
|
|
28
29
|
api_key_enc: string | null;
|
|
29
30
|
enabled: boolean;
|
|
31
|
+
/** (141) a remote server whose base URL (plus server_path) the service calls instead of base_url */
|
|
32
|
+
server_id?: number | null;
|
|
33
|
+
server_path?: string | null;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
/** The default model of a new Claude service; OpenAI services name theirs. */
|
|
@@ -78,7 +82,12 @@ export function serviceKey(s: AiService): string {
|
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
export async function loadService(name: string): Promise<AiService | undefined> {
|
|
81
|
-
|
|
85
|
+
const s = await owner.one<AiService>('select * from meta.ai_service where name = $1', [name.toUpperCase()]);
|
|
86
|
+
if (!s?.server_id) return s;
|
|
87
|
+
// (141) on a remote server: its URL now (base_url is then what baseUrlFor gives the SDK)
|
|
88
|
+
const r = await aiServerUrl(s.server_id, s.server_path);
|
|
89
|
+
if (!r.url) throw new AiError('config', `AI service ${s.name}: ${r.problem}`);
|
|
90
|
+
return { ...s, base_url: r.url };
|
|
82
91
|
}
|
|
83
92
|
|
|
84
93
|
/** The provider of a service, with its key (decrypted only here, never stored elsewhere). */
|
package/src/app.ts
CHANGED
|
@@ -96,6 +96,7 @@ import { findWorkspaceRoutes } from './builder/find-workspace.ts';
|
|
|
96
96
|
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
|
+
import { workspaceResourceRoutes } from './builder/workspace-resources.ts';
|
|
99
100
|
import { runtimeScope } from './db.ts';
|
|
100
101
|
// (sprint 139, AP-6) OpenTelemetry: a trace per request, /metrics, the browser relay
|
|
101
102
|
import { startTelemetry } from './telemetry/export.ts';
|
|
@@ -281,6 +282,7 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
|
|
|
281
282
|
await app.register(mailQueueRoutes);
|
|
282
283
|
await app.register(networkAccessRoutes);
|
|
283
284
|
await app.register(workspaceMailRoutes);
|
|
285
|
+
await app.register(workspaceResourceRoutes);
|
|
284
286
|
// (sprint 138, AP-5) an application's Utilities
|
|
285
287
|
await app.register(utilityRoutes);
|
|
286
288
|
await app.register(dependencyRoutes);
|
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':
|
|
@@ -386,6 +387,10 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
|
|
|
386
387
|
// (104) a document button names a document template or a report query
|
|
387
388
|
const documents = new Set(all.filter((x) => x.kind === 'document_template' || x.kind === 'report_query').map((x) => x.row.name.toUpperCase()));
|
|
388
389
|
const missing = (entry: Entry | null, field: string, message: string, severity: Severity = 'error') => findings.push({ severity, entry, field, message });
|
|
390
|
+
// (141) remote servers and web credentials of the application's workspace (sources and invoke steps may name them)
|
|
391
|
+
const wsServers = new Map((await query('select static_id, credential from meta.remote_server where workspace_id = meta.app_workspace($1)', [appId])).rows.map((r) => [r.static_id as string, r.credential as string | null]));
|
|
392
|
+
const wsCredentials = new Set((await query('select name from meta.workspace_credential where workspace_id = meta.app_workspace($1)', [appId])).rows.map((r) => r.name as string));
|
|
393
|
+
const appCredentials = new Set(all.filter((x) => x.kind === 'web_credential').map((x) => String(x.row.name).toUpperCase()));
|
|
389
394
|
|
|
390
395
|
for (const e of entries) {
|
|
391
396
|
for (const f of e.fields) {
|
|
@@ -428,6 +433,9 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
|
|
|
428
433
|
if (kind === 'region' && row.type === 'grid' && !all.some((x) => x.kind === 'process' && x.row.type === 'grid_dml' && x.row.region_id === row.id))
|
|
429
434
|
missing(e, 'Source', 'No grid_dml process saves this grid, so it is read-only.', 'info');
|
|
430
435
|
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.`);
|
|
436
|
+
// (140) a list item (select list, radio group, shuttle, select one or many …) shows no choices without a list of values
|
|
437
|
+
if (kind === 'item' && LOV_CHECKED.has(row.type) && !String(row.lov ?? '').trim())
|
|
438
|
+
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
439
|
// (078) a URL region: a literal origin, valid attributes
|
|
432
440
|
if (kind === 'region' && row.type === 'url') {
|
|
433
441
|
const target = urlTarget(row.source);
|
|
@@ -453,6 +461,14 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
|
|
|
453
461
|
if (kind === 'button' && row.action === 'document' && !documents.has(String(row.document ?? '').toUpperCase()))
|
|
454
462
|
missing(e, 'Document template', row.document ? `Document template or report query ${row.document} doesn't exist.` : 'A document button needs a document template or a report query.');
|
|
455
463
|
if (kind === 'rest_module') for (const problem of handlerProblems(row.handlers)) missing(e, 'Handlers (JSON)', problem);
|
|
464
|
+
if (kind === 'rest_source') {
|
|
465
|
+
// (141) an export may name a server or credential this workspace doesn't have (yet): it imports, and says so here
|
|
466
|
+
if (row.server && !wsServers.has(row.server))
|
|
467
|
+
missing(e, 'Remote server', `Remote server ${row.server} doesn't exist in this application's workspace: add it under Workspace utilities → Remote servers (calls fail until then).`, 'warning');
|
|
468
|
+
const credential = row.credential || (row.server ? wsServers.get(row.server) : null);
|
|
469
|
+
if (credential && !appCredentials.has(credential) && !wsCredentials.has(credential))
|
|
470
|
+
missing(e, 'Web credential', `Web credential ${credential} is neither the application's nor its workspace's (calls fail until it exists).`, 'warning');
|
|
471
|
+
}
|
|
456
472
|
if (kind === 'workflow_definition') {
|
|
457
473
|
const tasks = new Set(all.filter((x) => x.kind === 'task_definition').map((x) => x.row.name.toUpperCase()));
|
|
458
474
|
const outcomes = outcomeMap(all.filter((x) => x.kind === 'task_definition').map((x) => x.row));
|
|
@@ -461,7 +477,7 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
|
|
|
461
477
|
// invoke_api steps: REST data sources, parameters, web credentials, variables (sprint 32)
|
|
462
478
|
const refs = {
|
|
463
479
|
sources: new Map(all.filter((x) => x.kind === 'rest_source').map((x) => [String(x.row.name).toUpperCase(), x.row])),
|
|
464
|
-
credentials: new Set(
|
|
480
|
+
credentials: new Set([...appCredentials, ...wsCredentials]),
|
|
465
481
|
startVars: [...String(row.title ?? '').matchAll(/&([A-Za-z][A-Za-z0-9_]*)\./g)].map((m) => m[1]),
|
|
466
482
|
};
|
|
467
483
|
const invokeRefs = invokeStepReferences(row.steps, refs);
|
package/src/builder/ai.ts
CHANGED
|
@@ -55,7 +55,7 @@ function serviceForm(s: Partial<AiService>, action: string, csrfField: ReturnTyp
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/** The form's values, checked; throws with a message. */
|
|
58
|
-
function formValues(b: Record<string, string | undefined
|
|
58
|
+
function formValues(b: Record<string, string | undefined>, hasServer = false) {
|
|
59
59
|
const provider: ProviderName = (['openai', 'gemini', 'openai_compatible'] as const).find((p) => p === b.provider) ?? 'anthropic';
|
|
60
60
|
const model = (b.model ?? '').trim();
|
|
61
61
|
if (!/^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,199}$/.test(model)) throw new Error('The model is a name like claude-opus-5-5 (letters, digits and . _ : / @ -).');
|
|
@@ -65,7 +65,7 @@ function formValues(b: Record<string, string | undefined>) {
|
|
|
65
65
|
if (!Number.isInteger(timeout) || timeout < 5 || timeout > 600) throw new Error('Time limit: 5 to 600 seconds.');
|
|
66
66
|
const baseUrl = (b.base_url ?? '').trim() || null;
|
|
67
67
|
if (baseUrl && (!/^https?:\/\/[^\s]+$/i.test(baseUrl) || baseUrl.length > 500)) throw new Error('The base URL starts with http:// or https://.');
|
|
68
|
-
if (!baseUrl && provider === 'openai_compatible') throw new Error('An OpenAI-compatible service needs a base URL (e.g. http://localhost:11434/v1 for Ollama).');
|
|
68
|
+
if (!baseUrl && provider === 'openai_compatible' && !hasServer) throw new Error('An OpenAI-compatible service needs a base URL (e.g. http://localhost:11434/v1 for Ollama).');
|
|
69
69
|
const effort = ['low', 'medium', 'high', 'xhigh', 'max'].includes(b.effort ?? '') ? b.effort! : null;
|
|
70
70
|
return [(b.description ?? '').trim() || null, provider, model, provider === 'anthropic' ? effort : null, b.refusal_fallback === 'true', maxTokens, timeout, baseUrl, b.enabled === 'true'];
|
|
71
71
|
}
|
|
@@ -157,12 +157,20 @@ export async function aiRoutes(app: FastifyInstance) {
|
|
|
157
157
|
delete s.state[TEST_RESULT];
|
|
158
158
|
const recent = (await owner.query(`select u.*, a.alias as app from meta.ai_usage u left join meta.app a on a.id = u.app_id where u.service_id = $1 order by u.id desc limit 50`, [x.id])).rows;
|
|
159
159
|
const view = { ...x, api_key_enc: x.api_key_enc ? 'stored' : null }; // the key itself never reaches the page
|
|
160
|
+
// (141) the remote servers of every workspace (administrators choose one)
|
|
161
|
+
const servers = (await owner.query<{ id: number; static_id: string; workspace: string }>(
|
|
162
|
+
'select r.id, r.static_id, w.name as workspace from meta.remote_server r join meta.workspace w on w.id = r.workspace_id order by lower(w.name), r.static_id')).rows;
|
|
160
163
|
const main = html`
|
|
161
164
|
<div class="title-row"><h1 class="ai-name">${x.name}</h1></div>
|
|
162
165
|
<div class="columns wide-left">
|
|
163
166
|
${region('Settings', html`${serviceForm(view, `${BASE}/ai/${x.id}`, csrf(s), false)}
|
|
164
167
|
<form method="post" action="${BASE}/ai/${x.id}/delete" class="danger-zone">${csrf(s)}
|
|
165
168
|
<button class="btn btn-danger" data-confirm="Delete AI service ${x.name}? Processes that use it fail until another service gets its name.">Delete service</button></form>`)}
|
|
169
|
+
${region('Remote server', html`<form method="post" action="${BASE}/ai/${x.id}/server">${csrf(s)}
|
|
170
|
+
<p class="muted u-mt0">Instead of the base URL: a remote server's URL (Workspace utilities → Remote servers) plus a path, so moving the endpoint changes one value. The API key is sent there: choose a server whose workspace administrators you trust with it.</p>
|
|
171
|
+
<div class="form-grid">${select('server_id', 'Remote server', x.server_id ? String(x.server_id) : '', [['', 'none: the base URL above'], ...servers.map((r): [string, string] => [String(r.id), `${r.static_id} (${r.workspace})`])])}
|
|
172
|
+
${input('server_path', 'Path', x.server_path ?? '', { placeholder: '/v1', help: 'After the server\'s base URL, e.g. /v1; empty: the base URL itself.' })}</div>
|
|
173
|
+
<div class="buttons"><button class="btn">Save</button></div></form>`)}
|
|
166
174
|
${region('Test', html`
|
|
167
175
|
<p class="muted u-mt0">Sends a prompt to the service (counted in the usage log as a builder test).</p>
|
|
168
176
|
<form method="post" action="${BASE}/ai/${x.id}/test">${csrf(s)}
|
|
@@ -194,7 +202,7 @@ export async function aiRoutes(app: FastifyInstance) {
|
|
|
194
202
|
`update meta.ai_service set (${COLUMNS}) = ($2, $3, $4, $5, $6, $7, $8, $9, $10), updated_at = now(),
|
|
195
203
|
api_key_enc = case when $12 then null when $11::text is null then api_key_enc else $11 end
|
|
196
204
|
where id = $1`,
|
|
197
|
-
[req.params.id, ...formValues(b), key, b.remove_key === 'true'],
|
|
205
|
+
[req.params.id, ...formValues(b, !!(await owner.one('select 1 as ok from meta.ai_service where id = $1 and server_id is not null', [req.params.id]))), key, b.remove_key === 'true'],
|
|
198
206
|
);
|
|
199
207
|
flash(s, r.rowCount ? 'AI service saved.' : 'Not found.', r.rowCount ? 'ok' : 'error');
|
|
200
208
|
} catch (e) {
|
|
@@ -203,6 +211,24 @@ export async function aiRoutes(app: FastifyInstance) {
|
|
|
203
211
|
return back(reply, s, `${BASE}/ai/${req.params.id}`);
|
|
204
212
|
});
|
|
205
213
|
|
|
214
|
+
// (141) the service's remote server and path
|
|
215
|
+
app.post(`${BASE}/ai/:id(^\\d+$)/server`, async (req: Req, reply) => {
|
|
216
|
+
const s = await developer(req, reply);
|
|
217
|
+
if (!s || !(await adminOnly(s, reply, 'AI services'))) return;
|
|
218
|
+
const b = req.body ?? {};
|
|
219
|
+
try {
|
|
220
|
+
const serverId = /^\d{1,9}$/.test(String(b.server_id ?? '')) ? Number(b.server_id) : null;
|
|
221
|
+
const path = String(b.server_path ?? '').trim() || null;
|
|
222
|
+
if (path && (!/^\/[^\s]*$/.test(path) || path.length > 500)) throw new Error('The path starts with /, without spaces, e.g. /v1.');
|
|
223
|
+
if (serverId && !(await owner.one('select 1 as ok from meta.remote_server where id = $1', [serverId]))) throw new Error('No such remote server.');
|
|
224
|
+
const r = await owner.query('update meta.ai_service set server_id = $2, server_path = $3, updated_at = now() where id = $1', [req.params.id, serverId, serverId ? path : null]);
|
|
225
|
+
flash(s, r.rowCount ? (serverId ? 'The service now calls its remote server: test it.' : 'The service calls its base URL again.') : 'Not found.', r.rowCount ? 'ok' : 'error');
|
|
226
|
+
} catch (e) {
|
|
227
|
+
flash(s, (e as Error).message.includes('ai_service_compatible_url') ? 'An OpenAI-compatible service needs a base URL or a remote server.' : (e as Error).message, 'error');
|
|
228
|
+
}
|
|
229
|
+
return back(reply, s, `${BASE}/ai/${req.params.id}`);
|
|
230
|
+
});
|
|
231
|
+
|
|
206
232
|
app.post(`${BASE}/ai/:id(^\\d+$)/apps`, async (req: Req, reply) => {
|
|
207
233
|
const s = await developer(req, reply);
|
|
208
234
|
if (!s || !(await adminOnly(s, reply, 'AI services'))) return;
|
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') {
|