pgkiln 0.45.0 → 0.46.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/106_form_designer.sql +102 -0
- package/db/migrations/107_app_version.sql +9 -0
- package/docs/apex-feature-parity.md +6 -3
- package/docs/guide/03-builder.md +36 -12
- package/docs/guide/04-pages-and-regions.md +74 -2
- package/docs/guide/05-items.md +14 -2
- package/docs/guide/09-reference.md +1 -1
- package/docs/guide/11-from-apex.md +2 -1
- package/docs/guide/12-development.md +6 -1
- package/examples/hr/hr_61_form_designer.sql +80 -0
- package/package.json +1 -1
- package/public/app.css +62 -2
- package/public/app.js +244 -35
- package/public/builder.css +21 -1
- package/public/builder.js +67 -0
- package/public/sw.js +2 -1
- package/src/app.ts +9 -1
- package/src/builder/appwizard.ts +22 -6
- package/src/builder/arrange.ts +33 -12
- package/src/builder/code-editor.ts +1 -1
- package/src/builder/components.ts +1 -1
- package/src/builder/designer.ts +44 -17
- package/src/builder/errors.ts +65 -0
- package/src/builder/form-designer.ts +229 -0
- package/src/builder/locks.ts +6 -0
- package/src/builder/newapp.ts +2 -1
- package/src/builder/object-browser.ts +81 -18
- package/src/builder/region-help.ts +2 -0
- package/src/builder/region-settings.ts +2 -0
- package/src/builder/reporter.ts +1 -1
- package/src/builder/routes.ts +7 -5
- package/src/builder/search.ts +3 -3
- package/src/builder/shared.ts +2 -2
- package/src/builder/ui.ts +2 -0
- package/src/cli/replace.ts +10 -0
- package/src/i18n/ar.ts +62 -0
- package/src/i18n/cs.ts +62 -0
- package/src/i18n/da.ts +62 -0
- package/src/i18n/de.ts +62 -0
- package/src/i18n/el.ts +62 -0
- package/src/i18n/es.ts +62 -0
- package/src/i18n/fi.ts +62 -0
- package/src/i18n/fr.ts +62 -0
- package/src/i18n/he.ts +62 -0
- package/src/i18n/it.ts +62 -0
- package/src/i18n/ja.ts +62 -0
- package/src/i18n/ko.ts +62 -0
- package/src/i18n/nb.ts +62 -0
- package/src/i18n/pl.ts +62 -0
- package/src/i18n/pt.ts +62 -0
- package/src/i18n/ru.ts +62 -0
- package/src/i18n/sv.ts +62 -0
- package/src/i18n/tr.ts +62 -0
- package/src/i18n/uk.ts +62 -0
- package/src/i18n/zh.ts +62 -0
- package/src/i18n.ts +124 -0
- package/src/metadata.ts +4 -2
- package/src/runtime/devbar.ts +1 -1
- package/src/runtime/facet-state.ts +10 -5
- package/src/runtime/facets.ts +29 -5
- package/src/runtime/form-designer.ts +676 -0
- package/src/runtime/grid.ts +15 -8
- package/src/runtime/items.ts +33 -21
- package/src/runtime/lists.ts +1 -1
- package/src/runtime/pdf.ts +2 -1
- package/src/runtime/regions.ts +4 -0
- package/src/runtime/render.ts +7 -3
- package/src/runtime/report.ts +2 -2
- package/src/runtime/routes.ts +3 -1
- package/src/runtime/smart-filters.ts +2 -1
- package/src/runtime/tasks.ts +6 -3
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
import pg from 'pg';
|
|
2
|
+
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
3
|
+
import { appTx, savepoint } from '../db.ts';
|
|
4
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
5
|
+
import { icon } from '../icons.ts';
|
|
6
|
+
import type { Region } from '../metadata.ts';
|
|
7
|
+
import { logActivity, saveState } from '../session.ts';
|
|
8
|
+
import { checkPageAccess, computeVisibility, Forbidden, isAuthorized } from './authz.ts';
|
|
9
|
+
import { publicError, type PageContext } from './context.ts';
|
|
10
|
+
import { appDateMask, appDateReader, dateField, heading, lovContains, lovOptions } from './items.ts';
|
|
11
|
+
import { RESERVED_SCHEMAS, SOURCE_ID } from './data-reporter.ts';
|
|
12
|
+
import { cell, isNumeric, regionUrl } from './report.ts';
|
|
13
|
+
import { loadContext, simplePage, txContext, type Req } from './routes.ts';
|
|
14
|
+
|
|
15
|
+
// Form Designer (sprint 85, migration 106): the application's administrators
|
|
16
|
+
// build their own data entry forms in the running application, on the tables
|
|
17
|
+
// and views the developer offers in the region's settings:
|
|
18
|
+
//
|
|
19
|
+
// config: {
|
|
20
|
+
// "sources": [{"id": "orders", "label": "Orders", "description": "…",
|
|
21
|
+
// "schema": "sales", "table": "orders", "key": "id",
|
|
22
|
+
// "columns": [{"name": "customer", "label": "Customer"}, {"name": "status", "lov": "LOV:ORDER_STATUS"}],
|
|
23
|
+
// "insert": true, "update": true, "delete": false}],
|
|
24
|
+
// "design_authz": "ADMIN", // who may design forms (an authorization scheme; none: nobody)
|
|
25
|
+
// "page_size": 25
|
|
26
|
+
// }
|
|
27
|
+
//
|
|
28
|
+
// A user form (meta.user_forms) is a definition (FormDef): the source, its
|
|
29
|
+
// fields (an offered column each, with a label, help, required, display and a
|
|
30
|
+
// default for new rows), the columns of its row list and what users may do
|
|
31
|
+
// (add, change, delete rows: at most what the source allows). Published forms
|
|
32
|
+
// are used by everyone who can see the region; drafts only by designers.
|
|
33
|
+
//
|
|
34
|
+
// Every definition is checked against the source's offered columns that the
|
|
35
|
+
// application's role can read before it renders or saves (checkFormDef), so a
|
|
36
|
+
// forged definition reaches nothing else. Rows are read and written as the
|
|
37
|
+
// application's database role: grants and row level security apply. The
|
|
38
|
+
// identifiers are only the developer's schema, table and column names, always
|
|
39
|
+
// quoted; values are always parameters. A changed row is detected by a hash
|
|
40
|
+
// of its fields' values when the form was opened (lost update detection).
|
|
41
|
+
|
|
42
|
+
export interface FdColumn {
|
|
43
|
+
name: string;
|
|
44
|
+
label?: string;
|
|
45
|
+
/** a shared list of values ("LOV:NAME") or a static one ("STATIC:…"): the field may be a select list or radio group */
|
|
46
|
+
lov?: string;
|
|
47
|
+
}
|
|
48
|
+
export interface FdSource {
|
|
49
|
+
id: string;
|
|
50
|
+
label?: string;
|
|
51
|
+
description?: string;
|
|
52
|
+
schema: string;
|
|
53
|
+
table: string;
|
|
54
|
+
/** the column that identifies a row: needed to change and delete rows */
|
|
55
|
+
key?: string;
|
|
56
|
+
columns: FdColumn[];
|
|
57
|
+
insert: boolean;
|
|
58
|
+
update: boolean;
|
|
59
|
+
delete: boolean;
|
|
60
|
+
}
|
|
61
|
+
export const DISPLAYS = ['auto', 'textarea', 'select', 'radio'] as const;
|
|
62
|
+
export type Display = (typeof DISPLAYS)[number];
|
|
63
|
+
export interface Field {
|
|
64
|
+
column: string;
|
|
65
|
+
label: string;
|
|
66
|
+
help: string;
|
|
67
|
+
required: boolean;
|
|
68
|
+
display: Display;
|
|
69
|
+
default: string;
|
|
70
|
+
}
|
|
71
|
+
export interface FormDef {
|
|
72
|
+
source: string;
|
|
73
|
+
fields: Field[];
|
|
74
|
+
list: string[];
|
|
75
|
+
actions: { insert: boolean; update: boolean; delete: boolean };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const FD_LIMITS = { fields: 40, list: 8, label: 80, help: 300, default: 200, value: 4000 };
|
|
79
|
+
const DEFAULT_PAGE_SIZE = 25;
|
|
80
|
+
const MAX_PAGE = 10_000;
|
|
81
|
+
const TEXT_OIDS = new Set([25, 1043, 1042]);
|
|
82
|
+
const DATE = 1082;
|
|
83
|
+
const TIMESTAMPS = new Set([1114, 1184]);
|
|
84
|
+
const BOOL = 16;
|
|
85
|
+
// binary, JSON and other values that no plain field edits
|
|
86
|
+
const NOT_EDITABLE = new Set([17, 114, 3802, 142]);
|
|
87
|
+
|
|
88
|
+
const str = (v: unknown) => (typeof v === 'string' ? v : '');
|
|
89
|
+
const clip = (v: unknown, max: number) => str(v).replace(/\0/g, '').trim().slice(0, max);
|
|
90
|
+
|
|
91
|
+
/** The sources of a region's settings, leaving out malformed entries and pgkiln's and the system's schemas. */
|
|
92
|
+
export function fdSourcesOf(r: { config: Record<string, any> }): FdSource[] {
|
|
93
|
+
const list = Array.isArray(r.config.sources) ? r.config.sources : [];
|
|
94
|
+
const out: FdSource[] = [];
|
|
95
|
+
for (const s of list) {
|
|
96
|
+
if (!s || typeof s !== 'object' || !SOURCE_ID.test(str(s.id)) || !str(s.schema) || !str(s.table) || out.some((x) => x.id === s.id)) continue;
|
|
97
|
+
if (RESERVED_SCHEMAS.test(s.schema)) continue;
|
|
98
|
+
const columns: FdColumn[] = (Array.isArray(s.columns) ? s.columns : [])
|
|
99
|
+
.filter((c: any) => c && typeof c === 'object' && str(c.name))
|
|
100
|
+
.map((c: any) => ({ name: c.name, ...(str(c.label) ? { label: c.label } : {}), ...(/^(LOV:[A-Z0-9_]+|STATIC:.+)$/is.test(str(c.lov)) ? { lov: c.lov } : {}) }));
|
|
101
|
+
const key = str(s.key) || undefined;
|
|
102
|
+
out.push({
|
|
103
|
+
id: s.id, label: str(s.label) || undefined, description: str(s.description) || undefined, schema: s.schema, table: s.table, key, columns,
|
|
104
|
+
insert: s.insert !== false, update: !!key && s.update !== false, delete: !!key && s.delete === true,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** An offered column that exists and the application's role can read, with its type. */
|
|
111
|
+
export interface FdCol {
|
|
112
|
+
name: string;
|
|
113
|
+
label: string;
|
|
114
|
+
type: number;
|
|
115
|
+
lov?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const objectSql = (s: FdSource) => `${pg.escapeIdentifier(s.schema)}.${pg.escapeIdentifier(s.table)}`;
|
|
119
|
+
const id = pg.escapeIdentifier;
|
|
120
|
+
|
|
121
|
+
/** The offered columns of a source (and whether its key exists), read as the application's role (no access: an error). */
|
|
122
|
+
export async function fdColumns(c: pg.PoolClient, s: FdSource, tr: (x: string) => string = (x) => x) {
|
|
123
|
+
const res = await savepoint(c, () => c.query(`select * from ${objectSql(s)} limit 0`));
|
|
124
|
+
const types = new Map(res.fields.map((f) => [f.name, f.dataTypeID]));
|
|
125
|
+
const cols = new Map<string, FdCol>();
|
|
126
|
+
for (const sc of s.columns)
|
|
127
|
+
if (types.has(sc.name) && !cols.has(sc.name) && !NOT_EDITABLE.has(types.get(sc.name)!))
|
|
128
|
+
cols.set(sc.name, { name: sc.name, label: tr(sc.label || heading(sc.name)), type: types.get(sc.name)!, ...(sc.lov ? { lov: sc.lov } : {}) });
|
|
129
|
+
return { cols, key: s.key && types.has(s.key) ? s.key : null };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The displays a column allows: a text area for text, a select list or radio group with a list of values. */
|
|
133
|
+
export const displaysOf = (col: FdCol): Display[] => ['auto', ...(TEXT_OIDS.has(col.type) && !col.lov ? ['textarea' as const] : []), ...(col.lov ? ['select' as const, 'radio' as const] : [])];
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A definition with only what the source allows: offered, readable columns,
|
|
137
|
+
* a display the column's type allows, bounded counts and lengths, actions the
|
|
138
|
+
* source allows (changing and deleting need its key). Anything else is dropped.
|
|
139
|
+
*/
|
|
140
|
+
export function checkFormDef(loose: any, source: FdSource, cols: Map<string, FdCol>, key: string | null): FormDef {
|
|
141
|
+
const arr = (v: unknown) => (Array.isArray(v) ? v : []);
|
|
142
|
+
const fields: Field[] = [];
|
|
143
|
+
for (const f of arr(loose?.fields)) {
|
|
144
|
+
const col = cols.get(str(f?.column));
|
|
145
|
+
if (!col || fields.some((x) => x.column === col.name) || fields.length >= FD_LIMITS.fields) continue;
|
|
146
|
+
const display = displaysOf(col).includes(f.display) ? (f.display as Display) : col.lov ? 'select' : 'auto';
|
|
147
|
+
fields.push({ column: col.name, label: clip(f.label, FD_LIMITS.label), help: clip(f.help, FD_LIMITS.help), required: f.required === true, display, default: clip(f.default, FD_LIMITS.default) });
|
|
148
|
+
}
|
|
149
|
+
const list = key ? [...new Set(arr(loose?.list).filter((c): c is string => typeof c === 'string' && cols.has(c)))].slice(0, FD_LIMITS.list) : [];
|
|
150
|
+
const a = loose?.actions ?? {};
|
|
151
|
+
const actions = {
|
|
152
|
+
insert: source.insert && a.insert === true && fields.length > 0,
|
|
153
|
+
update: source.update && !!key && a.update === true && fields.length > 0,
|
|
154
|
+
delete: source.delete && !!key && a.delete === true,
|
|
155
|
+
};
|
|
156
|
+
return { source: source.id, fields, list, actions };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------- values
|
|
160
|
+
|
|
161
|
+
/** A field's value as the text a form shows (from the column's value as text). */
|
|
162
|
+
function shownValue(type: number, v: string | null) {
|
|
163
|
+
if (v === null) return '';
|
|
164
|
+
if (TIMESTAMPS.has(type)) return v.slice(0, 16).replace(' ', 'T');
|
|
165
|
+
return v;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A posted value for a column: '' is null; a date in the application's date
|
|
170
|
+
* format (or YYYY-MM-DD) as YYYY-MM-DD; a checkbox as true or false. Other
|
|
171
|
+
* text goes to the database as a parameter, which refuses what its type can't take.
|
|
172
|
+
*/
|
|
173
|
+
export function postedValue(ctx: PageContext, col: FdCol, raw: unknown): string | null {
|
|
174
|
+
if (col.type === BOOL) return raw === 'true' ? 'true' : 'false';
|
|
175
|
+
const v = str(Array.isArray(raw) ? raw[0] : raw).replace(/\0/g, '').slice(0, FD_LIMITS.value);
|
|
176
|
+
if (!v.trim()) return null;
|
|
177
|
+
if (col.type === DATE) return appDateReader(ctx)(v.trim()) ?? v.trim();
|
|
178
|
+
if (isNumeric(col.type) || TIMESTAMPS.has(col.type)) return v.trim();
|
|
179
|
+
return v;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The text of a field's values and their hash (lost update detection), in the order of the fields. */
|
|
183
|
+
const valuesSql = (def: FormDef) => def.fields.map((f) => `${id(f.column)}::text`).join(', ');
|
|
184
|
+
const hashSql = (def: FormDef) => `md5(jsonb_build_array(${valuesSql(def)})::text)`;
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------- rendering
|
|
187
|
+
|
|
188
|
+
interface StoredForm {
|
|
189
|
+
id: number;
|
|
190
|
+
region_id: number;
|
|
191
|
+
name: string;
|
|
192
|
+
description: string | null;
|
|
193
|
+
published: boolean;
|
|
194
|
+
definition: any;
|
|
195
|
+
updated_by: string;
|
|
196
|
+
updated_at: string | Date;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export const fdPrefix = (r: { id: number }) => `fd${r.id}_`;
|
|
200
|
+
|
|
201
|
+
/** May the current user design forms in this region? Only users that pass its authorization scheme. */
|
|
202
|
+
export async function mayDesign(ctx: PageContext, r: Region) {
|
|
203
|
+
if (ctx.user === 'nobody') return false;
|
|
204
|
+
const scheme = r.config.design_authz;
|
|
205
|
+
return typeof scheme === 'string' && scheme.trim() !== '' && isAuthorized(ctx, scheme);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const pageSize = (r: Region) => {
|
|
209
|
+
const n = Number(r.config.page_size);
|
|
210
|
+
return Number.isInteger(n) && n >= 5 && n <= 200 ? n : DEFAULT_PAGE_SIZE;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/** A link to this page with the region's own parameters replaced. */
|
|
214
|
+
function url(ctx: PageContext, r: Region, own: [string, string][]) {
|
|
215
|
+
const prefix = fdPrefix(r);
|
|
216
|
+
return regionUrl(ctx, r, (p) => {
|
|
217
|
+
for (const k of [...p.keys()]) if (k.startsWith(prefix)) p.delete(k);
|
|
218
|
+
for (const [k, v] of own) p.append(`${prefix}${k}`, v);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const sourceLabel = (ctx: PageContext, s: FdSource) => ctx.locale.tr(s.label || heading(s.table));
|
|
223
|
+
const alert = (msg: string | Raw) => html`<div class="alert alert-error" role="alert">${msg}</div>`;
|
|
224
|
+
|
|
225
|
+
/** A draft kept in the session when saving a row failed: the typed values come back with the message. */
|
|
226
|
+
interface Draft {
|
|
227
|
+
form: number;
|
|
228
|
+
row: string;
|
|
229
|
+
values: Record<string, string>;
|
|
230
|
+
error: string;
|
|
231
|
+
}
|
|
232
|
+
const draftKey = (r: { id: number }) => `__FD_${r.id}`;
|
|
233
|
+
|
|
234
|
+
/** The Form Designer region: the list of forms, a form's rows, a row's form, or (designers) a form's design. */
|
|
235
|
+
export async function renderFormDesigner(ctx: PageContext, r: Region): Promise<Raw> {
|
|
236
|
+
const t = ctx.locale.t;
|
|
237
|
+
const c = ctx.client!;
|
|
238
|
+
const prefix = fdPrefix(r);
|
|
239
|
+
const sources = fdSourcesOf(r);
|
|
240
|
+
if (!sources.length) return html`<p class="empty">${t('formdesigner.no_sources')}</p>`;
|
|
241
|
+
const designer = await mayDesign(ctx, r);
|
|
242
|
+
const draft = (() => {
|
|
243
|
+
const raw = ctx.session.state[draftKey(r)];
|
|
244
|
+
if (!raw) return null;
|
|
245
|
+
delete ctx.session.state[draftKey(r)];
|
|
246
|
+
try { return JSON.parse(raw) as Draft; } catch { return null; }
|
|
247
|
+
})();
|
|
248
|
+
|
|
249
|
+
// a form's design (designers only)
|
|
250
|
+
const design = ctx.params.get(`${prefix}design`);
|
|
251
|
+
if (design && designer) {
|
|
252
|
+
if (design === 'new') {
|
|
253
|
+
const source = sources.find((s) => s.id === ctx.params.get(`${prefix}src`)) ?? sources[0];
|
|
254
|
+
return designView(ctx, r, source, undefined);
|
|
255
|
+
}
|
|
256
|
+
const form = await loadForm(ctx, r, Number(design), true);
|
|
257
|
+
const source = form && sources.find((s) => s.id === str(form.definition?.source));
|
|
258
|
+
if (!form || !source) return html`${alert(t('formdesigner.form_not_found'))}${await home(ctx, r, sources, designer)}`;
|
|
259
|
+
return designView(ctx, r, source, form);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const formId = Number(ctx.params.get(`${prefix}form`));
|
|
263
|
+
if (!Number.isInteger(formId) || formId <= 0) return home(ctx, r, sources, designer);
|
|
264
|
+
const form = await loadForm(ctx, r, formId, designer);
|
|
265
|
+
if (!form) return html`${alert(t('formdesigner.form_not_found'))}${await home(ctx, r, sources, designer)}`;
|
|
266
|
+
const source = sources.find((s) => s.id === str(form.definition?.source));
|
|
267
|
+
if (!source) return html`${alert(t('formdesigner.unknown_source'))}${await home(ctx, r, sources, designer)}`;
|
|
268
|
+
let cols: Map<string, FdCol>, key: string | null;
|
|
269
|
+
try {
|
|
270
|
+
({ cols, key } = await fdColumns(c, source, ctx.locale.tr));
|
|
271
|
+
} catch (e) {
|
|
272
|
+
return html`${alert(await publicError(ctx, e, `form designer "${r.title ?? r.id}" source ${source.id}`))}${await home(ctx, r, sources, designer)}`;
|
|
273
|
+
}
|
|
274
|
+
const def = checkFormDef(form.definition, source, cols, key);
|
|
275
|
+
const row = ctx.params.get(`${prefix}row`);
|
|
276
|
+
const mine = draft && draft.form === form.id ? draft : null;
|
|
277
|
+
if (row === 'new' || (row === null && !def.list.length && def.actions.insert)) {
|
|
278
|
+
if (!def.actions.insert) return html`${alert(t('formdesigner.not_allowed'))}${await rowsView(ctx, r, form, source, cols, key, def, designer)}`;
|
|
279
|
+
return rowView(ctx, r, form, source, cols, key, def, null, mine && mine.row === '' ? mine : null, designer);
|
|
280
|
+
}
|
|
281
|
+
if (row !== null && key && (def.actions.update || def.actions.delete)) return rowView(ctx, r, form, source, cols, key, def, row.slice(0, 200), mine && mine.row === row ? mine : null, designer);
|
|
282
|
+
return rowsView(ctx, r, form, source, cols, key, def, designer);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function loadForm(ctx: PageContext, r: Region, formId: number, drafts: boolean) {
|
|
286
|
+
if (!Number.isInteger(formId) || formId <= 0 || formId > 2 ** 31 - 1) return undefined;
|
|
287
|
+
const c = ctx.client!;
|
|
288
|
+
const form = (await savepoint(c, () => c.query<StoredForm>('select * from meta.user_forms where id = $1 and region_id = $2', [formId, r.id]))).rows[0];
|
|
289
|
+
return form && (form.published || drafts) ? form : undefined;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The forms of the region as cards (drafts for designers only), and for designers "New form". */
|
|
293
|
+
async function home(ctx: PageContext, r: Region, sources: FdSource[], designer: boolean): Promise<Raw> {
|
|
294
|
+
const t = ctx.locale.t;
|
|
295
|
+
const c = ctx.client!;
|
|
296
|
+
const prefix = fdPrefix(r);
|
|
297
|
+
const forms = (await savepoint(c, () => c.query<StoredForm>(
|
|
298
|
+
`select * from meta.user_forms where region_id = $1 and (published or $2) order by lower(name), id`, [r.id, designer]))).rows;
|
|
299
|
+
let start: Raw | '' = '';
|
|
300
|
+
if (designer) {
|
|
301
|
+
const form = `fdn${r.id}`;
|
|
302
|
+
const keep = [...ctx.params.entries()].filter(([k]) => !k.startsWith(prefix) && !['clear', 'cs'].includes(k));
|
|
303
|
+
ctx.detached.push(html`<form id="${form}" method="get" action="${ctx.base}/${ctx.page.page_no}">${keep.map(([k, v]) => html`<input type="hidden" name="${k}" value="${v}">`)}<input type="hidden" name="${prefix}design" value="new"></form>`);
|
|
304
|
+
start = html`<div class="reporter-new fd-new">
|
|
305
|
+
<label class="label" for="${form}_src">${t('formdesigner.new')}</label>
|
|
306
|
+
<div class="reporter-row">
|
|
307
|
+
<select id="${form}_src" name="${prefix}src" form="${form}">${sources.map((s) => html`<option value="${s.id}">${sourceLabel(ctx, s)}</option>`)}</select>
|
|
308
|
+
<button class="btn btn-hot" form="${form}">${t('formdesigner.create')}</button>
|
|
309
|
+
</div></div>`;
|
|
310
|
+
}
|
|
311
|
+
const label = (f: StoredForm) => {
|
|
312
|
+
const s = sources.find((x) => x.id === str(f.definition?.source));
|
|
313
|
+
return s ? sourceLabel(ctx, s) : '';
|
|
314
|
+
};
|
|
315
|
+
return html`<div class="reporter fd">
|
|
316
|
+
${forms.length
|
|
317
|
+
? html`<ul class="reporter-cards fd-cards">${forms.map((f) => html`<li><a class="reporter-card" href="${url(ctx, r, [['form', String(f.id)]])}">
|
|
318
|
+
<strong>${f.name}${f.published ? '' : html` <span class="tag">${t('formdesigner.draft')}</span>`}</strong>
|
|
319
|
+
${f.description ? html`<span class="muted">${f.description}</span>` : ''}
|
|
320
|
+
${designer ? html`<span class="reporter-card-meta">${label(f)}</span>` : ''}</a>
|
|
321
|
+
${designer ? html`<a class="fd-design-link" href="${url(ctx, r, [['design', String(f.id)]])}">${icon('settings')} ${t('formdesigner.design')}<span class="sr-only">: ${f.name}</span></a>` : ''}</li>`)}</ul>`
|
|
322
|
+
: html`<p class="empty">${t('formdesigner.no_forms')}</p>`}
|
|
323
|
+
${start}
|
|
324
|
+
</div>`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const head = (ctx: PageContext, r: Region, form: StoredForm, designer: boolean, back: [string, string][] = []) => {
|
|
328
|
+
const t = ctx.locale.t;
|
|
329
|
+
return html`<div class="reporter-head">
|
|
330
|
+
<a href="${url(ctx, r, back)}">‹ ${back.length ? form.name : t('formdesigner.back')}</a>
|
|
331
|
+
<h3 class="reporter-title">${form.name}${form.published ? '' : html` <span class="tag">${t('formdesigner.draft')}</span>`}</h3>
|
|
332
|
+
${designer ? html`<a href="${url(ctx, r, [['design', String(form.id)]])}">${icon('settings')} ${t('formdesigner.design')}</a>` : ''}
|
|
333
|
+
${form.description ? html`<p class="muted reporter-desc">${form.description}</p>` : ''}
|
|
334
|
+
</div>`;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
/** A form's row list (its list columns, paged) with Add and an Edit link per row. */
|
|
338
|
+
async function rowsView(ctx: PageContext, r: Region, form: StoredForm, source: FdSource, cols: Map<string, FdCol>, key: string | null, def: FormDef, designer: boolean): Promise<Raw> {
|
|
339
|
+
const t = ctx.locale.t;
|
|
340
|
+
const c = ctx.client!;
|
|
341
|
+
const prefix = fdPrefix(r);
|
|
342
|
+
const add = def.actions.insert ? html`<p><a class="btn btn-hot" href="${url(ctx, r, [['form', String(form.id)], ['row', 'new']])}">${icon('plus')} ${t('formdesigner.add')}</a></p>` : '';
|
|
343
|
+
if (!def.fields.length) return html`<div class="reporter fd">${head(ctx, r, form, designer)}<p class="empty">${t('formdesigner.nothing')}</p></div>`;
|
|
344
|
+
if (!def.list.length || !key) return html`<div class="reporter fd">${head(ctx, r, form, designer)}${add}</div>`;
|
|
345
|
+
const size = pageSize(r);
|
|
346
|
+
const pg0 = Number(ctx.params.get(`${prefix}pg`));
|
|
347
|
+
const page = Number.isInteger(pg0) && pg0 > 1 ? Math.min(pg0, MAX_PAGE) : 1;
|
|
348
|
+
const editable = def.actions.update || def.actions.delete;
|
|
349
|
+
let table: Raw;
|
|
350
|
+
try {
|
|
351
|
+
const res = await savepoint(c, () => c.query({
|
|
352
|
+
text: `select ${id(key)}::text, ${def.list.map(id).join(', ')} from ${objectSql(source)} order by ${id(key)} desc limit ${size + 1} offset ${(page - 1) * size}`,
|
|
353
|
+
rowMode: 'array',
|
|
354
|
+
}));
|
|
355
|
+
const rows = res.rows.slice(0, size);
|
|
356
|
+
const more = res.rows.length > size;
|
|
357
|
+
const heads = def.list.map((n) => ({ label: def.fields.find((f) => f.column === n)?.label || cols.get(n)!.label, num: isNumeric(cols.get(n)!.type) }));
|
|
358
|
+
const go = (n: number) => url(ctx, r, [['form', String(form.id)], ...(n > 1 ? [['pg', String(n)] as [string, string]] : [])]);
|
|
359
|
+
table = html`<div class="table-wrap"><table class="report report-reflow fd-rows" aria-label="${t('formdesigner.rows', { form: form.name })}">
|
|
360
|
+
<thead><tr>${heads.map((h) => html`<th scope="col" class="${h.num ? 'num' : null}">${h.label}</th>`)}${editable ? html`<th scope="col"><span class="sr-only">${t('formdesigner.edit_row')}</span></th>` : ''}</tr></thead>
|
|
361
|
+
<tbody>${rows.length
|
|
362
|
+
? rows.map((row: unknown[]) => html`<tr>${row.slice(1).map((v, i) => html`<td class="${heads[i].num ? 'num' : null}" data-label="${heads[i].label}">${cell(v, res.fields[i + 1].dataTypeID, ctx.locale.format)}</td>`)}${editable
|
|
363
|
+
? html`<td><a href="${url(ctx, r, [['form', String(form.id)], ['row', String(row[0])]])}">${icon('edit')} ${t('formdesigner.edit_row')}</a></td>` : ''}</tr>`)
|
|
364
|
+
: html`<tr><td colspan="${heads.length + (editable ? 1 : 0)}" class="empty">${r.config.empty ?? t('report.no_data')}</td></tr>`}</tbody>
|
|
365
|
+
</table></div>
|
|
366
|
+
${more || page > 1 ? html`<nav class="pager" aria-label="${t('report.pagination')}">
|
|
367
|
+
${page > 1 ? html`<a class="btn" href="${go(page - 1)}">‹ ${t('report.previous')}</a>` : ''}
|
|
368
|
+
${more && page < MAX_PAGE ? html`<a class="btn" href="${go(page + 1)}">${t('report.next')} ›</a>` : ''}</nav>` : ''}`;
|
|
369
|
+
} catch (e) {
|
|
370
|
+
table = alert(await publicError(ctx, e, `form designer "${r.title ?? r.id}" rows`));
|
|
371
|
+
}
|
|
372
|
+
return html`<div class="reporter fd">${head(ctx, r, form, designer)}${add}${table}</div>`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** One row's form: a new row (rowKey null) or an existing one; Save, Delete, Cancel. */
|
|
376
|
+
async function rowView(ctx: PageContext, r: Region, form: StoredForm, source: FdSource, cols: Map<string, FdCol>, key: string | null, def: FormDef, rowKey: string | null, draft: Draft | null, designer: boolean): Promise<Raw> {
|
|
377
|
+
const t = ctx.locale.t;
|
|
378
|
+
const c = ctx.client!;
|
|
379
|
+
const back: [string, string][] = def.list.length ? [['form', String(form.id)]] : [];
|
|
380
|
+
let values: Record<string, string> = {};
|
|
381
|
+
let version = '';
|
|
382
|
+
if (rowKey !== null && key) {
|
|
383
|
+
let row: (string | null)[] | undefined;
|
|
384
|
+
try {
|
|
385
|
+
row = (await savepoint(c, () => c.query({
|
|
386
|
+
text: `select ${hashSql(def)}, ${valuesSql(def)} from ${objectSql(source)} where ${id(key)} = $1 limit 1`, values: [rowKey], rowMode: 'array',
|
|
387
|
+
}))).rows[0];
|
|
388
|
+
} catch {
|
|
389
|
+
// a key that isn't a value of the key's type
|
|
390
|
+
row = undefined;
|
|
391
|
+
}
|
|
392
|
+
if (!row) return html`${alert(t('formdesigner.row_not_found'))}${await rowsView(ctx, r, form, source, cols, key, def, designer)}`;
|
|
393
|
+
version = row[0] ?? '';
|
|
394
|
+
def.fields.forEach((f, i) => (values[f.column] = shownValue(cols.get(f.column)!.type, row[i + 1])));
|
|
395
|
+
} else {
|
|
396
|
+
for (const f of def.fields) values[f.column] = f.default;
|
|
397
|
+
}
|
|
398
|
+
if (draft) values = { ...values, ...draft.values };
|
|
399
|
+
|
|
400
|
+
const fid = `fdr${r.id}`;
|
|
401
|
+
const action = `${ctx.base}/${ctx.page.page_no}/formdesigner/${r.id}/row`;
|
|
402
|
+
ctx.detached.push(html`<form id="${fid}" method="post" action="${action}"><input type="hidden" name="__csrf" value="${ctx.session.csrf_token}">
|
|
403
|
+
<input type="hidden" name="form" value="${form.id}"><input type="hidden" name="row" value="${rowKey ?? ''}"><input type="hidden" name="version" value="${version}"></form>`);
|
|
404
|
+
const fields = await Promise.all(def.fields.map(async (f, i) => {
|
|
405
|
+
const col = cols.get(f.column)!;
|
|
406
|
+
const fieldId = `${fid}_${i}`;
|
|
407
|
+
const name = `c:${f.column}`;
|
|
408
|
+
const label = f.label || col.label;
|
|
409
|
+
const v = values[f.column] ?? '';
|
|
410
|
+
const help = f.help ? html`<small class="help" id="${fieldId}_help">${f.help}</small>` : '';
|
|
411
|
+
const described = f.help ? raw(` aria-describedby="${fieldId}_help"`) : '';
|
|
412
|
+
const req = f.required ? raw(' required') : '';
|
|
413
|
+
const lbl = html`<label class="label" for="${fieldId}">${label}${f.required ? html` <span class="required" aria-hidden="true">*</span>` : ''}</label>`;
|
|
414
|
+
if (col.type === BOOL)
|
|
415
|
+
return html`<div class="field"><label class="check"><input type="checkbox" id="${fieldId}" name="${name}" value="true" form="${fid}"${v === 'true' ? raw(' checked') : ''}${described}> ${label}</label>${help}</div>`;
|
|
416
|
+
if ((f.display === 'select' || f.display === 'radio') && col.lov) {
|
|
417
|
+
let options: { display: string; value: string }[] = [];
|
|
418
|
+
try {
|
|
419
|
+
options = await lovOptions(ctx, col.lov);
|
|
420
|
+
} catch (e) {
|
|
421
|
+
return html`<div class="field">${lbl}${alert(await publicError(ctx, e, `form designer list of values ${col.lov}`))}</div>`;
|
|
422
|
+
}
|
|
423
|
+
if (f.display === 'radio')
|
|
424
|
+
return html`<fieldset class="field fd-radios"${described}><legend class="label">${label}${f.required ? html` <span class="required" aria-hidden="true">*</span>` : ''}</legend>
|
|
425
|
+
${options.map((o, j) => html`<label class="check"><input type="radio" id="${j ? `${fieldId}_${j}` : fieldId}" name="${name}" value="${o.value}" form="${fid}"${o.value === v ? raw(' checked') : ''}${j === 0 ? req : ''}> ${o.display}</label>`)}${help}</fieldset>`;
|
|
426
|
+
return html`<div class="field">${lbl}<select id="${fieldId}" name="${name}" form="${fid}"${req}${described}><option value="">${t('formdesigner.choose')}</option>${options.map((o) => html`<option value="${o.value}"${o.value === v ? raw(' selected') : ''}>${o.display}</option>`)}</select>${help}</div>`;
|
|
427
|
+
}
|
|
428
|
+
let control: Raw;
|
|
429
|
+
if (col.type === DATE) control = dateField(ctx, { id: fieldId, iso: v, typed: v, mask: appDateMask(ctx), attrs: html` name="${name}" form="${fid}"${req}${described}` });
|
|
430
|
+
else if (TIMESTAMPS.has(col.type)) control = html`<input type="datetime-local" id="${fieldId}" name="${name}" form="${fid}" value="${v}"${req}${described}>`;
|
|
431
|
+
else if (isNumeric(col.type)) control = html`<input type="number" step="any" inputmode="decimal" id="${fieldId}" name="${name}" form="${fid}" value="${v}"${req}${described}>`;
|
|
432
|
+
else if (f.display === 'textarea') control = html`<textarea id="${fieldId}" name="${name}" form="${fid}" rows="4" maxlength="${FD_LIMITS.value}"${req}${described}>${v}</textarea>`;
|
|
433
|
+
else control = html`<input type="text" id="${fieldId}" name="${name}" form="${fid}" value="${v}" maxlength="${FD_LIMITS.value}"${req}${described}>`;
|
|
434
|
+
return html`<div class="field"${f.display === 'textarea' ? raw(' data-wide') : ''}>${lbl}${control}${help}</div>`;
|
|
435
|
+
}));
|
|
436
|
+
const canSave = rowKey === null ? def.actions.insert : def.actions.update;
|
|
437
|
+
return html`<div class="reporter fd">
|
|
438
|
+
${head(ctx, r, form, designer, back)}
|
|
439
|
+
${draft?.error ? alert(draft.error) : ''}
|
|
440
|
+
<div class="form-grid fd-form">${fields}</div>
|
|
441
|
+
<div class="buttons">
|
|
442
|
+
${canSave ? html`<button class="btn btn-hot" form="${fid}" name="mode" value="save">${t('formdesigner.save')}</button>` : ''}
|
|
443
|
+
${rowKey !== null && def.actions.delete ? html`<button class="btn btn-danger" form="${fid}" name="mode" value="delete" formnovalidate data-confirm="${t('formdesigner.delete_confirm')}">${t('formdesigner.delete')}</button>` : ''}
|
|
444
|
+
<a class="btn" href="${url(ctx, r, back)}">${t('formdesigner.cancel')}</a>
|
|
445
|
+
</div>
|
|
446
|
+
</div>`;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** The designer: the form's name, description, published, actions, and a row per offered column. */
|
|
450
|
+
async function designView(ctx: PageContext, r: Region, source: FdSource, form: StoredForm | undefined): Promise<Raw> {
|
|
451
|
+
const t = ctx.locale.t;
|
|
452
|
+
const c = ctx.client!;
|
|
453
|
+
let cols: Map<string, FdCol>, key: string | null;
|
|
454
|
+
try {
|
|
455
|
+
({ cols, key } = await fdColumns(c, source, ctx.locale.tr));
|
|
456
|
+
} catch (e) {
|
|
457
|
+
return alert(await publicError(ctx, e, `form designer "${r.title ?? r.id}" source ${source.id}`));
|
|
458
|
+
}
|
|
459
|
+
const def = form
|
|
460
|
+
? checkFormDef(form.definition, source, cols, key)
|
|
461
|
+
: checkFormDef({ fields: [...cols.values()].filter((x) => x.name !== key).map((x) => ({ column: x.name })), list: [...cols.keys()].filter((x) => x !== key).slice(0, 4), actions: { insert: true, update: true } }, source, cols, key);
|
|
462
|
+
const fid = `fdd${r.id}`;
|
|
463
|
+
const base = `${ctx.base}/${ctx.page.page_no}/formdesigner/${r.id}`;
|
|
464
|
+
const csrf = html`<input type="hidden" name="__csrf" value="${ctx.session.csrf_token}">`;
|
|
465
|
+
ctx.detached.push(html`<form id="${fid}" method="post" action="${base}/design">${csrf}<input type="hidden" name="src" value="${source.id}">${form ? html`<input type="hidden" name="form" value="${form.id}">` : ''}</form>`);
|
|
466
|
+
if (form) ctx.detached.push(html`<form id="fdx${r.id}" method="post" action="${base}/delete">${csrf}<input type="hidden" name="form" value="${form.id}"></form>`);
|
|
467
|
+
const x = (n: string) => `${fid}_${n}`;
|
|
468
|
+
// the form's fields first (in their order), then the other offered columns
|
|
469
|
+
const ordered = [...def.fields.map((f) => f.column), ...[...cols.keys()].filter((n) => !def.fields.some((f) => f.column === n))];
|
|
470
|
+
const rows = ordered.map((n, j) => {
|
|
471
|
+
const col = cols.get(n)!;
|
|
472
|
+
const f = def.fields.find((y) => y.column === n);
|
|
473
|
+
const opts = displaysOf(col);
|
|
474
|
+
return html`<tr>
|
|
475
|
+
<td data-label="${t('formdesigner.field_on')}"><input type="hidden" name="col_${j}" value="${n}"><input type="checkbox" name="on_${j}" value="true" form="${fid}"${f ? raw(' checked') : ''} aria-label="${t('formdesigner.field_on')}: ${col.label}"></td>
|
|
476
|
+
<td data-label="${t('formdesigner.column')}">${col.label}${n === key ? html` <span class="tag">${t('formdesigner.key')}</span>` : ''}</td>
|
|
477
|
+
<td data-label="${t('formdesigner.order')}"><input type="number" name="seq_${j}" form="${fid}" min="1" max="999" value="${(j + 1) * 10}" class="fd-seq" aria-label="${t('formdesigner.order')}: ${col.label}"></td>
|
|
478
|
+
<td data-label="${t('formdesigner.label')}"><input name="label_${j}" form="${fid}" maxlength="${FD_LIMITS.label}" value="${f?.label ?? ''}" placeholder="${col.label}" aria-label="${t('formdesigner.label')}: ${col.label}"></td>
|
|
479
|
+
<td data-label="${t('formdesigner.help')}"><input name="help_${j}" form="${fid}" maxlength="${FD_LIMITS.help}" value="${f?.help ?? ''}" aria-label="${t('formdesigner.help')}: ${col.label}"></td>
|
|
480
|
+
<td data-label="${t('formdesigner.required_short')}">${col.type === BOOL ? '' : html`<input type="checkbox" name="req_${j}" value="true" form="${fid}"${f?.required ? raw(' checked') : ''} aria-label="${t('formdesigner.required_short')}: ${col.label}">`}</td>
|
|
481
|
+
<td data-label="${t('formdesigner.display')}">${opts.length > 1
|
|
482
|
+
? html`<select name="disp_${j}" form="${fid}" aria-label="${t('formdesigner.display')}: ${col.label}">${opts.map((d) => html`<option value="${d}"${(f?.display ?? (col.lov ? 'select' : 'auto')) === d ? raw(' selected') : ''}>${t(`formdesigner.display_${d}`)}</option>`)}</select>`
|
|
483
|
+
: html`<input type="hidden" name="disp_${j}" value="auto" form="${fid}">${t('formdesigner.display_auto')}`}</td>
|
|
484
|
+
<td data-label="${t('formdesigner.default')}"><input name="def_${j}" form="${fid}" maxlength="${FD_LIMITS.default}" value="${f?.default ?? ''}" aria-label="${t('formdesigner.default')}: ${col.label}"></td>
|
|
485
|
+
<td data-label="${t('formdesigner.in_list')}">${key ? html`<input type="checkbox" name="list_${j}" value="true" form="${fid}"${def.list.includes(n) ? raw(' checked') : ''} aria-label="${t('formdesigner.in_list')}: ${col.label}">` : ''}</td>
|
|
486
|
+
</tr>`;
|
|
487
|
+
});
|
|
488
|
+
const action = (k: 'insert' | 'update' | 'delete') =>
|
|
489
|
+
source[k] ? html`<label class="check"><input type="checkbox" name="allow_${k}" value="true" form="${fid}"${def.actions[k] || (!form && k !== 'delete' && source[k]) ? raw(' checked') : ''}> ${t(`formdesigner.allow_${k}`)}</label>` : '';
|
|
490
|
+
return html`<div class="reporter fd fd-designer">
|
|
491
|
+
<div class="reporter-head">
|
|
492
|
+
<a href="${url(ctx, r, form ? [['form', String(form.id)]] : [])}">‹ ${form ? form.name : t('formdesigner.back')}</a>
|
|
493
|
+
<h3 class="reporter-title">${form ? t('formdesigner.design_of', { name: form.name }) : t('formdesigner.new')}</h3>
|
|
494
|
+
<span class="muted">${sourceLabel(ctx, source)}${form ? html` · ${t('formdesigner.changed_by', { user: form.updated_by, date: String(form.updated_at instanceof Date ? form.updated_at.toISOString() : form.updated_at).slice(0, 10) })}` : ''}</span>
|
|
495
|
+
</div>
|
|
496
|
+
<div class="form-grid">
|
|
497
|
+
<div class="field"><label class="label" for="${x('name')}">${t('formdesigner.name')}</label><input id="${x('name')}" name="name" form="${fid}" required maxlength="80" value="${form?.name ?? ''}"></div>
|
|
498
|
+
<div class="field" data-wide><label class="label" for="${x('desc')}">${t('formdesigner.description')}</label><input id="${x('desc')}" name="description" form="${fid}" maxlength="500" value="${form?.description ?? ''}"></div>
|
|
499
|
+
</div>
|
|
500
|
+
<fieldset class="reporter-set"><legend>${t('formdesigner.actions')}</legend>
|
|
501
|
+
<div class="reporter-checks">${action('insert')}${action('update')}${action('delete')}</div>
|
|
502
|
+
<small class="help">${t('formdesigner.list_help')}</small>
|
|
503
|
+
</fieldset>
|
|
504
|
+
<fieldset class="reporter-set"><legend>${t('formdesigner.fields')}</legend>
|
|
505
|
+
<div class="table-wrap"><table class="report report-reflow fd-fields"><thead><tr>
|
|
506
|
+
<th scope="col">${t('formdesigner.field_on')}</th><th scope="col">${t('formdesigner.column')}</th><th scope="col">${t('formdesigner.order')}</th>
|
|
507
|
+
<th scope="col">${t('formdesigner.label')}</th><th scope="col">${t('formdesigner.help')}</th><th scope="col">${t('formdesigner.required_short')}</th>
|
|
508
|
+
<th scope="col">${t('formdesigner.display')}</th><th scope="col">${t('formdesigner.default')}</th><th scope="col">${t('formdesigner.in_list')}</th>
|
|
509
|
+
</tr></thead><tbody>${rows}</tbody></table></div>
|
|
510
|
+
</fieldset>
|
|
511
|
+
<label class="check"><input type="checkbox" name="published" value="true" form="${fid}"${form?.published ? raw(' checked') : ''}> ${t('formdesigner.published')}</label>
|
|
512
|
+
<div class="buttons">
|
|
513
|
+
<button class="btn btn-hot" form="${fid}">${t('formdesigner.save_form')}</button>
|
|
514
|
+
${form ? html`<button class="btn btn-danger" form="fdx${r.id}" data-confirm="${t('formdesigner.delete_form_confirm', { name: form.name })}">${t('formdesigner.delete_form')}</button>` : ''}
|
|
515
|
+
</div>
|
|
516
|
+
</div>`;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** The designer's posted form as a loose definition (checked by checkFormDef). */
|
|
520
|
+
export function formDefFromBody(b: Record<string, unknown>, sourceId: string) {
|
|
521
|
+
const items: { seq: number; j: number; field: Record<string, unknown> }[] = [];
|
|
522
|
+
const list: string[] = [];
|
|
523
|
+
for (let j = 0; j < 500 && typeof b[`col_${j}`] === 'string'; j++) {
|
|
524
|
+
const column = b[`col_${j}`] as string;
|
|
525
|
+
if (b[`list_${j}`] === 'true') list.push(column);
|
|
526
|
+
if (b[`on_${j}`] !== 'true') continue;
|
|
527
|
+
const seq = Number(b[`seq_${j}`]);
|
|
528
|
+
items.push({ seq: Number.isFinite(seq) ? seq : (j + 1) * 10, j, field: { column, label: b[`label_${j}`], help: b[`help_${j}`], required: b[`req_${j}`] === 'true', display: b[`disp_${j}`], default: b[`def_${j}`] } });
|
|
529
|
+
}
|
|
530
|
+
items.sort((a, z) => a.seq - z.seq || a.j - z.j);
|
|
531
|
+
return { source: sourceId, fields: items.map((x) => x.field), list, actions: { insert: b.allow_insert === 'true', update: b.allow_update === 'true', delete: b.allow_delete === 'true' } };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ---------------------------------------------------------------- saving
|
|
535
|
+
|
|
536
|
+
class UserError extends Error {}
|
|
537
|
+
|
|
538
|
+
export async function formDesignerRoutes(app: FastifyInstance) {
|
|
539
|
+
/** Shared checks: CSRF, the region visible on the page; back to the page afterwards. */
|
|
540
|
+
const action = async (
|
|
541
|
+
req: Req, reply: FastifyReply, opts: { design: boolean },
|
|
542
|
+
run: (ctx: PageContext, r: Region) => Promise<{ message: string; back: [string, string][]; error?: boolean }>,
|
|
543
|
+
onError?: (ctx: PageContext, r: Region, message: string) => [string, string][],
|
|
544
|
+
) => {
|
|
545
|
+
const ctx = await loadContext(req, reply);
|
|
546
|
+
if (!ctx) return;
|
|
547
|
+
const body = req.body ?? {};
|
|
548
|
+
const refuse = (detail: string) => {
|
|
549
|
+
logActivity({ appId: ctx.app.id, pageNo: ctx.page.page_no, username: ctx.user, event: 'forbidden', ip: ctx.ip, detail });
|
|
550
|
+
return simplePage(reply, 403, ctx.locale.t('error.access_denied'), ctx.locale.t(detail.includes('csrf') ? 'error.session_reload' : 'error.report_unavailable'), `${ctx.base}/${ctx.page.page_no}`, ctx.locale);
|
|
551
|
+
};
|
|
552
|
+
if (typeof body.__csrf !== 'string' || body.__csrf !== ctx.session.csrf_token) return refuse('form designer: csrf');
|
|
553
|
+
const r = ctx.page.regions.find((x) => x.id === Number(req.params.id) && x.type === 'form_designer');
|
|
554
|
+
if (!r) return refuse(`form designer: region ${req.params.id} on page ${ctx.page.page_no}`);
|
|
555
|
+
let back: [string, string][] = [];
|
|
556
|
+
try {
|
|
557
|
+
const out = await appTx(txContext(ctx), async (c) => {
|
|
558
|
+
ctx.client = c;
|
|
559
|
+
await checkPageAccess(ctx);
|
|
560
|
+
const vis = await computeVisibility(ctx);
|
|
561
|
+
if (!vis.regions.has(r.id)) throw new Forbidden('region');
|
|
562
|
+
if (opts.design && !(await mayDesign(ctx, r))) throw new Forbidden('designer');
|
|
563
|
+
return run(ctx, r);
|
|
564
|
+
});
|
|
565
|
+
ctx.session.state[out.error ? '__FLASH_ERROR' : '__FLASH'] = out.message;
|
|
566
|
+
back = out.back;
|
|
567
|
+
} catch (e) {
|
|
568
|
+
if (e instanceof Forbidden) return refuse(`form designer: ${e.message} ${r.id} on page ${ctx.page.page_no}`);
|
|
569
|
+
const message = e instanceof UserError ? e.message : await publicError(ctx, e, 'form designer');
|
|
570
|
+
back = onError ? onError(ctx, r, message) : [];
|
|
571
|
+
if (!onError) ctx.session.state.__FLASH_ERROR = message;
|
|
572
|
+
}
|
|
573
|
+
await saveState(ctx.session);
|
|
574
|
+
const p = new URLSearchParams(back.map(([k, v]) => [`${fdPrefix(r)}${k}`, v]));
|
|
575
|
+
const q = p.toString();
|
|
576
|
+
return reply.redirect(`${ctx.base}/${ctx.page.page_no}${q ? `?${q}` : ''}`, 303);
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
const formIdOf = (v: unknown) => (/^\d{1,9}$/.test(String(v ?? '')) ? Number(v) : 0);
|
|
580
|
+
|
|
581
|
+
// a row: add, change or delete
|
|
582
|
+
app.post('/a/:alias/:page/formdesigner/:id/row', async (req: Req, reply) => {
|
|
583
|
+
const b = (req.body ?? {}) as Record<string, unknown>;
|
|
584
|
+
const formId = formIdOf(b.form);
|
|
585
|
+
const rowKey = str(b.row).slice(0, 200);
|
|
586
|
+
const posted: Record<string, string> = {};
|
|
587
|
+
for (const [k, v] of Object.entries(b)) if (k.startsWith('c:') && Object.keys(posted).length < FD_LIMITS.fields + 10) posted[k.slice(2)] = str(Array.isArray(v) ? v[0] : v).slice(0, FD_LIMITS.value);
|
|
588
|
+
return action(req, reply, { design: false }, async (ctx, r) => {
|
|
589
|
+
const t = ctx.locale.t;
|
|
590
|
+
const c = ctx.client!;
|
|
591
|
+
const designer = await mayDesign(ctx, r);
|
|
592
|
+
const form = await loadForm(ctx, r, formId, designer);
|
|
593
|
+
const source = form && fdSourcesOf(r).find((s) => s.id === str(form.definition?.source));
|
|
594
|
+
if (!form || !source) throw new UserError(t('formdesigner.form_not_found'));
|
|
595
|
+
const { cols, key } = await fdColumns(c, source);
|
|
596
|
+
const def = checkFormDef(form.definition, source, cols, key);
|
|
597
|
+
const label = (f: Field) => f.label || ctx.locale.tr(source.columns.find((x) => x.name === f.column)?.label || heading(f.column));
|
|
598
|
+
const back: [string, string][] = def.list.length ? [['form', String(form.id)]] : def.actions.insert ? [['form', String(form.id)], ['row', 'new']] : [];
|
|
599
|
+
const obj = objectSql(source);
|
|
600
|
+
|
|
601
|
+
if (b.mode === 'delete') {
|
|
602
|
+
if (!def.actions.delete || !key || !rowKey) throw new UserError(t('formdesigner.not_allowed'));
|
|
603
|
+
const n = (await savepoint(c, () => c.query(`delete from ${obj} where ${id(key)} = $1`, [rowKey]))).rowCount;
|
|
604
|
+
if (!n) throw new UserError(t('formdesigner.row_not_found'));
|
|
605
|
+
logActivity({ appId: ctx.app.id, pageNo: ctx.page.page_no, username: ctx.user, event: 'form_designer', ip: ctx.ip, detail: `form ${form.id}: deleted a row of ${source.schema}.${source.table}` });
|
|
606
|
+
return { message: t('formdesigner.deleted_row'), back: def.list.length ? [['form', String(form.id)]] : [] };
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const isNew = rowKey === '';
|
|
610
|
+
if (isNew ? !def.actions.insert : !def.actions.update || !key) throw new UserError(t('formdesigner.not_allowed'));
|
|
611
|
+
// the form's fields only: the values the user typed, checked
|
|
612
|
+
const errors: string[] = [];
|
|
613
|
+
const values: (string | null)[] = [];
|
|
614
|
+
for (const f of def.fields) {
|
|
615
|
+
const col = cols.get(f.column)!;
|
|
616
|
+
const v = postedValue(ctx, col, posted[f.column]);
|
|
617
|
+
if (f.required && col.type !== BOOL && v === null) errors.push(t('formdesigner.required', { field: label(f) }));
|
|
618
|
+
else if (v !== null && col.lov && (f.display === 'select' || f.display === 'radio') && !(await lovContains(ctx, col.lov, [v]))) errors.push(t('formdesigner.not_in_list', { field: label(f) }));
|
|
619
|
+
values.push(v);
|
|
620
|
+
}
|
|
621
|
+
if (errors.length) throw new UserError(errors.join(' '));
|
|
622
|
+
if (isNew) {
|
|
623
|
+
await savepoint(c, () => c.query(`insert into ${obj} (${def.fields.map((f) => id(f.column)).join(', ')}) values (${values.map((_, i) => `$${i + 1}`).join(', ')})`, values));
|
|
624
|
+
} else {
|
|
625
|
+
const n = (await savepoint(c, () => c.query(
|
|
626
|
+
`update ${obj} set ${def.fields.map((f, i) => `${id(f.column)} = $${i + 1}`).join(', ')}
|
|
627
|
+
where ${id(key!)} = $${values.length + 1} and ${hashSql(def)} = $${values.length + 2}`, [...values, rowKey, str(b.version)]))).rowCount;
|
|
628
|
+
if (!n) {
|
|
629
|
+
// gone (or hidden), changed meanwhile, or not ours to change (row level security)
|
|
630
|
+
const now = (await savepoint(c, () => c.query({ text: `select ${hashSql(def)} from ${obj} where ${id(key!)} = $1 limit 1`, values: [rowKey], rowMode: 'array' }))).rows[0];
|
|
631
|
+
throw new UserError(t(!now ? 'formdesigner.row_not_found' : now[0] === str(b.version) ? 'formdesigner.not_allowed' : 'formdesigner.changed'));
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
logActivity({ appId: ctx.app.id, pageNo: ctx.page.page_no, username: ctx.user, event: 'form_designer', ip: ctx.ip, detail: `form ${form.id}: ${isNew ? 'added' : 'changed'} a row of ${source.schema}.${source.table}` });
|
|
635
|
+
return { message: t('formdesigner.saved_row'), back };
|
|
636
|
+
}, (ctx, r, message) => {
|
|
637
|
+
// back to the row's form with what was typed, and the message
|
|
638
|
+
const draft: Draft = { form: formId, row: rowKey, values: posted, error: message };
|
|
639
|
+
const raw = JSON.stringify(draft);
|
|
640
|
+
if (raw.length <= 64_000) ctx.session.state[draftKey(r)] = raw;
|
|
641
|
+
else ctx.session.state.__FLASH_ERROR = message;
|
|
642
|
+
return formId ? [['form', String(formId)], ['row', rowKey || 'new']] : [];
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
// a form's design: save (new or existing)
|
|
647
|
+
app.post('/a/:alias/:page/formdesigner/:id/design', async (req: Req, reply) =>
|
|
648
|
+
action(req, reply, { design: true }, async (ctx, r) => {
|
|
649
|
+
const t = ctx.locale.t;
|
|
650
|
+
const c = ctx.client!;
|
|
651
|
+
const b = (req.body ?? {}) as Record<string, unknown>;
|
|
652
|
+
const source = fdSourcesOf(r).find((s) => s.id === str(b.src));
|
|
653
|
+
if (!source) throw new UserError(t('formdesigner.unknown_source'));
|
|
654
|
+
const existing = formIdOf(b.form) || null;
|
|
655
|
+
const back: [string, string][] = existing ? [['design', String(existing)]] : [['design', 'new'], ['src', source.id]];
|
|
656
|
+
const { cols, key } = await fdColumns(c, source);
|
|
657
|
+
const def = checkFormDef(formDefFromBody(b, source.id), source, cols, key);
|
|
658
|
+
const name = clip(b.name, 80);
|
|
659
|
+
if (!name) return { message: t('formdesigner.name_required'), back, error: true };
|
|
660
|
+
if (!def.fields.length) return { message: t('formdesigner.no_fields'), back, error: true };
|
|
661
|
+
const formId = (await c.query<{ id: number }>('select meta.save_user_form($1, $2, $3, $4, $5::jsonb, $6) as id',
|
|
662
|
+
[r.id, existing, name, clip(b.description, 500), JSON.stringify(def), b.published === 'true'])).rows[0].id;
|
|
663
|
+
logActivity({ appId: ctx.app.id, pageNo: ctx.page.page_no, username: ctx.user, event: 'form_designer', ip: ctx.ip, detail: `form ${formId} "${name}" saved` });
|
|
664
|
+
return { message: t('formdesigner.form_saved', { name }), back: [['form', String(formId)]] };
|
|
665
|
+
}),
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
app.post('/a/:alias/:page/formdesigner/:id/delete', async (req: Req, reply) =>
|
|
669
|
+
action(req, reply, { design: true }, async (ctx, r) => {
|
|
670
|
+
const formId = formIdOf(req.body?.form);
|
|
671
|
+
const done = (await ctx.client!.query<{ d: boolean }>('select meta.delete_user_form($1, $2) as d', [r.id, formId])).rows[0].d;
|
|
672
|
+
if (done) logActivity({ appId: ctx.app.id, pageNo: ctx.page.page_no, username: ctx.user, event: 'form_designer', ip: ctx.ip, detail: `form ${formId} deleted` });
|
|
673
|
+
return { message: ctx.locale.t(done ? 'formdesigner.form_deleted' : 'formdesigner.form_not_found'), back: [] };
|
|
674
|
+
}),
|
|
675
|
+
);
|
|
676
|
+
}
|