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.
- package/db/migrations/097_generated_app_quality.sql +911 -0
- package/db/migrations/098_app_activity.sql +37 -0
- package/db/migrations/099_layout_positions.sql +29 -0
- package/docs/apex-feature-parity.md +38 -11
- package/docs/guide/03-builder.md +133 -31
- package/docs/guide/04-pages-and-regions.md +46 -3
- package/docs/guide/05-items.md +1 -1
- package/docs/guide/09-reference.md +10 -3
- package/docs/guide/11-from-apex.md +1 -1
- package/docs/guide/12-development.md +3 -2
- package/docs/guide/14-globalization.md +7 -3
- package/docs/guide/17-mobile.md +1 -1
- package/package.json +1 -1
- package/public/app.css +233 -33
- package/public/app.js +263 -0
- package/public/builder.css +142 -2
- package/public/builder.js +311 -3
- package/src/builder/appfromfile.ts +5 -4
- package/src/builder/appsheets.ts +23 -9
- package/src/builder/appwizard.ts +215 -70
- package/src/builder/arrange.ts +133 -17
- package/src/builder/blueprints.ts +2 -1
- package/src/builder/components.ts +29 -23
- package/src/builder/designer.ts +67 -18
- package/src/builder/forms.ts +4 -1
- package/src/builder/home.ts +2 -32
- package/src/builder/newapp.ts +182 -5
- package/src/builder/object-browser.ts +342 -0
- package/src/builder/pwa.ts +3 -3
- package/src/builder/routes.ts +86 -51
- package/src/builder/shared.ts +103 -1
- package/src/builder/sql.ts +13 -65
- package/src/builder/ui.ts +21 -6
- package/src/i18n/ar.ts +17 -0
- package/src/i18n/cs.ts +17 -0
- package/src/i18n/da.ts +17 -0
- package/src/i18n/de.ts +17 -0
- package/src/i18n/el.ts +17 -0
- package/src/i18n/es.ts +17 -0
- package/src/i18n/fi.ts +17 -0
- package/src/i18n/fr.ts +17 -0
- package/src/i18n/he.ts +17 -0
- package/src/i18n/it.ts +17 -0
- package/src/i18n/ja.ts +17 -0
- package/src/i18n/ko.ts +17 -0
- package/src/i18n/nb.ts +17 -0
- package/src/i18n/pl.ts +17 -0
- package/src/i18n/pt.ts +17 -0
- package/src/i18n/ru.ts +17 -0
- package/src/i18n/sv.ts +17 -0
- package/src/i18n/tr.ts +17 -0
- package/src/i18n/uk.ts +17 -0
- package/src/i18n/zh.ts +17 -0
- package/src/i18n.ts +34 -0
- package/src/icons.ts +2 -1
- package/src/layout.ts +2 -2
- package/src/metadata.ts +5 -1
- package/src/runtime/charts.ts +8 -3
- package/src/runtime/devbar.ts +35 -14
- package/src/runtime/engine.ts +13 -4
- package/src/runtime/format.ts +52 -0
- package/src/runtime/items.ts +27 -0
- package/src/runtime/locale.ts +5 -2
- package/src/runtime/regions.ts +61 -4
- package/src/runtime/render.ts +38 -8
- package/src/runtime/report.ts +149 -88
- package/src/runtime/routes.ts +34 -3
- 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 },
|
package/src/builder/designer.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { regionSettingsForm } from './region-settings.ts';
|
|
|
11
11
|
import { usedInPanel } from './search.ts';
|
|
12
12
|
import { processJobsPanel } from './process-jobs.ts';
|
|
13
13
|
import { appLocks, lockPanel, lockText } from './locks.ts';
|
|
14
|
-
import { arrangeRoutes, BUTTON_ACTIONS, BUTTON_LABELS, ITEM_LABELS, ITEM_TYPES, REGION_LABELS, REGION_TYPES, undoState } from './arrange.ts';
|
|
14
|
+
import { arrangeRoutes, BOTTOM_SLOTS, BUTTON_ACTIONS, BUTTON_LABELS, ITEM_LABELS, ITEM_TYPES, POSITION_LABELS, POSITIONS, REGION_LABELS, REGION_TYPES, SLOT_LABELS, TOP_SLOTS, undoState } from './arrange.ts';
|
|
15
15
|
|
|
16
16
|
// Page designer, laid out like APEX's Page Designer: the component tree on
|
|
17
17
|
// the left (Rendering / Dynamic actions / Processing / Shared components),
|
|
@@ -80,19 +80,30 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
80
80
|
const regionTitle = (r: any) => r.title ?? `(${r.type})`;
|
|
81
81
|
|
|
82
82
|
// ------------------------------------------------------------ left: component tree
|
|
83
|
+
// (sprint 77) data-kind and data-id: the node's context menu (Duplicate, Copy to other page, Delete…) and icon colour
|
|
83
84
|
const leaf = (k: string, r: any, ic: Raw | '', label: string, note = '') =>
|
|
84
|
-
html`<li><a class="pd-node" href="${url(`c=${k}-${r.id}`)}"${cur(`${k}-${r.id}`)}>${ic}<span class="pd-label">${label}</span>${badges(r)}${note ? html`<span class="pd-note">${note}</span>` : ''}</a></li>`;
|
|
85
|
+
html`<li><a class="pd-node" href="${url(`c=${k}-${r.id}`)}"${cur(`${k}-${r.id}`)} data-kind="${k}" data-id="${r.id}">${ic}<span class="pd-label">${label}</span>${badges(r)}${note ? html`<span class="pd-note">${note}</span>` : ''}</a></li>`;
|
|
85
86
|
const folder = (label: string, children: Raw[] | Raw, open = true) =>
|
|
86
87
|
html`<li${open ? '' : raw(' data-collapsed')}><span class="pd-node pd-folder"><span class="pd-label">${label}</span></span><ul>${children}</ul></li>`;
|
|
87
88
|
const none = (text: string) => html`<li class="pd-empty">${text}</li>`;
|
|
88
89
|
const itemLeaf = (i: any) => leaf('item', i, bicon(itemIcon(i.type)), i.name, i.type);
|
|
89
90
|
const buttonLeaf = (b: any) => leaf('button', b, bicon('button'), b.name, b.action);
|
|
90
|
-
const regionLeaf = (r: any) => {
|
|
91
|
+
const regionLeaf = (r: any, next: any) => {
|
|
91
92
|
const items = rows.item.filter((i) => i.region_id === r.id);
|
|
92
93
|
const buttons = rows.button.filter((b) => b.region_id === r.id);
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
// (sprint 77, APEX: region slots) buttons by their position in the region
|
|
95
|
+
const bySlot = ['region', ...TOP_SLOTS, ...BOTTOM_SLOTS].map((sl) => [sl, buttons.filter((b) => (b.slot ?? 'region') === sl)] as const).filter(([, list]) => list.length);
|
|
96
|
+
const kids = [...(items.length ? [folder('Items', items.map(itemLeaf))] : []),
|
|
97
|
+
...bySlot.map(([sl, list]) => folder(sl === 'region' ? 'Buttons' : `Buttons: ${SLOT_LABELS[sl]}`, list.map(buttonLeaf)))];
|
|
98
|
+
return html`<li><a class="pd-node" href="${url(`c=region-${r.id}`)}"${cur(`region-${r.id}`)} data-kind="region" data-id="${r.id}" data-position="${r.position ?? ''}" data-next="${next?.id ?? ''}">${bicon(regionIcon(r.type))}<span class="pd-label">${regionTitle(r)}</span>${badges(r)}<span class="pd-note">${r.type}</span></a>${kids.length ? html`<ul>${kids}</ul>` : ''}</li>`;
|
|
95
99
|
};
|
|
100
|
+
// (sprint 77, APEX: the rendering tree by display position) the page template's positions, and others in use
|
|
101
|
+
const treeSide = p.page_template === 'left_side_column' ? 'left' : p.page_template === 'right_side_column' ? 'right' : null;
|
|
102
|
+
const regionFolders = ['banner', 'breadcrumb', 'left', 'body', 'right', 'footer'].map((pos) => {
|
|
103
|
+
const list = rows.region.filter((r) => (r.position ?? 'body') === pos);
|
|
104
|
+
if (!list.length && pos !== 'body' && pos !== treeSide) return '';
|
|
105
|
+
return folder(POSITION_LABELS[pos], list.length ? list.map((r, i) => regionLeaf(r, list[i + 1])) : none(pos === 'body' ? 'No regions yet' : 'No regions'), list.length > 0);
|
|
106
|
+
});
|
|
96
107
|
const pageItems = rows.item.filter((i) => i.region_id === null);
|
|
97
108
|
const pageButtons = rows.button.filter((b) => b.region_id === null);
|
|
98
109
|
const loadProcs = rows.process.filter((x) => x.point === 'load');
|
|
@@ -108,15 +119,25 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
108
119
|
html`<div class="pd-treebar">${links.map(([k, label]) => html`<a class="tb-btn tb-text" href="${url(`new=${k}`)}">${icon('plus')}<span>${label}</span></a>`)}</div>`;
|
|
109
120
|
|
|
110
121
|
const rendering = html`${createBar([['region', 'Region'], ['item', 'Item'], ['button', 'Button']])}
|
|
111
|
-
<ul class="pd-tree" aria-label="Rendering">
|
|
122
|
+
<ul class="pd-tree" aria-label="Rendering" data-tree-menu data-page-id="${p.id}">
|
|
112
123
|
<li><a class="pd-node" href="${url('c=page')}"${cur('page')}>${icon('file')}<span class="pd-label">Page ${p.page_no}: ${p.name}</span>${badges(p)}</a>
|
|
113
124
|
<ul>
|
|
114
125
|
${folder('Pre-Rendering (before header)', preRendering.length ? preRendering : none('No branches, computations or processes before rendering'), preRendering.length > 0)}
|
|
115
|
-
${
|
|
126
|
+
${regionFolders}
|
|
116
127
|
${pageItems.length ? folder('Page items', pageItems.map(itemLeaf)) : ''}
|
|
117
128
|
${pageButtons.length ? folder('Page buttons', pageButtons.map(buttonLeaf)) : ''}
|
|
118
129
|
</ul></li>
|
|
119
|
-
</ul
|
|
130
|
+
</ul>
|
|
131
|
+
<dialog class="pd-dialog" id="pd-copy-dialog" aria-labelledby="pd-copy-title">
|
|
132
|
+
<form method="post" action="${url('')}/layout/copy">${csrf(s)}<input type="hidden" name="kind" value=""><input type="hidden" name="id" value="">
|
|
133
|
+
<h2 id="pd-copy-title">Copy to other page</h2>
|
|
134
|
+
<p class="muted" data-copy-what></p>
|
|
135
|
+
<div class="field"><label class="label" for="pd-copy-page">Page</label>
|
|
136
|
+
<select id="pd-copy-page" name="page">${pages.map((x) => html`<option value="${x.id}"${x.id === p.id ? raw(' selected') : ''}>${x.page_no}. ${x.name}</option>`)}</select>
|
|
137
|
+
<small class="help">Items named P${p.page_no}_… get the other page's number; a region's items and buttons come along. References in SQL and conditions are not changed.</small></div>
|
|
138
|
+
<div class="pd-dialog-buttons"><button type="button" class="btn" data-dialog-close>Cancel</button><button class="btn btn-primary">Copy</button></div>
|
|
139
|
+
</form>
|
|
140
|
+
</dialog>`;
|
|
120
141
|
const events: [string, string][] = [['change', 'Change'], ['click', 'Click'], ['load', 'Page load']];
|
|
121
142
|
const dynamicActions = html`${createBar([['dynamic_action', 'Dynamic action']])}
|
|
122
143
|
<ul class="pd-tree" aria-label="Dynamic actions">
|
|
@@ -162,8 +183,15 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
162
183
|
// ------------------------------------------------------------ center: layout + gallery
|
|
163
184
|
const chip = (k: 'item' | 'button', r: any) =>
|
|
164
185
|
html`<a class="pd-chip pd-chip-${k}${isSel(`${k}-${r.id}`) ? ' is-selected' : ''}${k === 'button' && r.hot ? ' is-hot' : ''}" href="${url(`c=${k}-${r.id}`)}" draggable="true" data-kind="${k}" data-id="${r.id}"${cur(`${k}-${r.id}`)} aria-describedby="pd-kbd">${k === 'item' ? bicon(itemIcon(r.type)) : ''}<span>${r.label || r.name}</span>${k === 'item' ? html`<small>${r.name}</small>` : ''}</a>`;
|
|
165
|
-
const slot = (k: 'item' | 'button', regionId: number | null, list: any[]) =>
|
|
166
|
-
html`<div class="pd-slot pd-slot-${k}" data-drop="${k}" data-region="${regionId ?? ''}">${list.length ? list.map((r) => chip(k, r)) : html`<span class="pd-slot-hint">${k === 'item' ? 'Items' : 'Buttons'}</span>`}</div>`;
|
|
186
|
+
const slot = (k: 'item' | 'button', regionId: number | null, list: any[], buttonSlot = '') =>
|
|
187
|
+
html`<div class="pd-slot pd-slot-${k}${buttonSlot && buttonSlot !== 'region' && !list.length ? ' pd-slot-spare' : ''}" data-drop="${k}" data-region="${regionId ?? ''}"${buttonSlot ? raw(` data-slot="${buttonSlot}"`) : ''}${buttonSlot && buttonSlot !== 'region' ? raw(` title="Button position: ${SLOT_LABELS[buttonSlot]}"`) : ''}>${list.length ? list.map((r) => chip(k, r)) : html`<span class="pd-slot-hint">${k === 'item' ? 'Items' : buttonSlot && buttonSlot !== 'region' ? SLOT_LABELS[buttonSlot] : 'Buttons'}</span>`}</div>`;
|
|
188
|
+
// (sprint 77, APEX: region slots) a region's buttons by position: the header's, the region's own, the bottom bar's
|
|
189
|
+
const buttonSlots = (r: any) => {
|
|
190
|
+
const mine = rows.button.filter((b) => b.region_id === r.id);
|
|
191
|
+
const at = (sl: string) => mine.filter((b) => (b.slot ?? 'region') === sl);
|
|
192
|
+
const row = (list: string[], cls: string) => html`<div class="pd-slots ${cls}">${list.map((sl) => slot('button', r.id, at(sl), sl))}</div>`;
|
|
193
|
+
return html`${row(TOP_SLOTS, 'pd-slots-top')}${slot('button', r.id, at('region'), 'region')}${row(BOTTOM_SLOTS, 'pd-slots-bottom')}`;
|
|
194
|
+
};
|
|
167
195
|
const block = (r: any) => html`<div class="pd-region pd-span-${r.columns}${isSel(`region-${r.id}`) ? ' is-selected' : ''}" data-kind="region" data-id="${r.id}" data-span="${r.columns}">
|
|
168
196
|
<div class="pd-region-head" draggable="true" data-kind="region" data-id="${r.id}">
|
|
169
197
|
<span class="pd-grip" title="Drag to move">${bicon('grip')}</span>
|
|
@@ -174,10 +202,29 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
174
202
|
<div class="pd-region-type">${REGION_LABELS[r.type]?.[0] ?? r.type}${r.template !== 'standard' ? ` · ${r.template}` : ''}</div>
|
|
175
203
|
${r.source || r.table_name ? html`<code class="pd-region-src">${firstLine(r.table_name ?? r.source)}</code>` : ''}
|
|
176
204
|
${slot('item', r.id, rows.item.filter((i) => i.region_id === r.id))}
|
|
177
|
-
${
|
|
205
|
+
${buttonSlots(r)}
|
|
178
206
|
</div>
|
|
179
207
|
<span class="pd-resize" title="Drag to change the column span" aria-hidden="true"></span>
|
|
180
208
|
</div>`;
|
|
209
|
+
// (sprint 77, APEX: the page template's display positions) a drop zone per position of the page template, and
|
|
210
|
+
// for any other position that has regions (they show in the body when the page runs)
|
|
211
|
+
const side = p.page_template === 'left_side_column' ? 'left' : p.page_template === 'right_side_column' ? 'right' : null;
|
|
212
|
+
const byPos = (pos: string) => rows.region.filter((r) => (r.position ?? 'body') === pos);
|
|
213
|
+
const zone = (pos: string) => {
|
|
214
|
+
const list = byPos(pos);
|
|
215
|
+
const off = (pos === 'left' || pos === 'right') && pos !== side;
|
|
216
|
+
return html`<section class="pd-zone pd-zone-${pos}${list.length ? '' : ' is-empty'}" aria-label="${POSITION_LABELS[pos]}">
|
|
217
|
+
<div class="pd-zone-label">${POSITION_LABELS[pos]}${off ? html` <small>(not in this page template: shown in the body)</small>` : ''}</div>
|
|
218
|
+
<div class="pd-grid" data-drop="region" data-position="${pos}">
|
|
219
|
+
${list.map(block)}
|
|
220
|
+
<div class="pd-grid-end" data-end><a href="${url(`new=region${pos === 'body' ? '' : `&position=${pos}`}`)}">${icon('plus')} Region${pos === 'body' ? '' : html`<span class="sr-only"> in ${POSITION_LABELS[pos]}</span>`}</a></div>
|
|
221
|
+
</div></section>`;
|
|
222
|
+
};
|
|
223
|
+
const extra = (pos: string) => pos !== side && byPos(pos).length > 0;
|
|
224
|
+
const zones = html`${zone('banner')}${zone('breadcrumb')}
|
|
225
|
+
<div class="pd-columns${side ? ` pd-columns-${side}` : ''}">
|
|
226
|
+
${side === 'left' || extra('left') ? zone('left') : ''}${zone('body')}${side === 'right' || extra('right') ? zone('right') : ''}
|
|
227
|
+
</div>${zone('footer')}`;
|
|
181
228
|
const gallery = (k: 'region' | 'item' | 'button', types: string[], labels: Record<string, [string, string]>) =>
|
|
182
229
|
html`<ul class="pd-gallery-list" aria-label="${COMPONENTS[k].plural}">${types.map((t) => html`<li><a class="pd-gal" href="${url(`new=${k}&type=${t}`)}" draggable="true" data-new="${k}" data-type="${t}" title="Drag onto the layout, or open to create">${bicon(labels[t]?.[1] ?? k)}<span>${labels[t]?.[0] ?? t}</span></a></li>`)}</ul>`;
|
|
183
230
|
const layout = html`
|
|
@@ -189,10 +236,7 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
189
236
|
</div>
|
|
190
237
|
<div class="pd-canvas" id="pd-layout" data-page-id="${p.id}">
|
|
191
238
|
<div class="pd-page-label">${icon('file')} Page ${p.page_no}: ${p.name}${p.mode === 'modal' ? ' (modal dialog)' : ''}</div>
|
|
192
|
-
|
|
193
|
-
${rows.region.map(block)}
|
|
194
|
-
<div class="pd-grid-end" data-end><a href="${url('new=region')}">${icon('plus')} Region</a></div>
|
|
195
|
-
</div>
|
|
239
|
+
${zones}
|
|
196
240
|
<div class="pd-pagelevel">
|
|
197
241
|
<div class="pd-pagelevel-title">Page level (no region)</div>
|
|
198
242
|
${slot('item', null, pageItems)}
|
|
@@ -256,7 +300,8 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
256
300
|
const lastSeq = Math.max(0, ...rows[newKind].map((r) => r.seq));
|
|
257
301
|
const typeField = newKind === 'button' ? 'action' : 'type';
|
|
258
302
|
const presetType = spec.fields.find((f) => f.name === typeField)?.options?.includes(req.query.type ?? '') ? { [typeField]: req.query.type } : {};
|
|
259
|
-
const
|
|
303
|
+
const presetPlace = newKind === 'region' && POSITIONS().includes(req.query.position ?? '') ? { position: req.query.position } : {};
|
|
304
|
+
const defaults = { seq: lastSeq + 10, ...spec.defaults, ...presetType, ...presetPlace, region_id: /^\d+$/.test(req.query.region ?? '') ? Number(req.query.region) : null };
|
|
260
305
|
heading = html`${icon(spec.icon)}<span>New ${spec.label.toLowerCase()}</span>`;
|
|
261
306
|
props = peTabs([tab('pd-r-props', spec.label, componentForm(spec, newKind, defaults, lk, `${BASE}/pages/${p.id}/c/${newKind}`, s, `Create ${spec.label.toLowerCase()}`, { id: formId }), true)]);
|
|
262
307
|
} else if (kind && kind !== 'page' && PAGE_KINDS.includes(kind)) {
|
|
@@ -303,7 +348,7 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
303
348
|
<fieldset class="prop-group"><legend>Appearance</legend><div class="form-grid">
|
|
304
349
|
${select('mode', 'Page mode', p.mode, [['normal', 'Normal'], ['modal', 'Modal dialog']])}
|
|
305
350
|
${select('page_group', 'Page group', p.page_group ?? '', [['', '- none -'], ...lk.pageGroups.map((g): [string, string] => [g, g]), ...(p.page_group && !lk.pageGroups.includes(p.page_group) ? [[p.page_group, `${p.page_group} (no such group)`] as [string, string]] : [])], 'Organises the pages in the App Builder (Shared Components → Page groups). Changes nothing when the application runs.')}
|
|
306
|
-
${select('page_template', 'Page template', p.page_template ?? 'standard', [['standard', 'Standard'], ['minimal', 'Minimal (no navigation)']], 'Minimal: no header and navigation menu, e.g. for a login page (Application settings → Login page).')}
|
|
351
|
+
${select('page_template', 'Page template', p.page_template ?? 'standard', [['standard', 'Standard'], ['left_side_column', 'Left side column'], ['right_side_column', 'Right side column'], ['minimal', 'Minimal (no navigation)']], 'Side column: regions with position left (or right) show in a column beside the body (under it on narrow screens). Minimal: no header and navigation menu, e.g. for a login page (Application settings → Login page).')}
|
|
307
352
|
${select('dialog_position', 'Dialog position', p.dialog_position ?? 'center', [['center', 'Centred dialog'], ['right', 'Drawer from the right'], ['left', 'Drawer from the left'], ['top', 'Drawer from the top'], ['bottom', 'Drawer from the bottom']], 'Modal pages only. On phones dialogs and side drawers fill the screen.')}
|
|
308
353
|
${select('dialog_size', 'Dialog size', p.dialog_size ?? 'medium', [['small', 'Small'], ['medium', 'Medium'], ['large', 'Large']], 'The width (the height of top and bottom drawers).')}
|
|
309
354
|
${input('static_includes', 'JavaScript and CSS files', (p.static_includes ?? []).join(', '), { placeholder: 'e.g. page-3.js, page-3.css', help: 'Static application files (.js, .css) this page loads after the application\'s own, in this order.' })}
|
|
@@ -371,6 +416,10 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
371
416
|
const toolbar = html`
|
|
372
417
|
<div class="tb-group pd-pagenav" role="group" aria-label="Page">
|
|
373
418
|
${prev ? html`<a class="tb-btn" href="${BASE}/pages/${prev.id}" title="Previous page: ${prev.page_no}. ${prev.name}">${bicon('left')}<span class="sr-only">Previous page</span></a>` : html`<span class="tb-btn" aria-disabled="true">${bicon('left')}</span>`}
|
|
419
|
+
<form method="get" action="${BASE}/apps/${p.app_id}/goto" class="tb-form pd-goto-no">
|
|
420
|
+
<label class="sr-only" for="pd-goto-no">Page number (Enter goes there)</label>
|
|
421
|
+
<input id="pd-goto-no" name="page" type="number" min="0" max="999999999" step="1" value="${p.page_no}" inputmode="numeric" title="Page number: type or step to a number, then Enter">
|
|
422
|
+
</form>
|
|
374
423
|
<form method="get" action="${BASE}/apps/${p.app_id}/goto" class="tb-form pd-goto">
|
|
375
424
|
<label class="sr-only" for="pd-goto">Go to page</label>
|
|
376
425
|
<select id="pd-goto" name="page" data-autosubmit>${pages.map((x) => html`<option value="${x.page_no}"${x.id === p.id ? raw(' selected') : ''}>${x.page_no} · ${x.name}</option>`)}</select>
|
|
@@ -416,7 +465,7 @@ export async function designerRoutes(app: FastifyInstance) {
|
|
|
416
465
|
`update meta.page set page_no = $2, name = $3, title = $4, requires_auth = $5, mode = $6, parent_page = $7, authz = $8, protection = $9, build_option = $10,
|
|
417
466
|
dialog_position = $11, dialog_size = $12, static_includes = $13, warn_unsaved = $14, page_template = $15, page_group = $16 where id = $1`,
|
|
418
467
|
[req.params.pid, Number(b.page_no), b.name?.trim(), b.title?.trim() || null, b.requires_auth === 'true', b.mode, b.parent_page ? Number(b.parent_page) : null, b.authz || null, b.protection, b.build_option?.trim().toUpperCase() || null,
|
|
419
|
-
['left', 'right', 'top', 'bottom'].includes(b.dialog_position ?? '') ? b.dialog_position : 'center', ['small', 'large'].includes(b.dialog_size ?? '') ? b.dialog_size : 'medium', parseIncludes(b.static_includes), b.warn_unsaved === 'true', b.page_template
|
|
468
|
+
['left', 'right', 'top', 'bottom'].includes(b.dialog_position ?? '') ? b.dialog_position : 'center', ['small', 'large'].includes(b.dialog_size ?? '') ? b.dialog_size : 'medium', parseIncludes(b.static_includes), b.warn_unsaved === 'true', ['minimal', 'left_side_column', 'right_side_column'].includes(b.page_template ?? '') ? b.page_template : 'standard', b.page_group?.trim() || null],
|
|
420
469
|
);
|
|
421
470
|
// the page's lock and comments follow a new page number
|
|
422
471
|
if (before && before.page_no !== Number(b.page_no))
|
package/src/builder/forms.ts
CHANGED
|
@@ -164,9 +164,12 @@ export function componentForm(spec: ComponentSpec, kind: string, row: any, lk: L
|
|
|
164
164
|
return html`<div class="field" data-prop="${f.name}"${f.wide ? raw(' data-wide') : ''}><label class="label" for="${id}">${f.label}</label>${control}${help}</div>`;
|
|
165
165
|
};
|
|
166
166
|
const groups = [...new Set(spec.fields.map((f) => f.group ?? ''))];
|
|
167
|
+
// (sprint 77, APEX: Show Common / Show All) the other properties are marked, for the property editor's toggle
|
|
168
|
+
const marked = spec.fields.some((f) => f.common);
|
|
169
|
+
const shown = (f: Field) => (marked && !f.common ? html`<div class="pe-more" data-more>${field(f)}</div>` : field(f));
|
|
167
170
|
return html`<form method="post" action="${action}" class="component-form"${opts.id ? html` id="${opts.id}"` : ''}>
|
|
168
171
|
${csrf(s)}
|
|
169
|
-
${groups.map((g) => html`<fieldset class="prop-group">${g ? html`<legend>${g}</legend>` : ''}<div class="form-grid">${spec.fields.filter((f) => (f.group ?? '') === g).map(
|
|
172
|
+
${groups.map((g) => html`<fieldset class="prop-group">${g ? html`<legend>${g}</legend>` : ''}<div class="form-grid">${spec.fields.filter((f) => (f.group ?? '') === g).map(shown)}</div></fieldset>`)}
|
|
170
173
|
<div class="buttons"><button class="btn btn-hot">${submit}</button></div>
|
|
171
174
|
</form>`;
|
|
172
175
|
}
|
package/src/builder/home.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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);
|