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.
Files changed (71) hide show
  1. package/db/migrations/106_form_designer.sql +102 -0
  2. package/db/migrations/107_app_version.sql +9 -0
  3. package/docs/apex-feature-parity.md +6 -3
  4. package/docs/guide/03-builder.md +36 -12
  5. package/docs/guide/04-pages-and-regions.md +74 -2
  6. package/docs/guide/05-items.md +14 -2
  7. package/docs/guide/09-reference.md +1 -1
  8. package/docs/guide/11-from-apex.md +2 -1
  9. package/docs/guide/12-development.md +6 -1
  10. package/examples/hr/hr_61_form_designer.sql +80 -0
  11. package/package.json +1 -1
  12. package/public/app.css +62 -2
  13. package/public/app.js +244 -35
  14. package/public/builder.css +21 -1
  15. package/public/builder.js +67 -0
  16. package/public/sw.js +2 -1
  17. package/src/app.ts +9 -1
  18. package/src/builder/appwizard.ts +22 -6
  19. package/src/builder/arrange.ts +33 -12
  20. package/src/builder/code-editor.ts +1 -1
  21. package/src/builder/components.ts +1 -1
  22. package/src/builder/designer.ts +44 -17
  23. package/src/builder/errors.ts +65 -0
  24. package/src/builder/form-designer.ts +229 -0
  25. package/src/builder/locks.ts +6 -0
  26. package/src/builder/newapp.ts +2 -1
  27. package/src/builder/object-browser.ts +81 -18
  28. package/src/builder/region-help.ts +2 -0
  29. package/src/builder/region-settings.ts +2 -0
  30. package/src/builder/reporter.ts +1 -1
  31. package/src/builder/routes.ts +7 -5
  32. package/src/builder/search.ts +3 -3
  33. package/src/builder/shared.ts +2 -2
  34. package/src/builder/ui.ts +2 -0
  35. package/src/cli/replace.ts +10 -0
  36. package/src/i18n/ar.ts +62 -0
  37. package/src/i18n/cs.ts +62 -0
  38. package/src/i18n/da.ts +62 -0
  39. package/src/i18n/de.ts +62 -0
  40. package/src/i18n/el.ts +62 -0
  41. package/src/i18n/es.ts +62 -0
  42. package/src/i18n/fi.ts +62 -0
  43. package/src/i18n/fr.ts +62 -0
  44. package/src/i18n/he.ts +62 -0
  45. package/src/i18n/it.ts +62 -0
  46. package/src/i18n/ja.ts +62 -0
  47. package/src/i18n/ko.ts +62 -0
  48. package/src/i18n/nb.ts +62 -0
  49. package/src/i18n/pl.ts +62 -0
  50. package/src/i18n/pt.ts +62 -0
  51. package/src/i18n/ru.ts +62 -0
  52. package/src/i18n/sv.ts +62 -0
  53. package/src/i18n/tr.ts +62 -0
  54. package/src/i18n/uk.ts +62 -0
  55. package/src/i18n/zh.ts +62 -0
  56. package/src/i18n.ts +124 -0
  57. package/src/metadata.ts +4 -2
  58. package/src/runtime/devbar.ts +1 -1
  59. package/src/runtime/facet-state.ts +10 -5
  60. package/src/runtime/facets.ts +29 -5
  61. package/src/runtime/form-designer.ts +676 -0
  62. package/src/runtime/grid.ts +15 -8
  63. package/src/runtime/items.ts +33 -21
  64. package/src/runtime/lists.ts +1 -1
  65. package/src/runtime/pdf.ts +2 -1
  66. package/src/runtime/regions.ts +4 -0
  67. package/src/runtime/render.ts +7 -3
  68. package/src/runtime/report.ts +2 -2
  69. package/src/runtime/routes.ts +3 -1
  70. package/src/runtime/smart-filters.ts +2 -1
  71. package/src/runtime/tasks.ts +6 -3
@@ -268,7 +268,8 @@ export async function createApp(c: Client, a: CheckedApp): Promise<{ id: number;
268
268
  if (base !== 'standard') theme.base = base;
269
269
  if (a.topNav) theme.nav = 'top';
270
270
  if (a.accent) theme.accent = a.accent;
271
- const r = await c.query('insert into meta.app (alias, name, authentication, db_role, theme, language) values ($1, $2, $3, $4, $5, $6) returning id', [a.alias, a.name, a.authentication, a.role, theme, a.language ?? 'en']);
271
+ // (107) a new application starts at Release 1.0, like APEX's
272
+ const r = await c.query(`insert into meta.app (alias, name, authentication, db_role, theme, language, version) values ($1, $2, $3, $4, $5, $6, 'Release 1.0') returning id`, [a.alias, a.name, a.authentication, a.role, theme, a.language ?? 'en']);
272
273
  const appId = r.rows[0].id as number;
273
274
  await placeApp(c, appId, a.workspaceId);
274
275
  const p = await c.query(`insert into meta.page (app_id, page_no, name, title) values ($1, 1, 'Home', 'Home') returning id`, [appId]);
@@ -4,7 +4,9 @@ import { html, raw, type Raw } from '../html.ts';
4
4
  import { icon } from '../icons.ts';
5
5
  import { readSchema } from '../ddl.ts';
6
6
  import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Req } from './ui.ts';
7
- import { sessionSchema, workshopDb } from './workspaces.ts';
7
+ import { currentWorkspace, inWorkspaceSql, sessionSchema, workshopDb } from './workspaces.ts';
8
+ import { owner } from '../db.ts';
9
+ import type { Session } from '../session.ts';
8
10
 
9
11
  // (sprint 75) The SQL Workshop's Object Browser like APEX's: a schema in the header (the session's
10
12
  // schema by default), a tree with a folder per object type, a filter, a + menu that starts a CREATE
@@ -61,6 +63,23 @@ export function createTemplate(kind: string, schema: string): string | null {
61
63
  }
62
64
 
63
65
  const PAGE_SIZE = 25;
66
+
67
+ /**
68
+ * (sprint 89, SQL-4) The parsing schema of the application changed last in the developer's workspace: the
69
+ * schema its database role may use (not public, pgkiln's or the system's). APEX opens the Object Browser
70
+ * on the workspace's parsing schema.
71
+ */
72
+ async function parsingSchema(s: Session): Promise<string | null> {
73
+ const row = await owner.one<{ n: string }>(
74
+ `select n.nspname as n
75
+ from meta.app a join pg_roles r on r.rolname = a.db_role
76
+ join pg_namespace n on has_schema_privilege(r.oid, n.oid, 'USAGE')
77
+ where ${inWorkspaceSql('a', 1)} and n.nspname !~ '^pg_' and n.nspname not in ('information_schema', 'public') and not (n.nspname = any ($2))
78
+ order by a.updated_at desc, a.id desc, n.nspname limit 1`,
79
+ [currentWorkspace(s), SYSTEM_SCHEMAS],
80
+ ).catch(() => undefined);
81
+ return row?.n ?? null;
82
+ }
64
83
  /** A column type in an action: a type name with an optional size and array brackets. */
