pgkiln 0.42.0 → 0.44.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 (60) hide show
  1. package/db/migrations/099_layout_positions.sql +29 -0
  2. package/db/migrations/100_generated_labels.sql +381 -0
  3. package/db/migrations/101_theme_choice.sql +15 -0
  4. package/docs/apex-feature-parity.md +36 -9
  5. package/docs/guide/03-builder.md +82 -21
  6. package/docs/guide/04-pages-and-regions.md +55 -5
  7. package/docs/guide/05-items.md +1 -1
  8. package/docs/guide/09-reference.md +5 -1
  9. package/docs/guide/12-development.md +2 -1
  10. package/docs/guide/14-globalization.md +5 -1
  11. package/package.json +1 -1
  12. package/public/app.css +80 -0
  13. package/public/app.js +189 -1
  14. package/public/builder.css +112 -0
  15. package/public/builder.js +280 -3
  16. package/src/builder/appsheets.ts +53 -13
  17. package/src/builder/appwizard.ts +5 -3
  18. package/src/builder/arrange.ts +133 -17
  19. package/src/builder/components.ts +28 -22
  20. package/src/builder/designer.ts +67 -18
  21. package/src/builder/forms.ts +4 -1
  22. package/src/builder/object-browser.ts +342 -0
  23. package/src/builder/pwa.ts +3 -3
  24. package/src/builder/routes.ts +85 -26
  25. package/src/builder/shared.ts +103 -1
  26. package/src/builder/sql.ts +13 -65
  27. package/src/builder/ui.ts +21 -6
  28. package/src/i18n/ar.ts +18 -0
  29. package/src/i18n/cs.ts +18 -0
  30. package/src/i18n/da.ts +18 -0
  31. package/src/i18n/de.ts +18 -0
  32. package/src/i18n/el.ts +18 -0
  33. package/src/i18n/es.ts +18 -0
  34. package/src/i18n/fi.ts +18 -0
  35. package/src/i18n/fr.ts +18 -0
  36. package/src/i18n/he.ts +18 -0
  37. package/src/i18n/it.ts +18 -0
  38. package/src/i18n/ja.ts +18 -0
  39. package/src/i18n/ko.ts +18 -0
  40. package/src/i18n/nb.ts +18 -0
  41. package/src/i18n/pl.ts +18 -0
  42. package/src/i18n/pt.ts +18 -0
  43. package/src/i18n/ru.ts +18 -0
  44. package/src/i18n/sv.ts +18 -0
  45. package/src/i18n/tr.ts +18 -0
  46. package/src/i18n/uk.ts +18 -0
  47. package/src/i18n/zh.ts +18 -0
  48. package/src/i18n.ts +36 -0
  49. package/src/metadata.ts +5 -1
  50. package/src/runtime/account.ts +5 -5
  51. package/src/runtime/devbar.ts +16 -13
  52. package/src/runtime/engine.ts +6 -0
  53. package/src/runtime/facets.ts +34 -3
  54. package/src/runtime/format.ts +52 -0
  55. package/src/runtime/items.ts +27 -0
  56. package/src/runtime/locale.ts +7 -1
  57. package/src/runtime/regions.ts +36 -2
  58. package/src/runtime/render.ts +27 -6
  59. package/src/runtime/report.ts +141 -81
  60. package/src/runtime/routes.ts +39 -7
