pgkiln 0.50.0 → 0.52.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/SECURITY.md +7 -0
- package/db/migrations/119_workspace_home.sql +30 -0
- package/db/migrations/120_datasets.sql +87 -0
- package/db/migrations/121_ai_agents.sql +94 -0
- package/db/migrations/122_team_development.sql +186 -0
- package/db/migrations/123_workflow_outcomes.sql +407 -0
- package/db/migrations/124_component_details.sql +51 -0
- package/docs/apex-feature-parity.md +15 -12
- package/docs/guide/01-installation.md +9 -3
- package/docs/guide/03-builder.md +211 -11
- package/docs/guide/04-pages-and-regions.md +96 -22
- package/docs/guide/05-items.md +7 -1
- package/docs/guide/06-processing.md +123 -8
- package/docs/guide/07-dynamic-actions.md +34 -0
- package/docs/guide/09-reference.md +2 -1
- package/docs/guide/11-from-apex.md +2 -1
- package/docs/guide/12-development.md +70 -11
- package/docs/guide/20-ai-agents.md +4 -2
- package/examples/hr/README.md +2 -0
- package/examples/hr/hr_63_datasets.sql +29 -0
- package/examples/hr/hr_64_ai_agent.sql +97 -0
- package/examples/hr/hr_65_ask_reports.sql +21 -0
- package/examples/hr/hr_66_team_development.sql +58 -0
- package/examples/hr/hr_67_workflow_outcomes.sql +113 -0
- package/examples/hr/hr_68_component_details.sql +96 -0
- package/package.json +1 -1
- package/public/app.css +46 -0
- package/public/app.js +134 -20
- package/public/builder.css +116 -0
- package/public/builder.js +47 -0
- package/src/ai/chat.ts +18 -18
- package/src/ai/service.ts +35 -11
- package/src/ai/types.ts +7 -2
- package/src/app.ts +11 -1
- package/src/builder/advisor.ts +6 -3
- package/src/builder/ai-agents.ts +372 -0
- package/src/builder/ai-builder.ts +3 -3
- package/src/builder/ai.ts +13 -9
- package/src/builder/assistant.ts +22 -7
- package/src/builder/components.ts +38 -9
- package/src/builder/datasets.ts +472 -0
- package/src/builder/designer.ts +8 -3
- package/src/builder/feedback.ts +6 -1
- package/src/builder/home.ts +12 -7
- package/src/builder/instance.ts +2 -0
- package/src/builder/locks.ts +7 -1
- package/src/builder/region-help.ts +2 -2
- package/src/builder/reporter.ts +52 -3
- package/src/builder/routes.ts +7 -4
- package/src/builder/shared.ts +1 -1
- package/src/builder/subscriptions.ts +1 -0
- package/src/builder/team.ts +991 -0
- package/src/builder/ui.ts +8 -5
- package/src/builder/workflow-migrate.ts +210 -0
- package/src/builder/workflows.ts +29 -6
- package/src/builder/workspace-home.ts +285 -0
- package/src/builder/workspace-transfer.ts +1 -1
- package/src/builder/workspaces.ts +7 -4
- package/src/cli/replace.ts +7 -1
- package/src/datasets.ts +367 -0
- package/src/i18n/ar.ts +33 -0
- package/src/i18n/cs.ts +33 -0
- package/src/i18n/da.ts +33 -0
- package/src/i18n/de.ts +33 -0
- package/src/i18n/el.ts +33 -0
- package/src/i18n/es.ts +33 -0
- package/src/i18n/fi.ts +33 -0
- package/src/i18n/fr.ts +33 -0
- package/src/i18n/he.ts +33 -0
- package/src/i18n/it.ts +33 -0
- package/src/i18n/ja.ts +33 -0
- package/src/i18n/ko.ts +33 -0
- package/src/i18n/nb.ts +33 -0
- package/src/i18n/pl.ts +33 -0
- package/src/i18n/pt.ts +33 -0
- package/src/i18n/ru.ts +33 -0
- package/src/i18n/sv.ts +33 -0
- package/src/i18n/tr.ts +33 -0
- package/src/i18n/uk.ts +33 -0
- package/src/i18n/zh.ts +33 -0
- package/src/i18n.ts +66 -0
- package/src/instance.ts +3 -0
- package/src/metadata.ts +9 -3
- package/src/release-check.ts +139 -0
- package/src/reporting-app.ts +101 -0
- package/src/runtime/ai-agents.ts +365 -0
- package/src/runtime/ai-filter.ts +385 -107
- package/src/runtime/assistant.ts +64 -11
- package/src/runtime/context.ts +2 -0
- package/src/runtime/data-reporter.ts +199 -32
- package/src/runtime/ir-phrases.ts +812 -0
- package/src/runtime/ir-plan.ts +449 -0
- package/src/runtime/items.ts +1 -1
- package/src/runtime/regions.ts +6 -2
- package/src/runtime/render.ts +1 -1
- package/src/runtime/report.ts +1 -1
- package/src/runtime/tasks.ts +63 -12
- package/src/session.ts +2 -0
- package/src/subscriptions.ts +2 -1
- package/src/task-events.ts +232 -0
- package/src/team-samples.ts +74 -0
- package/src/team.ts +342 -0
- package/src/workflow.ts +135 -18
- package/src/workspace-export.ts +39 -4
|
@@ -11,6 +11,7 @@ import { scheduleProblem } from '../automations.ts';
|
|
|
11
11
|
import { ICONS, isIcon } from '../icons.ts';
|
|
12
12
|
import { templateProblem } from '../runtime/document.ts';
|
|
13
13
|
import { stepProblems } from '../workflow.ts';
|
|
14
|
+
import { eventProblems, notificationProblems, outcomeProblems } from '../task-events.ts';
|
|
14
15
|
import { workflowBeforeSave } from './workflows.ts';
|
|
15
16
|
import { handlerProblems } from '../runtime/rest.ts';
|
|
16
17
|
import { TEMPLATE_COMPONENT_SPEC } from './template-spec.ts';
|
|
@@ -89,6 +90,14 @@ const conditionFields = (group: string): Field[] => [
|
|
|
89
90
|
const menuBeforeSave = async (v: Record<string, unknown>) => {
|
|
90
91
|
if (v.menu === '{}' || v.menu === '[]') v.menu = null;
|
|
91
92
|
if (typeof v.menu === 'string' && !Array.isArray(JSON.parse(v.menu))) throw new Error('Menu entries: a JSON array, e.g. [{"label": "Details", "page": 3, "items": {"P3_ID": "&P2_ID."}}, {"label": "Archive", "request": "ARCHIVE"}]');
|
|
93
|
+
// (124) the database checks every entry too (meta.button_menu_ok); this names the one that's wrong
|
|
94
|
+
if (typeof v.menu === 'string')
|
|
95
|
+
for (const e of JSON.parse(v.menu) as Record<string, unknown>[]) {
|
|
96
|
+
const kinds = ['page', 'request', 'action'].filter((k) => e && typeof e === 'object' && k in e);
|
|
97
|
+
if (kinds.length !== 1) throw new Error(`Menu entry "${String(e?.label ?? '')}": exactly one of "page", "request" or "action".`);
|
|
98
|
+
if ('action' in e && !/^[A-Z][A-Z0-9_]{0,59}$/.test(String(e.action)))
|
|
99
|
+
throw new Error(`Menu entry "${String(e.label ?? '')}": "action" is a name in capitals (A-Z, 0-9, _), e.g. EXPORT.`);
|
|
100
|
+
}
|
|
92
101
|
};
|
|
93
102
|
const CLASS_LIST = /^[a-z][a-z0-9_-]{0,39}( [a-z][a-z0-9_-]{0,39}){0,4}$/;
|
|
94
103
|
|
|
@@ -175,7 +184,7 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
175
184
|
{ name: 'target_items', label: 'Set items (JSON)', kind: 'json', group: 'Behaviour', help: '{"P3_ID": "&P2_ID."}' },
|
|
176
185
|
{ name: 'confirm', label: 'Confirm message', kind: 'text', group: 'Behaviour' },
|
|
177
186
|
{ name: 'menu', label: 'Menu entries (action menu, JSON)', kind: 'json', wide: true, group: 'Behaviour',
|
|
178
|
-
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,
|
|
187
|
+
help: '[{"label": "Details", "page": 3, "items": {"P3_ID": "&P2_ID."}}, {"label": "Archive", "request": "ARCHIVE", "confirm": "Archive it?", "authz": "ADMIN", "icon": "inbox"}, {"label": "Export", "action": "EXPORT"}] — a link to a page, a submit with that request (processes and branches see it as the button pressed), or an action: the dynamic actions of event menu_entry with that name (or the menu button\'s name) as trigger. At most 20.' },
|
|
179
188
|
{ name: 'hot', common: true, label: 'Primary (hot) button', kind: 'bool', group: 'Appearance' },
|
|
180
189
|
{ name: 'template_options', label: 'Template options', kind: 'options', choices: BUTTON_OPTIONS, group: 'Appearance',
|
|
181
190
|
help: 'CSS classes from a fixed list, added to the button (APEX: Template Options).' },
|
|
@@ -206,18 +215,18 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
206
215
|
: null,
|
|
207
216
|
fields: [
|
|
208
217
|
{ name: 'name', label: 'Name', kind: 'text', group: 'When' },
|
|
209
|
-
{ name: 'event', label: 'Event', kind: 'select', options: ['change', 'click', 'load', 'dialog_closed', 'focus', 'blur', 'key_release', 'after_refresh', 'selection_change', 'before_submit', 'custom'], group: 'When',
|
|
210
|
-
help: 'dialog_closed: a modal dialog opened from this page was submitted and closed (instead of reloading the page), e.g. refresh a region. focus / blur / key_release: an item gets or loses the focus, a key is released in it. after_refresh: a region was refreshed or loaded. selection_change: rows were selected in a report (row selection) or a grid\'s master row. before_submit: the page is about to be submitted (a declined confirm keeps it). custom: JavaScript raised it with pgkiln.trigger(\'NAME\', value). The actions of one event and trigger run in sequence order; a declined confirm stops the ones after it.' },
|
|
211
|
-
{ name: 'trigger_element', label: 'Item(s) / button / region / page / event', kind: 'upper', group: 'When', help: 'Comma separated: item names (change, focus, blur, key_release), a button name (click), region ids or static ids (after_refresh, selection_change), dialog page numbers (dialog_closed; empty: any dialog), event names (custom). Empty for load and before_submit.' },
|
|
218
|
+
{ name: 'event', label: 'Event', kind: 'select', options: ['change', 'click', 'load', 'dialog_closed', 'focus', 'blur', 'key_release', 'after_refresh', 'selection_change', 'before_submit', 'custom', 'menu_entry'], group: 'When',
|
|
219
|
+
help: 'dialog_closed: a modal dialog opened from this page was submitted and closed (instead of reloading the page), e.g. refresh a region. focus / blur / key_release: an item gets or loses the focus, a key is released in it. after_refresh: a region was refreshed or loaded. selection_change: rows were selected in a report (row selection) or a grid\'s master row. before_submit: the page is about to be submitted (a declined confirm keeps it). custom: JavaScript raised it with pgkiln.trigger(\'NAME\', value). menu_entry (Menu entry chosen): an entry {"label": …, "action": "NAME"} of a menu button was chosen. The actions of one event and trigger run in sequence order; a declined confirm stops the ones after it.' },
|
|
220
|
+
{ name: 'trigger_element', label: 'Item(s) / button / region / page / event', kind: 'upper', group: 'When', help: 'Comma separated: item names (change, focus, blur, key_release), a button name (click), region ids or static ids (after_refresh, selection_change), dialog page numbers (dialog_closed; empty: any dialog), event names (custom), menu entry action names or menu button names (menu_entry: any of its entries; a client-side condition tests the entry\'s name). Empty for load and before_submit.' },
|
|
212
221
|
{ name: 'condition_type', label: 'Client-side condition', kind: 'select', options: ['', 'equals', 'not_equals', 'in_list', 'is_null', 'is_not_null'], group: 'When' },
|
|
213
222
|
{ name: 'condition_value', label: 'Condition value', kind: 'text', group: 'When' },
|
|
214
|
-
{ name: 'action', label: 'Action', kind: 'select', options: ['show', 'hide', 'enable', 'disable', 'set_value', 'execute_sql', 'refresh_region', 'refresh_item', 'alert', 'submit', 'set_focus', 'add_class', 'remove_class', 'show_success', 'show_error', 'clear_errors', 'ai_generate', 'execute_javascript', 'plugin', 'push_subscribe', 'confirm', 'close_dialog', 'cancel_dialog', 'clear', 'open_region', 'close_region', 'download', 'print', 'current_position', 'share'], group: 'Action',
|
|
215
|
-
help: 'show/hide/enable/disable reverse automatically when the condition is false. set_focus: the first affected item (or the region). show_error: on the affected items, or at the top. clear_errors: of the affected items, or all. ai_generate: runs the "Generate text with AI" process named in Code through AJAX, without submitting the page (on a submit button, the button submits instead when JavaScript is off). execute_javascript: calls the function named in Code, registered by a static application file (Shared Components → Static Application Files). push_subscribe: turns on push notifications on the user\'s device (use it on click: browsers ask only after one; the app needs push notifications on in Settings → Progressive Web App); Message is shown when it worked. confirm: asks Message; No stops the actions after it. close_dialog / cancel_dialog: close the modal page this runs on (close_dialog refreshes the page below and runs its dialog_closed actions). clear: empties the affected items. open_region / close_region: a collapsible region opens or closes (another region shows or hides). download: the file of the download process named in Code (Items to submit are sent first). print: the browser\'s print dialog. current_position: the device\'s position as latitude,longitude into the first affected item. share: the device\'s share sheet for this page (Message as the text), or the link copied.' },
|
|
223
|
+
{ name: 'action', label: 'Action', kind: 'select', options: ['show', 'hide', 'enable', 'disable', 'set_value', 'execute_sql', 'refresh_region', 'refresh_item', 'alert', 'submit', 'set_focus', 'add_class', 'remove_class', 'show_success', 'show_error', 'clear_errors', 'ai_generate', 'execute_javascript', 'plugin', 'push_subscribe', 'confirm', 'close_dialog', 'cancel_dialog', 'clear', 'open_region', 'close_region', 'download', 'print', 'current_position', 'share', 'wait_popup', 'remove_wait_popup'], group: 'Action',
|
|
224
|
+
help: 'show/hide/enable/disable reverse automatically when the condition is false. set_focus: the first affected item (or the region). show_error: on the affected items, or at the top. clear_errors: of the affected items, or all. ai_generate: runs the "Generate text with AI" process named in Code through AJAX, without submitting the page (on a submit button, the button submits instead when JavaScript is off). execute_javascript: calls the function named in Code, registered by a static application file (Shared Components → Static Application Files). push_subscribe: turns on push notifications on the user\'s device (use it on click: browsers ask only after one; the app needs push notifications on in Settings → Progressive Web App); Message is shown when it worked. confirm: asks Message; No stops the actions after it. close_dialog / cancel_dialog: close the modal page this runs on (close_dialog refreshes the page below and runs its dialog_closed actions). clear: empties the affected items. open_region / close_region: a collapsible region opens or closes (another region shows or hides). download: the file of the download process named in Code (Items to submit are sent first). print: the browser\'s print dialog. current_position: the device\'s position as latitude,longitude into the first affected item. share: the device\'s share sheet for this page (Message as the text), or the link copied. wait_popup: a modal "please wait" overlay (Message as its text) until remove_wait_popup or the next page, e.g. on before_submit around a long process, or wait_popup, execute_sql, remove_wait_popup around an Ajax call.' },
|
|
216
225
|
{ name: 'affected_items', label: 'Affected items', kind: 'upper', group: 'Action' },
|
|
217
226
|
{ name: 'affected_region_id', label: 'Affected region', kind: 'region', group: 'Action' },
|
|
218
227
|
{ name: 'code', label: 'SQL', kind: 'code', wide: true, group: 'Action', help: 'set_value: a SELECT whose columns set the affected items · execute_sql: any SQL; returned columns named like items set them · ai_generate: the name of an ai_generate process on this page (its output items are updated; items to submit default to the items its prompts use) · execute_javascript: the name of a function registered with pgkiln.actions.register(name, fn) in a static file the page loads.' },
|
|
219
228
|
{ name: 'items_to_submit', label: 'Items to submit', kind: 'upper', group: 'Action' },
|
|
220
|
-
{ name: 'message', label: 'Message (alert, show_success, show_error)', kind: 'text', group: 'Action' },
|
|
229
|
+
{ name: 'message', label: 'Message (alert, confirm, show_success, show_error, wait_popup)', kind: 'text', group: 'Action' },
|
|
221
230
|
{ name: 'config', label: 'Plug-in attributes (JSON)', kind: 'json', group: 'Action', help: 'plugin: {"attributes": {"NAME": "value or &ITEM."}} (Shared Components → Plug-ins lists each plug-in\'s attributes).' },
|
|
222
231
|
{ name: 'css_classes', label: 'CSS classes (add_class, remove_class)', kind: 'text', group: 'Action', help: 'Up to five class names, e.g. is-highlight is-muted (lower case letters, digits, - and _).' },
|
|
223
232
|
{ name: 'seq', label: 'Sequence', kind: 'int', group: 'Security' },
|
|
@@ -614,7 +623,18 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
614
623
|
if (v.expiration && !v.due_in) return 'When overdue: set "Due in" first (a task without a due date is never overdue).';
|
|
615
624
|
if (v.expiration === 'renew' && v.max_renewals !== null && v.max_renewals !== undefined && v.max_renewals !== '' && (Number(v.max_renewals) < 1 || Number(v.max_renewals) > 100)) return 'Maximum renewals: 1 to 100.';
|
|
616
625
|
if (v.expiration === 'escalate' && !String(v.escalate_role ?? '').trim()) return 'Escalation role: the role that takes the task over.';
|
|
617
|
-
|
|
626
|
+
// (123) custom outcomes, events and notifications (parseFields gives the JSON as text)
|
|
627
|
+
const json = (x: unknown) => {
|
|
628
|
+
const parsed = typeof x === 'string' ? (x.trim() ? JSON.parse(x) : null) : x;
|
|
629
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) && !Object.keys(parsed).length ? null : parsed;
|
|
630
|
+
};
|
|
631
|
+
const problems = [...outcomeProblems(json(v.outcomes)), ...eventProblems(json(v.events)), ...notificationProblems(json(v.notifications))];
|
|
632
|
+
return problems.length ? problems.join(' ') : null;
|
|
633
|
+
},
|
|
634
|
+
// (123) an empty JSON field is "none" (null), not {}
|
|
635
|
+
beforeSave: async (v) => {
|
|
636
|
+
for (const k of ['outcomes', 'events', 'notifications']) if (v[k] === '{}' || v[k] === '[]' || v[k] === '') v[k] = null;
|
|
637
|
+
if (v.fail_recovery === '') v.fail_recovery = null;
|
|
618
638
|
},
|
|
619
639
|
fields: [
|
|
620
640
|
{ name: 'name', label: 'Name', kind: 'upper', group: 'Identification', help: "Application SQL creates tasks with meta.create_task('NAME', :P1_ID, params, owners)." },
|
|
@@ -632,7 +652,16 @@ export const COMPONENTS: Record<string, ComponentSpec> = {
|
|
|
632
652
|
{ name: 'details_page', label: 'Details page', kind: 'page', group: 'Details', help: 'The subject links here, setting the item below to the task\'s record key.' },
|
|
633
653
|
{ name: 'details_item', label: 'Details item', kind: 'upper', group: 'Details', help: 'e.g. P7_ID' },
|
|
634
654
|
{ name: 'action_code', label: 'On completion (SQL)', kind: 'code', wide: true, group: 'Action',
|
|
635
|
-
help: 'Runs as the application\'s role when the task is approved, rejected or completed, in the same transaction (an error undoes the decision). Binds: :TASK_ID, :DETAIL_PK, :OUTCOME (APPROVED, REJECTED, COMPLETED), :COMMENT, :APPROVER, :INITIATOR and the task parameters, e.g. select expenses.decide(:DETAIL_PK::int, :OUTCOME, :COMMENT)' },
|
|
655
|
+
help: 'Runs as the application\'s role when the task is approved, rejected or completed, in the same transaction (an error undoes the decision). Binds: :TASK_ID, :DETAIL_PK, :OUTCOME (APPROVED, REJECTED, COMPLETED, or a custom outcome such as YES), :COMMENT, :APPROVER, :INITIATOR and the task parameters, e.g. select expenses.decide(:DETAIL_PK::int, :OUTCOME, :COMMENT)' },
|
|
656
|
+
// (123) custom outcomes, events and notifications
|
|
657
|
+
{ name: 'outcomes', label: 'Outcomes (JSON)', kind: 'json', wide: true, group: 'Outcomes',
|
|
658
|
+
help: 'Custom outcomes instead of Approve / Reject (or Complete): [{"name": "YES", "label": "Yes", "colour": "success"}, {"name": "NO", "label": "No", "colour": "danger"}, {"name": "UNDECIDED", "label": "Undecided", "colour": "warning"}] · colours: default, primary, success, danger, warning. The task list shows one button each; :OUTCOME is the name (YES); a workflow\'s task step names them in "next" ({"yes": "PAY", "no": "END"}) and a switch case in "outcome". Empty: the type\'s own outcomes.' },
|
|
659
|
+
{ name: 'events', label: 'Events (JSON)', kind: 'json', wide: true, group: 'Events',
|
|
660
|
+
help: '[{"on": "complete", "type": "sql", "code": "select expenses.log(:TASK_ID::bigint, :OUTCOME)"}, {"on": "expire", "type": "mail", "to": "select email from staff where role = \'manager\'", "template": "TASK_EXPIRED"}, {"on": "fail", "type": "workflow", "workflow": "FIX_REQUEST", "vars": {"REASON": "&ERROR."}}] · on: complete, expire, fail (the completion SQL failed) · type: sql (as the application\'s role), mail (meta.send_mail_template with a "template", or "subject" and "body"; "to" is a SELECT returning addresses, or addresses), workflow (meta.start_workflow with the task\'s record key and parameters, plus "vars"). Binds and #NAME# placeholders: the task parameters, :TASK_ID, :DETAIL_PK, :SUBJECT, :OUTCOME, :APPROVER, :INITIATOR, :EVENT; for fail also :ERROR and :COMMENT. Complete and expire run right after the commit, in a transaction of their own; fail runs in the request.' },
|
|
661
|
+
{ name: 'fail_recovery', label: 'When the completion SQL fails', kind: 'select', options: ['', 'reopen', 'complete'], group: 'Events',
|
|
662
|
+
help: 'reopen (the default): the decision is undone and the task stays open; the user sees the error. complete: the task is completed with the outcome, without the SQL\'s effect. The "fail" events run either way.' },
|
|
663
|
+
{ name: 'notifications', label: 'Notifications (JSON)', kind: 'json', wide: true, group: 'Events',
|
|
664
|
+
help: 'E-mail templates (Shared Components → E-mail templates) sent by pgkiln itself: {"create": "TASK_CREATED", "complete": "TASK_DONE", "expire": "TASK_EXPIRED"} · create: to the potential owners (named users and the application\'s users with an owner role), complete: to the initiator, expire: to both; at the e-mail address of their account. Placeholders: #SUBJECT#, #TASK_ID#, #DETAIL_PK#, #OUTCOME#, #APPROVER#, #INITIATOR#, #EVENT# and the task parameters. The application needs a mail service.' },
|
|
636
665
|
],
|
|
637
666
|
},
|
|
638
667
|
template_component: TEMPLATE_COMPONENT_SPEC,
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
2
|
+
import {
|
|
3
|
+
catalogOf, checkDataset, DATASET_LIMITS, DATASET_NAME, datasetFile, datasetOf, datasetSql, proposeDataset, proposeJoin, readDatasetFile,
|
|
4
|
+
readSchemaCatalog, storeDataset, workspaceDatasets, type Dataset, type DatasetRow,
|
|
5
|
+
} from '../datasets.ts';
|
|
6
|
+
import { owner, type Db } from '../db.ts';
|
|
7
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
8
|
+
import { icon } from '../icons.ts';
|
|
9
|
+
import { createReportingApp, MAX_REPORTING_DATASETS, reportingApps } from '../reporting-app.ts';
|
|
10
|
+
import { readMultipart } from '../runtime/files.ts';
|
|
11
|
+
import { heading } from '../runtime/items.ts';
|
|
12
|
+
import { opLabel, OPERATORS } from '../runtime/report.ts';
|
|
13
|
+
import { english } from '../i18n.ts';
|
|
14
|
+
import { logActivity, clientIp, type Session } from '../session.ts';
|
|
15
|
+
import { checkNewApp, createAppError } from './newapp.ts';
|
|
16
|
+
import { back, BASE, csrf, developer, flash, input, region, send, shell, type Body, type Req } from './ui.ts';
|
|
17
|
+
import { currentWorkspace, usableSchemas, workshopDb } from './workspaces.ts';
|
|
18
|
+
|
|
19
|
+
// (sprint 110, migration 120) App Builder → Data Reporter (/builder/reporter; APEX 26.1: Data Reporter as a
|
|
20
|
+
// workspace tool): the workspace's datasets (tables and views of one schema, joins proposed from foreign keys,
|
|
21
|
+
// the columns business users may see with labels, format masks and descriptions, a row filter), their files
|
|
22
|
+
// (pgkiln-dataset/1: download one or all, import), and reporting applications made from them (two clicks: Create
|
|
23
|
+
// reporting application on a dataset, then Create). Every developer of the workspace manages them; the catalog is
|
|
24
|
+
// read through the SQL Workshop's connection (an isolated workspace's own login), and a dataset is checked
|
|
25
|
+
// against it before it is saved.
|
|
26
|
+
|
|
27
|
+
const root = `${BASE}/reporter`;
|
|
28
|
+
const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
29
|
+
const PREVIEW_ROWS = 10;
|
|
30
|
+
const crumbs = (extra: [string, string?][] = []): [string, string?][] => [['App Builder', BASE], ['Data Reporter', root], ...extra];
|
|
31
|
+
const when = (d: string | Date | null) => (d ? new Date(d).toISOString().slice(0, 16).replace('T', ' ') : '');
|
|
32
|
+
|
|
33
|
+
const page = (s: Session, reply: FastifyReply, title: string, extra: [string, string?][], main: Raw) =>
|
|
34
|
+
send(reply, s, shell(s, title, crumbs(extra), html`<div class="ab-narrow dataset-page">${main}</div>`, 'reporter'));
|
|
35
|
+
|
|
36
|
+
/** A dataset of the current workspace by id (null: none, or another workspace's). */
|
|
37
|
+
async function datasetRow(s: Session, id: string): Promise<DatasetRow | null> {
|
|
38
|
+
if (!/^\d{1,12}$/.test(id)) return null;
|
|
39
|
+
return (await owner.one<DatasetRow>('select * from meta.dataset where id = $1 and workspace_id = $2', [id, currentWorkspace(s)])) ?? null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A schema the developer's SQL Workshop connection may use, or null. */
|
|
43
|
+
async function usableSchema(db: Db, schema: string) {
|
|
44
|
+
return schema && (await usableSchemas(db)).includes(schema) ? schema : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** "deptno = deptno, x = y" → join column pairs (this table's column = the earlier table's). */
|
|
48
|
+
export function parseOn(text: string) {
|
|
49
|
+
return text
|
|
50
|
+
.split(',')
|
|
51
|
+
.map((p) => p.split('=').map((x) => x.trim()))
|
|
52
|
+
.filter((p) => p.length === 2 && p[0] && p[1])
|
|
53
|
+
.map(([column, ref]) => ({ column, ref }))
|
|
54
|
+
.slice(0, DATASET_LIMITS.joinColumns);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The dataset of the edit form (loose: checkDataset() does the checking). */
|
|
58
|
+
export function datasetFromForm(d: Dataset, b: Body, cat: Awaited<ReturnType<typeof readSchemaCatalog>>): Record<string, unknown> {
|
|
59
|
+
const tables: any[] = [];
|
|
60
|
+
for (let i = 0; i < DATASET_LIMITS.tables && b[`t${i}_alias`] !== undefined; i++) {
|
|
61
|
+
const old = d.tables.find((t) => t.alias === b[`t${i}_alias`]);
|
|
62
|
+
if (!old || b[`t${i}_remove`] === 'true') continue;
|
|
63
|
+
tables.push(i === 0 || !tables.length ? { alias: old.alias, table: old.table } : {
|
|
64
|
+
alias: old.alias, table: old.table,
|
|
65
|
+
join: { to: String(b[`t${i}_to`] ?? ''), type: b[`t${i}_type`] === 'inner' ? 'inner' : 'left', on: parseOn(String(b[`t${i}_on`] ?? '')) },
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const added = String(b.new_table ?? '');
|
|
69
|
+
if (added && cat.objects.some((o) => o.name === added) && tables.length < DATASET_LIMITS.tables) {
|
|
70
|
+
let n = 1;
|
|
71
|
+
while (tables.some((t) => t.alias === `t${n}`) || d.tables.some((t) => t.alias === `t${n}`)) n++;
|
|
72
|
+
const on = parseOn(String(b.new_on ?? ''));
|
|
73
|
+
const join = tables.length
|
|
74
|
+
? on.length ? { to: String(b.new_to || tables[0].alias), type: 'left', on } : proposeJoin(added, tables, cat.fks)
|
|
75
|
+
: undefined;
|
|
76
|
+
tables.push({ alias: `t${n}`, table: added, ...(join ? { join } : {}) });
|
|
77
|
+
}
|
|
78
|
+
const columns: any[] = [];
|
|
79
|
+
for (let j = 0; j < 1000 && b[`c${j}_ref`] !== undefined; j++) {
|
|
80
|
+
if (b[`c${j}_on`] !== 'true') continue;
|
|
81
|
+
const [alias, ...rest] = String(b[`c${j}_ref`]).split('.');
|
|
82
|
+
columns.push({ table: alias, column: rest.join('.'), name: b[`c${j}_name`], label: b[`c${j}_label`], format: b[`c${j}_format`], description: b[`c${j}_desc`] });
|
|
83
|
+
}
|
|
84
|
+
// a table added just now: its columns are offered at once (except the join's and binary ones)
|
|
85
|
+
const fresh = tables.find((t) => !d.tables.some((x) => x.alias === t.alias));
|
|
86
|
+
if (fresh) {
|
|
87
|
+
const taken = new Set(columns.map((c) => String(c.name ?? '')));
|
|
88
|
+
const skip = new Set((fresh.join?.on ?? []).map((p: { column: string }) => p.column));
|
|
89
|
+
for (const c of cat.objects.find((o) => o.name === fresh.table)?.columns ?? []) {
|
|
90
|
+
if (c.type === 'bytea' || skip.has(c.name)) continue;
|
|
91
|
+
let name = c.name.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^[^a-z]+/, '').slice(0, 40) || 'col';
|
|
92
|
+
if (taken.has(name)) name = `${fresh.table}_${name}`.toLowerCase().replace(/[^a-z0-9_]+/g, '_').slice(0, 40);
|
|
93
|
+
taken.add(name);
|
|
94
|
+
columns.push({ table: fresh.alias, column: c.name, name, label: heading(c.name) });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const filters: any[] = [];
|
|
98
|
+
for (let k = 0; k < DATASET_LIMITS.filters; k++) {
|
|
99
|
+
const ref = String(b[`f${k}_col`] ?? '');
|
|
100
|
+
if (!ref) continue;
|
|
101
|
+
const [alias, ...rest] = ref.split('.');
|
|
102
|
+
filters.push({ table: alias, column: rest.join('.'), op: b[`f${k}_op`], value: b[`f${k}_value`] ?? '' });
|
|
103
|
+
}
|
|
104
|
+
return { name: d.name, label: b.label, description: b.description, schema: d.schema, tables, columns, filters };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The first rows of a dataset, read only, through the SQL Workshop's connection (a timeout; errors as text). */
|
|
108
|
+
async function preview(db: Db, d: Dataset): Promise<{ fields: string[]; rows: unknown[][] } | { error: string }> {
|
|
109
|
+
if (!d.columns.length) return { error: 'The dataset offers no columns.' };
|
|
110
|
+
try {
|
|
111
|
+
return await db.tx(async (c) => {
|
|
112
|
+
await c.query('set transaction read only');
|
|
113
|
+
await c.query(`set local statement_timeout = 5000`);
|
|
114
|
+
const r = await c.query({ text: `select * from (${datasetSql(d)}) "__q" limit ${PREVIEW_ROWS}`, rowMode: 'array' });
|
|
115
|
+
return { fields: r.fields.map((f) => f.name), rows: r.rows };
|
|
116
|
+
});
|
|
117
|
+
} catch (e) {
|
|
118
|
+
return { error: (e as Error).message };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const cellText = (v: unknown) => (v === null || v === undefined ? '' : v instanceof Date ? v.toISOString().slice(0, 19).replace('T', ' ') : typeof v === 'object' ? JSON.stringify(v) : String(v));
|
|
123
|
+
|
|
124
|
+
export async function datasetRoutes(app: FastifyInstance) {
|
|
125
|
+
// ---------------------------------------------------------------- the home: datasets and reporting applications
|
|
126
|
+
app.get(root, async (req: Req, reply) => {
|
|
127
|
+
const s = await developer(req, reply);
|
|
128
|
+
if (!s) return;
|
|
129
|
+
const ws = currentWorkspace(s);
|
|
130
|
+
const sets = await workspaceDatasets(owner, ws);
|
|
131
|
+
const apps = await reportingApps(ws);
|
|
132
|
+
const main = html`<div class="title-row"><h1>Data Reporter</h1>
|
|
133
|
+
<div class="buttons"><a class="btn btn-hot" href="${root}/datasets/new">${icon('plus')} New dataset</a><a class="btn" href="${root}/import">${icon('upload')} Import</a>${sets.length ? html`<a class="btn" href="${root}/export">${icon('download')} Export all</a>` : ''}</div></div>
|
|
134
|
+
<p class="muted u-mt0">Datasets are trusted views of ${s.workspace?.name ?? 'this workspace'}'s data for business users: tables and views of one schema, joined, with the columns they may see, labels, format masks and descriptions, and a row filter. A reporting application on one or more datasets lets its users build, save, publish and share reports; every report runs as the application's database role, so grants and row level security apply.</p>
|
|
135
|
+
${region('Datasets', html`<div class="table-wrap"><table class="report report-reflow">
|
|
136
|
+
<thead><tr><th scope="col">Dataset</th><th scope="col">Schema</th><th scope="col">Tables and views</th><th scope="col" class="num">Columns</th><th scope="col">Updated (UTC)</th><th scope="col">Actions</th></tr></thead>
|
|
137
|
+
<tbody>${sets.length ? sets.map((d) => html`<tr>
|
|
138
|
+
<td data-label="Dataset"><a href="${root}/datasets/${d.id}">${d.label}</a> <code>${d.name}</code>${d.description ? html`<br><span class="muted small">${d.description.slice(0, 160)}</span>` : ''}</td>
|
|
139
|
+
<td data-label="Schema"><code>${d.schema_name}</code></td>
|
|
140
|
+
<td data-label="Tables and views">${d.tables.map((t) => t.table).join(', ')}</td>
|
|
141
|
+
<td class="num" data-label="Columns">${d.columns.length}</td>
|
|
142
|
+
<td data-label="Updated (UTC)">${when(d.updated_at)}${d.updated_by ? html` <span class="muted">${d.updated_by}</span>` : ''}</td>
|
|
143
|
+
<td data-label=""><a class="btn" href="${root}/apps/new?dataset=${encodeURIComponent(d.name)}">${icon('plus')} Create reporting application</a></td>
|
|
144
|
+
</tr>`) : html`<tr><td colspan="6" class="empty">No datasets yet: create one on tables and views of a schema, or import a dataset file.</td></tr>`}</tbody></table></div>`)}
|
|
145
|
+
${region('Reporting applications', html`<div class="table-wrap"><table class="report report-reflow">
|
|
146
|
+
<thead><tr><th scope="col">Application</th><th scope="col">Datasets</th><th scope="col" class="num">Users</th><th scope="col" class="num">Published reports</th><th scope="col">Actions</th></tr></thead>
|
|
147
|
+
<tbody>${apps.length ? apps.map((a) => html`<tr>
|
|
148
|
+
<td data-label="Application"><a href="${BASE}/apps/${a.id}">${a.name}</a> <code>${a.alias}</code></td>
|
|
149
|
+
<td data-label="Datasets">${a.datasets.join(', ')}</td>
|
|
150
|
+
<td class="num" data-label="Users">${a.users}</td>
|
|
151
|
+
<td class="num" data-label="Published reports">${a.reports}</td>
|
|
152
|
+
<td data-label=""><a class="btn" href="/a/${a.alias}/">${icon('play')} Run</a> <a class="btn" href="${BASE}/apps/${a.id}/shared">${icon('users')} Users and roles</a></td>
|
|
153
|
+
</tr>`) : html`<tr><td colspan="5" class="empty">No reporting applications yet: choose Create reporting application on a dataset.</td></tr>`}</tbody></table></div>
|
|
154
|
+
<p class="muted small">Roles: <b>viewer</b> runs published reports (every user with access), <b>editor</b> also builds, saves, publishes and unpublishes reports, <b>administrator</b> also unpublishes and deletes anyone's. Give accounts access and roles under Users and roles.</p>`)}`;
|
|
155
|
+
return page(s, reply, 'Data Reporter', [], main);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------- a new dataset: schema, then tables and views
|
|
159
|
+
app.get(`${root}/datasets/new`, async (req: Req, reply) => {
|
|
160
|
+
const s = await developer(req, reply);
|
|
161
|
+
if (!s) return;
|
|
162
|
+
const db = await workshopDb(s);
|
|
163
|
+
const schemas = await usableSchemas(db);
|
|
164
|
+
const schema = await usableSchema(db, String(req.query.schema ?? ''));
|
|
165
|
+
const cat = schema ? await readSchemaCatalog(db, schema) : null;
|
|
166
|
+
const main = html`<h1>New dataset</h1>
|
|
167
|
+
${region('1. Schema', html`<form method="get" action="${root}/datasets/new" class="search u-mwnone dataset-schema">
|
|
168
|
+
<label class="label" for="f_schema">Schema</label>
|
|
169
|
+
<select id="f_schema" name="schema">${schemas.map((n) => html`<option${n === schema ? raw(' selected') : ''}>${n}</option>`)}</select>
|
|
170
|
+
<button class="btn">Show its tables and views</button></form>`)}
|
|
171
|
+
${cat ? region('2. Tables and views', html`<form method="post" action="${root}/datasets">${csrf(s)}<input type="hidden" name="schema" value="${schema}">
|
|
172
|
+
<div class="form-grid">
|
|
173
|
+
${input('name', 'Name', '', { required: true, placeholder: 'e.g. sales', help: 'Lower case letters, digits and _ (at most 40): reports and files use it. It can\'t be changed later.' })}
|
|
174
|
+
${input('label', 'Label', '', { placeholder: 'e.g. Sales', help: 'What business users see.' })}
|
|
175
|
+
</div>
|
|
176
|
+
<fieldset class="prop-group"><legend>Tables and views (the first is the main one)</legend>
|
|
177
|
+
${cat.objects.length ? html`<div class="dataset-checks">${cat.objects.map((o) => html`<label class="check"><input type="checkbox" name="tables" value="${o.name}"> ${o.name} <span class="muted">${o.kind}, ${o.columns.length} columns</span></label>`)}</div>`
|
|
178
|
+
: html`<p class="muted">The schema has no tables or views you may use.</p>`}
|
|
179
|
+
<small class="help">Joins are proposed from foreign keys; every column is offered at first, labelled from its name. You change all of it on the next page.</small>
|
|
180
|
+
</fieldset>
|
|
181
|
+
<div class="buttons"><a class="btn" href="${root}">Cancel</a><button class="btn btn-hot">${icon('plus')} Create dataset</button></div></form>`) : ''}`;
|
|
182
|
+
return page(s, reply, 'New dataset', [['New dataset']], main);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
app.post(`${root}/datasets`, async (req: Req, reply) => {
|
|
186
|
+
const s = await developer(req, reply);
|
|
187
|
+
if (!s) return;
|
|
188
|
+
const b = req.body ?? {};
|
|
189
|
+
const db = await workshopDb(s);
|
|
190
|
+
const schema = await usableSchema(db, String(b.schema ?? ''));
|
|
191
|
+
const name = String(b.name ?? '').trim().toLowerCase();
|
|
192
|
+
const again = (msg: string) => (flash(s, msg, 'error'), back(reply, s, `${root}/datasets/new${schema ? `?schema=${encodeURIComponent(schema)}` : ''}`));
|
|
193
|
+
if (!schema) return again('Choose one of the schemas in the list.');
|
|
194
|
+
if (!DATASET_NAME.test(name)) return again(`"${name}" is not a valid dataset name: lower case letters, digits and _, starting with a letter (at most 40).`);
|
|
195
|
+
const raw = (req.body as unknown as Record<string, unknown>)?.tables;
|
|
196
|
+
const chosen = (Array.isArray(raw) ? raw : raw ? [raw] : []).map(String);
|
|
197
|
+
if (!chosen.length) return again('Choose at least one table or view.');
|
|
198
|
+
const cat = await readSchemaCatalog(db, schema);
|
|
199
|
+
const proposed = proposeDataset(name, String(b.label ?? '').trim(), schema, chosen, cat);
|
|
200
|
+
const { dataset, errors } = checkDataset(proposed.dataset, catalogOf(cat.objects));
|
|
201
|
+
if (errors.length || !dataset.tables.length) return again([...proposed.errors, ...errors].join(' ') || 'The dataset is not valid.');
|
|
202
|
+
const exists = await owner.one('select 1 from meta.dataset where workspace_id = $1 and name = $2', [currentWorkspace(s), name]);
|
|
203
|
+
if (exists) return again(`There is already a dataset ${name}.`);
|
|
204
|
+
const id = await storeDataset(owner, currentWorkspace(s), dataset, s.username, { replace: false });
|
|
205
|
+
logActivity({ appId: null, username: s.username, event: 'dataset_create', ip: clientIp(req), detail: `${name} on ${schema}` });
|
|
206
|
+
flash(s, proposed.errors.length ? `Dataset ${name} created. ${proposed.errors.join(' ')}` : `Dataset ${name} created: check its columns and labels.`, proposed.errors.length ? 'error' : 'ok');
|
|
207
|
+
return back(reply, s, `${root}/datasets/${id}`);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------- a dataset
|
|
211
|
+
app.get(`${root}/datasets/:id`, async (req: Req, reply) => {
|
|
212
|
+
const s = await developer(req, reply);
|
|
213
|
+
if (!s) return;
|
|
214
|
+
const row = await datasetRow(s, req.params.id);
|
|
215
|
+
if (!row) return reply.code(404).send('Not found');
|
|
216
|
+
const d = datasetOf(row);
|
|
217
|
+
const db = await workshopDb(s);
|
|
218
|
+
const cat = await readSchemaCatalog(db, d.schema);
|
|
219
|
+
const { errors } = checkDataset({ ...d }, catalogOf(cat.objects));
|
|
220
|
+
const used = (await reportingApps(currentWorkspace(s))).filter((a) => a.datasets.includes(d.name));
|
|
221
|
+
const objCols = (table: string) => cat.objects.find((o) => o.name === table)?.columns ?? [];
|
|
222
|
+
// every column of the dataset's tables: the offered ones first (in their order), then the others
|
|
223
|
+
const rows: { ref: string; alias: string; table: string; column: string; type: string; c?: Dataset['columns'][number] }[] = [];
|
|
224
|
+
for (const c of d.columns) {
|
|
225
|
+
const t = d.tables.find((x) => x.alias === c.table)!;
|
|
226
|
+
rows.push({ ref: `${c.table}.${c.column}`, alias: c.table, table: t.table, column: c.column, type: objCols(t.table).find((x) => x.name === c.column)?.type ?? '', c });
|
|
227
|
+
}
|
|
228
|
+
for (const t of d.tables)
|
|
229
|
+
for (const col of objCols(t.table))
|
|
230
|
+
if (!d.columns.some((c) => c.table === t.alias && c.column === col.name)) rows.push({ ref: `${t.alias}.${col.name}`, alias: t.alias, table: t.table, column: col.name, type: col.type });
|
|
231
|
+
const colOptions = d.tables.flatMap((t) => objCols(t.table).map((c): [string, string] => [`${t.alias}.${c.name}`, `${t.alias} ${t.table}.${c.name}`]));
|
|
232
|
+
const shown = req.query.preview === '1' ? await preview(db, d) : null;
|
|
233
|
+
const opt = (v: string, l: string, cur: string) => html`<option value="${v}"${v === cur ? raw(' selected') : ''}>${l}</option>`;
|
|
234
|
+
const main = html`<div class="title-row"><h1>${d.label} <code>${d.name}</code></h1>
|
|
235
|
+
<div class="buttons"><a class="btn" href="${root}/datasets/${row.id}/export">${icon('download')} Download</a><a class="btn btn-hot" href="${root}/apps/new?dataset=${encodeURIComponent(d.name)}">${icon('plus')} Create reporting application</a></div></div>
|
|
236
|
+
<p class="muted u-mt0">On schema <code>${d.schema}</code>${used.length ? html` · used by ${used.map((a, i) => html`${i ? ', ' : ''}<a href="${BASE}/apps/${a.id}">${a.name}</a>`)}` : ''} · updated ${when(row.updated_at)} (UTC)${row.updated_by ? ` by ${row.updated_by}` : ''}</p>
|
|
237
|
+
${errors.length ? html`<div class="alert alert-error" role="alert"><b>The dataset doesn't match the database (any more):</b><ul>${errors.map((e) => html`<li>${e}</li>`)}</ul></div>` : ''}
|
|
238
|
+
<form method="post" action="${root}/datasets/${row.id}">${csrf(s)}
|
|
239
|
+
${region('Dataset', html`<div class="form-grid">
|
|
240
|
+
${input('label', 'Label', d.label, { required: true })}
|
|
241
|
+
<div class="field" data-wide><label class="label" for="f_description">Description (shown to users)</label><input id="f_description" name="description" maxlength="${DATASET_LIMITS.description}" value="${d.description ?? ''}"></div>
|
|
242
|
+
</div>`)}
|
|
243
|
+
${region('Tables and views', html`<div class="table-wrap"><table class="report report-reflow">
|
|
244
|
+
<thead><tr><th scope="col">Alias</th><th scope="col">Table or view</th><th scope="col">Joins to</th><th scope="col">Join</th><th scope="col">On (column = its column)</th><th scope="col">Remove</th></tr></thead><tbody>
|
|
245
|
+
${d.tables.map((t, i) => html`<tr>
|
|
246
|
+
<td data-label="Alias"><code>${t.alias}</code><input type="hidden" name="t${i}_alias" value="${t.alias}"></td>
|
|
247
|
+
<td data-label="Table or view"><code>${t.table}</code></td>
|
|
248
|
+
${i === 0 ? html`<td data-label="Joins to" colspan="3"><span class="muted">the main table</span></td>` : html`
|
|
249
|
+
<td data-label="Joins to"><select name="t${i}_to" aria-label="${t.table} joins to">${d.tables.slice(0, i).map((x) => opt(x.alias, `${x.alias} ${x.table}`, t.join?.to ?? ''))}</select></td>
|
|
250
|
+
<td data-label="Join"><select name="t${i}_type" aria-label="Join type of ${t.table}">${opt('left', 'Left (keep rows without a match)', t.join?.type ?? 'left')}${opt('inner', 'Inner (only matching rows)', t.join?.type ?? 'left')}</select></td>
|
|
251
|
+
<td data-label="On"><input name="t${i}_on" value="${t.join?.on.map((p) => `${p.column} = ${p.ref}`).join(', ') ?? ''}" aria-label="Join columns of ${t.table}" placeholder="deptno = deptno"></td>`}
|
|
252
|
+
<td data-label="Remove">${i === 0 ? '' : html`<input type="checkbox" name="t${i}_remove" value="true" aria-label="Remove ${t.table}">`}</td>
|
|
253
|
+
</tr>`)}</tbody></table></div>
|
|
254
|
+
${d.tables.length < DATASET_LIMITS.tables ? html`<div class="form-grid">
|
|
255
|
+
<div class="field"><label class="label" for="f_new_table">Add a table or view</label><select id="f_new_table" name="new_table"><option value="">- none -</option>${cat.objects.map((o) => html`<option value="${o.name}">${o.name} (${o.kind})</option>`)}</select></div>
|
|
256
|
+
<div class="field"><label class="label" for="f_new_to">Joined to</label><select id="f_new_to" name="new_to">${d.tables.map((x) => opt(x.alias, `${x.alias} ${x.table}`, ''))}</select></div>
|
|
257
|
+
<div class="field"><label class="label" for="f_new_on">On</label><input id="f_new_on" name="new_on" placeholder="from a foreign key"><small class="help">Empty: proposed from a foreign key.</small></div>
|
|
258
|
+
</div>` : ''}`)}
|
|
259
|
+
${region('Columns', html`<p class="muted u-mt0">Business users see only the offered columns, under their labels. The name is how reports refer to a column: changing it breaks saved reports that use it.</p>
|
|
260
|
+
<div class="table-wrap"><table class="report report-reflow dataset-columns">
|
|
261
|
+
<thead><tr><th scope="col">Offer</th><th scope="col">Column</th><th scope="col">Name</th><th scope="col">Label</th><th scope="col">Format mask</th><th scope="col">Description</th></tr></thead><tbody>
|
|
262
|
+
${rows.map((r, j) => html`<tr>
|
|
263
|
+
<td data-label="Offer"><input type="hidden" name="c${j}_ref" value="${r.ref}"><input type="checkbox" name="c${j}_on" value="true"${r.c ? raw(' checked') : ''} aria-label="Offer ${r.table}.${r.column}"></td>
|
|
264
|
+
<td data-label="Column"><code>${r.alias}.${r.column}</code> <span class="muted">${r.type}</span></td>
|
|
265
|
+
<td data-label="Name"><input name="c${j}_name" maxlength="40" value="${r.c?.name ?? r.column.toLowerCase()}" aria-label="Name of ${r.table}.${r.column}"></td>
|
|
266
|
+
<td data-label="Label"><input name="c${j}_label" maxlength="${DATASET_LIMITS.label}" value="${r.c?.label ?? ''}" placeholder="${heading(r.column)}" aria-label="Label of ${r.table}.${r.column}"></td>
|
|
267
|
+
<td data-label="Format mask"><input name="c${j}_format" maxlength="${DATASET_LIMITS.format}" value="${r.c?.format ?? ''}" placeholder="999G990D00" aria-label="Format mask of ${r.table}.${r.column}"></td>
|
|
268
|
+
<td data-label="Description"><input name="c${j}_desc" maxlength="${DATASET_LIMITS.columnDescription}" value="${r.c?.description ?? ''}" aria-label="Description of ${r.table}.${r.column}"></td>
|
|
269
|
+
</tr>`)}</tbody></table></div>`)}
|
|
270
|
+
${region('Row filter', html`<p class="muted u-mt0">Only rows that match every condition are in the dataset (on top of the application role's row level security).</p>
|
|
271
|
+
${Array.from({ length: DATASET_LIMITS.filters }, (_, k) => {
|
|
272
|
+
const f = d.filters[k];
|
|
273
|
+
return html`<div class="dataset-filter">
|
|
274
|
+
<select name="f${k}_col" aria-label="Filter ${k + 1}: column">${opt('', '- none -', '')}${colOptions.map(([v, l]) => opt(v, l, f ? `${f.table}.${f.column}` : ''))}</select>
|
|
275
|
+
<select name="f${k}_op" aria-label="Filter ${k + 1}: operator">${Object.keys(OPERATORS).map((o) => opt(o, opLabel(english, o), f?.op ?? 'eq'))}</select>
|
|
276
|
+
<input name="f${k}_value" maxlength="${DATASET_LIMITS.value}" value="${f?.value ?? ''}" aria-label="Filter ${k + 1}: value" placeholder="value">
|
|
277
|
+
</div>`;
|
|
278
|
+
})}`)}
|
|
279
|
+
<div class="buttons"><button class="btn btn-hot">${icon('save')} Save dataset</button></div>
|
|
280
|
+
</form>
|
|
281
|
+
${region('Query and preview', html`<p class="muted u-mt0">The query reports run on (as the application's role). The preview runs it read only through your SQL Workshop connection.</p>
|
|
282
|
+
<pre class="code" data-code-view="sql">${datasetSql(d)}</pre>
|
|
283
|
+
<p><a class="btn" href="${root}/datasets/${row.id}?preview=1#preview">${icon('play')} Preview ${PREVIEW_ROWS} rows</a></p>
|
|
284
|
+
${shown ? 'error' in shown ? html`<div class="alert alert-error" role="alert" id="preview">${shown.error}</div>` : html`<div class="table-wrap" id="preview"><table class="report report-reflow"><thead><tr>${shown.fields.map((f) => html`<th scope="col">${d.columns.find((c) => c.name === f)?.label ?? f}</th>`)}</tr></thead>
|
|
285
|
+
<tbody>${shown.rows.length ? shown.rows.map((r) => html`<tr>${r.map((v, i) => html`<td data-label="${shown.fields[i]}">${cellText(v)}</td>`)}</tr>`) : html`<tr><td colspan="${shown.fields.length}" class="empty">No rows.</td></tr>`}</tbody></table></div>` : ''}`)}
|
|
286
|
+
${region('Delete', html`<form method="post" action="${root}/datasets/${row.id}/delete">${csrf(s)}
|
|
287
|
+
<p class="muted u-mt0">${used.length ? `Reporting applications using it (${used.map((a) => a.name).join(', ')}) lose this data source: their reports on it stop working.` : 'No reporting application uses it.'}</p>
|
|
288
|
+
<button class="btn btn-danger" data-confirm="Delete the dataset ${d.name}?">${icon('trash')} Delete dataset</button></form>`)}`;
|
|
289
|
+
return page(s, reply, d.label, [[d.label]], main);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
app.post(`${root}/datasets/:id`, async (req: Req, reply) => {
|
|
293
|
+
const s = await developer(req, reply);
|
|
294
|
+
if (!s) return;
|
|
295
|
+
const row = await datasetRow(s, req.params.id);
|
|
296
|
+
if (!row) return reply.code(404).send('Not found');
|
|
297
|
+
const d = datasetOf(row);
|
|
298
|
+
const db = await workshopDb(s);
|
|
299
|
+
if (!(await usableSchema(db, d.schema))) {
|
|
300
|
+
flash(s, `Your SQL Workshop connection may not use the schema ${d.schema}.`, 'error');
|
|
301
|
+
return back(reply, s, `${root}/datasets/${row.id}`);
|
|
302
|
+
}
|
|
303
|
+
const cat = await readSchemaCatalog(db, d.schema);
|
|
304
|
+
const { dataset, errors } = checkDataset(datasetFromForm(d, req.body ?? {}, cat), catalogOf(cat.objects));
|
|
305
|
+
if (!dataset.tables.length || !dataset.columns.length) {
|
|
306
|
+
flash(s, [...errors, 'A dataset needs a table and at least one offered column: nothing was saved.'].join(' '), 'error');
|
|
307
|
+
return back(reply, s, `${root}/datasets/${row.id}`);
|
|
308
|
+
}
|
|
309
|
+
await storeDataset(owner, row.workspace_id, dataset, s.username);
|
|
310
|
+
logActivity({ appId: null, username: s.username, event: 'dataset_save', ip: clientIp(req), detail: dataset.name });
|
|
311
|
+
flash(s, errors.length ? `Saved, but: ${errors.join(' ')}` : 'Dataset saved.', errors.length ? 'error' : 'ok');
|
|
312
|
+
return back(reply, s, `${root}/datasets/${row.id}`);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
app.post(`${root}/datasets/:id/delete`, async (req: Req, reply) => {
|
|
316
|
+
const s = await developer(req, reply);
|
|
317
|
+
if (!s) return;
|
|
318
|
+
const row = await datasetRow(s, req.params.id);
|
|
319
|
+
if (!row) return reply.code(404).send('Not found');
|
|
320
|
+
await owner.query('delete from meta.dataset where id = $1', [row.id]);
|
|
321
|
+
logActivity({ appId: null, username: s.username, event: 'dataset_delete', ip: clientIp(req), detail: row.name });
|
|
322
|
+
flash(s, `Dataset ${row.name} deleted.`);
|
|
323
|
+
return back(reply, s, root);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// ---------------------------------------------------------------- files
|
|
327
|
+
const download = (reply: FastifyReply, name: string, list: Dataset[]) =>
|
|
328
|
+
reply.type('application/json').header('content-disposition', `attachment; filename="${name}.dataset.json"`).send(datasetFile(list));
|
|
329
|
+
|
|
330
|
+
app.get(`${root}/datasets/:id/export`, async (req: Req, reply) => {
|
|
331
|
+
const s = await developer(req, reply);
|
|
332
|
+
if (!s) return;
|
|
333
|
+
const row = await datasetRow(s, req.params.id);
|
|
334
|
+
if (!row) return reply.code(404).send('Not found');
|
|
335
|
+
return download(reply, row.name, [datasetOf(row)]);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
app.get(`${root}/export`, async (req: Req, reply) => {
|
|
339
|
+
const s = await developer(req, reply);
|
|
340
|
+
if (!s) return;
|
|
341
|
+
const list = (await workspaceDatasets(owner, currentWorkspace(s))).map(datasetOf);
|
|
342
|
+
return download(reply, 'datasets', list);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
app.get(`${root}/import`, async (req: Req, reply) => {
|
|
346
|
+
const s = await developer(req, reply);
|
|
347
|
+
if (!s) return;
|
|
348
|
+
const main = html`<h1>Import datasets</h1>
|
|
349
|
+
${region('A dataset file', html`<form method="post" action="${root}/import" enctype="multipart/form-data">${csrf(s)}
|
|
350
|
+
<p class="muted u-mt0">A file downloaded from a Data Reporter (<code>*.dataset.json</code>, format pgkiln-dataset/1), with one dataset or several. Each is checked against this installation's database: its schema, tables, views and columns must exist here.</p>
|
|
351
|
+
<div class="form-grid">
|
|
352
|
+
<div class="field"><label class="label" for="f_file">File</label><input id="f_file" type="file" name="file" accept=".json,application/json"></div>
|
|
353
|
+
<div class="field" data-wide><label class="label" for="f_text">Or paste it</label><textarea id="f_text" name="text" class="code" rows="5" spellcheck="false"></textarea></div>
|
|
354
|
+
<div class="field"><label class="check"><input type="checkbox" name="replace" value="true"> Replace datasets with the same name</label></div>
|
|
355
|
+
</div>
|
|
356
|
+
<div class="buttons"><a class="btn" href="${root}">Cancel</a><button class="btn btn-hot">${icon('upload')} Import</button></div></form>`)}`;
|
|
357
|
+
return page(s, reply, 'Import datasets', [['Import']], main);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
app.post(`${root}/import`, async (req: Req, reply) => {
|
|
361
|
+
// a multipart form: the session and CSRF token are checked after reading it
|
|
362
|
+
let text = '';
|
|
363
|
+
let tooLarge = false;
|
|
364
|
+
if (req.isMultipart()) {
|
|
365
|
+
const parsed = await readMultipart(req, MAX_FILE_BYTES / 1024 / 1024);
|
|
366
|
+
req.body = parsed.body as Body;
|
|
367
|
+
const f = parsed.files.get('file');
|
|
368
|
+
if (f?.truncated) tooLarge = true;
|
|
369
|
+
else if (f) text = f.data.toString('utf8');
|
|
370
|
+
}
|
|
371
|
+
const s = await developer(req, reply);
|
|
372
|
+
if (!s) return;
|
|
373
|
+
const fail = (msg: string) => (flash(s, msg, 'error'), back(reply, s, `${root}/import`));
|
|
374
|
+
if (tooLarge) return fail('The file is larger than 2 MB.');
|
|
375
|
+
if (!text.trim()) text = String(req.body?.text ?? '');
|
|
376
|
+
if (!text.trim()) return fail('Choose a file or paste one.');
|
|
377
|
+
let list: any[];
|
|
378
|
+
try {
|
|
379
|
+
list = readDatasetFile(text);
|
|
380
|
+
} catch (e) {
|
|
381
|
+
return fail((e as Error).message);
|
|
382
|
+
}
|
|
383
|
+
const db = await workshopDb(s);
|
|
384
|
+
const schemas = await usableSchemas(db);
|
|
385
|
+
const ws = currentWorkspace(s);
|
|
386
|
+
const replace = req.body?.replace === 'true';
|
|
387
|
+
const lines: string[] = [];
|
|
388
|
+
try {
|
|
389
|
+
await owner.tx(async (c) => {
|
|
390
|
+
const cats = new Map<string, Awaited<ReturnType<typeof readSchemaCatalog>>>();
|
|
391
|
+
for (const loose of list) {
|
|
392
|
+
const schema = typeof loose?.schema === 'string' ? loose.schema : '';
|
|
393
|
+
const name = typeof loose?.name === 'string' ? loose.name : '?';
|
|
394
|
+
if (!schemas.includes(schema)) throw new Error(`Dataset ${name}: the schema "${schema}" doesn't exist here, or your SQL Workshop connection may not use it.`);
|
|
395
|
+
if (!cats.has(schema)) cats.set(schema, await readSchemaCatalog(db, schema));
|
|
396
|
+
const { dataset, errors } = checkDataset(loose, catalogOf(cats.get(schema)!.objects));
|
|
397
|
+
if (errors.length || !dataset.columns.length) throw new Error(`Dataset ${name}: ${errors.join(' ') || 'it offers no columns.'}`);
|
|
398
|
+
const exists = (await c.query('select 1 from meta.dataset where workspace_id = $1 and name = $2', [ws, dataset.name])).rowCount;
|
|
399
|
+
if (exists && !replace) {
|
|
400
|
+
lines.push(`${dataset.name}: kept (same name)`);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
await storeDataset(c, ws, dataset, s.username);
|
|
404
|
+
lines.push(`${dataset.name}: ${exists ? 'replaced' : 'added'}`);
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
} catch (e) {
|
|
408
|
+
return fail(`Nothing was imported. ${(e as Error).message}`);
|
|
409
|
+
}
|
|
410
|
+
logActivity({ appId: null, username: s.username, event: 'dataset_import', ip: clientIp(req), detail: lines.join('; ') });
|
|
411
|
+
flash(s, `Imported: ${lines.join('; ')}.`);
|
|
412
|
+
return back(reply, s, root);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// ---------------------------------------------------------------- create a reporting application
|
|
416
|
+
const appForm = async (s: Session, b: Body & { datasets?: string | string[] }, sets: DatasetRow[], error?: string) => {
|
|
417
|
+
const chosen = new Set(Array.isArray(b.datasets) ? b.datasets : b.datasets ? [b.datasets] : []);
|
|
418
|
+
return html`<h1>Create a reporting application</h1>
|
|
419
|
+
${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
|
|
420
|
+
<form method="post" action="${root}/apps">${csrf(s)}
|
|
421
|
+
${region('Application', html`<div class="form-grid">
|
|
422
|
+
${input('name', 'Name', b.name ?? '', { required: true })}
|
|
423
|
+
${input('alias', 'Alias', b.alias ?? '', { required: true, help: 'Its address: /a/<alias>. Its database role is app_<alias>, which may only read the datasets\' tables and views.' })}
|
|
424
|
+
${input('admin_user', 'First administrator', b.admin_user ?? s.username ?? '', { required: true, help: 'An account of the user directory (users sign in with their workspace accounts).' })}
|
|
425
|
+
${input('admin_password', 'Password', '', { type: 'password', auto: 'new-password', help: 'Only for a new account.' })}
|
|
426
|
+
</div>`)}
|
|
427
|
+
${region('Datasets', html`<div class="dataset-checks">${sets.map((d) => html`<label class="check"><input type="checkbox" name="datasets" value="${d.name}"${chosen.has(d.name) ? raw(' checked') : ''}> ${d.label} <code>${d.name}</code> <span class="muted">${d.schema_name}: ${d.tables.map((t) => t.table).join(', ')}</span></label>`)}</div>
|
|
428
|
+
<small class="help">The first dataset's schema is the application's parsing schema.</small>`)}
|
|
429
|
+
<div class="buttons"><a class="btn" href="${root}">Cancel</a><button class="btn btn-hot">${icon('plus')} Create</button></div></form>`;
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
app.get(`${root}/apps/new`, async (req: Req, reply) => {
|
|
433
|
+
const s = await developer(req, reply);
|
|
434
|
+
if (!s) return;
|
|
435
|
+
const sets = await workspaceDatasets(owner, currentWorkspace(s));
|
|
436
|
+
if (!sets.length) {
|
|
437
|
+
flash(s, 'Create a dataset first.', 'error');
|
|
438
|
+
return back(reply, s, root);
|
|
439
|
+
}
|
|
440
|
+
const first = sets.find((d) => d.name === req.query.dataset) ?? sets[0];
|
|
441
|
+
const base = first.name.replace(/_/g, '-').slice(0, 40);
|
|
442
|
+
return page(s, reply, 'Create reporting application', [['Create reporting application']],
|
|
443
|
+
await appForm(s, { name: `${first.label} reports`, alias: `${base}-reports`, datasets: first.name }, sets));
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
app.post(`${root}/apps`, async (req: Req, reply) => {
|
|
447
|
+
const s = await developer(req, reply);
|
|
448
|
+
if (!s) return;
|
|
449
|
+
const b = (req.body ?? {}) as Body & { datasets?: string | string[] };
|
|
450
|
+
const sets = await workspaceDatasets(owner, currentWorkspace(s));
|
|
451
|
+
const names = Array.isArray(b.datasets) ? b.datasets : b.datasets ? [b.datasets] : [];
|
|
452
|
+
const chosen = names.map((n) => sets.find((d) => d.name === n)).filter((d): d is DatasetRow => !!d);
|
|
453
|
+
const failed = async (msg: string) => {
|
|
454
|
+
reply.code(422);
|
|
455
|
+
return page(s, reply, 'Create reporting application', [['Create reporting application']], await appForm(s, b, sets, msg));
|
|
456
|
+
};
|
|
457
|
+
if (!chosen.length) return failed('Choose at least one dataset.');
|
|
458
|
+
if (chosen.length > MAX_REPORTING_DATASETS) return failed(`Choose at most ${MAX_REPORTING_DATASETS} datasets.`);
|
|
459
|
+
let created: { id: number; existingAccount: boolean };
|
|
460
|
+
let alias = '';
|
|
461
|
+
try {
|
|
462
|
+
const checked = await checkNewApp({ name: b.name, alias: b.alias, schema: chosen[0].schema_name, authentication: 'app_users', admin_user: b.admin_user, admin_password: b.admin_password }, currentWorkspace(s));
|
|
463
|
+
alias = checked.alias;
|
|
464
|
+
created = await owner.tx((c) => createReportingApp(c, checked, chosen));
|
|
465
|
+
} catch (e) {
|
|
466
|
+
return failed(createAppError(e, alias || String(b.alias ?? '')));
|
|
467
|
+
}
|
|
468
|
+
logActivity({ appId: created.id, username: s.username, event: 'app_create', ip: clientIp(req), detail: `reporting application on ${chosen.map((d) => d.name).join(', ')}` });
|
|
469
|
+
flash(s, `Reporting application ${b.name} created: run it at /a/${alias}/, and give accounts access and the roles viewer, editor or administrator under Users and roles.`);
|
|
470
|
+
return back(reply, s, root);
|
|
471
|
+
});
|
|
472
|
+
}
|