65
84
  const TYPE_RE = /^[a-z][a-z0-9_ ]{0,40}(\(\d{1,4}(,\s?\d{1,4})?\))?(\[\])?$/i;
66
85
  const NAME_RE = /^[A-Za-z_][A-Za-z0-9_$]{0,62}$/;
@@ -81,8 +100,15 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
81
100
  [system, SYSTEM_SCHEMAS],
82
101
  )
83
102
  ).rows.map((r) => r.n);
84
- const own = await sessionSchema(s, db);
85
- const schema = typeof q.schema === 'string' && schemas.includes(q.schema) ? q.schema : own && schemas.includes(own) ? own : (schemas[0] ?? '');
103
+ // (sprint 89, SQL-4) the schema asked for, else the last one shown (remembered per session), else the session's
104
+ // schema of SQL Commands, else the parsing schema of the application changed last, else the first
105
+ const usable = (v: unknown): v is string => typeof v === 'string' && schemas.includes(v);
106
+ const asked = usable(q.schema) ? q.schema : null;
107
+ const last = s.state.__OBSCHEMA;
108
+ const own = asked || usable(last) ? null : await sessionSchema(s, db);
109
+ const parsing = asked || usable(last) || usable(own) ? null : await parsingSchema(s);
110
+ const schema = asked ?? (usable(last) ? last : usable(own) ? own : usable(parsing) ? parsing : (schemas[0] ?? ''));
111
+ if (schema && schema !== last) s.state.__OBSCHEMA = schema;
86
112
  const filter = typeof q.q === 'string' ? q.q.trim().slice(0, 100) : '';
87
113
  const objects = schema ? await listObjects(db, schema, system) : [];
88
114
  const shown = objects.filter((o) => !filter || o.name.toLowerCase().includes(filter.toLowerCase()));
@@ -161,6 +187,24 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
161
187
  if (!cols.includes(col)) problem = 'Choose a column of the table.';
162
188
  else if (!NAME_RE.test(to) || cols.includes(to)) problem = 'The new name must be a valid name that the table doesn\'t have yet.';
163
189
  else sql = `alter table ${table.q} rename column ${ident(col)} to ${ident(to)};`;
