pgkiln 0.41.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/db/migrations/097_generated_app_quality.sql +911 -0
  2. package/db/migrations/098_app_activity.sql +37 -0
  3. package/db/migrations/099_layout_positions.sql +29 -0
  4. package/docs/apex-feature-parity.md +38 -11
  5. package/docs/guide/03-builder.md +133 -31
  6. package/docs/guide/04-pages-and-regions.md +46 -3
  7. package/docs/guide/05-items.md +1 -1
  8. package/docs/guide/09-reference.md +10 -3
  9. package/docs/guide/11-from-apex.md +1 -1
  10. package/docs/guide/12-development.md +3 -2
  11. package/docs/guide/14-globalization.md +7 -3
  12. package/docs/guide/17-mobile.md +1 -1
  13. package/package.json +1 -1
  14. package/public/app.css +233 -33
  15. package/public/app.js +263 -0
  16. package/public/builder.css +142 -2
  17. package/public/builder.js +311 -3
  18. package/src/builder/appfromfile.ts +5 -4
  19. package/src/builder/appsheets.ts +23 -9
  20. package/src/builder/appwizard.ts +215 -70
  21. package/src/builder/arrange.ts +133 -17
  22. package/src/builder/blueprints.ts +2 -1
  23. package/src/builder/components.ts +29 -23
  24. package/src/builder/designer.ts +67 -18
  25. package/src/builder/forms.ts +4 -1
  26. package/src/builder/home.ts +2 -32
  27. package/src/builder/newapp.ts +182 -5
  28. package/src/builder/object-browser.ts +342 -0
  29. package/src/builder/pwa.ts +3 -3
  30. package/src/builder/routes.ts +86 -51
  31. package/src/builder/shared.ts +103 -1
  32. package/src/builder/sql.ts +13 -65
  33. package/src/builder/ui.ts +21 -6
  34. package/src/i18n/ar.ts +17 -0
  35. package/src/i18n/cs.ts +17 -0
  36. package/src/i18n/da.ts +17 -0
  37. package/src/i18n/de.ts +17 -0
  38. package/src/i18n/el.ts +17 -0
  39. package/src/i18n/es.ts +17 -0
  40. package/src/i18n/fi.ts +17 -0
  41. package/src/i18n/fr.ts +17 -0
  42. package/src/i18n/he.ts +17 -0
  43. package/src/i18n/it.ts +17 -0
  44. package/src/i18n/ja.ts +17 -0
  45. package/src/i18n/ko.ts +17 -0
  46. package/src/i18n/nb.ts +17 -0
  47. package/src/i18n/pl.ts +17 -0
  48. package/src/i18n/pt.ts +17 -0
  49. package/src/i18n/ru.ts +17 -0
  50. package/src/i18n/sv.ts +17 -0
  51. package/src/i18n/tr.ts +17 -0
  52. package/src/i18n/uk.ts +17 -0
  53. package/src/i18n/zh.ts +17 -0
  54. package/src/i18n.ts +34 -0
  55. package/src/icons.ts +2 -1
  56. package/src/layout.ts +2 -2
  57. package/src/metadata.ts +5 -1
  58. package/src/runtime/charts.ts +8 -3
  59. package/src/runtime/devbar.ts +35 -14
  60. package/src/runtime/engine.ts +13 -4
  61. package/src/runtime/format.ts +52 -0
  62. package/src/runtime/items.ts +27 -0
  63. package/src/runtime/locale.ts +5 -2
  64. package/src/runtime/regions.ts +61 -4
  65. package/src/runtime/render.ts +38 -8
  66. package/src/runtime/report.ts +149 -88
  67. package/src/runtime/routes.ts +34 -3
  68. package/src/runtime/styles.ts +5 -2
@@ -9,7 +9,7 @@ import { icon } from '../icons.ts';
9
9
  import { clientIp, logActivity, type Session } from '../session.ts';
10
10
  import { builderService } from './ai-builder.ts';
11
11
  import { addDashboard } from './appsheets.ts';
12
- import { checkNewApp, createApp, createAppError } from './newapp.ts';
12
+ import { finishGeneratedApp, checkNewApp, createApp, createAppError } from './newapp.ts';
13
13
  import { currentWorkspace } from './workspaces.ts';
14
14
  import { blueprintFromApp } from '../blueprint-from-app.ts';
15
15
  import { devDbForApp } from '../workspace-db.ts';
@@ -284,6 +284,7 @@ export async function blueprintRoutes(app: FastifyInstance) {
284
284
  throw new Error(createAppError(e, checked.alias));
285
285
  });
286
286
  const r = await buildBlueprint(c, blueprint, checked.role, { dashboard: (tables, no) => addDashboard(c, checked.alias, tables, no) });
287
+ await finishGeneratedApp(c, a.id, { description: checked.description });
287
288
  if (o.id) await c.query('update meta.blueprint set app_id = $2, spec = $3, updated_at = now() where id = $1 and workspace_id = $4', [o.id, a.id, JSON.stringify(json), currentWorkspace(s)]);
288
289
  else await c.query('insert into meta.blueprint (name, spec, created_by, app_id, workspace_id) values ($1, $2, $3, $4, $5)', [o.name, JSON.stringify(json), s.username, a.id, currentWorkspace(s)]);
289
290
  return { ...r, existing: a.existingAccount };
@@ -51,6 +51,8 @@ export interface Field {
51
51
  group?: string;
52
52
  /** shown, not editable (the form still posts it) */
53
53
  readonly?: boolean;
54
+ /** (sprint 77) shown with the property editor's "Common" toggle on (a spec without any shows every field) */
55
+ common?: boolean;
54
56
  /** (sprint 51) help that depends on the component being edited (wins over `help` when it gives some) */
55
57
  helpBy?: (row: any) => { text: string; url: string | null } | null;
56
58
  }
@@ -101,24 +103,26 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
101
103
  defaults: { type: 'report', columns: 12, template: 'standard' },
102
104
  validate: (v) => formatSettingsProblem(v.config) ?? (v.type === 'file' ? objectStoreProblem((v.config as Record<string, unknown> | null)?.object_store) : null),