@@ -22,6 +22,19 @@ const options = (kind: string, field: string) => COMPONENTS[kind].fields.find((f
22
22
  export const REGION_TYPES = () => options('region', 'type');
23
23
  export const ITEM_TYPES = () => options('item', 'type');
24
24
  export const BUTTON_ACTIONS = () => options('button', 'action');
25
+ /** (099) display positions of regions and button positions (slots) of region buttons; body and region are the defaults (null) */
26
+ export const POSITIONS = () => options('region', 'position');
27
+ export const SLOTS = () => options('button', 'slot');
28
+ export const POSITION_LABELS: Record<string, string> = {
29
+ banner: 'Banner', breadcrumb: 'Breadcrumb bar', left: 'Left column', body: 'Body', right: 'Right column', footer: 'Footer',
30
+ };
31
+ export const SLOT_LABELS: Record<string, string> = {
32
+ region: 'Region buttons', create: 'Create', edit: 'Edit', change: 'Change', copy: 'Copy', help: 'Help', up: 'Up',
33
+ previous: 'Previous', close: 'Close', delete: 'Delete', next: 'Next',
34
+ };
35
+ /** The button positions in a region's header and in its bottom bar (previous, close, delete at the left; next at the right). */
36
+ export const TOP_SLOTS = ['create', 'edit', 'change', 'copy', 'help', 'up'];
37
+ export const BOTTOM_SLOTS = ['previous', 'close', 'delete', 'next'];
25
38
 
26
39
  /** Gallery names and icons. */
27
40
  export const REGION_LABELS: Record<string, [string, string]> = {
@@ -58,9 +71,10 @@ const NEW_REGION_SOURCE: Record<string, string | null> = {
58
71
  // ---------------------------------------------------------------- snapshots (undo / redo)
59
72
 
60
73
  interface Snap {
61
- region: { id: number; seq: number; columns: number }[];
74
+ // position and slot (099) are missing from snapshots taken before them: those leave them as they are
75
+ region: { id: number; seq: number; columns: number; position?: string | null }[];
62
76
  item: { id: number; seq: number; region_id: number | null }[];
63
- button: { id: number; seq: number; region_id: number | null }[];
77
+ button: { id: number; seq: number; region_id: number | null; slot?: string | null }[];
64
78
  }
65
79
  interface History {
66
80
  pid: number;
@@ -74,19 +88,22 @@ type Q = { query: (sql: string, params?: unknown[]) => Promise<{ rows: any[]; ro
74
88
 
75
89
  async function snapshot(c: Q, pid: number): Promise<Snap> {
76
90
  // one after the other: a transaction's client runs one query at a time
77
- const region = await c.query('select id, seq, columns from meta.region where page_id = $1 order by id', [pid]);
91
+ const region = await c.query('select id, seq, columns, position from meta.region where page_id = $1 order by id', [pid]);
78
92
  const item = await c.query('select id, seq, region_id from meta.item where page_id = $1 order by id', [pid]);
79
- const button = await c.query('select id, seq, region_id from meta.button where page_id = $1 order by id', [pid]);
93
+ const button = await c.query('select id, seq, region_id, slot from meta.button where page_id = $1 order by id', [pid]);
80
94
  return { region: region.rows, item: item.rows, button: button.rows };
81
95
  }
82
96
 
83
97
  /** Puts the layout of a snapshot back; components deleted since are skipped, regions that are gone become "no region". */
84
98
  async function restore(c: Q, pid: number, snap: Snap) {
85
99
  const regions = new Set((await c.query('select id from meta.region where page_id = $1', [pid])).rows.map((r) => r.id));
86
- for (const r of snap.region) await c.query('update meta.region set seq = $3, columns = $4 where id = $1 and page_id = $2', [r.id, pid, r.seq, r.columns]);
87
- for (const kind of ['item', 'button'] as const)
88
- for (const r of snap[kind])
89
- await c.query(`update ${COMPONENTS[kind].table} set seq = $3, region_id = $4 where id = $1 and page_id = $2`, [r.id, pid, r.seq, r.region_id !== null && regions.has(r.region_id) ? r.region_id : null]);
100
+ for (const r of snap.region)
101
+ await c.query('update meta.region set seq = $3, columns = $4, position = case when $5 then $6 else position end where id = $1 and page_id = $2', [r.id, pid, r.seq, r.columns, 'position' in r, r.position ?? null]);
102
+ for (const r of snap.item)
103
+ await c.query('update meta.item set seq = $3, region_id = $4 where id = $1 and page_id = $2', [r.id, pid, r.seq, r.region_id !== null && regions.has(r.region_id) ? r.region_id : null]);
104
+ for (const r of snap.button)
105
+ await c.query('update meta.button set seq = $3, region_id = $4, slot = case when $5 then $6 else slot end where id = $1 and page_id = $2',
106
+ [r.id, pid, r.seq, r.region_id !== null && regions.has(r.region_id) ? r.region_id : null, 'slot' in r, r.slot ?? null]);
90
107
  }
91
108
 
92
109
  function history(s: Session, pid: number): History {
@@ -128,17 +145,29 @@ async function targetRegion(c: Q, pid: number, value: string): Promise<number |
128
145
  /**
129
146
  * Moves a region, item or button. `dir` moves it one place up or down among
130
147
  * its neighbours; otherwise it goes before `before` (an id in the target
131
- * container, or '' for the end) in `region` (items and buttons; '' = page level).
148
+ * container, or '' for the end) in `region` (items and buttons; '' = page level),
149
+ * `position` (regions: a display position, '' = body) and `slot` (buttons: a
150
+ * button position, '' = with the region's other buttons).
132
151
  * Sequences in the target container are renumbered 10, 20, 30…
133
152
  */
134
- export async function moveComponent(c: Q, pid: number, kind: LayoutKind, id: number, o: { dir?: string; region?: string; before?: string }) {
153
+ export async function moveComponent(c: Q, pid: number, kind: LayoutKind, id: number, o: { dir?: string; region?: string; before?: string; position?: string; slot?: string }) {
135
154
  const table = COMPONENTS[kind].table;
136
155
  const row = (await c.query(`select * from ${table} where id = $1 and page_id = $2`, [id, pid])).rows[0];
137
156
  if (!row) throw new LayoutError(404, `${COMPONENTS[kind].label} not found on this page.`);
138
157
  let region: number | null = kind === 'region' ? null : row.region_id;
139
158
  if (kind !== 'region' && o.region !== undefined && !o.dir) region = await targetRegion(c, pid, o.region);
140
- const scope = kind === 'region' ? '' : ' and region_id is not distinct from $2';
141
- const list: number[] = (await c.query(`select id from ${table} where page_id = $1${scope} order by seq, id`, kind === 'region' ? [pid] : [pid, region])).rows.map((r) => r.id);
159
+ // the container: a region's display position; a button's region and position in it (page-level buttons have none)
160
+ let place: string = kind === 'region' ? (row.position ?? 'body') : kind === 'button' && region !== null ? (row.slot ?? 'region') : '';
161
+ if (kind === 'region' && o.position !== undefined && !o.dir) place = o.position || 'body';
162
+ if (kind === 'button' && o.slot !== undefined && !o.dir) place = o.slot || 'region';
163
+ if (kind === 'button' && region === null) place = '';
164
+ if (kind === 'region' && !POSITIONS().includes(place)) throw new LayoutError(400, 'Unknown position.');
165
+ if (kind === 'button' && place && !SLOTS().includes(place)) throw new LayoutError(400, 'Unknown button position.');
166
+ const scope = kind === 'region' ? " and coalesce(position, 'body') = $2"
167
+ : kind === 'button' ? " and region_id is not distinct from $2 and (region_id is null or coalesce(slot, 'region') = $3)"
168
+ : ' and region_id is not distinct from $2';
169
+ const params = kind === 'region' ? [pid, place] : kind === 'button' ? [pid, region, place] : [pid, region];
170
+ const list: number[] = (await c.query(`select id from ${table} where page_id = $1${scope} order by seq, id`, params)).rows.map((r) => r.id);
142
171
  const others = list.filter((x) => x !== id);
143
172
  let at: number;
144
173
  if (o.dir === 'up' || o.dir === 'down') {
@@ -154,8 +183,10 @@ export async function moveComponent(c: Q, pid: number, kind: LayoutKind, id: num
154
183
  if (at < 0) throw new LayoutError(400, 'The place to move to is not in that region.');
155
184
  }
156
185
  others.splice(at, 0, id);
186
+ const stored = place === 'body' || place === 'region' || place === '' ? null : place;
157
187
  for (const [i, x] of others.entries()) {
158
- if (kind === 'region') await c.query(`update ${table} set seq = $3 where id = $1 and page_id = $2`, [x, pid, (i + 1) * 10]);
188
+ if (kind === 'region') await c.query(`update ${table} set seq = $3, position = case when id = $4 then $5::text else position end where id = $1 and page_id = $2`, [x, pid, (i + 1) * 10, id, stored]);
189
+ else if (kind === 'button') await c.query(`update ${table} set seq = $3, region_id = case when id = $4 then $5::int else region_id end, slot = case when id = $4 then $6::text else slot end where id = $1 and page_id = $2`, [x, pid, (i + 1) * 10, id, region, stored]);
159
190
  else await c.query(`update ${table} set seq = $3, region_id = case when id = $4 then $5::int else region_id end where id = $1 and page_id = $2`, [x, pid, (i + 1) * 10, id, region]);
160
191
  }
161
192
  }
@@ -171,7 +202,7 @@ export async function spanRegion(c: Q, pid: number, id: number, o: { columns?: s
171
202
  }
172
203
 
173
204
  /** A new component from the gallery, with a working default; placed like moveComponent. Returns its id. */
174
- export async function createComponent(c: Q, pid: number, kind: LayoutKind, type: string, o: { region?: string; before?: string }) {
205
+ export async function createComponent(c: Q, pid: number, kind: LayoutKind, type: string, o: { region?: string; before?: string; position?: string; slot?: string }) {
175
206
  const page = (await c.query('select page_no from meta.page where id = $1', [pid])).rows[0];
176
207
  if (!page) throw new LayoutError(404, 'Page not found.');
177
208
  let id: number;
@@ -196,10 +227,62 @@ export async function createComponent(c: Q, pid: number, kind: LayoutKind, type:
196
227
  id = (await c.query(`insert into meta.button (page_id, region_id, seq, name, label, action) values ($1, $2, 999999, $3, 'New button', $4) returning id`, [pid, region, name, type])).rows[0].id;
197
228
  }
198
229
  }
199
- await moveComponent(c, pid, kind, id, { region: o.region ?? '', before: o.before ?? '' });
230
+ await moveComponent(c, pid, kind, id, { region: o.region ?? '', before: o.before ?? '', position: o.position ?? '', slot: o.slot ?? '' });
200
231
  return id;
201
232
  }
202
233
 
234
+ /** A copy of a row of a page component table with some columns changed; returns its id. */
235
+ async function copyRow(c: Q, kind: LayoutKind, id: number, over: Record<string, unknown>): Promise<number> {
236
+ const table = COMPONENTS[kind].table;
237
+ return (await c.query(
238
+ `insert into ${table} select (jsonb_populate_record(null::${table}, to_jsonb(x) || jsonb_build_object('id', nextval('${table}_id_seq')) || $2::jsonb)).* from ${table} x where id = $1 returning id`,
239
+ [id, JSON.stringify(over)],
240
+ )).rows[0].id;
241
+ }
242
+
243
+ /** NAME, or NAME_2, NAME_3… when the page has an item (or button) of that name already. */
244
+ async function freeName(c: Q, kind: 'item' | 'button', pid: number, name: string) {
245
+ const taken = new Set((await c.query(`select name from ${COMPONENTS[kind].table} where page_id = $1`, [pid])).rows.map((r) => r.name));
246
+ let out = name;
247
+ for (let i = 2; taken.has(out); i++) out = `${name}_${i}`;
248
+ return out;
249
+ }
250
+
251
+ /**
252
+ * (sprint 77, APEX: Duplicate and Copy to other Page in the rendering tree's menu) A copy of a region (with its
253
+ * items and buttons), an item or a button: on the same page right after the original, or at the end of another
254
+ * page of the same application (items named P<n>_ get that page's number; components of a region go to page level
255
+ * there). Item and button names are made unique on the page. Returns the copy's id.
256
+ */
257
+ export async function duplicateComponent(c: Q, pid: number, kind: LayoutKind, id: number, toPid: number = pid) {
258
+ const table = COMPONENTS[kind].table;
259
+ const row = (await c.query(`select * from ${table} where id = $1 and page_id = $2`, [id, pid])).rows[0];
260
+ if (!row) throw new LayoutError(404, `${COMPONENTS[kind].label} not found on this page.`);
261
+ const pages = (await c.query('select t.page_no from meta.page t join meta.page f on f.app_id = t.app_id where t.id = $1 and f.id = $2', [toPid, pid])).rows;
262
+ if (!pages.length) throw new LayoutError(400, 'That page is not in this application.');
263
+ const same = toPid === pid;
264
+ const rename = (name: string) => (same ? name : name.replace(/^P\d+_/, `P${pages[0].page_no}_`));
265
+ const copyChild = async (k: 'item' | 'button', x: any, regionId: number | null) =>
266
+ copyRow(c, k, x.id, { page_id: toPid, region_id: regionId, name: await freeName(c, k, toPid, rename(x.name)), ...(k === 'button' && regionId === null ? { slot: null } : {}) });
267
+ // where the copy goes: after the original in its container, or at the end of the other page
268
+ let place: { region?: string; position?: string; slot?: string; before: string } = { before: '' };
269
+ if (same) {
270
+ const scope = kind === 'region' ? " and coalesce(position, 'body') = coalesce($2, 'body')" : ' and region_id is not distinct from $2';
271
+ const list: number[] = (await c.query(`select id from ${table} where page_id = $1${scope} order by seq, id`, [pid, kind === 'region' ? row.position : row.region_id])).rows.map((r) => r.id);
272
+ const next = list[list.indexOf(id) + 1];
273
+ place = { region: String(row.region_id ?? ''), position: row.position ?? '', slot: row.slot ?? '', before: next ? String(next) : '' };
274
+ } else place = { region: '', position: row.position ?? '', slot: '', before: '' };
275
+ let copy: number;
276
+ if (kind === 'region') {
277
+ const staticId = row.static_id && !same && !(await c.query('select 1 from meta.region where page_id = $1 and static_id = $2', [toPid, row.static_id])).rowCount ? row.static_id : null;
278
+ copy = await copyRow(c, 'region', id, { page_id: toPid, static_id: staticId });
279
+ for (const k of ['item', 'button'] as const)
280
+ for (const x of (await c.query(`select id, name from ${COMPONENTS[k].table} where region_id = $1 order by seq, id`, [id])).rows) await copyChild(k, x, copy);
281
+ } else copy = await copyChild(kind, row, same ? row.region_id : null);
282
+ await moveComponent(c, toPid, kind, copy, place);
283
+ return copy;
284
+ }
285
+
203
286
  // ---------------------------------------------------------------- routes
204
287
 
205
288
  const wantsJson = (req: Req) => String(req.headers.accept ?? '').includes('application/json');
@@ -245,7 +328,7 @@ export async function arrangeRoutes(app: FastifyInstance) {
245
328
  const kind = b.kind;
246
329
  const id = int(b.id);
247
330
  if (!isKind(kind) || id === null) throw new LayoutError(400, 'Unknown component.');
248
- await recorded(s, pid, `Move ${await describe(kind, id)}`, (c) => moveComponent(c, pid, kind, id, { dir: b.dir, region: b.region, before: b.before }));
331
+ await recorded(s, pid, `Move ${await describe(kind, id)}`, (c) => moveComponent(c, pid, kind, id, { dir: b.dir, region: b.region, before: b.before, position: b.position, slot: b.slot }));
249
332
  return { sel: `${kind}-${id}`, message: `${COMPONENTS[kind].label} moved.` };
250
333
  });
251
334
 
@@ -262,11 +345,44 @@ export async function arrangeRoutes(app: FastifyInstance) {
262
345
  route('create', async (s, pid, b) => {
263
346
  const kind = b.kind;
264
347
  if (!isKind(kind)) throw new LayoutError(400, 'Unknown component type.');
265
- const id = await owner.tx((c) => createComponent(c, pid, kind, b.type ?? '', { region: b.region, before: b.before }));
348
+ const id = await owner.tx((c) => createComponent(c, pid, kind, b.type ?? '', { region: b.region, before: b.before, position: b.position, slot: b.slot }));
266
349
  keep(s, { pid, undo: [], redo: [] }); // earlier layout steps no longer line up with the new component
267
350
  return { sel: `${kind}-${id}`, message: `${COMPONENTS[kind].label} created; set its properties on the right.` };
268
351
  });
269
352
 
353
+ // (sprint 77) the rendering tree's menu: Duplicate, Copy to other Page
354
+ route('duplicate', async (s, pid, b) => {
355
+ const kind = b.kind;
356
+ const id = int(b.id);
357
+ if (!isKind(kind) || id === null) throw new LayoutError(400, 'Unknown component.');
358
+ const copy = await owner.tx((c) => duplicateComponent(c, pid, kind, id));
359
+ keep(s, { pid, undo: [], redo: [] });
360
+ return { sel: `${kind}-${copy}`, message: `${COMPONENTS[kind].label} duplicated.` };
361
+ });
362
+
363
+ app.post(`${BASE}/pages/:pid/layout/copy`, async (req: Req, reply) => {
364
+ const s = await developer(req, reply);
365
+ if (!s) return;
366
+ const b = (req.body ?? {}) as Record<string, string | undefined>;
367
+ const pid = int(req.params.pid);
368
+ const to = int(b.page);
369
+ const kind = b.kind;
370
+ const id = int(b.id);
371
+ try {
372
+ if (pid === null || !(await owner.one('select 1 as ok from meta.page where id = $1', [pid]))) throw new LayoutError(404, 'Page not found');
373
+ if (!isKind(kind) || id === null || to === null) throw new LayoutError(400, 'Unknown component or page.');
374
+ const copy = await owner.tx((c) => duplicateComponent(c, pid, kind, id, to));
375
+ if (to === pid) keep(s, { pid, undo: [], redo: [] });
376
+ flash(s, `${COMPONENTS[kind].label} copied.`);
377
+ await saveState(s);
378
+ const location = `${BASE}/pages/${to}?c=${kind}-${copy}`;
379
+ return wantsJson(req) ? reply.send({ ok: true, location }) : reply.redirect(location, 303);
380
+ } catch (e) {
381
+ if (!(e instanceof LayoutError)) throw e;
382
+ return wantsJson(req) ? reply.code(e.status).send({ ok: false, error: e.message }) : reply.code(e.status).type('text/plain').send(e.message);
383
+ }
384
+ });
385
+
270
386
  for (const op of ['undo', 'redo'] as const)
271
387
  route(op, async (s, pid) => {
272
388
  const h = history(s, pid);
@@ -51,6 +51,8 @@ export interface Field {
51
51
  group?: string;
52
52
  /** shown, not editable (the form still posts it) */
53
53
  readonly?: boolean;
54
+ /** (sprint 77) shown with the property editor's "Common" toggle on (a spec without any shows every field) */
55
+ common?: boolean;
54
56
  /** (sprint 51) help that depends on the component being edited (wins over `help` when it gives some) */
55
57
  helpBy?: (row: any) => { text: string; url: string | null } | null;
56
58
  }
@@ -101,24 +103,26 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
101
103
  defaults: { type: 'report', columns: 12, template: 'standard' },
102
104
  validate: (v) => formatSettingsProblem(v.config) ?? (v.type === 'file' ? objectStoreProblem((v.config as Record<string, unknown> | null)?.object_store) : null),
103
105
  fields: [
104
- { name: 'title', label: 'Title', kind: 'text', group: 'Identification' },
106
+ { name: 'title', common: true, label: 'Title', kind: 'text', group: 'Identification' },
105
107
  { name: 'static_id', label: 'Static ID', kind: 'text', group: 'Identification',
106
108
  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.' },
107
- { name: 'type', 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', 'ai_assistant', 'plugin', 'url', 'search'], group: 'Identification' },
108
- { name: 'source', label: 'Source', kind: 'code', wide: true, group: 'Source',
109
+ { 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', 'ai_assistant', 'plugin', 'url', 'search'], group: 'Identification' },
110
+ { name: 'source', common: true, label: 'Source', kind: 'code', wide: true, group: 'Source',
109
111
  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).' },
110
112
  { name: 'rest_source', label: 'REST data source', kind: 'rest_source', group: 'Source',
111
113
  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.' },
112
- { name: 'table_name', label: 'Table (form, grid)', kind: 'text', help: 'e.g. sales.orders', group: 'Source' },
114
+ { name: 'table_name', common: true, label: 'Table (form, grid)', kind: 'text', help: 'e.g. sales.orders', group: 'Source' },
113
115
  { name: 'pk_column', label: 'Primary key column (form, grid)', kind: 'text', group: 'Source' },
114
116
  { name: 'pk_item', label: 'Primary key item (form)', kind: 'upper', group: 'Source' },
115
117
  { name: 'seq', label: 'Sequence', kind: 'int', group: 'Layout' },
116
- { name: 'columns', label: 'Column span (1-12)', kind: 'int', group: 'Layout' },
117
- { name: 'template', label: 'Template', kind: 'select', options: ['standard', 'plain', 'collapsible', 'hero', 'title_bar', 'buttons_container', 'inline_dialog', 'inline_drawer', 'wizard'], group: 'Layout', help: 'hero: a large banner (title, text, {"icon":"…"} in the attributes, buttons). title_bar: title and buttons on one line. buttons_container: the buttons only. inline_dialog, inline_drawer: hidden until a dynamic action Open region (or a link to #R<id>) opens it over the page. wizard: a narrow card for one step (a list region with template wizard_progress shows the steps).' },
118
+ { name: 'columns', common: true, label: 'Column span (1-12)', kind: 'int', group: 'Layout' },
119
+ { name: 'template', common: true, label: 'Template', kind: 'select', options: ['standard', 'plain', 'collapsible', 'hero', 'title_bar', 'buttons_container', 'inline_dialog', 'inline_drawer', 'wizard'], group: 'Layout', help: 'hero: a large banner (title, text, {"icon":"…"} in the attributes, buttons). title_bar: title and buttons on one line. buttons_container: the buttons only. inline_dialog, inline_drawer: hidden until a dynamic action Open region (or a link to #R<id>) opens it over the page. wizard: a narrow card for one step (a list region with template wizard_progress shows the steps).' },
120
+ { name: 'position', common: true, label: 'Position', kind: 'select', options: ['', 'banner', 'breadcrumb', 'left', 'body', 'right', 'footer'], group: 'Layout',
121
+ help: 'Where the region shows (APEX: Layout → Position). banner: above everything else in the page; breadcrumb: in the bar under the title; left / right: in the side column of a page with template Left or Right side column (on other pages in the body); footer: after the body. Empty is body.' },
118
122
  { name: 'template_options', label: 'Template options', kind: 'options', choices: REGION_OPTIONS, group: 'Appearance',
119
123
  help: 'CSS classes from a fixed list, added to the region (APEX: Template Options). "Hide the header" applies to the standard template.' },
120
- { name: 'condition', label: 'Server-side condition (SQL)', kind: 'code', group: 'Security', help: 'Boolean expression; the region renders only when true.' },
121
- { name: 'authz', label: 'Authorization', kind: 'authz', group: 'Security', help: AUTHZ_HELP },
124
+ { name: 'condition', common: true, label: 'Server-side condition (SQL)', kind: 'code', group: 'Security', help: 'Boolean expression; the region renders only when true.' },
125
+ { name: 'authz', common: true, label: 'Authorization', kind: 'authz', group: 'Security', help: AUTHZ_HELP },
122
126
  buildOption('Security'),
123
127
  { name: 'config', label: 'Attributes (JSON)', kind: 'json', wide: true, group: 'Attributes',
124
128
  help: ALL_REGION_CONFIG_HELP, helpBy: (row: any) => regionConfigHelp(row?.type) },
@@ -134,17 +138,17 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
134
138
  defaults: { type: 'text' },
135
139
  validate: (v) => formatSettingsProblem(v.config),
136
140
  fields: [
137
- { name: 'name', label: 'Name', kind: 'upper', help: 'Referenced in SQL as :NAME, e.g. P3_NAME', group: 'Identification' },
138
- { name: 'type', 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' },
139
- { name: 'label', label: 'Label', kind: 'text', group: 'Identification' },
140
- { name: 'region_id', label: 'Region', kind: 'region', group: 'Layout' },
141
+ { name: 'name', common: true, label: 'Name', kind: 'upper', help: 'Referenced in SQL as :NAME, e.g. P3_NAME', group: 'Identification' },
142
+ { 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' },
143
+ { name: 'label', common: true, label: 'Label', kind: 'text', group: 'Identification' },
144
+ { name: 'region_id', common: true, label: 'Region', kind: 'region', group: 'Layout' },
141
145
  { name: 'seq', label: 'Sequence', kind: 'int', group: 'Layout' },
142
- { name: 'source_column', label: 'Source column (form)', kind: 'text', group: 'Source' },
143
- { name: 'default_value', label: 'Default value', kind: 'text', group: 'Source' },
144
- { name: 'lov', 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, \':\')' },
146
+ { name: 'source_column', common: true, label: 'Source column (form)', kind: 'text', group: 'Source' },
147
+ { name: 'default_value', common: true, label: 'Default value', kind: 'text', group: 'Source' },
148
+ { 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, \':\')' },
145
149
  { name: 'template_options', label: 'Template options', kind: 'options', choices: ITEM_OPTIONS, group: 'Appearance',
146
150
  help: 'CSS classes from a fixed list, added to the item (APEX: Template Options).' },
147
- { name: 'required', label: 'Value required', kind: 'bool', group: 'Validation' },
151
+ { name: 'required', common: true, label: 'Value required', kind: 'bool', group: 'Validation' },
148
152
  { name: 'help', label: 'Help text', kind: 'text', group: 'Validation' },
149
153
  { 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.' },
150
154
  { name: 'authz', label: 'Authorization', kind: 'authz', group: 'Security', help: AUTHZ_HELP },
@@ -162,21 +166,23 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
162
166
  defaults: { action: 'submit' },
163
167
  beforeSave: menuBeforeSave,
164
168
  fields: [
165
- { name: 'name', label: 'Name (request)', kind: 'upper', group: 'Identification' },
166
- { name: 'label', label: 'Label', kind: 'text', group: 'Identification' },
167
- { name: 'action', label: 'Action', kind: 'select', options: ['submit', 'redirect', 'da', 'document', 'menu'], group: 'Behaviour', help: 'menu = a menu of links and submit requests (Menu entries) · da = "Defined by dynamic action"; document = download a document template (filled with the page\'s values as last loaded or saved)' },
169
+ { name: 'name', common: true, label: 'Name (request)', kind: 'upper', group: 'Identification' },
170
+ { name: 'label', common: true, label: 'Label', kind: 'text', group: 'Identification' },
171
+ { name: 'action', common: true, label: 'Action', kind: 'select', options: ['submit', 'redirect', 'da', 'document', 'menu'], group: 'Behaviour', help: 'menu = a menu of links and submit requests (Menu entries) · da = "Defined by dynamic action"; document = download a document template (filled with the page\'s values as last loaded or saved)' },
168
172
  { name: 'document', label: 'Document template (action document)', kind: 'upper', group: 'Behaviour', help: 'The name of a document template (Shared Components → Document templates).' },
169
- { name: 'target_page', label: 'Target / branch page', kind: 'page', group: 'Behaviour' },
173
+ { name: 'target_page', common: true, label: 'Target / branch page', kind: 'page', group: 'Behaviour' },
170
174
  { name: 'target_items', label: 'Set items (JSON)', kind: 'json', group: 'Behaviour', help: '{"P3_ID": "&P2_ID."}' },
171
175
  { name: 'confirm', label: 'Confirm message', kind: 'text', group: 'Behaviour' },
172
176
  { name: 'menu', label: 'Menu entries (action menu, JSON)', kind: 'json', wide: true, group: 'Behaviour',
173
177
  help: '[{"label": "Details", "page": 3, "items": {"P3_ID": "&P2_ID."}}, {"label": "Archive", "request": "ARCHIVE", "confirm": "Archive it?", "authz": "ADMIN", "icon": "inbox"}] — a link to a page, or a submit with that request (processes and branches see it as the button pressed). At most 20.' },
174
- { name: 'hot', label: 'Primary (hot) button', kind: 'bool', group: 'Appearance' },
178
+ { name: 'hot', common: true, label: 'Primary (hot) button', kind: 'bool', group: 'Appearance' },
175
179
  { name: 'template_options', label: 'Template options', kind: 'options', choices: BUTTON_OPTIONS, group: 'Appearance',
176
180
  help: 'CSS classes from a fixed list, added to the button (APEX: Template Options).' },
177
181
  { name: 'badge', label: 'Badge', kind: 'text', group: 'Appearance', help: 'A short value shown on the button, e.g. &P2_OPEN_COUNT. (empty value: no badge).' },
178
182
  { name: 'badge_query', label: 'Badge (SQL)', kind: 'code', group: 'Appearance', help: 'Instead: a SELECT whose first value is the badge, e.g. select count(*) from sales.orders where status = \'OPEN\'. Runs as the application\'s role.' },
179
- { name: 'region_id', label: 'Region', kind: 'region', group: 'Layout' },
183
+ { name: 'region_id', common: true, label: 'Region', kind: 'region', group: 'Layout' },
184
+ { name: 'slot', common: true, label: 'Button position', kind: 'select', options: ['', 'region', 'create', 'edit', 'change', 'copy', 'help', 'up', 'previous', 'close', 'delete', 'next'], group: 'Layout',
185
+ help: 'A region button\'s place (APEX: Button Position): create, edit, change, copy, help and up in the region\'s header; previous, close and delete at the left of its bottom bar, next at the right. Empty or region: with its other buttons (the header of reports and charts, the bottom of forms and static content).' },
180
186
  { name: 'seq', label: 'Sequence', kind: 'int', group: 'Layout' },
181
187
  { name: 'condition', label: 'Server-side condition (SQL)', kind: 'code', group: 'Security', help: 'e.g. :P3_ID is not null — also re-checked when the button is pressed.' },
182
188
  { name: 'authz', label: 'Authorization', kind: 'authz', group: 'Security', help: AUTHZ_HELP },
@@ -11,7 +11,7 @@ import { regionSettingsForm } from './region-settings.ts';
11
11
  import { usedInPanel } from './search.ts';
12
12
  import { processJobsPanel } from './process-jobs.ts';
13
13
  import { appLocks, lockPanel, lockText } from './locks.ts';
14
- import { arrangeRoutes, BUTTON_ACTIONS, BUTTON_LABELS, ITEM_LABELS, ITEM_TYPES, REGION_LABELS, REGION_TYPES, undoState } from './arrange.ts';
14
+ import { arrangeRoutes, BOTTOM_SLOTS, BUTTON_ACTIONS, BUTTON_LABELS, ITEM_LABELS, ITEM_TYPES, POSITION_LABELS, POSITIONS, REGION_LABELS, REGION_TYPES, SLOT_LABELS, TOP_SLOTS, undoState } from './arrange.ts';
15
15
 
16
16
  // Page designer, laid out like APEX's Page Designer: the component tree on
17
17
  // the left (Rendering / Dynamic actions / Processing / Shared components),
@@ -80,19 +80,30 @@ export async function designerRoutes(app: FastifyInstance) {
80
80
  const regionTitle = (r: any) => r.title ?? `(${r.type})`;
81
81
 
82
82
  // ------------------------------------------------------------ left: component tree
83
+ // (sprint 77) data-kind and data-id: the node's context menu (Duplicate, Copy to other page, Delete…) and icon colour
83
84
  const leaf = (k: string, r: any, ic: Raw | '', label: string, note = '') =>
84
- html`<li><a class="pd-node" href="${url(`c=${k}-${r.id}`)}"${cur(`${k}-${r.id}`)}>${ic}<span class="pd-label">${label}</span>${badges(r)}${note ? html`<span class="pd-note">${note}</span>` : ''}</a></li>`;
85
+ html`<li><a class="pd-node" href="${url(`c=${k}-${r.id}`)}"${cur(`${k}-${r.id}`)} data-kind="${k}" data-id="${r.id}">${ic}<span class="pd-label">${label}</span>${badges(r)}${note ? html`<span class="pd-note">${note}</span>` : ''}</a></li>`;
85
86
  const folder = (label: string, children: Raw[] | Raw, open = true) =>
86
87
  html`<li${open ? '' : raw(' data-collapsed')}><span class="pd-node pd-folder"><span class="pd-label">${label}</span></span><ul>${children}</ul></li>`;
87
88
  const none = (text: string) => html`<li class="pd-empty">${text}</li>`;
88
89
  const itemLeaf = (i: any) => leaf('item', i, bicon(itemIcon(i.type)), i.name, i.type);
89
90
  const buttonLeaf = (b: any) => leaf('button', b, bicon('button'), b.name, b.action);
90
- const regionLeaf = (r: any) => {
91
+ const regionLeaf = (r: any, next: any) => {
91
92
  const items = rows.item.filter((i) => i.region_id === r.id);
92
93
  const buttons = rows.button.filter((b) => b.region_id === r.id);
93
- const kids = [...(items.length ? [folder('Items', items.map(itemLeaf))] : []), ...(buttons.length ? [folder('Buttons', buttons.map(buttonLeaf))] : [])];
94
- return html`<li><a class="pd-node" href="${url(`c=region-${r.id}`)}"${cur(`region-${r.id}`)}>${bicon(regionIcon(r.type))}<span class="pd-label">${regionTitle(r)}</span>${badges(r)}<span class="pd-note">${r.type}</span></a>${kids.length ? html`<ul>${kids}</ul>` : ''}</li>`;
94
+ // (sprint 77, APEX: region slots) buttons by their position in the region
95
+ const bySlot = ['region', ...TOP_SLOTS, ...BOTTOM_SLOTS].map((sl) => [sl, buttons.filter((b) => (b.slot ?? 'region') === sl)] as const).filter(([, list]) => list.length);
96
+ const kids = [...(items.length ? [folder('Items', items.map(itemLeaf))] : []),
97
+ ...bySlot.map(([sl, list]) => folder(sl === 'region' ? 'Buttons' : `Buttons: ${SLOT_LABELS[sl]}`, list.map(buttonLeaf)))];
98
+ return html`<li><a class="pd-node" href="${url(`c=region-${r.id}`)}"${cur(`region-${r.id}`)} data-kind="region" data-id="${r.id}" data-position="${r.position ?? ''}" data-next="${next?.id ?? ''}">${bicon(regionIcon(r.type))}<span class="pd-label">${regionTitle(r)}</span>${badges(r)}<span class="pd-note">${r.type}</span></a>${kids.length ? html`<ul>${kids}</ul>` : ''}</li>`;
95
99
  };
100
+ // (sprint 77, APEX: the rendering tree by display position) the page template's positions, and others in use
101
+ const treeSide = p.page_template === 'left_side_column' ? 'left' : p.page_template === 'right_side_column' ? 'right' : null;
102
+ const regionFolders = ['banner', 'breadcrumb', 'left', 'body', 'right', 'footer'].map((pos) => {
103
+ const list = rows.region.filter((r) => (r.position ?? 'body') === pos);
104
+ if (!list.length && pos !== 'body' && pos !== treeSide) return '';
105
+ return folder(POSITION_LABELS[pos], list.length ? list.map((r, i) => regionLeaf(r, list[i + 1])) : none(pos === 'body' ? 'No regions yet' : 'No regions'), list.length > 0);
106
+ });
96
107
  const pageItems = rows.item.filter((i) => i.region_id === null);
97
108
  const pageButtons = rows.button.filter((b) => b.region_id === null);
98
109
  const loadProcs = rows.process.filter((x) => x.point === 'load');
@@ -108,15 +119,25 @@ export async function designerRoutes(app: FastifyInstance) {
108
119
  html`<div class="pd-treebar">${links.map(([k, label]) => html`<a class="tb-btn tb-text" href="${url(`new=${k}`)}">${icon('plus')}<span>${label}</span></a>`)}</div>`;
109
120
 
110
121
  const rendering = html`${createBar([['region', 'Region'], ['item', 'Item'], ['button', 'Button']])}
111
- <ul class="pd-tree" aria-label="Rendering">
122
+ <ul class="pd-tree" aria-label="Rendering" data-tree-menu data-page-id="${p.id}">
112
123
  <li><a class="pd-node" href="${url('c=page')}"${cur('page')}>${icon('file')}<span class="pd-label">Page ${p.page_no}: ${p.name}</span>${badges(p)}</a>
113
124
  <ul>
114
125
  ${folder('Pre-Rendering (before header)', preRendering.length ? preRendering : none('No branches, computations or processes before rendering'), preRendering.length > 0)}
115
- ${folder('Regions', rows.region.length ? rows.region.map(regionLeaf) : none('No regions yet'))}
126
+ ${regionFolders}
116
127
  ${pageItems.length ? folder('Page items', pageItems.map(itemLeaf)) : ''}
117
128
  ${pageButtons.length ? folder('Page buttons', pageButtons.map(buttonLeaf)) : ''}
118
129
  </ul></li>
119
- </ul>`;
130
+ </ul>
131
+ <dialog class="pd-dialog" id="pd-copy-dialog" aria-labelledby="pd-copy-title">
132
+ <form method="post" action="${url('')}/layout/copy">${csrf(s)}<input type="hidden" name="kind" value=""><input type="hidden" name="id" value="">
133
+ <h2 id="pd-copy-title">Copy to other page</h2>
134
+ <p class="muted" data-copy-what></p>
135
+ <div class="field"><label class="label" for="pd-copy-page">Page</label>
136
+ <select id="pd-copy-page" name="page">${pages.map((x) => html`<option value="${x.id}"${x.id === p.id ? raw(' selected') : ''}>${x.page_no}. ${x.name}</option>`)}</select>
137
+ <small class="help">Items named P${p.page_no}_… get the other page's number; a region's items and buttons come along. References in SQL and conditions are not changed.</small></div>
138
+ <div class="pd-dialog-buttons"><button type="button" class="btn" data-dialog-close>Cancel</button><button class="btn btn-primary">Copy</button></div>
139
+ </form>
140
+ </dialog>`;
120
141
  const events: [string, string][] = [['change', 'Change'], ['click', 'Click'], ['load', 'Page load']];
121
142
  const dynamicActions = html`${createBar([['dynamic_action', 'Dynamic action']])}
122
143
  <ul class="pd-tree" aria-label="Dynamic actions">
@@ -162,8 +183,15 @@ export async function designerRoutes(app: FastifyInstance) {
162
183
  // ------------------------------------------------------------ center: layout + gallery
163
184
  const chip = (k: 'item' | 'button', r: any) =>
164
185
  html`<a class="pd-chip pd-chip-${k}${isSel(`${k}-${r.id}`) ? ' is-selected' : ''}${k === 'button' && r.hot ? ' is-hot' : ''}" href="${url(`c=${k}-${r.id}`)}" draggable="true" data-kind="${k}" data-id="${r.id}"${cur(`${k}-${r.id}`)} aria-describedby="pd-kbd">${k === 'item' ? bicon(itemIcon(r.type)) : ''}<span>${r.label || r.name}</span>${k === 'item' ? html`<small>${r.name}</small>` : ''}</a>`;
165
- const slot = (k: 'item' | 'button', regionId: number | null, list: any[]) =>
166
- html`<div class="pd-slot pd-slot-${k}" data-drop="${k}" data-region="${regionId ?? ''}">${list.length ? list.map((r) => chip(k, r)) : html`<span class="pd-slot-hint">${k === 'item' ? 'Items' : 'Buttons'}</span>`}</div>`;
186
+ const slot = (k: 'item' | 'button', regionId: number | null, list: any[], buttonSlot = '') =>
187
+ html`<div class="pd-slot pd-slot-${k}${buttonSlot && buttonSlot !== 'region' && !list.length ? ' pd-slot-spare' : ''}" data-drop="${k}" data-region="${regionId ?? ''}"${buttonSlot ? raw(` data-slot="${buttonSlot}"`) : ''}${buttonSlot && buttonSlot !== 'region' ? raw(` title="Button position: ${SLOT_LABELS[buttonSlot]}"`) : ''}>${list.length ? list.map((r) => chip(k, r)) : html`<span class="pd-slot-hint">${k === 'item' ? 'Items' : buttonSlot && buttonSlot !== 'region' ? SLOT_LABELS[buttonSlot] : 'Buttons'}</span>`}</div>`;
188
+ // (sprint 77, APEX: region slots) a region's buttons by position: the header's, the region's own, the bottom bar's
189
+ const buttonSlots = (r: any) => {
190
+ const mine = rows.button.filter((b) => b.region_id === r.id);
191
+ const at = (sl: string) => mine.filter((b) => (b.slot ?? 'region') === sl);
192
+ const row = (list: string[], cls: string) => html`<div class="pd-slots ${cls}">${list.map((sl) => slot('button', r.id, at(sl), sl))}</div>`;
193
+ return html`${row(TOP_SLOTS, 'pd-slots-top')}${slot('button', r.id, at('region'), 'region')}${row(BOTTOM_SLOTS, 'pd-slots-bottom')}`;
194
+ };
167
195
  const block = (r: any) => html`<div class="pd-region pd-span-${r.columns}${isSel(`region-${r.id}`) ? ' is-selected' : ''}" data-kind="region" data-id="${r.id}" data-span="${r.columns}">
168
196
  <div class="pd-region-head" draggable="true" data-kind="region" data-id="${r.id}">
169
197
  <span class="pd-grip" title="Drag to move">${bicon('grip')}</span>
@@ -174,10 +202,29 @@ export async function designerRoutes(app: FastifyInstance) {
174
202
  <div class="pd-region-type">${REGION_LABELS[r.type]?.[0] ?? r.type}${r.template !== 'standard' ? ` · ${r.template}` : ''}</div>
175
203
  ${r.source || r.table_name ? html`<code class="pd-region-src">${firstLine(r.table_name ?? r.source)}</code>` : ''}
176
204
  ${slot('item', r.id, rows.item.filter((i) => i.region_id === r.id))}
177
- ${slot('button', r.id, rows.button.filter((b) => b.region_id === r.id))}
205
+ ${buttonSlots(r)}
178
206
  </div>
179
207
  <span class="pd-resize" title="Drag to change the column span" aria-hidden="true"></span>
180
208
  </div>`;
209
+ // (sprint 77, APEX: the page template's display positions) a drop zone per position of the page template, and
210
+ // for any other position that has regions (they show in the body when the page runs)
211
+ const side = p.page_template === 'left_side_column' ? 'left' : p.page_template === 'right_side_column' ? 'right' : null;
212
+ const byPos = (pos: string) => rows.region.filter((r) => (r.position ?? 'body') === pos);
213
+ const zone = (pos: string) => {
214
+ const list = byPos(pos);
215
+ const off = (pos === 'left' || pos === 'right') && pos !== side;
216
+ return html`<section class="pd-zone pd-zone-${pos}${list.length ? '' : ' is-empty'}" aria-label="${POSITION_LABELS[pos]}">
217
+ <div class="pd-zone-label">${POSITION_LABELS[pos]}${off ? html` <small>(not in this page template: shown in the body)</small>` : ''}</div>
218
+ <div class="pd-grid" data-drop="region" data-position="${pos}">
219
+ ${list.map(block)}
220
+ <div class="pd-grid-end" data-end><a href="${url(`new=region${pos === 'body' ? '' : `&position=${pos}`}`)}">${icon('plus')} Region${pos === 'body' ? '' : html`<span class="sr-only"> in ${POSITION_LABELS[pos]}</span>`}</a></div>
221
+ </div></section>`;
222
+ };
223
+ const extra = (pos: string) => pos !== side && byPos(pos).length > 0;
224
+ const zones = html`${zone('banner')}${zone('breadcrumb')}
225
+ <div class="pd-columns${side ? ` pd-columns-${side}` : ''}">
226
+ ${side === 'left' || extra('left') ? zone('left') : ''}${zone('body')}${side === 'right' || extra('right') ? zone('right') : ''}
227
+ </div>${zone('footer')}`;
181
228
  const gallery = (k: 'region' | 'item' | 'button', types: string[], labels: Record<string, [string, string]>) =>
182
229
  html`<ul class="pd-gallery-list" aria-label="${COMPONENTS[k].plural}">${types.map((t) => html`<li><a class="pd-gal" href="${url(`new=${k}&type=${t}`)}" draggable="true" data-new="${k}" data-type="${t}" title="Drag onto the layout, or open to create">${bicon(labels[t]?.[1] ?? k)}<span>${labels[t]?.[0] ?? t}</span></a></li>`)}</ul>`;
183
230
  const layout = html`
@@ -189,10 +236,7 @@ export async function designerRoutes(app: FastifyInstance) {
189
236
  </div>
190
237
  <div class="pd-canvas" id="pd-layout" data-page-id="${p.id}">
191
238
  <div class="pd-page-label">${icon('file')} Page ${p.page_no}: ${p.name}${p.mode === 'modal' ? ' (modal dialog)' : ''}</div>
192
- <div class="pd-grid" data-drop="region">
193
- ${rows.region.map(block)}
194
- <div class="pd-grid-end" data-end><a href="${url('new=region')}">${icon('plus')} Region</a></div>
195
- </div>
239
+ ${zones}
196
240
  <div class="pd-pagelevel">
197
241
  <div class="pd-pagelevel-title">Page level (no region)</div>
198
242
  ${slot('item', null, pageItems)}
@@ -256,7 +300,8 @@ export async function designerRoutes(app: FastifyInstance) {
256
300
  const lastSeq = Math.max(0, ...rows[newKind].map((r) => r.seq));
257
301
  const typeField = newKind === 'button' ? 'action' : 'type';
258
302
  const presetType = spec.fields.find((f) => f.name === typeField)?.options?.includes(req.query.type ?? '') ? { [typeField]: req.query.type } : {};
259
- const defaults = { seq: lastSeq + 10, ...spec.defaults, ...presetType, region_id: /^\d+$/.test(req.query.region ?? '') ? Number(req.query.region) : null };
303
+ const presetPlace = newKind === 'region' && POSITIONS().includes(req.query.position ?? '') ? { position: req.query.position } : {};
304
+ const defaults = { seq: lastSeq + 10, ...spec.defaults, ...presetType, ...presetPlace, region_id: /^\d+$/.test(req.query.region ?? '') ? Number(req.query.region) : null };
260
305
  heading = html`${icon(spec.icon)}<span>New ${spec.label.toLowerCase()}</span>`;
261
306
  props = peTabs([tab('pd-r-props', spec.label, componentForm(spec, newKind, defaults, lk, `${BASE}/pages/${p.id}/c/${newKind}`, s, `Create ${spec.label.toLowerCase()}`, { id: formId }), true)]);
262
307
  } else if (kind && kind !== 'page' && PAGE_KINDS.includes(kind)) {
@@ -303,7 +348,7 @@ export async function designerRoutes(app: FastifyInstance) {
303
348
  <fieldset class="prop-group"><legend>Appearance</legend><div class="form-grid">
304
349
  ${select('mode', 'Page mode', p.mode, [['normal', 'Normal'], ['modal', 'Modal dialog']])}
305
350
  ${select('page_group', 'Page group', p.page_group ?? '', [['', '- none -'], ...lk.pageGroups.map((g): [string, string] => [g, g]), ...(p.page_group && !lk.pageGroups.includes(p.page_group) ? [[p.page_group, `${p.page_group} (no such group)`] as [string, string]] : [])], 'Organises the pages in the App Builder (Shared Components → Page groups). Changes nothing when the application runs.')}
306
- ${select('page_template', 'Page template', p.page_template ?? 'standard', [['standard', 'Standard'], ['minimal', 'Minimal (no navigation)']], 'Minimal: no header and navigation menu, e.g. for a login page (Application settings → Login page).')}
351
+ ${select('page_template', 'Page template', p.page_template ?? 'standard', [['standard', 'Standard'], ['left_side_column', 'Left side column'], ['right_side_column', 'Right side column'], ['minimal', 'Minimal (no navigation)']], 'Side column: regions with position left (or right) show in a column beside the body (under it on narrow screens). Minimal: no header and navigation menu, e.g. for a login page (Application settings → Login page).')}
307
352
  ${select('dialog_position', 'Dialog position', p.dialog_position ?? 'center', [['center', 'Centred dialog'], ['right', 'Drawer from the right'], ['left', 'Drawer from the left'], ['top', 'Drawer from the top'], ['bottom', 'Drawer from the bottom']], 'Modal pages only. On phones dialogs and side drawers fill the screen.')}
308
353
  ${select('dialog_size', 'Dialog size', p.dialog_size ?? 'medium', [['small', 'Small'], ['medium', 'Medium'], ['large', 'Large']], 'The width (the height of top and bottom drawers).')}
309
354
  ${input('static_includes', 'JavaScript and CSS files', (p.static_includes ?? []).join(', '), { placeholder: 'e.g. page-3.js, page-3.css', help: 'Static application files (.js, .css) this page loads after the application\'s own, in this order.' })}
@@ -371,6 +416,10 @@ export async function designerRoutes(app: FastifyInstance) {
371
416
  const toolbar = html`
372
417
  <div class="tb-group pd-pagenav" role="group" aria-label="Page">
373
418
  ${prev ? html`<a class="tb-btn" href="${BASE}/pages/${prev.id}" title="Previous page: ${prev.page_no}. ${prev.name}">${bicon('left')}<span class="sr-only">Previous page</span></a>` : html`<span class="tb-btn" aria-disabled="true">${bicon('left')}</span>`}
419
+ <form method="get" action="${BASE}/apps/${p.app_id}/goto" class="tb-form pd-goto-no">
420
+ <label class="sr-only" for="pd-goto-no">Page number (Enter goes there)</label>
421
+ <input id="pd-goto-no" name="page" type="number" min="0" max="999999999" step="1" value="${p.page_no}" inputmode="numeric" title="Page number: type or step to a number, then Enter">
422
+ </form>
374
423
  <form method="get" action="${BASE}/apps/${p.app_id}/goto" class="tb-form pd-goto">
375
424
  <label class="sr-only" for="pd-goto">Go to page</label>
376
425
  <select id="pd-goto" name="page" data-autosubmit>${pages.map((x) => html`<option value="${x.page_no}"${x.id === p.id ? raw(' selected') : ''}>${x.page_no} · ${x.name}</option>`)}</select>
@@ -416,7 +465,7 @@ export async function designerRoutes(app: FastifyInstance) {
416
465
  `update meta.page set page_no = $2, name = $3, title = $4, requires_auth = $5, mode = $6, parent_page = $7, authz = $8, protection = $9, build_option = $10,
417
466
  dialog_position = $11, dialog_size = $12, static_includes = $13, warn_unsaved = $14, page_template = $15, page_group = $16 where id = $1`,
418
467
  [req.params.pid, Number(b.page_no), b.name?.trim(), b.title?.trim() || null, b.requires_auth === 'true', b.mode, b.parent_page ? Number(b.parent_page) : null, b.authz || null, b.protection, b.build_option?.trim().toUpperCase() || null,
419
- ['left', 'right', 'top', 'bottom'].includes(b.dialog_position ?? '') ? b.dialog_position : 'center', ['small', 'large'].includes(b.dialog_size ?? '') ? b.dialog_size : 'medium', parseIncludes(b.static_includes), b.warn_unsaved === 'true', b.page_template === 'minimal' ? 'minimal' : 'standard', b.page_group?.trim() || null],
468
+ ['left', 'right', 'top', 'bottom'].includes(b.dialog_position ?? '') ? b.dialog_position : 'center', ['small', 'large'].includes(b.dialog_size ?? '') ? b.dialog_size : 'medium', parseIncludes(b.static_includes), b.warn_unsaved === 'true', ['minimal', 'left_side_column', 'right_side_column'].includes(b.page_template ?? '') ? b.page_template : 'standard', b.page_group?.trim() || null],
420
469
  );
421
470
  // the page's lock and comments follow a new page number
422
471
  if (before && before.page_no !== Number(b.page_no))
@@ -164,9 +164,12 @@ export function componentForm(spec: ComponentSpec, kind: string, row: any, lk: L
164
164
  return html`<div class="field" data-prop="${f.name}"${f.wide ? raw(' data-wide') : ''}><label class="label" for="${id}">${f.label}</label>${control}${help}</div>`;
165
165
  };
166
166
  const groups = [...new Set(spec.fields.map((f) => f.group ?? ''))];
167
+ // (sprint 77, APEX: Show Common / Show All) the other properties are marked, for the property editor's toggle
168
+ const marked = spec.fields.some((f) => f.common);
169
+ const shown = (f: Field) => (marked && !f.common ? html`<div class="pe-more" data-more>${field(f)}</div>` : field(f));
167
170
  return html`<form method="post" action="${action}" class="component-form"${opts.id ? html` id="${opts.id}"` : ''}>
168
171
  ${csrf(s)}
169
- ${groups.map((g) => html`<fieldset class="prop-group">${g ? html`<legend>${g}</legend>` : ''}<div class="form-grid">${spec.fields.filter((f) => (f.group ?? '') === g).map(field)}</div></fieldset>`)}
172
+ ${groups.map((g) => html`<fieldset class="prop-group">${g ? html`<legend>${g}</legend>` : ''}<div class="form-grid">${spec.fields.filter((f) => (f.group ?? '') === g).map(shown)}</div></fieldset>`)}
170
173
  <div class="buttons"><button class="btn btn-hot">${submit}</button></div>
171
174
  </form>`;
172
175
  }