190
+ } else if (b.action === 'modify') {
191
+ // (sprint 89, SQL-4) type, nulls and default in one ALTER TABLE; a default is one expression, never a statement
192
+ const type = String(b.type ?? '').trim();
193
+ const expr = String(b.default ?? '').trim();
194
+ const parts: string[] = [];
195
+ const c = ident(col);
196
+ if (!cols.includes(col)) problem = 'Choose a column of the table.';
197
+ else if (type && !TYPE_RE.test(type)) problem = 'A type is a name with an optional size, e.g. text, numeric(9,2), varchar(40), int[].';
198
+ else if (b.default_action === 'set' && (!expr || expr.length > 200 || /;|--|\/\*/.test(expr))) problem = 'A default is one expression (at most 200 characters, without ; or comments), e.g. now(), 0 or \'new\'.';
199
+ else {
200
+ if (type) parts.push(`alter column ${c} type ${type} using ${c}::${type}`);
201
+ if (b.nullable === 'not_null') parts.push(`alter column ${c} set not null`);
202
+ else if (b.nullable === 'null') parts.push(`alter column ${c} drop not null`);
203
+ if (b.default_action === 'set') parts.push(`alter column ${c} set default ${expr}`);
204
+ else if (b.default_action === 'drop') parts.push(`alter column ${c} drop default`);
205
+ if (!parts.length) problem = 'Nothing to change: choose a new type, nulls or a default.';
206
+ else sql = `alter table ${table.q}\n ${parts.join(',\n ')};`;
207
+ }
164
208
  } else if (b.action === 'drop') {
165
209
  if (!cols.includes(col)) problem = 'Choose a column of the table.';
166
210
  else sql = `alter table ${table.q} drop column ${ident(col)};`;
@@ -210,7 +254,7 @@ async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s: { csr
210
254
  const table = (rows: Record<string, unknown>[], empty = 'None.') =>
211
255
  rows.length
212
256
  ? html`<div class="table-wrap"><table class="report"><thead><tr>${Object.keys(rows[0]).map((k) => html`<th scope="col">${k}</th>`)}</tr></thead>
213
- <tbody>${rows.map((r) => html`<tr>${Object.values(r).map((v) => html`<td>${v === null ? html`<span class="null">null</span>` : typeof v === 'object' ? JSON.stringify(v) : String(v)}</td>`)}</tr>`)}</tbody></table></div>`
257
+ <tbody>${rows.map((r) => html`<tr>${Object.values(r).map((v) => html`<td>${v === null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v)}</td>`)}</tr>`)}</tbody></table></div>`
214
258
  : html`<p class="muted">${empty}</p>`;
215
259
  const ddlOf = async (match: (x: { kind: string; name: string; table?: string }) => boolean) => {
216
260
  const objects = await readSchema(db.pool as pg.Pool, schema).catch(() => []);
@@ -247,7 +291,14 @@ async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s: { csr
247
291
  const isTable = o.type === 'table';
248
292
  const tabs = TABLE_TABS.filter(([k]) => isTable || !['indexes', 'constraints', 'triggers', 'rls'].includes(k) || (o.type === 'matview' && k === 'indexes'));
249
293
  const tab = tabs.some(([k]) => k === tabAsked) ? tabAsked : 'columns';
250
- const nav = html`<nav class="settings-tabs ob-tabs" aria-label="${o.name}">${tabs.map(([k, label]) => html`<a href="${link({ k: o.type, o: o.ref, tab: k })}"${k === tab ? raw(' aria-current="page"') : ''}>${label}</a>`)}</nav>`;
294
+ // (sprint 89, SQL-4) the tabs on one row: the main ones and the current one, the rest under More
295
+ const MAIN = ['columns', 'data', 'indexes', 'constraints', 'ddl'];
296
+ const tabLink = ([k, label]: readonly [string, string]) => html`<a href="${link({ k: o.type, o: o.ref, tab: k })}"${k === tab ? raw(' aria-current="page"') : ''}>${label}</a>`;
297
+ const shownTabs = tabs.filter(([k]) => MAIN.includes(k) || k === tab);
298
+ const moreTabs = tabs.filter(([k]) => !shownTabs.some(([x]) => x === k));
299
+ const nav = html`<nav class="settings-tabs ob-tabs" aria-label="${o.name}">${shownTabs.map(tabLink)}${moreTabs.length
300
+ ? html`<details class="menu ob-more"><summary>More ${icon('chevron-down')}</summary><div class="menu-panel"><div class="menu-section menu-links">${moreTabs.map(tabLink)}</div></div></details>`
301
+ : ''}</nav>`;
251
302
  let body: Raw;
252
303
  switch (tab) {
253
304
  case 'columns': {
@@ -262,20 +313,32 @@ async function objectDetail(db: Awaited<ReturnType<typeof workshopDb>>, s: { csr
262
313
  ).rows;
263
314
  const names = cols.map((c) => String(c.column));
264
315
  const hidden = html`${raw(`<input type="hidden" name="__csrf" value="${s.csrf_token}">`)}<input type="hidden" name="table" value="${o.ref}"><input type="hidden" name="schema" value="${schema}">`;
265
- const pick = (id: string) => html`<label class="sr-only" for="${id}">Column</label><select id="${id}" name="column">${names.map((n) => html`<option>${n}</option>`)}</select>`;
266
- body = html`${table(cols)}${isTable
267
- ? html`<div class="ob-actions">
268
- <form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="add">
269
- <b>Add column</b><label class="sr-only" for="ob-add-name">Name</label><input id="ob-add-name" name="column" placeholder="name" required>
270
- <label class="sr-only" for="ob-add-type">Type</label><input id="ob-add-type" name="type" placeholder="text" required>
271
- <label class="check"><input type="checkbox" name="not_null" value="true"> not null</label><button class="btn btn-sm">Show the SQL</button></form>
272
- <form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="rename">
273
- <b>Rename column</b>${pick('ob-ren-col')}<label class="sr-only" for="ob-ren-to">New name</label><input id="ob-ren-to" name="new_name" placeholder="new name" required><button class="btn btn-sm">Show the SQL</button></form>
274
- <form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="drop">
275
- <b>Drop column</b>${pick('ob-drop-col')}<button class="btn btn-sm">Show the SQL</button></form>
276
- <p class="muted u-m0">Each action writes the ALTER TABLE statement into SQL Commands: review it there and press Run.</p>
316
+ const pick = (id: string) => html`<label class="label" for="${id}">Column</label><select id="${id}" name="column">${names.map((n) => html`<option>${n}</option>`)}</select>`;
317
+ // (sprint 89, SQL-4; APEX: the Columns toolbar) Add, Modify, Rename and Drop above the columns, each a small form
318
+ body = html`${isTable
319
+ ? html`<div class="ob-toolbar" role="toolbar" aria-label="Columns">
320
+ <details class="menu ob-tool"><summary class="btn btn-sm">${icon('plus')} Add column</summary><div class="menu-panel">
321
+ <form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="add">
322
+ <label class="label" for="ob-add-name">Name</label><input id="ob-add-name" name="column" placeholder="name" required>
323
+ <label class="label" for="ob-add-type">Type</label><input id="ob-add-type" name="type" placeholder="text" required>
324
+ <label class="check"><input type="checkbox" name="not_null" value="true"> not null</label><button class="btn btn-sm btn-hot">Show the SQL</button></form></div></details>
325
+ <details class="menu ob-tool"><summary class="btn btn-sm">${icon('edit')} Modify column</summary><div class="menu-panel">
326
+ <form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="modify">
327
+ ${pick('ob-mod-col')}
328
+ <label class="label" for="ob-mod-type">New type</label><input id="ob-mod-type" name="type" placeholder="the same">
329
+ <label class="label" for="ob-mod-null">Nulls</label><select id="ob-mod-null" name="nullable"><option value="">the same</option><option value="null">allowed</option><option value="not_null">not null</option></select>
330
+ <label class="label" for="ob-mod-def">Default</label><select id="ob-mod-def" name="default_action"><option value="">the same</option><option value="set">set to</option><option value="drop">none</option></select>
331
+ <label class="sr-only" for="ob-mod-expr">Default expression</label><input id="ob-mod-expr" name="default" placeholder="now(), 0, 'x'">
332
+ <button class="btn btn-sm btn-hot">Show the SQL</button></form></div></details>
333
+ <details class="menu ob-tool"><summary class="btn btn-sm">${icon('tag')} Rename column</summary><div class="menu-panel">
334
+ <form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="rename">
335
+ ${pick('ob-ren-col')}<label class="label" for="ob-ren-to">New name</label><input id="ob-ren-to" name="new_name" placeholder="new name" required><button class="btn btn-sm btn-hot">Show the SQL</button></form></div></details>
336
+ <details class="menu ob-tool"><summary class="btn btn-sm">${icon('trash')} Drop column</summary><div class="menu-panel">
337
+ <form method="post" action="${BASE}/sql/objects/alter" class="ob-action">${hidden}<input type="hidden" name="action" value="drop">
338
+ ${pick('ob-drop-col')}<button class="btn btn-sm btn-danger">Show the SQL</button></form></div></details>
339
+ <span class="muted ob-toolbar-note">Each writes the ALTER TABLE statement into SQL Commands: review it there and press Run.</span>
277
340
  </div>`
278
- : ''}`;
341
+ : ''}${table(cols)}`;
279
342
  break;
280
343
  }
281
344
  case 'data': {
@@ -21,6 +21,7 @@ export const REGION_CONFIG_HELP: [string[], string][] = [
21
21
  [['*'], '{"display_selector":true} puts it in the page\'s display selector'],
22
22
  [['report', 'chart', 'cards', 'dynamic', 'tree', 'template_component'], '{"lazy":true} loads it after the page shows, {"cache":{"scope":"user"|"session"|"all","seconds":300}} keeps its HTML (a submit of the page empties it)'],
23
23
  [['data_reporter'], '{"sources":[{"id":"orders","label":"Orders","description":"…","schema":"sales","table":"order_v","columns":[{"name":"total","label":"Total","format":"FML999G990D00"}]}],"sharing":true,"share_authz":"SCHEME","page_size":25} (the Data Reporter settings below edit it)'],
24
+ [['form_designer'], '{"sources":[{"id":"orders","label":"Orders","description":"…","schema":"sales","table":"orders","key":"id","columns":[{"name":"customer","label":"Customer"},{"name":"status","lov":"LOV:ORDER_STATUS"}],"insert":true,"update":true,"delete":false}],"design_authz":"SCHEME","page_size":25} (the Form Designer region\'s Settings; users that pass design_authz design forms)'],
24
25
  [['ai_assistant'], '{"service":"NAME","system":"…","welcome":"…","context":[{"name":"policy","sql":"select … :AI_PROMPT …"}],"tools":[{"name":"my_leave","description":"…","sql":"select … where status = :STATUS","parameters":{"STATUS":{"type":"string","enum":["PENDING"]}}},{"name":"dept","type":"rest","source":"DEPARTMENT","description":"…"}],"max_rounds":5,"max_turns":20} (the AI assistant settings below edit it)'],
25
26
  [['report'], '{"ai_filter":{"service":"NAME"}} adds an "Ask in your own words" box (natural-language filters)'],
26
27
  [['plugin'], '{"plugin":"name","attributes":{"NAME":"value"}} (a region plug-in; a Source SELECT feeds its template)'],
@@ -45,6 +46,7 @@ const ANCHORS: Record<string, string> = {
45
46
  tree: 'tree',
46
47
  list: 'list-lists',
47
48
  data_reporter: 'data-reporter-data-reporter',
49
+ form_designer: 'form_designer-form-designer',
48
50
  ai_assistant: 'ai-assistant-ai-assistant',
49
51
  template_component: 'template-components',
50
52
  static: 'static-and-dynamic-content',
@@ -16,6 +16,7 @@ import { LIST_TEMPLATES } from '../runtime/lists.ts';
16
16
  import { MAX_EXTRA_LAYERS } from '../runtime/maps.ts';
17
17
  import { postgis } from '../runtime/spatial.ts';
18
18
  import { reporterSettingsForm } from './reporter.ts';
19
+ import { formDesignerSettingsForm } from './form-designer.ts';
19
20
 
20
21
  // Page designer → a region → Settings: the region's "config" JSON as a form
21
22
  // for grid, chart, cards, calendar and faceted search regions (report
@@ -732,6 +733,7 @@ export async function regionSettingsForm(pageId: number, appId: number, r: Regio
732
733
  if (r.type === 'ai_assistant') return assistantSettingsForm(pageId, appId, r, s);
733
734
  if (r.type === 'template_component') return templateRegionForm(pageId, appId, r, s);
734
735
  if (r.type === 'data_reporter') return reporterSettingsForm(pageId, appId, r, s);
736
+ if (r.type === 'form_designer') return formDesignerSettingsForm(pageId, appId, r, s);
735
737
  if (!(SETTINGS_TYPES as readonly string[]).includes(r.type)) return '';
736
738
  const cfg = r.config ?? {};
737
739
  const id = (n: string) => `rg_${r.id}_${n}`;
@@ -43,7 +43,7 @@ async function objectByOid(oid: number): Promise<DbObject | null> {
43
43
  }
44
44
 
45
45
  /** The tables and views to choose from, with whether the application's role may read them. */
46
- async function objectList(dbRole: string | null) {
46
+ export async function objectList(dbRole: string | null) {
47
47
  return (
48
48
  await owner.query<{ oid: number; name: string; access: boolean }>(
49
49
  `select c.oid::int as oid, format('%I.%I', n.nspname, c.relname) as name,
@@ -13,7 +13,7 @@ import { documentShell } from '../layout.ts';
13
13
  import { passwordProblem } from '../accounts.ts';
14
14
  import { clientIp, createSession, destroySession, getSession, loginThrottled, logActivity, saveState, takeFlash } from '../session.ts';
15
15
  import { loginSettingsFields, saveLoginSettings } from './login-page.ts';
16
- import { APP_COLORS, appHeader, back, BASE, builderHead, csrf, developer, flash, input, region, select, send, shell, THEME_COOKIE, validTheme, type Req } from './ui.ts';
16
+ import { APP_COLORS, appHeader, back, BASE, designerUrl, builderHead, csrf, developer, flash, input, region, select, send, shell, THEME_COOKIE, validTheme, type Req } from './ui.ts';
17
17
  import { appOr404 } from './forms.ts';
18
18
  import { docToFiles, filesToZip } from '../appfiles.ts';
19
19
  import { homeRoutes, rememberApp } from './home.ts';
@@ -278,11 +278,11 @@ export async function builderRoutes(app: FastifyInstance) {
278
278
  <select id="f_group" name="group">${[['', 'All pages'], ...groups.map((g) => [g, g]), ['-', 'Without a group']].map(([v, l]) => html`<option value="${v}"${v === groupFilter ? raw(' selected') : ''}>${l}</option>`)}</select></div>
279
279
  <button class="btn u-selfend">Show</button></form>`
280
280
  : ''}${icons
281
- ? html`<ul class="page-icons" id="pages-rows">${shownPages.map((p) => html`<li><a href="${BASE}/pages/${p.id}" class="page-icon"><span class="page-icon-no">${p.page_no}</span><b>${p.name}</b><small class="muted">${p.mode}, ${p.regions} region${p.regions === 1 ? '' : 's'}</small></a></li>`)}</ul>`
281
+ ? html`<ul class="page-icons" id="pages-rows">${shownPages.map((p) => html`<li><a href="${designerUrl(a.id, p.page_no)}" class="page-icon"><span class="page-icon-no">${p.page_no}</span><b>${p.name}</b><small class="muted">${p.mode}, ${p.regions} region${p.regions === 1 ? '' : 's'}</small></a></li>`)}</ul>`
282
282
  : html`<div class="table-wrap"><table class="report">
283
283
  <thead><tr><th class="num">Page</th><th>Name</th>${grouped ? html`<th>Group</th>` : ''}<th>Mode</th><th class="num">Regions</th><th class="num">Items</th><th class="num">DAs</th><th class="num">Processes</th><th>Authorization</th><th>Protection</th><th>Lock</th><th></th></tr></thead>
284
284
  <tbody id="pages-rows">${shownPages.map((p) => html`<tr>
285
- <td class="num">${p.page_no}</td><td><a href="${BASE}/pages/${p.id}">${p.name}</a>${commentsPerPage.get(p.page_no) ? html` <span class="tag" title="Developer comments">${commentsPerPage.get(p.page_no)} comment${commentsPerPage.get(p.page_no) === 1 ? '' : 's'}</span>` : ''}</td>${grouped ? html`<td>${p.page_group ?? '—'}</td>` : ''}<td>${p.mode}</td>
285
+ <td class="num">${p.page_no}</td><td><a href="${designerUrl(a.id, p.page_no)}">${p.name}</a>${commentsPerPage.get(p.page_no) ? html` <span class="tag" title="Developer comments">${commentsPerPage.get(p.page_no)} comment${commentsPerPage.get(p.page_no) === 1 ? '' : 's'}</span>` : ''}</td>${grouped ? html`<td>${p.page_group ?? '—'}</td>` : ''}<td>${p.mode}</td>
286
286
  <td class="num">${p.regions}</td><td class="num">${p.items}</td><td class="num">${p.das}</td><td class="num">${p.processes}</td>
287
287
  <td>${p.requires_auth ? (p.authz ?? '—') : 'public'}</td><td>${p.protection}</td>
288
288
  <td>${((l) => (l ? html`<span class="lock-tag" title="${lockText(l)}">${icon('key')}<span>${l.locked_by === s.username ? 'you' : l.locked_by}</span></span>` : '—'))(locks.get(p.page_no))}</td>
@@ -401,6 +401,7 @@ export async function builderRoutes(app: FastifyInstance) {
401
401
  <div class="form-grid">
402
402
  ${input('name', 'Name', a.name, { required: true })}
403
403
  ${input('alias', 'Alias', a.alias, { required: true })}
404
+ ${input('version', 'Version', a.version ?? '', { placeholder: 'Release 1.0', help: 'Shown in the footer of the application\'s pages; empty: none.' })}
404
405
  ${input('home_page', 'Home page', a.home_page, { type: 'number' })}
405
406
  ${select('app_type', 'Application type', a.app_type ?? 'standard', APP_TYPES.map((t): [string, string] => [t, APP_TYPE_LABELS[t]]),
406
407
  'Theme and library applications offer their theme or shared components to other applications (Shared Components → Subscriptions); Create application can start from a boilerplate application.')}
@@ -546,7 +547,7 @@ export async function builderRoutes(app: FastifyInstance) {
546
547
  date_format = $14, timestamp_format = $15, remember_me_days = $16, ldap_directories = $17,
547
548
  header_name = $18, header_auto_create = $19, logout_url = $20, db_auth_roles = $21, db_auth_member_of = $22,
548
549
  custom_auth_function = $23, custom_auth_code = $24, custom_auth_post_code = $25, nav_list = $26, navbar_list = $27,
549
- app_type = $28, session_group = $29, updated_at = now() where id = $1`,
550
+ app_type = $28, session_group = $29, version = $30, updated_at = now() where id = $1`,
550
551
  [req.params.id, b.name?.trim(), b.alias?.trim().toLowerCase(), Number(b.home_page) || 1, b.authentication, b.db_role?.trim() || null, b.debug === 'true',
551
552
  JSON.stringify({
552
553
  base: baseStyleOf({ base: b.base }) === 'standard' ? undefined : baseStyleOf({ base: b.base }),
@@ -579,7 +580,8 @@ export async function builderRoutes(app: FastifyInstance) {
579
580
  b.nav_list?.trim().toUpperCase() || null,
580
581
  b.navbar_list?.trim().toUpperCase() || null,
581
582
  (APP_TYPES as readonly string[]).includes(b.app_type ?? '') ? b.app_type : 'standard',
582
- /^[a-z][a-z0-9_]{0,29}$/.test(b.session_group?.trim().toLowerCase() ?? '') ? b.session_group!.trim().toLowerCase() : null],
583
+ /^[a-z][a-z0-9_]{0,29}$/.test(b.session_group?.trim().toLowerCase() ?? '') ? b.session_group!.trim().toLowerCase() : null,
584
+ (b.version ?? '').trim().slice(0, 40) || null],
583
585
  );
584
586
  await saveTimeZoneSettings(req.params.id, b);
585
587
  await saveLoginSettings(req.params.id, b);
@@ -3,7 +3,7 @@ import { owner } from '../db.ts';
3
3
  import { html, type Raw } from '../html.ts';
4
4
  import { icon } from '../icons.ts';
5
5
  import { COMPONENTS, type FieldKind } from './components.ts';
6
- import { appHeader, BASE, developer, region, send, shell, type Req } from './ui.ts';
6
+ import { appHeader, BASE, designerUrl, developer, region, send, shell, type Req } from './ui.ts';
7
7
 
8
8
  // App Builder → Search and "Where used": every component of an application
9
9
  // (pages, regions, items, buttons, dynamic actions, validations, processes
@@ -46,7 +46,7 @@ export async function appEntries(appId: number, query: (sql: string, params?: un
46
46
  const pageById = new Map(pages.map((p) => [p.id, p]));
47
47
  const out: Entry[] = pages.map((p) => ({
48
48
  kind: 'page', id: p.id, label: `Page ${p.page_no}: ${p.name}`, kindLabel: 'Page', icon: 'file', pageNo: p.page_no,
49
- url: `${BASE}/pages/${p.id}?c=page`,
49
+ url: designerUrl(appId, p.page_no, 'c=page'),
50
50
  fields: PAGE_FIELDS.map((f) => ({ ...f, value: asText(p[f.name]) })),
51
51
  }));
52
52
  for (const [kind, spec] of Object.entries(COMPONENTS)) {
@@ -57,7 +57,7 @@ export async function appEntries(appId: number, query: (sql: string, params?: un
57
57
  const page = spec.scope === 'page' ? pageById.get(row.page_id) : null;
58
58
  out.push({
59
59
  kind, id: row.id, label: spec.summary(row), kindLabel: spec.label, icon: spec.icon, pageNo: page?.page_no ?? null,
60
- url: page ? `${BASE}/pages/${page.id}?c=${kind}-${row.id}` : `${BASE}/apps/${appId}/shared?c=${kind}-${row.id}`,
60
+ url: page ? designerUrl(appId, page.page_no, `c=${kind}-${row.id}`) : `${BASE}/apps/${appId}/shared?c=${kind}-${row.id}`,
61
61
  fields: spec.fields.filter((f) => TEXT_KINDS.has(f.kind)).map((f) => ({ name: f.name, label: f.label, kind: f.kind, value: asText(row[f.name]) })),
62
62
  });
63
63
  }
@@ -13,7 +13,7 @@ import { layoutExtras } from './layouts.ts';
13
13
  import { templateExtras, templateImport } from './templates.ts';
14
14
  import { credentialExtras, needsSecret, restSourceExtras } from './websources.ts';
15
15
  import { dataLoadDefExtras } from './dataload.ts';
16
- import { appHeader, back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
16
+ import { appHeader, back, BASE, designerUrl, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
17
17
  import { endSessions, grantAccess, roleHints, roleHintsHtml, splitRoles } from './users.ts';
18
18
  import { appOr404, componentForm, lookups, saveComponent } from './forms.ts';
19
19
  import { catalogPicker } from './rest-catalogs.ts';
@@ -89,7 +89,7 @@ export async function sharedRoutes(app: FastifyInstance) {
89
89
  if (!kids.length) return html``;
90
90
  return html`<ul class="tree">${kids.map((p) => {
91
91
  const next = new Set(seen).add(p.page_no);
92
- return html`<li><a href="${BASE}/pages/${p.id}">${icon('file')}<span>${p.name}</span><span class="kind">p${p.page_no}</span></a>${tree(p.page_no, next)}</li>`;
92
+ return html`<li><a href="${designerUrl(a.id, p.page_no)}">${icon('file')}<span>${p.name}</span><span class="kind">p${p.page_no}</span></a>${tree(p.page_no, next)}</li>`;
93
93
  })}</ul>`;
94
94
  };
95
95
  const main = html`${appHeader(a, 'shared')}
package/src/builder/ui.ts CHANGED
@@ -12,6 +12,8 @@ import { ensureDevbar } from '../runtime/devbar.ts';
12
12
  // Shared building blocks of the builder UI.
13
13
 
14
14
  export const BASE = '/builder';
15
+ /** (sprint 86) The page designer's address: the application's id and the page's number (/builder/pages/:id redirects here). */
16
+ export const designerUrl = (appId: number, pageNo: number, query = '') => `${BASE}/apps/${appId}/pages/${pageNo}${query ? `?${query}` : ''}`;
15
17
 
16
18
  /** pgkiln's version and the database name, for the builder's status bar. */
17
19
  const VERSION = (JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) as { version: string }).version;
@@ -51,6 +51,8 @@ export const KEPT = ['app_access', 'api_client', 'session', 'sso_pending', 'save
51
51
  'subscription',
52
52
  // (057) users' Data Reporter reports (user data, like saved reports)
53
53
  'data_report',
54
+ // (106) Form Designer forms, built by the application's administrators (installation data, like reports)
55
+ 'user_form',
54
56
  // (060) which AI services an application may use (installation data), its AI usage log and AI requests from SQL
55
57
  'app_ai_service', 'ai_usage', 'ai_request',
56
58
  // (061) AI assistant conversations (per session, follow their region)
@@ -79,6 +81,8 @@ const REPOINTED = ['saved_report.region_id', 'task.definition_id', 'workflow.def
79
81
  'rest_sync_log.source_id',
80
82
  // (057) Data Reporter reports follow their region like saved reports
81
83
  'data_report.region_id',
84
+ // (106) Form Designer forms follow their region
85
+ 'user_form.region_id',
82
86
  // (061) AI assistant conversations follow their region
83
87
  'ai_conversation.region_id'];
84
88
 
@@ -201,6 +205,12 @@ export async function replaceApp(db: Db, doc: unknown, alias: string, opts: Repl
201
205
  from unnest($1::int[], $2::int[]) as m(old, neu) where s.region_id = m.old`,
202
206
  [pairs.map((p) => p[0]), pairs.map((p) => p[1])],
203
207
  );
208
+ if (pairs.length)
209
+ await db.query(
210
+ `update meta.user_form s set region_id = m.neu
211
+ from unnest($1::int[], $2::int[]) as m(old, neu) where s.region_id = m.old`,
212
+ [pairs.map((p) => p[0]), pairs.map((p) => p[1])],
213
+ );
204
214
  if (pairs.length)
205
215
  await db.query(
206
216
  `update meta.ai_conversation s set region_id = m.neu
package/src/i18n/ar.ts CHANGED
@@ -63,6 +63,7 @@ export const ar: Record<MessageKey, string> = {
63
63
  'common.back': "رجوع",
64
64
  'common.home': "الانتقال إلى الصفحة الرئيسية",
65
65
  'common.skip': "تخطَّ إلى المحتوى",
66
+ 'common.back_to_top': "العودة إلى الأعلى",
66
67
  'common.toggle_nav': "إظهار التنقل أو إخفاؤه",
67
68
  'common.dismiss': "إغلاق",
68
69
  // messages and errors
@@ -390,6 +391,9 @@ export const ar: Record<MessageKey, string> = {
390
391
  'facets.stars_up': "{n} نجوم فأكثر",
391
392
  'facets.star_up': "نجمة واحدة فأكثر",
392
393
  'facets.clear_short': "مسح",
394
+ 'facets.menu': "خيارات {facet}",
395
+ 'facets.sort_count': "الترتيب حسب العدد",
396
+ 'facets.sort_name': "الترتيب حسب الاسم",
393
397
  'facets.show_all': "عرض الكل ({n})",
394
398
  'facets.total': "إجمالي عدد الصفوف {n}",
395
399
  'smart.placeholder': "بحث أو تصفية…",
@@ -685,6 +689,64 @@ export const ar: Record<MessageKey, string> = {
685
689
  'reporter.changed': "عُدِّل في {date}",
686
690
  'reporter.no_match': "لا توجد تقارير مطابقة للبحث.",
687
691
  'reporter.chart_tag': "مخطط",
692
+ // Form Designer (form-designer.ts)
693
+ 'formdesigner.no_sources': "لم تُعدّ أي جداول لهذه المنطقة بعد.",
694
+ 'formdesigner.no_forms': "لا توجد نماذج بعد.",
695
+ 'formdesigner.new': "نموذج جديد",
696
+ 'formdesigner.create': "تصميم",
697
+ 'formdesigner.draft': "مسودة",
698
+ 'formdesigner.design': "تصميم النموذج",
699
+ 'formdesigner.design_of': "تصميم {name}",
700
+ 'formdesigner.back': "كل النماذج",
701
+ 'formdesigner.add': "إضافة",
702
+ 'formdesigner.edit_row': "تعديل",
703
+ 'formdesigner.rows': "صفوف {form}",
704
+ 'formdesigner.save': "حفظ",
705
+ 'formdesigner.delete': "حذف",
706
+ 'formdesigner.delete_confirm': "حذف هذا الصف؟",
707
+ 'formdesigner.cancel': "إلغاء",
708
+ 'formdesigner.saved_row': "تم الحفظ.",
709
+ 'formdesigner.deleted_row': "تم الحذف.",
710
+ 'formdesigner.required': "الحقل {field} مطلوب.",
711
+ 'formdesigner.not_in_list': "{field}: اختر قيمة من القائمة.",
712
+ 'formdesigner.changed': "غيّر شخص آخر هذا الصف في هذه الأثناء. راجعه واحفظ مرة أخرى.",
713
+ 'formdesigner.row_not_found': "هذا الصف غير موجود، أو لا يُسمح لك برؤيته.",
714
+ 'formdesigner.not_allowed': "لا يُسمح لك بفعل ذلك بهذا الصف.",
715
+ 'formdesigner.form_not_found': "هذا النموذج غير موجود (بعد الآن).",
716
+ 'formdesigner.unknown_source': "لم يعد جدول هذا النموذج معروضًا.",
717
+ 'formdesigner.nothing': "لا يحتوي هذا النموذج على حقول بعد.",
718
+ 'formdesigner.choose': "- اختر -",
719
+ 'formdesigner.name': "الاسم",
720
+ 'formdesigner.description': "الوصف",
721
+ 'formdesigner.published': "منشور: يمكن لكل من يرى هذه الصفحة استخدام النموذج",
722
+ 'formdesigner.fields': "الحقول",
723
+ 'formdesigner.field_on': "في النموذج",
724
+ 'formdesigner.column': "العمود",
725
+ 'formdesigner.key': "مفتاح",
726
+ 'formdesigner.order': "الترتيب",
727
+ 'formdesigner.label': "التسمية",
728
+ 'formdesigner.help': "نص المساعدة",
729
+ 'formdesigner.required_short': "مطلوب",
730
+ 'formdesigner.display': "العرض",
731
+ 'formdesigner.default': "الافتراضي",
732
+ 'formdesigner.in_list': "في القائمة",
733
+ 'formdesigner.display_auto': "حقل",
734
+ 'formdesigner.display_textarea': "منطقة نص",
735
+ 'formdesigner.display_select': "قائمة اختيار",
736
+ 'formdesigner.display_radio': "مجموعة خيارات",
737
+ 'formdesigner.actions': "يمكن للمستخدمين",
738
+ 'formdesigner.allow_insert': "إضافة صفوف",
739
+ 'formdesigner.allow_update': "تغيير صفوف",
740
+ 'formdesigner.allow_delete': "حذف صفوف",
741
+ 'formdesigner.list_help': "تعرض قائمة الصفوف الأعمدة المحددة «في القائمة»؛ وبدونها يضيف النموذج صفوفًا فقط.",
742
+ 'formdesigner.save_form': "حفظ النموذج",
743
+ 'formdesigner.delete_form': "حذف النموذج",
744
+ 'formdesigner.delete_form_confirm': "حذف النموذج «{name}»؟ تبقى الصفوف التي حفظها.",
745
+ 'formdesigner.form_saved': "تم حفظ النموذج «{name}».",
746
+ 'formdesigner.form_deleted': "تم حذف النموذج.",
747
+ 'formdesigner.name_required': "أعطِ النموذج اسمًا.",
748
+ 'formdesigner.no_fields': "ضع عمودًا واحدًا على الأقل في النموذج.",
749
+ 'formdesigner.changed_by': "غيّره {user} في {date}",
688
750
  // AI assistant region and natural-language report filters (assistant.ts, ai-filter.ts)
689
751
  'assistant.conversation': "المحادثة",
690
752
  'assistant.you': "أنت",
package/src/i18n/cs.ts CHANGED
@@ -63,6 +63,7 @@ export const cs: Record<MessageKey, string> = {
63
63
  'common.back': "Zpět",
64
64
  'common.home': "Na úvodní stránku",
65
65
  'common.skip': "Přejít na obsah",
66
+ 'common.back_to_top': "Zpět nahoru",
66
67
  'common.toggle_nav': "Zobrazit nebo skrýt navigaci",
67
68
  'common.dismiss': "Zavřít",
68
69
  // messages and errors
@@ -390,6 +391,9 @@ export const cs: Record<MessageKey, string> = {
390
391
  'facets.stars_up': "{n} hvězdičky a více",
391
392
  'facets.star_up': "1 hvězdička a více",
392
393
  'facets.clear_short': "Vymazat",
394
+ 'facets.menu': "Možnosti: {facet}",
395
+ 'facets.sort_count': "Seřadit podle počtu",
396
+ 'facets.sort_name': "Seřadit podle názvu",
393
397
  'facets.show_all': "Zobrazit vše ({n})",
394
398
  'facets.total': "Celkový počet řádků {n}",
395
399
  'smart.placeholder': "Hledat nebo filtrovat…",
@@ -685,6 +689,64 @@ export const cs: Record<MessageKey, string> = {
685
689
  'reporter.changed': "změněno {date}",
686
690
  'reporter.no_match': "Hledání neodpovídá žádná sestava.",
687
691
  'reporter.chart_tag': "graf",
692
+ // Form Designer (form-designer.ts)
693
+ 'formdesigner.no_sources': "Pro tuto oblast zatím nejsou nastaveny žádné tabulky.",
694
+ 'formdesigner.no_forms': "Zatím nejsou žádné formuláře.",
695
+ 'formdesigner.new': "Nový formulář",
696
+ 'formdesigner.create': "Navrhnout",
697
+ 'formdesigner.draft': "Koncept",
698
+ 'formdesigner.design': "Navrhnout formulář",
699
+ 'formdesigner.design_of': "Návrh: {name}",
700
+ 'formdesigner.back': "Všechny formuláře",
701
+ 'formdesigner.add': "Přidat",
702
+ 'formdesigner.edit_row': "Upravit",
703
+ 'formdesigner.rows': "Řádky: {form}",
704
+ 'formdesigner.save': "Uložit",
705
+ 'formdesigner.delete': "Smazat",
706
+ 'formdesigner.delete_confirm': "Smazat tento řádek?",
707
+ 'formdesigner.cancel': "Zrušit",
708
+ 'formdesigner.saved_row': "Uloženo.",
709
+ 'formdesigner.deleted_row': "Smazáno.",
710
+ 'formdesigner.required': "{field} je povinné.",
711
+ 'formdesigner.not_in_list': "{field}: vyberte hodnotu ze seznamu.",
712
+ 'formdesigner.changed': "Někdo jiný mezitím tento řádek změnil. Zkontrolujte ho a uložte znovu.",
713
+ 'formdesigner.row_not_found': "Tento řádek neexistuje, nebo ho nesmíte vidět.",
714
+ 'formdesigner.not_allowed': "To s tímto řádkem dělat nesmíte.",
715
+ 'formdesigner.form_not_found': "Tento formulář neexistuje (už).",
716
+ 'formdesigner.unknown_source': "Tabulka tohoto formuláře se už nenabízí.",
717
+ 'formdesigner.nothing': "Tento formulář zatím nemá žádná pole.",
718
+ 'formdesigner.choose': "- Vyberte -",
719
+ 'formdesigner.name': "Název",
720
+ 'formdesigner.description': "Popis",
721
+ 'formdesigner.published': "Zveřejněno: formulář může používat každý, kdo vidí tuto stránku",
722
+ 'formdesigner.fields': "Pole",
723
+ 'formdesigner.field_on': "Ve formuláři",
724
+ 'formdesigner.column': "Sloupec",
725
+ 'formdesigner.key': "klíč",
726
+ 'formdesigner.order': "Pořadí",
727
+ 'formdesigner.label': "Popisek",
728
+ 'formdesigner.help': "Nápověda",
729
+ 'formdesigner.required_short': "Povinné",
730
+ 'formdesigner.display': "Zobrazení",
731
+ 'formdesigner.default': "Výchozí",
732
+ 'formdesigner.in_list': "V seznamu",
733
+ 'formdesigner.display_auto': "Pole",
734
+ 'formdesigner.display_textarea': "Textová oblast",
735
+ 'formdesigner.display_select': "Výběrový seznam",
736
+ 'formdesigner.display_radio': "Přepínače",
737
+ 'formdesigner.actions': "Uživatelé smějí",
738
+ 'formdesigner.allow_insert': "přidávat řádky",
739
+ 'formdesigner.allow_update': "měnit řádky",
740
+ 'formdesigner.allow_delete': "mazat řádky",
741
+ 'formdesigner.list_help': "Seznam řádků ukazuje sloupce zaškrtnuté „V seznamu“; bez nich formulář jen přidává řádky.",
742
+ 'formdesigner.save_form': "Uložit formulář",
743
+ 'formdesigner.delete_form': "Smazat formulář",
744
+ 'formdesigner.delete_form_confirm': "Smazat formulář „{name}“? Řádky jím uložené zůstanou.",
745
+ 'formdesigner.form_saved': "Formulář „{name}“ uložen.",
746
+ 'formdesigner.form_deleted': "Formulář smazán.",
747
+ 'formdesigner.name_required': "Pojmenujte formulář.",
748
+ 'formdesigner.no_fields': "Dejte do formuláře alespoň jeden sloupec.",
749
+ 'formdesigner.changed_by': "změnil(a) {user} dne {date}",
688
750
  // AI assistant region and natural-language report filters (assistant.ts, ai-filter.ts)
689
751
  'assistant.conversation': "Konverzace",
690
752
  'assistant.you': "Vy",
package/src/i18n/da.ts CHANGED
@@ -63,6 +63,7 @@ export const da: Record<MessageKey, string> = {
63
63
  'common.back': "Tilbage",
64
64
  'common.home': "Gå til forsiden",
65
65
  'common.skip': "Gå til indholdet",
66
+ 'common.back_to_top': "Til toppen",
66
67
  'common.toggle_nav': "Vis eller skjul navigationen",
67
68
  'common.dismiss': "Luk",
68
69
  // messages and errors
@@ -390,6 +391,9 @@ export const da: Record<MessageKey, string> = {
390
391
  'facets.stars_up': "{n} stjerner og op",
391
392
  'facets.star_up': "1 stjerne og op",
392
393
  'facets.clear_short': "Ryd",
394
+ 'facets.menu': "Indstillinger for {facet}",
395
+ 'facets.sort_count': "Sortér efter antal",
396
+ 'facets.sort_name': "Sortér efter navn",
393
397
  'facets.show_all': "Vis alle ({n})",
394
398
  'facets.total': "Antal rækker i alt {n}",
395
399
  'smart.placeholder': "Søg eller filtrér…",
@@ -685,6 +689,64 @@ export const da: Record<MessageKey, string> = {
685
689
  'reporter.changed': "ændret {date}",
686
690
  'reporter.no_match': "Ingen rapporter matcher søgningen.",
687
691
  'reporter.chart_tag': "diagram",
692
+ // Form Designer (form-designer.ts)
693
+ 'formdesigner.no_sources': "Der er endnu ikke sat tabeller op for denne region.",
694
+ 'formdesigner.no_forms': "Der er endnu ingen formularer.",
695
+ 'formdesigner.new': "Ny formular",
696
+ 'formdesigner.create': "Design",
697
+ 'formdesigner.draft': "Kladde",
698
+ 'formdesigner.design': "Design formular",
699
+ 'formdesigner.design_of': "Design af {name}",
700
+ 'formdesigner.back': "Alle formularer",
701
+ 'formdesigner.add': "Tilføj",
702
+ 'formdesigner.edit_row': "Rediger",
703
+ 'formdesigner.rows': "Rækker i {form}",
704
+ 'formdesigner.save': "Gem",
705
+ 'formdesigner.delete': "Slet",
706
+ 'formdesigner.delete_confirm': "Slet denne række?",
707
+ 'formdesigner.cancel': "Annuller",
708
+ 'formdesigner.saved_row': "Gemt.",
709
+ 'formdesigner.deleted_row': "Slettet.",
710
+ 'formdesigner.required': "{field} er påkrævet.",
711
+ 'formdesigner.not_in_list': "{field}: vælg en værdi fra listen.",
712
+ 'formdesigner.changed': "En anden har ændret denne række i mellemtiden. Kontrollér den, og gem igen.",
713
+ 'formdesigner.row_not_found': "Denne række findes ikke, eller du må ikke se den.",
714
+ 'formdesigner.not_allowed': "Det må du ikke gøre med denne række.",
715
+ 'formdesigner.form_not_found': "Denne formular findes ikke (længere).",
716
+ 'formdesigner.unknown_source': "Denne formulars tabel tilbydes ikke længere.",
717
+ 'formdesigner.nothing': "Denne formular har endnu ingen felter.",
718
+ 'formdesigner.choose': "- Vælg -",
719
+ 'formdesigner.name': "Navn",
720
+ 'formdesigner.description': "Beskrivelse",
721
+ 'formdesigner.published': "Udgivet: alle, der kan se denne side, må bruge formularen",
722
+ 'formdesigner.fields': "Felter",
723
+ 'formdesigner.field_on': "På formularen",
724
+ 'formdesigner.column': "Kolonne",
725
+ 'formdesigner.key': "nøgle",
726
+ 'formdesigner.order': "Rækkefølge",
727
+ 'formdesigner.label': "Etiket",
728
+ 'formdesigner.help': "Hjælpetekst",
729
+ 'formdesigner.required_short': "Påkrævet",
730
+ 'formdesigner.display': "Visning",
731
+ 'formdesigner.default': "Standard",
732
+ 'formdesigner.in_list': "I listen",
733
+ 'formdesigner.display_auto': "Felt",
734
+ 'formdesigner.display_textarea': "Tekstområde",
735
+ 'formdesigner.display_select': "Valgliste",
736
+ 'formdesigner.display_radio': "Radioknapper",
737
+ 'formdesigner.actions': "Brugere må",
738
+ 'formdesigner.allow_insert': "tilføje rækker",
739
+ 'formdesigner.allow_update': "ændre rækker",
740
+ 'formdesigner.allow_delete': "slette rækker",
741
+ 'formdesigner.list_help': "Listen over rækker viser kolonnerne med flueben i \"I listen\"; uden nogen tilføjer formularen kun rækker.",
742
+ 'formdesigner.save_form': "Gem formular",
743
+ 'formdesigner.delete_form': "Slet formular",
744
+ 'formdesigner.delete_form_confirm': "Slet formularen \"{name}\"? Rækkerne, den har gemt, bliver.",
745
+ 'formdesigner.form_saved': "Formularen \"{name}\" er gemt.",
746
+ 'formdesigner.form_deleted': "Formularen er slettet.",
747
+ 'formdesigner.name_required': "Giv formularen et navn.",
748
+ 'formdesigner.no_fields': "Sæt mindst én kolonne på formularen.",
749
+ 'formdesigner.changed_by': "ændret af {user} den {date}",
688
750
  // AI assistant region and natural-language report filters (assistant.ts, ai-filter.ts)
689
751
  'assistant.conversation': "Samtale",
690
752
  'assistant.you': "Dig",