pgkiln 0.42.0 → 0.43.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/099_layout_positions.sql +29 -0
- package/docs/apex-feature-parity.md +34 -7
- package/docs/guide/03-builder.md +67 -13
- package/docs/guide/04-pages-and-regions.md +46 -3
- package/docs/guide/05-items.md +1 -1
- package/docs/guide/09-reference.md +4 -1
- package/docs/guide/12-development.md +2 -1
- package/package.json +1 -1
- package/public/app.css +58 -0
- package/public/app.js +183 -0
- package/public/builder.css +112 -0
- package/public/builder.js +280 -3
- package/src/builder/arrange.ts +133 -17
- package/src/builder/components.ts +28 -22
- package/src/builder/designer.ts +67 -18
- package/src/builder/forms.ts +4 -1
- package/src/builder/object-browser.ts +342 -0
- package/src/builder/pwa.ts +3 -3
- package/src/builder/routes.ts +85 -26
- package/src/builder/shared.ts +103 -1
- package/src/builder/sql.ts +13 -65
- package/src/builder/ui.ts +21 -6
- package/src/i18n/ar.ts +16 -0
- package/src/i18n/cs.ts +16 -0
- package/src/i18n/da.ts +16 -0
- package/src/i18n/de.ts +16 -0
- package/src/i18n/el.ts +16 -0
- package/src/i18n/es.ts +16 -0
- package/src/i18n/fi.ts +16 -0
- package/src/i18n/fr.ts +16 -0
- package/src/i18n/he.ts +16 -0
- package/src/i18n/it.ts +16 -0
- package/src/i18n/ja.ts +16 -0
- package/src/i18n/ko.ts +16 -0
- package/src/i18n/nb.ts +16 -0
- package/src/i18n/pl.ts +16 -0
- package/src/i18n/pt.ts +16 -0
- package/src/i18n/ru.ts +16 -0
- package/src/i18n/sv.ts +16 -0
- package/src/i18n/tr.ts +16 -0
- package/src/i18n/uk.ts +16 -0
- package/src/i18n/zh.ts +16 -0
- package/src/i18n.ts +32 -0
- package/src/metadata.ts +5 -1
- package/src/runtime/engine.ts +6 -0
- package/src/runtime/format.ts +52 -0
- package/src/runtime/items.ts +27 -0
- package/src/runtime/locale.ts +3 -0
- package/src/runtime/regions.ts +36 -2
- package/src/runtime/render.ts +20 -4
- package/src/runtime/report.ts +134 -79
- package/src/runtime/routes.ts +34 -3
package/src/builder/arrange.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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 },
|
package/src/builder/designer.ts
CHANGED
|
@@ -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
|
-
|
|
94
|
-
|
|
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
|
-
${
|
|
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
|
-
${
|
|
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
|
-
|
|
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
|
|
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
|
|
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))
|
package/src/builder/forms.ts
CHANGED
|
@@ -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(
|
|
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
|
}
|