103
105
  fields: [
104
- { name: 'title', label: 'Title', kind: 'text', group: 'Identification' },
106
+ { name: 'title', common: true, label: 'Title', kind: 'text', group: 'Identification' },
105
107
  { name: 'static_id', label: 'Static ID', kind: 'text', group: 'Identification',
106
108
  help: 'Optional, unique on the page: lower case letters, digits, _ and -, starting with a letter. Names the region in exported files (so renaming it keeps them) and is data-static-id on the page, for CSS and JavaScript.' },
107
- { name: 'type', label: 'Type', kind: 'select', options: ['report', 'grid', 'form', 'login', 'chart', 'cards', 'calendar', 'facets', 'smart_filters', 'display_selector', 'tasks', 'workflows', 'map', 'tree', 'template_component', 'list', 'static', 'dynamic', 'data_reporter', 'ai_assistant', 'plugin', 'url', 'search'], group: 'Identification' },
108
- { name: 'source', label: 'Source', kind: 'code', wide: true, group: 'Source',
109
+ { name: 'type', common: true, label: 'Type', kind: 'select', options: ['report', 'grid', 'form', 'login', 'chart', 'cards', 'calendar', 'facets', 'smart_filters', 'display_selector', 'tasks', 'workflows', 'map', 'tree', 'template_component', 'list', 'static', 'dynamic', 'data_reporter', 'ai_assistant', 'plugin', 'url', 'search'], group: 'Identification' },
110
+ { name: 'source', common: true, label: 'Source', kind: 'code', wide: true, group: 'Source',
109
111
  help: 'report/grid: a SELECT (use :ITEM binds) · chart: label column + one numeric column per series · cards: title, subtitle, body, badge, icon · calendar: start_date, end_date, title · map: lat and lng (or location "lat,lng"), title, body, geojson · tree: id, parent_id, label, icon · template_component: any SELECT (its columns are #COLUMN# in the template), or empty for one instance · dynamic: a SELECT returning HTML (escape with meta.html_escape) · static: HTML with &ITEM. substitutions · url: the page to show, https://host/path?q=&P1_ITEM. (the host is written out; items are URL-encoded) or a path on this server (/a/other/1).' },
110
112
  { name: 'rest_source', label: 'REST data source', kind: 'rest_source', group: 'Source',
111
113
  help: 'Read the rows of a REST data source (Shared Components) instead of a table: the source above is then optional SQL over them, e.g. select * from rest where price > 10. Parameters: {"rest_params": {"city": "&P1_CITY."}} in the attributes.' },
112
- { name: 'table_name', label: 'Table (form, grid)', kind: 'text', help: 'e.g. sales.orders', group: 'Source' },
114
+ { name: 'table_name', common: true, label: 'Table (form, grid)', kind: 'text', help: 'e.g. sales.orders', group: 'Source' },
113
115
  { name: 'pk_column', label: 'Primary key column (form, grid)', kind: 'text', group: 'Source' },
114
116
  { name: 'pk_item', label: 'Primary key item (form)', kind: 'upper', group: 'Source' },
115
117
  { name: 'seq', label: 'Sequence', kind: 'int', group: 'Layout' },
116
- { name: 'columns', label: 'Column span (1-12)', kind: 'int', group: 'Layout' },
117
- { name: 'template', label: 'Template', kind: 'select', options: ['standard', 'plain', 'collapsible', 'hero', 'title_bar', 'buttons_container', 'inline_dialog', 'inline_drawer', 'wizard'], group: 'Layout', help: 'hero: a large banner (title, text, {"icon":"…"} in the attributes, buttons). title_bar: title and buttons on one line. buttons_container: the buttons only. inline_dialog, inline_drawer: hidden until a dynamic action Open region (or a link to #R<id>) opens it over the page. wizard: a narrow card for one step (a list region with template wizard_progress shows the steps).' },
118
+ { name: 'columns', common: true, label: 'Column span (1-12)', kind: 'int', group: 'Layout' },
119
+ { name: 'template', common: true, label: 'Template', kind: 'select', options: ['standard', 'plain', 'collapsible', 'hero', 'title_bar', 'buttons_container', 'inline_dialog', 'inline_drawer', 'wizard'], group: 'Layout', help: 'hero: a large banner (title, text, {"icon":"…"} in the attributes, buttons). title_bar: title and buttons on one line. buttons_container: the buttons only. inline_dialog, inline_drawer: hidden until a dynamic action Open region (or a link to #R<id>) opens it over the page. wizard: a narrow card for one step (a list region with template wizard_progress shows the steps).' },
120
+ { name: 'position', common: true, label: 'Position', kind: 'select', options: ['', 'banner', 'breadcrumb', 'left', 'body', 'right', 'footer'], group: 'Layout',
121
+ help: 'Where the region shows (APEX: Layout → Position). banner: above everything else in the page; breadcrumb: in the bar under the title; left / right: in the side column of a page with template Left or Right side column (on other pages in the body); footer: after the body. Empty is body.' },
118
122
  { name: 'template_options', label: 'Template options', kind: 'options', choices: REGION_OPTIONS, group: 'Appearance',
119
123
  help: 'CSS classes from a fixed list, added to the region (APEX: Template Options). "Hide the header" applies to the standard template.' },
120
- { name: 'condition', label: 'Server-side condition (SQL)', kind: 'code', group: 'Security', help: 'Boolean expression; the region renders only when true.' },
121
- { name: 'authz', label: 'Authorization', kind: 'authz', group: 'Security', help: AUTHZ_HELP },
124
+ { name: 'condition', common: true, label: 'Server-side condition (SQL)', kind: 'code', group: 'Security', help: 'Boolean expression; the region renders only when true.' },
125
+ { name: 'authz', common: true, label: 'Authorization', kind: 'authz', group: 'Security', help: AUTHZ_HELP },
122
126
  buildOption('Security'),
123
127
  { name: 'config', label: 'Attributes (JSON)', kind: 'json', wide: true, group: 'Attributes',
124
128
  help: ALL_REGION_CONFIG_HELP, helpBy: (row: any) => regionConfigHelp(row?.type) },
@@ -134,22 +138,22 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
134
138
  defaults: { type: 'text' },
135
139
  validate: (v) => formatSettingsProblem(v.config),
136
140
  fields: [
137
- { name: 'name', label: 'Name', kind: 'upper', help: 'Referenced in SQL as :NAME, e.g. P3_NAME', group: 'Identification' },
138
- { name: 'type', label: 'Type', kind: 'select', options: ['text', 'textarea', 'number', 'date', 'datetime', 'select', 'popup_lov', 'radio', 'checkbox', 'switch', 'checkbox_group', 'multiselect', 'email', 'tel', 'url', 'color', 'file', 'location', 'richtext', 'markdown', 'rating', 'combobox', 'daterange', 'qrcode', 'image', 'address', 'hidden', 'display', 'password', 'plugin'], group: 'Identification' },
139
- { name: 'label', label: 'Label', kind: 'text', group: 'Identification' },
140
- { name: 'region_id', label: 'Region', kind: 'region', group: 'Layout' },
141
+ { name: 'name', common: true, label: 'Name', kind: 'upper', help: 'Referenced in SQL as :NAME, e.g. P3_NAME', group: 'Identification' },
142
+ { name: 'type', common: true, label: 'Type', kind: 'select', options: ['text', 'textarea', 'number', 'date', 'datetime', 'select', 'popup_lov', 'radio', 'checkbox', 'switch', 'checkbox_group', 'multiselect', 'email', 'tel', 'url', 'color', 'file', 'location', 'richtext', 'markdown', 'rating', 'combobox', 'daterange', 'qrcode', 'image', 'address', 'hidden', 'display', 'password', 'plugin'], group: 'Identification' },
143
+ { name: 'label', common: true, label: 'Label', kind: 'text', group: 'Identification' },
144
+ { name: 'region_id', common: true, label: 'Region', kind: 'region', group: 'Layout' },
141
145
  { name: 'seq', label: 'Sequence', kind: 'int', group: 'Layout' },
142
- { name: 'source_column', label: 'Source column (form)', kind: 'text', group: 'Source' },
143
- { name: 'default_value', label: 'Default value', kind: 'text', group: 'Source' },
144
- { name: 'lov', label: 'List of values', kind: 'code', wide: true, group: 'List of values', help: 'select display, return from … · STATIC:Yes;Y,No;N · LOV:NAME (shared) · checkbox_group/multiselect store values colon-separated: string_to_array(:P1_X, \':\')' },
146
+ { name: 'source_column', common: true, label: 'Source column (form)', kind: 'text', group: 'Source' },
147
+ { name: 'default_value', common: true, label: 'Default value', kind: 'text', group: 'Source' },
148
+ { name: 'lov', common: true, label: 'List of values', kind: 'code', wide: true, group: 'List of values', help: 'select display, return from … · STATIC:Yes;Y,No;N · LOV:NAME (shared) · checkbox_group/multiselect store values colon-separated: string_to_array(:P1_X, \':\')' },
145
149
  { name: 'template_options', label: 'Template options', kind: 'options', choices: ITEM_OPTIONS, group: 'Appearance',
146
150
  help: 'CSS classes from a fixed list, added to the item (APEX: Template Options).' },
147
- { name: 'required', label: 'Value required', kind: 'bool', group: 'Validation' },
151
+ { name: 'required', common: true, label: 'Value required', kind: 'bool', group: 'Validation' },
148
152
  { name: 'help', label: 'Help text', kind: 'text', group: 'Validation' },
149
153
  { name: 'readonly_condition', label: 'Read-only condition (SQL)', kind: 'code', group: 'Security', help: 'When true the item is shown read-only and ignored on submit.' },
150
154
  { name: 'authz', label: 'Authorization', kind: 'authz', group: 'Security', help: AUTHZ_HELP },
151
155
  buildOption('Security'),
152
- { name: 'config', label: 'Attributes (JSON)', kind: 'json', group: 'Attributes', help: '{"submit_on_change":true,"null_label":"- All -","cascade_parents":"P3_DEPTNO","wide":true}. {"warn_unsaved":false}: a change of this item doesn\'t count for the page\'s "Warn on unsaved changes". Lists of values read at most {"max_rows":5000} rows. File items: {"filename_column":"photo_name","mime_column":"photo_mime","accept":"image/*,.pdf","max_mb":2,"capture":"environment","max_px":1600} (capture opens the camera on phones; max_px makes photos smaller before upload). {"crop":"1:1"} lets the user crop a picture before upload (free, 1:1, 4:3, 3:4, 16:9, 3:2, 2:3). Several files: {"multiple":true,"max_files":5,"table":"doc.attachment","parent_column":"ticket_id","key_column":"id"} with the content column as source (one row per file). Text items: {"scan":true} adds a barcode/QR scan button where the browser can read codes. Location items hold "lat,lng" with a "Use my location" button. Rich text: sanitised HTML; markdown: Markdown text ({"rows":10}). Rating: {"max":5} stars, stored as 1..max. Combobox: free text with list-of-values suggestions, values colon-separated ({"multiple":false} for one value). Date range: "from:to" (ISO dates; split_part(:P1_X, \':\', 1)). QR code: shows the value as a QR code ({"ecc":"M","size":200,"show_value":true}). Password: {"reveal":true} adds a show/hide button. Image (display image): the value is an https URL, a path or a data:image URL; or {"sql":"select photo, photo_mime from hr.emp where id = :P3_ID"} shows the first column of the first row (a BLOB); {"alt":"Photo of &P3_NAME.","width":160,"height":160}. Address: {"coordinates_item":"P3_LOCATION","country":"nl"} (a "Check address" button where the installation has a geocoder, GEOCODER_URL; the chosen address\'s position goes into the item as "lat,lng"). Any item: {"persist":"user"} keeps its value per user across sessions (a user preference named like the item). Number and display items: {"format_mask":"999G999G990D00"} shows the number in the language\'s notation (FML999G990D00 adds the currency) and reads it back on submit. · plugin: {"plugin":"name","attributes":{"NAME":"value"}} (an item plug-in: a text field its JavaScript enhances)' },
156
+ { name: 'config', label: 'Attributes (JSON)', kind: 'json', group: 'Attributes', help: '{"submit_on_change":true,"null_label":"- All -","cascade_parents":"P3_DEPTNO","wide":true}. {"warn_unsaved":false}: a change of this item doesn\'t count for the page\'s "Warn on unsaved changes". Lists of values read at most {"max_rows":5000} rows. File items: {"filename_column":"photo_name","mime_column":"photo_mime","size_column":"photo_size","updated_column":"photo_updated","accept":"image/*,.pdf","max_mb":2,"capture":"environment","max_px":1600} (capture opens the camera on phones; max_px makes photos smaller before upload). {"crop":"1:1"} lets the user crop a picture before upload (free, 1:1, 4:3, 3:4, 16:9, 3:2, 2:3). Several files: {"multiple":true,"max_files":5,"table":"doc.attachment","parent_column":"ticket_id","key_column":"id"} with the content column as source (one row per file). Text items: {"scan":true} adds a barcode/QR scan button where the browser can read codes. Location items hold "lat,lng" with a "Use my location" button. Rich text: sanitised HTML; markdown: Markdown text ({"rows":10}). Rating: {"max":5} stars, stored as 1..max. Combobox: free text with list-of-values suggestions, values colon-separated ({"multiple":false} for one value). Date range: "from:to" (ISO dates; split_part(:P1_X, \':\', 1)). QR code: shows the value as a QR code ({"ecc":"M","size":200,"show_value":true}). Password: {"reveal":true} adds a show/hide button. Image (display image): the value is an https URL, a path or a data:image URL; or {"sql":"select photo, photo_mime from hr.emp where id = :P3_ID"} shows the first column of the first row (a BLOB); {"alt":"Photo of &P3_NAME.","width":160,"height":160}. Address: {"coordinates_item":"P3_LOCATION","country":"nl"} (a "Check address" button where the installation has a geocoder, GEOCODER_URL; the chosen address\'s position goes into the item as "lat,lng"). Any item: {"persist":"user"} keeps its value per user across sessions (a user preference named like the item). Number and display items: {"format_mask":"999G999G990D00"} shows the number in the language\'s notation (FML999G990D00 adds the currency) and reads it back on submit. · plugin: {"plugin":"name","attributes":{"NAME":"value"}} (an item plug-in: a text field its JavaScript enhances)' },
153
157
  ],
154
158
  },
155
159
  button: {
@@ -162,21 +166,23 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
162
166
  defaults: { action: 'submit' },
163
167
  beforeSave: menuBeforeSave,
164
168
  fields: [
165
- { name: 'name', label: 'Name (request)', kind: 'upper', group: 'Identification' },
166
- { name: 'label', label: 'Label', kind: 'text', group: 'Identification' },
167
- { name: 'action', label: 'Action', kind: 'select', options: ['submit', 'redirect', 'da', 'document', 'menu'], group: 'Behaviour', help: 'menu = a menu of links and submit requests (Menu entries) · da = "Defined by dynamic action"; document = download a document template (filled with the page\'s values as last loaded or saved)' },
169
+ { name: 'name', common: true, label: 'Name (request)', kind: 'upper', group: 'Identification' },
170
+ { name: 'label', common: true, label: 'Label', kind: 'text', group: 'Identification' },
171
+ { name: 'action', common: true, label: 'Action', kind: 'select', options: ['submit', 'redirect', 'da', 'document', 'menu'], group: 'Behaviour', help: 'menu = a menu of links and submit requests (Menu entries) · da = "Defined by dynamic action"; document = download a document template (filled with the page\'s values as last loaded or saved)' },
168
172
  { name: 'document', label: 'Document template (action document)', kind: 'upper', group: 'Behaviour', help: 'The name of a document template (Shared Components → Document templates).' },
169
- { name: 'target_page', label: 'Target / branch page', kind: 'page', group: 'Behaviour' },
173
+ { name: 'target_page', common: true, label: 'Target / branch page', kind: 'page', group: 'Behaviour' },
170
174
  { name: 'target_items', label: 'Set items (JSON)', kind: 'json', group: 'Behaviour', help: '{"P3_ID": "&P2_ID."}' },
171
175
  { name: 'confirm', label: 'Confirm message', kind: 'text', group: 'Behaviour' },
172
176
  { name: 'menu', label: 'Menu entries (action menu, JSON)', kind: 'json', wide: true, group: 'Behaviour',
173
177
  help: '[{"label": "Details", "page": 3, "items": {"P3_ID": "&P2_ID."}}, {"label": "Archive", "request": "ARCHIVE", "confirm": "Archive it?", "authz": "ADMIN", "icon": "inbox"}] — a link to a page, or a submit with that request (processes and branches see it as the button pressed). At most 20.' },
174
- { name: 'hot', label: 'Primary (hot) button', kind: 'bool', group: 'Appearance' },
178
+ { name: 'hot', common: true, label: 'Primary (hot) button', kind: 'bool', group: 'Appearance' },
175
179
  { name: 'template_options', label: 'Template options', kind: 'options', choices: BUTTON_OPTIONS, group: 'Appearance',
176
180
  help: 'CSS classes from a fixed list, added to the button (APEX: Template Options).' },
177
181
  { name: 'badge', label: 'Badge', kind: 'text', group: 'Appearance', help: 'A short value shown on the button, e.g. &P2_OPEN_COUNT. (empty value: no badge).' },
178
182
  { name: 'badge_query', label: 'Badge (SQL)', kind: 'code', group: 'Appearance', help: 'Instead: a SELECT whose first value is the badge, e.g. select count(*) from sales.orders where status = \'OPEN\'. Runs as the application\'s role.' },
179
- { name: 'region_id', label: 'Region', kind: 'region', group: 'Layout' },
183
+ { name: 'region_id', common: true, label: 'Region', kind: 'region', group: 'Layout' },
184
+ { name: 'slot', common: true, label: 'Button position', kind: 'select', options: ['', 'region', 'create', 'edit', 'change', 'copy', 'help', 'up', 'previous', 'close', 'delete', 'next'], group: 'Layout',
185
+ help: 'A region button\'s place (APEX: Button Position): create, edit, change, copy, help and up in the region\'s header; previous, close and delete at the left of its bottom bar, next at the right. Empty or region: with its other buttons (the header of reports and charts, the bottom of forms and static content).' },
180
186
  { name: 'seq', label: 'Sequence', kind: 'int', group: 'Layout' },
181
187
  { name: 'condition', label: 'Server-side condition (SQL)', kind: 'code', group: 'Security', help: 'e.g. :P3_ID is not null — also re-checked when the button is pressed.' },
182
188
  { name: 'authz', label: 'Authorization', kind: 'authz', group: 'Security', help: AUTHZ_HELP },
@@ -11,7 +11,7 @@ import { regionSettingsForm } from './region-settings.ts';
11
11
  import { usedInPanel } from './search.ts';
12
12
  import { processJobsPanel } from './process-jobs.ts';
13
13
  import { appLocks, lockPanel, lockText } from './locks.ts';
14
- import { arrangeRoutes, BUTTON_ACTIONS, BUTTON_LABELS, ITEM_LABELS, ITEM_TYPES, REGION_LABELS, REGION_TYPES, undoState } from './arrange.ts';
14
+ import { arrangeRoutes, BOTTOM_SLOTS, BUTTON_ACTIONS, BUTTON_LABELS, ITEM_LABELS, ITEM_TYPES, POSITION_LABELS, POSITIONS, REGION_LABELS, REGION_TYPES, SLOT_LABELS, TOP_SLOTS, undoState } from './arrange.ts';
15
15
 
16
16
  // Page designer, laid out like APEX's Page Designer: the component tree on
17
17
  // the left (Rendering / Dynamic actions / Processing / Shared components),
@@ -80,19 +80,30 @@ export async function designerRoutes(app: FastifyInstance) {
80
80
  const regionTitle = (r: any) => r.title ?? `(${r.type})`;
81
81
 
82
82
  // ------------------------------------------------------------ left: component tree
83
+ // (sprint 77) data-kind and data-id: the node's context menu (Duplicate, Copy to other page, Delete…) and icon colour
83
84
  const leaf = (k: string, r: any, ic: Raw | '', label: string, note = '') =>
84
- html`<li><a class="pd-node" href="${url(`c=${k}-${r.id}`)}"${cur(`${k}-${r.id}`)}>${ic}<span class="pd-label">${label}</span>${badges(r)}${note ? html`<span class="pd-note">${note}</span>` : ''}</a></li>`;
85
+ html`<li><a class="pd-node" href="${url(`c=${k}-${r.id}`)}"${cur(`${k}-${r.id}`)} data-kind="${k}" data-id="${r.id}">${ic}<span class="pd-label">${label}</span>${badges(r)}${note ? html`<span class="pd-note">${note}</span>` : ''}</a></li>`;
85
86
  const folder = (label: string, children: Raw[] | Raw, open = true) =>
86
87
  html`<li${open ? '' : raw(' data-collapsed')}><span class="pd-node pd-folder"><span class="pd-label">${label}</span></span><ul>${children}</ul></li>`;
87
88
  const none = (text: string) => html`<li class="pd-empty">${text}</li>`;
88
89
  const itemLeaf = (i: any) => leaf('item', i, bicon(itemIcon(i.type)), i.name, i.type);
89
90
  const buttonLeaf = (b: any) => leaf('button', b, bicon('button'), b.name, b.action);
90
- const regionLeaf = (r: any) => {
91
+ const regionLeaf = (r: any, next: any) => {
91
92
  const items = rows.item.filter((i) => i.region_id === r.id);
92
93
  const buttons = rows.button.filter((b) => b.region_id === r.id);
93
- const kids = [...(items.length ? [folder('Items', items.map(itemLeaf))] : []), ...(buttons.length ? [folder('Buttons', buttons.map(buttonLeaf))] : [])];
94
- return html`<li><a class="pd-node" href="${url(`c=region-${r.id}`)}"${cur(`region-${r.id}`)}>${bicon(regionIcon(r.type))}<span class="pd-label">${regionTitle(r)}</span>${badges(r)}<span class="pd-note">${r.type}</span></a>${kids.length ? html`<ul>${kids}</ul>` : ''}</li>`;
94
+ // (sprint 77, APEX: region slots) buttons by their position in the region
95
+ const bySlot = ['region', ...TOP_SLOTS, ...BOTTOM_SLOTS].map((sl) => [sl, buttons.filter((b) => (b.slot ?? 'region') === sl)] as const).filter(([, list]) => list.length);
96
+ const kids = [...(items.length ? [folder('Items', items.map(itemLeaf))] : []),
97
+ ...bySlot.map(([sl, list]) => folder(sl === 'region' ? 'Buttons' : `Buttons: ${SLOT_LABELS[sl]}`, list.map(buttonLeaf)))];
98
+ return html`<li><a class="pd-node" href="${url(`c=region-${r.id}`)}"${cur(`region-${r.id}`)} data-kind="region" data-id="${r.id}" data-position="${r.position ?? ''}" data-next="${next?.id ?? ''}">${bicon(regionIcon(r.type))}<span class="pd-label">${regionTitle(r)}</span>${badges(r)}<span class="pd-note">${r.type}</span></a>${kids.length ? html`<ul>${kids}</ul>` : ''}</li>`;
95
99
  };
100
+ // (sprint 77, APEX: the rendering tree by display position) the page template's positions, and others in use
101
+ const treeSide = p.page_template === 'left_side_column' ? 'left' : p.page_template === 'right_side_column' ? 'right' : null;
102
+ const regionFolders = ['banner', 'breadcrumb', 'left', 'body', 'right', 'footer'].map((pos) => {
103
+ const list = rows.region.filter((r) => (r.position ?? 'body') === pos);
104
+ if (!list.length && pos !== 'body' && pos !== treeSide) return '';
105
+ return folder(POSITION_LABELS[pos], list.length ? list.map((r, i) => regionLeaf(r, list[i + 1])) : none(pos === 'body' ? 'No regions yet' : 'No regions'), list.length > 0);
106
+ });
96
107
  const pageItems = rows.item.filter((i) => i.region_id === null);
97
108
  const pageButtons = rows.button.filter((b) => b.region_id === null);
98
109
  const loadProcs = rows.process.filter((x) => x.point === 'load');
@@ -108,15 +119,25 @@ export async function designerRoutes(app: FastifyInstance) {
108
119
  html`<div class="pd-treebar">${links.map(([k, label]) => html`<a class="tb-btn tb-text" href="${url(`new=${k}`)}">${icon('plus')}<span>${label}</span></a>`)}</div>`;
109
120
 
110
121
  const rendering = html`${createBar([['region', 'Region'], ['item', 'Item'], ['button', 'Button']])}
111
- <ul class="pd-tree" aria-label="Rendering">
122
+ <ul class="pd-tree" aria-label="Rendering" data-tree-menu data-page-id="${p.id}">
112
123
  <li><a class="pd-node" href="${url('c=page')}"${cur('page')}>${icon('file')}<span class="pd-label">Page ${p.page_no}: ${p.name}</span>${badges(p)}</a>
113
124
  <ul>
114
125
  ${folder('Pre-Rendering (before header)', preRendering.length ? preRendering : none('No branches, computations or processes before rendering'), preRendering.length > 0)}
115
- ${folder('Regions', rows.region.length ? rows.region.map(regionLeaf) : none('No regions yet'))}
126
+ ${regionFolders}
116
127
  ${pageItems.length ? folder('Page items', pageItems.map(itemLeaf)) : ''}
117
128
  ${pageButtons.length ? folder('Page buttons', pageButtons.map(buttonLeaf)) : ''}
118
129
  </ul></li>
119
- </ul>`;
130
+ </ul>
131
+ <dialog class="pd-dialog" id="pd-copy-dialog" aria-labelledby="pd-copy-title">
132
+ <form method="post" action="${url('')}/layout/copy">${csrf(s)}<input type="hidden" name="kind" value=""><input type="hidden" name="id" value="">
133
+ <h2 id="pd-copy-title">Copy to other page</h2>
134
+ <p class="muted" data-copy-what></p>
135
+ <div class="field"><label class="label" for="pd-copy-page">Page</label>
136
+ <select id="pd-copy-page" name="page">${pages.map((x) => html`<option value="${x.id}"${x.id === p.id ? raw(' selected') : ''}>${x.page_no}. ${x.name}</option>`)}</select>
137
+ <small class="help">Items named P${p.page_no}_… get the other page's number; a region's items and buttons come along. References in SQL and conditions are not changed.</small></div>
138
+ <div class="pd-dialog-buttons"><button type="button" class="btn" data-dialog-close>Cancel</button><button class="btn btn-primary">Copy</button></div>
139
+ </form>
140
+ </dialog>`;
120
141
  const events: [string, string][] = [['change', 'Change'], ['click', 'Click'], ['load', 'Page load']];
121
142
  const dynamicActions = html`${createBar([['dynamic_action', 'Dynamic action']])}
122
143
  <ul class="pd-tree" aria-label="Dynamic actions">
@@ -162,8 +183,15 @@ export async function designerRoutes(app: FastifyInstance) {
162
183
  // ------------------------------------------------------------ center: layout + gallery
163
184
  const chip = (k: 'item' | 'button', r: any) =>
164
185
  html`<a class="pd-chip pd-chip-${k}${isSel(`${k}-${r.id}`) ? ' is-selected' : ''}${k === 'button' && r.hot ? ' is-hot' : ''}" href="${url(`c=${k}-${r.id}`)}" draggable="true" data-kind="${k}" data-id="${r.id}"${cur(`${k}-${r.id}`)} aria-describedby="pd-kbd">${k === 'item' ? bicon(itemIcon(r.type)) : ''}<span>${r.label || r.name}</span>${k === 'item' ? html`<small>${r.name}</small>` : ''}</a>`;
165
- const slot = (k: 'item' | 'button', regionId: number | null, list: any[]) =>
166
- html`<div class="pd-slot pd-slot-${k}" data-drop="${k}" data-region="${regionId ?? ''}">${list.length ? list.map((r) => chip(k, r)) : html`<span class="pd-slot-hint">${k === 'item' ? 'Items' : 'Buttons'}</span>`}</div>`;
186
+ const slot = (k: 'item' | 'button', regionId: number | null, list: any[], buttonSlot = '') =>
187
+ html`<div class="pd-slot pd-slot-${k}${buttonSlot && buttonSlot !== 'region' && !list.length ? ' pd-slot-spare' : ''}" data-drop="${k}" data-region="${regionId ?? ''}"${buttonSlot ? raw(` data-slot="${buttonSlot}"`) : ''}${buttonSlot && buttonSlot !== 'region' ? raw(` title="Button position: ${SLOT_LABELS[buttonSlot]}"`) : ''}>${list.length ? list.map((r) => chip(k, r)) : html`<span class="pd-slot-hint">${k === 'item' ? 'Items' : buttonSlot && buttonSlot !== 'region' ? SLOT_LABELS[buttonSlot] : 'Buttons'}</span>`}</div>`;
188
+ // (sprint 77, APEX: region slots) a region's buttons by position: the header's, the region's own, the bottom bar's
189
+ const buttonSlots = (r: any) => {
190
+ const mine = rows.button.filter((b) => b.region_id === r.id);
191
+ const at = (sl: string) => mine.filter((b) => (b.slot ?? 'region') === sl);
192
+ const row = (list: string[], cls: string) => html`<div class="pd-slots ${cls}">${list.map((sl) => slot('button', r.id, at(sl), sl))}</div>`;
193
+ return html`${row(TOP_SLOTS, 'pd-slots-top')}${slot('button', r.id, at('region'), 'region')}${row(BOTTOM_SLOTS, 'pd-slots-bottom')}`;
194
+ };
167
195
  const block = (r: any) => html`<div class="pd-region pd-span-${r.columns}${isSel(`region-${r.id}`) ? ' is-selected' : ''}" data-kind="region" data-id="${r.id}" data-span="${r.columns}">
168
196
  <div class="pd-region-head" draggable="true" data-kind="region" data-id="${r.id}">
169
197
  <span class="pd-grip" title="Drag to move">${bicon('grip')}</span>
@@ -174,10 +202,29 @@ export async function designerRoutes(app: FastifyInstance) {
174
202
  <div class="pd-region-type">${REGION_LABELS[r.type]?.[0] ?? r.type}${r.template !== 'standard' ? ` · ${r.template}` : ''}</div>
175
203
  ${r.source || r.table_name ? html`<code class="pd-region-src">${firstLine(r.table_name ?? r.source)}</code>` : ''}
176
204
  ${slot('item', r.id, rows.item.filter((i) => i.region_id === r.id))}
177
- ${slot('button', r.id, rows.button.filter((b) => b.region_id === r.id))}
205
+ ${buttonSlots(r)}
178
206
  </div>
179
207
  <span class="pd-resize" title="Drag to change the column span" aria-hidden="true"></span>
180
208
  </div>`;
209
+ // (sprint 77, APEX: the page template's display positions) a drop zone per position of the page template, and
210
+ // for any other position that has regions (they show in the body when the page runs)
211
+ const side = p.page_template === 'left_side_column' ? 'left' : p.page_template === 'right_side_column' ? 'right' : null;
212
+ const byPos = (pos: string) => rows.region.filter((r) => (r.position ?? 'body') === pos);
213
+ const zone = (pos: string) => {
214
+ const list = byPos(pos);
215
+ const off = (pos === 'left' || pos === 'right') && pos !== side;
216
+ return html`<section class="pd-zone pd-zone-${pos}${list.length ? '' : ' is-empty'}" aria-label="${POSITION_LABELS[pos]}">
217
+ <div class="pd-zone-label">${POSITION_LABELS[pos]}${off ? html` <small>(not in this page template: shown in the body)</small>` : ''}</div>
218
+ <div class="pd-grid" data-drop="region" data-position="${pos}">
219
+ ${list.map(block)}
220
+ <div class="pd-grid-end" data-end><a href="${url(`new=region${pos === 'body' ? '' : `&position=${pos}`}`)}">${icon('plus')} Region${pos === 'body' ? '' : html`<span class="sr-only"> in ${POSITION_LABELS[pos]}</span>`}</a></div>
221
+ </div></section>`;
222
+ };
223
+ const extra = (pos: string) => pos !== side && byPos(pos).length > 0;
224
+ const zones = html`${zone('banner')}${zone('breadcrumb')}
225
+ <div class="pd-columns${side ? ` pd-columns-${side}` : ''}">
226
+ ${side === 'left' || extra('left') ? zone('left') : ''}${zone('body')}${side === 'right' || extra('right') ? zone('right') : ''}
227
+ </div>${zone('footer')}`;
181
228
  const gallery = (k: 'region' | 'item' | 'button', types: string[], labels: Record<string, [string, string]>) =>
182
229
  html`<ul class="pd-gallery-list" aria-label="${COMPONENTS[k].plural}">${types.map((t) => html`<li><a class="pd-gal" href="${url(`new=${k}&type=${t}`)}" draggable="true" data-new="${k}" data-type="${t}" title="Drag onto the layout, or open to create">${bicon(labels[t]?.[1] ?? k)}<span>${labels[t]?.[0] ?? t}</span></a></li>`)}</ul>`;
183
230
  const layout = html`
@@ -189,10 +236,7 @@ export async function designerRoutes(app: FastifyInstance) {
189
236
  </div>
190
237
  <div class="pd-canvas" id="pd-layout" data-page-id="${p.id}">
191
238
  <div class="pd-page-label">${icon('file')} Page ${p.page_no}: ${p.name}${p.mode === 'modal' ? ' (modal dialog)' : ''}</div>
192
- <div class="pd-grid" data-drop="region">
193
- ${rows.region.map(block)}
194
- <div class="pd-grid-end" data-end><a href="${url('new=region')}">${icon('plus')} Region</a></div>
195
- </div>
239
+ ${zones}
196
240
  <div class="pd-pagelevel">
197
241
  <div class="pd-pagelevel-title">Page level (no region)</div>
198
242
  ${slot('item', null, pageItems)}
@@ -256,7 +300,8 @@ export async function designerRoutes(app: FastifyInstance) {
256
300
  const lastSeq = Math.max(0, ...rows[newKind].map((r) => r.seq));
257
301
  const typeField = newKind === 'button' ? 'action' : 'type';
258
302
  const presetType = spec.fields.find((f) => f.name === typeField)?.options?.includes(req.query.type ?? '') ? { [typeField]: req.query.type } : {};
259
- const defaults = { seq: lastSeq + 10, ...spec.defaults, ...presetType, region_id: /^\d+$/.test(req.query.region ?? '') ? Number(req.query.region) : null };
303
+ const presetPlace = newKind === 'region' && POSITIONS().includes(req.query.position ?? '') ? { position: req.query.position } : {};
304
+ const defaults = { seq: lastSeq + 10, ...spec.defaults, ...presetType, ...presetPlace, region_id: /^\d+$/.test(req.query.region ?? '') ? Number(req.query.region) : null };
260
305
  heading = html`${icon(spec.icon)}<span>New ${spec.label.toLowerCase()}</span>`;
261
306
  props = peTabs([tab('pd-r-props', spec.label, componentForm(spec, newKind, defaults, lk, `${BASE}/pages/${p.id}/c/${newKind}`, s, `Create ${spec.label.toLowerCase()}`, { id: formId }), true)]);
262
307
  } else if (kind && kind !== 'page' && PAGE_KINDS.includes(kind)) {
@@ -303,7 +348,7 @@ export async function designerRoutes(app: FastifyInstance) {
303
348
  <fieldset class="prop-group"><legend>Appearance</legend><div class="form-grid">
304
349
  ${select('mode', 'Page mode', p.mode, [['normal', 'Normal'], ['modal', 'Modal dialog']])}
305
350
  ${select('page_group', 'Page group', p.page_group ?? '', [['', '- none -'], ...lk.pageGroups.map((g): [string, string] => [g, g]), ...(p.page_group && !lk.pageGroups.includes(p.page_group) ? [[p.page_group, `${p.page_group} (no such group)`] as [string, string]] : [])], 'Organises the pages in the App Builder (Shared Components → Page groups). Changes nothing when the application runs.')}
306
- ${select('page_template', 'Page template', p.page_template ?? 'standard', [['standard', 'Standard'], ['minimal', 'Minimal (no navigation)']], 'Minimal: no header and navigation menu, e.g. for a login page (Application settings → Login page).')}
351
+ ${select('page_template', 'Page template', p.page_template ?? 'standard', [['standard', 'Standard'], ['left_side_column', 'Left side column'], ['right_side_column', 'Right side column'], ['minimal', 'Minimal (no navigation)']], 'Side column: regions with position left (or right) show in a column beside the body (under it on narrow screens). Minimal: no header and navigation menu, e.g. for a login page (Application settings → Login page).')}
307
352
  ${select('dialog_position', 'Dialog position', p.dialog_position ?? 'center', [['center', 'Centred dialog'], ['right', 'Drawer from the right'], ['left', 'Drawer from the left'], ['top', 'Drawer from the top'], ['bottom', 'Drawer from the bottom']], 'Modal pages only. On phones dialogs and side drawers fill the screen.')}
308
353
  ${select('dialog_size', 'Dialog size', p.dialog_size ?? 'medium', [['small', 'Small'], ['medium', 'Medium'], ['large', 'Large']], 'The width (the height of top and bottom drawers).')}
309
354
  ${input('static_includes', 'JavaScript and CSS files', (p.static_includes ?? []).join(', '), { placeholder: 'e.g. page-3.js, page-3.css', help: 'Static application files (.js, .css) this page loads after the application\'s own, in this order.' })}
@@ -371,6 +416,10 @@ export async function designerRoutes(app: FastifyInstance) {
371
416
  const toolbar = html`
372
417
  <div class="tb-group pd-pagenav" role="group" aria-label="Page">
373
418
  ${prev ? html`<a class="tb-btn" href="${BASE}/pages/${prev.id}" title="Previous page: ${prev.page_no}. ${prev.name}">${bicon('left')}<span class="sr-only">Previous page</span></a>` : html`<span class="tb-btn" aria-disabled="true">${bicon('left')}</span>`}
419
+ <form method="get" action="${BASE}/apps/${p.app_id}/goto" class="tb-form pd-goto-no">
420
+ <label class="sr-only" for="pd-goto-no">Page number (Enter goes there)</label>
421
+ <input id="pd-goto-no" name="page" type="number" min="0" max="999999999" step="1" value="${p.page_no}" inputmode="numeric" title="Page number: type or step to a number, then Enter">
422
+ </form>
374
423
  <form method="get" action="${BASE}/apps/${p.app_id}/goto" class="tb-form pd-goto">
375
424
  <label class="sr-only" for="pd-goto">Go to page</label>
376
425
  <select id="pd-goto" name="page" data-autosubmit>${pages.map((x) => html`<option value="${x.page_no}"${x.id === p.id ? raw(' selected') : ''}>${x.page_no} · ${x.name}</option>`)}</select>
@@ -416,7 +465,7 @@ export async function designerRoutes(app: FastifyInstance) {
416
465
  `update meta.page set page_no = $2, name = $3, title = $4, requires_auth = $5, mode = $6, parent_page = $7, authz = $8, protection = $9, build_option = $10,
417
466
  dialog_position = $11, dialog_size = $12, static_includes = $13, warn_unsaved = $14, page_template = $15, page_group = $16 where id = $1`,
418
467
  [req.params.pid, Number(b.page_no), b.name?.trim(), b.title?.trim() || null, b.requires_auth === 'true', b.mode, b.parent_page ? Number(b.parent_page) : null, b.authz || null, b.protection, b.build_option?.trim().toUpperCase() || null,
419
- ['left', 'right', 'top', 'bottom'].includes(b.dialog_position ?? '') ? b.dialog_position : 'center', ['small', 'large'].includes(b.dialog_size ?? '') ? b.dialog_size : 'medium', parseIncludes(b.static_includes), b.warn_unsaved === 'true', b.page_template === 'minimal' ? 'minimal' : 'standard', b.page_group?.trim() || null],
468
+ ['left', 'right', 'top', 'bottom'].includes(b.dialog_position ?? '') ? b.dialog_position : 'center', ['small', 'large'].includes(b.dialog_size ?? '') ? b.dialog_size : 'medium', parseIncludes(b.static_includes), b.warn_unsaved === 'true', ['minimal', 'left_side_column', 'right_side_column'].includes(b.page_template ?? '') ? b.page_template : 'standard', b.page_group?.trim() || null],
420
469
  );
421
470
  // the page's lock and comments follow a new page number
422
471
  if (before && before.page_no !== Number(b.page_no))
@@ -164,9 +164,12 @@ export function componentForm(spec: ComponentSpec, kind: string, row: any, lk: L
164
164
  return html`<div class="field" data-prop="${f.name}"${f.wide ? raw(' data-wide') : ''}><label class="label" for="${id}">${f.label}</label>${control}${help}</div>`;
165
165
  };
166
166
  const groups = [...new Set(spec.fields.map((f) => f.group ?? ''))];
167
+ // (sprint 77, APEX: Show Common / Show All) the other properties are marked, for the property editor's toggle
168
+ const marked = spec.fields.some((f) => f.common);
169
+ const shown = (f: Field) => (marked && !f.common ? html`<div class="pe-more" data-more>${field(f)}</div>` : field(f));
167
170
  return html`<form method="post" action="${action}" class="component-form"${opts.id ? html` id="${opts.id}"` : ''}>
168
171
  ${csrf(s)}
169
- ${groups.map((g) => html`<fieldset class="prop-group">${g ? html`<legend>${g}</legend>` : ''}<div class="form-grid">${spec.fields.filter((f) => (f.group ?? '') === g).map(field)}</div></fieldset>`)}
172
+ ${groups.map((g) => html`<fieldset class="prop-group">${g ? html`<legend>${g}</legend>` : ''}<div class="form-grid">${spec.fields.filter((f) => (f.group ?? '') === g).map(shown)}</div></fieldset>`)}
170
173
  <div class="buttons"><button class="btn btn-hot">${submit}</button></div>
171
174
  </form>`;
172
175
  }
@@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify';
2
2
  import { owner } from '../db.ts';
3
3
  import { html, raw, type Raw } from '../html.ts';
4
4
  import { icon } from '../icons.ts';
5
- import { APP_COLORS, BASE, csrf, developer, input, region, select, send, shell, type Req } from './ui.ts';
5
+ import { APP_COLORS, BASE, csrf, developer, input, region, send, shell, type Req } from './ui.ts';
6
6
  import { crossAppLinks } from './cross-app.ts';
7
7
  import type { Session } from '../session.ts';
8
8
  import { isAdmin } from './locks.ts';
@@ -190,37 +190,7 @@ export async function homeRoutes(app: FastifyInstance) {
190
190
  });
191
191
 
192
192
  // ---------------------------------------------------------------- create and import
193
- app.get(`${BASE}/create`, async (req: Req, reply) => {
194
- const s = await developer(req, reply);
195
- if (!s) return;
196
- const schemas = await owner.query(`select nspname from pg_namespace where nspname !~ '^pg_' and nspname not in ('information_schema', 'meta') order by 1`);
197
- const boilerplates = (await owner.query(`select id, name from meta.app a where app_type = 'boilerplate' and ${inWorkspaceSql('a', 1)} order by lower(name), id`, [currentWorkspace(s)])).rows;
198
- const main = html`<div class="ab-narrow">
199
- <h1>Create an application</h1>
200
- <p class="muted">A blank application with a Home page, its own database role and a parsing schema. Add pages with the page wizards afterwards.</p>
201
- <p><a class="btn" href="${BASE}/create/file">${icon('upload')} From a file</a> <span class="muted small">Or start from a spreadsheet (CSV, Excel, JSON or XML): a table per sheet with its rows, a report and form per table, foreign keys, a dashboard and a faceted search.</span></p>
202
- <p><a class="btn" href="${BASE}/create/paste">${icon('file')} From pasted data</a> <span class="muted small">Paste rows copied from a spreadsheet, or CSV or TSV text.</span></p>
203
- <p><a class="btn" href="${BASE}/blueprints">${icon('layers')} From a blueprint</a> <span class="muted small">Describe tables, pages, menu and sample rows as a blueprint (or let AI draft one), review it, create it.</span></p>
204
- <p><a class="btn" href="${BASE}/create/tables">${icon('table')} From existing tables</a> <span class="muted small">Pick the tables and views of a schema: a report and form per table, navigation and a dashboard.</span></p>
205
- ${region('Application', html`<form method="post" action="${BASE}/apps">${csrf(s)}
206
- <div class="form-grid">
207
- ${input('name', 'Name', '', { required: true })}
208
- ${input('alias', 'Alias (URL)', '', { required: true, help: 'lowercase, e.g. inventory → /a/inventory' })}
209
- ${boilerplates.length
210
- ? select('boilerplate', 'Start from', /^\d{1,9}$/.test(String(req.query?.boilerplate ?? '')) ? String(req.query!.boilerplate) : '', [['', '- a blank application (a Home page) -'], ...boilerplates.map((x): [string, string] => [String(x.id), `${x.name} (boilerplate)`])],
211
- 'A boilerplate application\'s pages, shared components and settings are copied; the new application keeps its own name, alias, role and authentication.')
212
- : ''}
213
- ${select('schema', 'Parsing schema', '', [['', '- new schema named after the alias -'], ...schemas.rows.map((r): [string, string] => [r.nspname, r.nspname])],
214
- 'A database role app_<alias> is created with access to this schema only; the app runs as that role.')}
215
- ${select('authentication', 'Authentication', 'app_users', [['app_users', 'App users (login page)'], ['none', 'None (public)']])}
216
- ${input('admin_user', 'First user', '', { placeholder: 'e.g. your name', help: 'Gets the admin role. An existing account in Users is reused.' })}
217
- ${input('admin_password', 'Password', '', { type: 'password', auto: 'new-password', help: 'For a new account; at least 8 characters.' })}
218
- </div>
219
- <div class="buttons"><a class="btn" href="${BASE}">Cancel</a><button class="btn btn-hot">Create application</button></div>
220
- </form>`)}
221
- </div>`;
222
- return send(reply, s, shell(s, 'Create application', [['App Builder', BASE], ['Create']], main));
223
- });
193
+ // (sprint 73) GET /create, the Create Application wizard, is in appwizard.ts
224
194
 
225
195
  app.get(`${BASE}/import`, async (req: Req, reply) => {
226
196
  const s = await developer(req, reply);