pict-section-workspace 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Steven Velozo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # Pict-Section-Workspace
2
+
3
+ A declarative, config-driven tabbed workspace for a single entity record, built on the [Pict](https://fable-retold.github.io/pict/) application framework. Describe a header, a tab bar, and what each tab shows in one plain configuration object; the module registers a provider + view pair, wires the routes, loads the record, and renders the shell -- header, tabs, and the active tab body.
4
+
5
+ A workspace is the "detail screen" for one row of one entity: an Order, a Project, a Customer, a Device. Instead of hand-building a view per entity, you hand the factory a config and get a consistent, themeable, permission-aware screen with view, edit, and create modes.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install pict-section-workspace
11
+ ```
12
+
13
+ `pict-section-recordset` is an optional peer -- install it only if you use `assoc` (association-editor) tabs.
14
+
15
+ ## Quick Start
16
+
17
+ ```javascript
18
+ const libPict = require('pict');
19
+ const libWorkspace = require('pict-section-workspace');
20
+
21
+ const _Pict = new libPict({ Product: 'OrdersApp' });
22
+
23
+ const OrderWorkspaceConfig =
24
+ {
25
+ Identifier: 'OrderWorkspace',
26
+ Ordinal: 300,
27
+ Entity: { Name: 'Order', IDField: 'IDOrder', Collection: 'Orders', GUIDField: 'GUIDOrder' },
28
+ Header:
29
+ {
30
+ Eyebrow: 'Order',
31
+ Title: (pRecord) => pRecord.Name || `Order ${pRecord.IDOrder}`,
32
+ ListRoute: '#/Orders',
33
+ },
34
+ Tabs:
35
+ [
36
+ { Key: 'overview', Label: 'Overview', Icon: 'fas fa-circle-info', Content: { kind: 'overview' } },
37
+ { Key: 'lines', Label: 'Line Items', Icon: 'fas fa-list',
38
+ Content: { kind: 'childlist', ref: 'OrderLine', parentField: 'IDOrder',
39
+ columns: [ { Key: 'Description', Label: 'Item' }, { Key: 'Total', Label: 'Total', Num: true, Format: 'currency' } ] } },
40
+ ],
41
+ FieldGroups:
42
+ [
43
+ { Name: 'Identity', Fields:
44
+ [
45
+ { Key: 'Name', Label: 'Name' },
46
+ { Key: 'Total', Label: 'Order Total', Format: 'currency' },
47
+ { Key: 'PlacedDate', Label: 'Placed', Format: 'date' },
48
+ ] },
49
+ ],
50
+ };
51
+
52
+ // Register the provider + view pair.
53
+ libWorkspace.createWorkspace(_Pict, OrderWorkspaceConfig);
54
+ _Pict.initialize();
55
+
56
+ // Register the routes on your app router, then navigate to #/Order/Workspace/42
57
+ libWorkspace.registerWorkspaceRoutes(_Pict.providers.ApplicationRouter, OrderWorkspaceConfig);
58
+ ```
59
+
60
+ ## Features
61
+
62
+ - **Config-driven** -- one object describes the whole screen; the base provider owns the render loop
63
+ - **Five content kinds** -- `overview` (field-group grid), `render` (bespoke HTML), `embed` (host a full pict view), `assoc` (a recordset association editor), and `childlist` (an FK-children grid)
64
+ - **View / edit / create modes** -- edit and create hide the tab bar and render a form host; header actions gate on the current mode
65
+ - **Routing** -- numeric-ID and tab-deep-link routes, a `GUID` view bridge, and optional Edit / Create / Clone routes derived from the tab manifest
66
+ - **Sensible defaults** -- REST reads through a configurable fable client, a shared currency/percent/number/date/bool formatter, FK name resolution for overview cards, and fail-open-until-ready `<Entity>-Read` permission checks
67
+ - **Extensible** -- swap the provider or view class, add bespoke `render` tab methods in a subclass, or bring your own association-editor library
68
+
69
+ ## Documentation
70
+
71
+ - [Getting Started](https://fable-retold.github.io/pict-section-workspace/#/page/Getting_Started) -- build your first workspace end to end
72
+ - [Architecture](https://fable-retold.github.io/pict-section-workspace/#/page/Architecture) -- the provider/view split, the render loop, and the load/mode flow
73
+ - [Configuration](https://fable-retold.github.io/pict-section-workspace/#/page/Configuration) -- the full config schema, field by field
74
+ - [Content Kinds](https://fable-retold.github.io/pict-section-workspace/#/page/Content_Kinds) -- the five tab body kinds, each with a snippet
75
+ - [Modes](https://fable-retold.github.io/pict-section-workspace/#/page/Modes) -- view / edit / create, EditContent, and the Create / Edit routes
76
+ - [Subclassing](https://fable-retold.github.io/pict-section-workspace/#/page/Subclassing) -- extend the base provider for bespoke render tabs
77
+
78
+ ### API
79
+
80
+ - [createWorkspace](https://fable-retold.github.io/pict-section-workspace/#/page/api/createWorkspace)
81
+ - [registerWorkspaceRoutes](https://fable-retold.github.io/pict-section-workspace/#/page/api/registerWorkspaceRoutes)
82
+ - [setDefaultDestination](https://fable-retold.github.io/pict-section-workspace/#/page/api/setDefaultDestination)
83
+ - [Base Provider](https://fable-retold.github.io/pict-section-workspace/#/page/api/provider)
84
+
85
+ ## Ecosystem
86
+
87
+ Pict-Section-Workspace is part of the [Retold](https://github.com/fable-retold/retold) module suite:
88
+
89
+ - [pict](https://fable-retold.github.io/pict/) -- core MVC application framework
90
+ - [pict-view](https://fable-retold.github.io/pict-view/) -- view base class
91
+ - [pict-provider](https://fable-retold.github.io/pict-provider/) -- provider base class
92
+ - [pict-section-tabbar](https://fable-retold.github.io/pict-section-tabbar/) -- the tab bar renderer the shell uses
93
+ - [pict-section-recordset](https://fable-retold.github.io/pict-section-recordset/) -- association editors for `assoc` tabs (optional peer)
94
+ - [pict-section-form](https://fable-retold.github.io/pict-section-form/) -- form hosts for edit / create modes
95
+
96
+ ## License
97
+
98
+ MIT
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "pict-section-workspace",
3
+ "version": "1.0.0",
4
+ "description": "A declarative, config-driven tabbed workspace for a single entity record: a header, a tab bar, and per-tab content resolvers (overview / render / embed / assoc / childlist), plus view/edit/create modes and GUID-bridge routing.",
5
+ "main": "source/Pict-Section-Workspace.js",
6
+ "scripts": {
7
+ "test": "npx quack test",
8
+ "tests": "npx quack test -g",
9
+ "start": "node source/Pict-Section-Workspace.js",
10
+ "coverage": "npx quack coverage",
11
+ "build": "quack build",
12
+ "types": "tsc -p ."
13
+ },
14
+ "types": "types/Pict-Section-Workspace.d.ts",
15
+ "files": [ "source", "types" ],
16
+ "directories": { "test": "test" },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/fable-retold/pict-section-workspace.git"
20
+ },
21
+ "author": "steven velozo <steven@velozo.com>",
22
+ "license": "MIT",
23
+ "bugs": {
24
+ "url": "https://github.com/fable-retold/pict-section-workspace/issues"
25
+ },
26
+ "homepage": "https://github.com/fable-retold/pict-section-workspace#readme",
27
+ "devDependencies": {
28
+ "browser-env": "^3.3.0",
29
+ "pict": "^1.0.372",
30
+ "pict-application": "^1.0.34",
31
+ "pict-docuserve": "^1.4.19",
32
+ "pict-section-recordset": "^1.25.10",
33
+ "quackage": "^1.3.0",
34
+ "typescript": "^5.9.3"
35
+ },
36
+ "mocha": {
37
+ "diff": true,
38
+ "extension": [ "js" ],
39
+ "package": "./package.json",
40
+ "reporter": "spec",
41
+ "slow": "75",
42
+ "timeout": "5000",
43
+ "ui": "tdd",
44
+ "watch-files": [ "source/**/*.js", "test/**/*.js" ]
45
+ },
46
+ "dependencies": {
47
+ "pict-provider": "^1.0.13",
48
+ "pict-section-tabbar": "^1.0.0",
49
+ "pict-view": "^1.0.68"
50
+ }
51
+ }
@@ -0,0 +1,70 @@
1
+ 'use strict';
2
+
3
+ // The shared Workspace shell + generic-content CSS (the `.pw-*` family, promoted out of ProjectWorkspace so it is
4
+ // no longer a side-effect of whichever workspace paints first). Entity-specific styling (maps, staffing, logos,
5
+ // label managers, …) stays in each workspace config's `CSSExtra`. The tab-bar CSS comes from pict-section-tabbar.
6
+
7
+ const libTabBar = require('pict-section-tabbar');
8
+
9
+ const WORKSPACE_CSS = /*css*/`
10
+ .pw-wrap { padding: 1.25rem 1.5rem 2rem; }
11
+ .pw-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; margin: 0 0 1rem; flex-wrap: wrap; }
12
+ .pw-head-id { min-width: 0; }
13
+ .pw-eyebrow { font-size: 0.72rem; font-weight: 650; text-transform: uppercase; letter-spacing: 0.06em; color: var(--theme-color-text-muted, #6b7686); margin: 0; }
14
+ .pw-title { font-size: 1.5rem; font-weight: 700; line-height: 1.2; margin: 0.1rem 0 0; color: var(--theme-color-text-primary, #1f2733); display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
15
+ .pw-subtitle { font-size: 0.86rem; color: var(--theme-color-text-muted, #6b7686); margin: 0.3rem 0 0; }
16
+ .pw-head-actions, .pw-head-btns { display: inline-flex; gap: 0.4rem; flex: 0 0 auto; align-items: center; flex-wrap: wrap; }
17
+ .pw-btn { font-size: 0.82rem; font-weight: 600; padding: 0.42rem 0.85rem; border-radius: 7px; border: 1px solid var(--theme-color-border-medium, #d5dbe3); background: var(--theme-color-background-panel, #fff); color: var(--theme-color-text-secondary, #45505f); cursor: pointer; display: inline-flex; align-items: center; gap: 0.4rem; text-decoration: none; }
18
+ .pw-btn:hover { border-color: var(--theme-color-brand-primary, #5650e6); color: var(--theme-color-text-primary, #1f2733); text-decoration: none; }
19
+ .pw-btn.is-link { background: var(--theme-color-brand-primary, #5650e6); border-color: var(--theme-color-brand-primary, #5650e6); color: #fff; }
20
+ .pw-btn.is-link:hover { color: #fff; opacity: 0.94; }
21
+ .pw-btn.is-light { background: var(--theme-color-background-secondary, #f0f2f6); }
22
+ .pw-btn.is-danger { color: var(--theme-color-status-danger, #c0392b); border-color: var(--theme-color-status-danger, #c0392b); background: transparent; }
23
+ .pw-back { font-size: 0.82rem; color: var(--theme-color-text-muted, #6b7686); white-space: nowrap; align-self: center; }
24
+ .pw-back:hover { color: var(--theme-color-brand-primary, #5650e6); }
25
+
26
+ .pw-tabnav { border-bottom: 1px solid var(--theme-color-border-default, #d7dce3); margin: 0.6rem 0 0; }
27
+ .pw-panel { border: 1px solid var(--theme-color-border-default, #d7dce3); border-top: none; border-radius: 0 0 8px 8px; background: var(--theme-color-background-panel, #fff); padding: 1.4rem 1.5rem; min-height: 12rem; }
28
+ .pw-panel.pw-panel-standalone { border-top: 1px solid var(--theme-color-border-default, #d7dce3); border-radius: 8px; }
29
+
30
+ .pw-state { padding: 1.6rem 0.4rem; color: var(--theme-color-text-muted, #6b7686); font-size: 0.92rem; }
31
+ .pw-empty { text-align: center; color: var(--theme-color-text-muted, #8a94a3); padding: 2.2rem 0; }
32
+ .pw-empty .fa-inbox, .pw-empty .fa-circle-info { font-size: 1.6rem; opacity: 0.5; }
33
+ .pw-empty p { margin-top: 0.5rem; font-size: 0.9rem; }
34
+ .pw-error { font-size: 0.88rem; color: var(--theme-color-status-danger, #c0392b); }
35
+ .pw-spinner { display: inline-block; width: 1.4rem; height: 1.4rem; border: 3px solid var(--theme-color-border-default, #dbdbdb); border-top-color: var(--theme-color-brand-primary, #5650e6); border-radius: 50%; animation: pw-spin 0.8s linear infinite; vertical-align: middle; }
36
+ .pw-spinner-inline { width: 1rem; height: 1rem; border-width: 2px; }
37
+ @keyframes pw-spin { to { transform: rotate(360deg); } }
38
+
39
+ /* Overview field groups */
40
+ .pw-group { margin: 0 0 1.4rem; }
41
+ .pw-group:last-child { margin-bottom: 0; }
42
+ .pw-group-head { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--theme-color-brand-primary, #5650e6);
43
+ background: var(--theme-color-background-selected, #eef1f8); padding: 0.35rem 0.7rem; border-radius: 6px; margin: 0 0 0.7rem; display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
44
+ .pw-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); gap: 0.85rem 1.4rem; }
45
+ .pw-field.pw-field-wide { grid-column: 1 / -1; }
46
+ .pw-field-label { display: block; font-size: 0.68rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--theme-color-text-muted, #6b7686); margin-bottom: 0.1rem; }
47
+ .pw-field-value { font-size: 1.02rem; font-weight: 600; color: var(--theme-color-text-primary, #1f2733); word-break: break-word; }
48
+ .pw-field-value.pw-field-mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.82rem; font-weight: 500; }
49
+ .pw-desc { white-space: pre-wrap; font-weight: 500; font-size: 0.98rem; color: var(--theme-color-text-primary, #1f2733); }
50
+
51
+ /* Generic related-data tables (childlist / render) */
52
+ .pw-toolbar { display: flex; gap: 0.5rem; margin-bottom: 0.9rem; align-items: center; flex-wrap: wrap; }
53
+ .pw-tablewrap { overflow-x: auto; border: 1px solid var(--theme-color-border-light, #e8ebf0); border-radius: 8px; }
54
+ .pw-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 0.86rem; }
55
+ .pw-table thead th { text-align: left; font-size: 0.66rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--theme-color-text-muted, #6b7686); background: var(--theme-color-background-tertiary, #f4f6f9); padding: 0.5rem 0.85rem; border-bottom: 1px solid var(--theme-color-border-light, #e8ebf0); white-space: nowrap; }
56
+ .pw-table tbody td { padding: 0.5rem 0.85rem; border-bottom: 1px solid var(--theme-color-border-light, #eef1f5); vertical-align: middle; color: var(--theme-color-text-secondary, #45505f); }
57
+ .pw-table tbody tr:last-child td { border-bottom: none; }
58
+ .pw-table tbody tr:hover td { background: var(--theme-color-background-secondary, #f7f9fb); }
59
+ .pw-table .pw-num { text-align: right; font-variant-numeric: tabular-nums; }
60
+ .pw-cell-strong { font-weight: 600; color: var(--theme-color-text-primary, #1f2733); }
61
+ .pw-muted { color: var(--theme-color-text-muted, #8a94a3); }
62
+ .pw-row-actions { white-space: nowrap; text-align: right; }
63
+ .pw-row-actions .pw-btn { padding: 0.2rem 0.5rem; font-size: 0.74rem; margin-left: 0.25rem; }
64
+ .pw-embed-host { min-height: 4rem; }
65
+ .pw-status-pill { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; padding: 0.14rem 0.55rem; border-radius: 999px; background: var(--theme-color-background-selected, #eef1f8); color: var(--theme-color-brand-primary, #5650e6); }
66
+
67
+ ${ libTabBar.TABBAR_CSS }
68
+ `;
69
+
70
+ module.exports = { WORKSPACE_CSS };
@@ -0,0 +1,461 @@
1
+ 'use strict';
2
+
3
+ const libPictProvider = require('pict-provider');
4
+ const libTabBar = require('pict-section-tabbar');
5
+
6
+ // The base association-editor from pict-section-recordset (used for kind:'assoc' tabs) is an OPTIONAL peer: a host
7
+ // that uses assoc tabs installs pict-section-recordset; without it, assoc tabs degrade gracefully. The require is
8
+ // built from a computed path so the bundler + typechecker treat it as a runtime-only, optional dependency.
9
+ let libRecordSetAssociationEditor = null;
10
+ try { libRecordSetAssociationEditor = require([ 'pict-section-recordset', 'source/views/associate/RecordSet-AssociationEditor.js' ].join('/')); }
11
+ catch (pError) { /* assoc tabs unavailable without pict-section-recordset */ }
12
+
13
+ /**
14
+ * pict-section-workspace base provider — owns everything every entity workspace shares: the REST + format helpers,
15
+ * the GUID-bridge loader, tab routing, permissions, the shell render (header + tab bar + body panel), and the four
16
+ * tab content-kind resolvers (render / embed / assoc / childlist). A concrete workspace is either the base + a
17
+ * declarative config (registered by the factory) or a thin subclass that adds bespoke `render:` tab methods.
18
+ *
19
+ * Config is read from `this.workspaceConfig` — the factory sets `options.WorkspaceConfig`; a subclass may override
20
+ * the getter. See Pict-Section-Workspace.js for the config schema.
21
+ */
22
+ class PictSectionWorkspaceProvider extends libPictProvider
23
+ {
24
+ constructor(pFable, pOptions, pServiceHash)
25
+ {
26
+ super(pFable, pOptions, pServiceHash);
27
+ /** @type {any} */
28
+ this.pict;
29
+ this._record = null;
30
+ this._id = 0;
31
+ this._activeTab = null;
32
+ this._mode = 'view';
33
+ this._assocRenderQueue = Promise.resolve();
34
+ }
35
+
36
+ // --- config + identity -----------------------------------------------------------------------------------
37
+ get workspaceConfig() { return this.options.WorkspaceConfig || {}; }
38
+ get identifier() { return this.workspaceConfig.Identifier; }
39
+ get entityName() { return this.workspaceConfig.Entity.Name; }
40
+ get idField() { return this.workspaceConfig.Entity.IDField || `ID${this.entityName}`; }
41
+ get collection() { return this.workspaceConfig.Entity.Collection || `${this.entityName}s`; }
42
+ get guidField() { return this.workspaceConfig.Entity.GUIDField || `GUID${this.entityName}`; }
43
+ readEndpoint(pID) { const fn = this.workspaceConfig.Entity.ReadEndpoint; return fn ? fn(pID) : `${this.entityName}/${pID}`; }
44
+ /** URL segment for the workspace routes (#/<prefix>/Workspace/…). Defaults to the entity name; a config may
45
+ * override it when the route noun differs from the entity (e.g. PhysicalAsset records under #/Asset/Workspace). */
46
+ get routePrefix() { return this.workspaceConfig.RoutePrefix || this.entityName; }
47
+
48
+ get containerSelector() { return `#${this.identifier}-Container`; }
49
+ get bodySelector() { return `#${this.identifier}-Body`; }
50
+ get view() { return this.pict.views[this.identifier]; }
51
+ get record() { return this._record || {}; }
52
+ get idRecord() { return this._id; }
53
+
54
+ get state()
55
+ {
56
+ const tmpKey = this.identifier;
57
+ if (!this.pict.AppData[tmpKey]) { this.pict.AppData[tmpKey] = { Loading: false, Message: '', Tab: '' }; }
58
+ return this.pict.AppData[tmpKey];
59
+ }
60
+
61
+ // --- REST + helpers --------------------------------------------------------------------------------------
62
+ /** Name of the fable service that speaks to the record API (getJSON/putJSON/postJSON). Override per workspace
63
+ * via config `RestClientName`; defaults to `RestClient`. Falls back to a couple of common names so a host that
64
+ * registered its client under a different name still resolves. */
65
+ get restClientName() { return this.workspaceConfig.RestClientName || 'RestClient'; }
66
+ get restClient()
67
+ {
68
+ const tmpFable = this.pict.fable || {};
69
+ return tmpFable[this.restClientName] || tmpFable.RestClient || tmpFable.HeadlightRestClient || this.pict.RestClient || this.pict.HeadlightRestClient || null;
70
+ }
71
+ _getJSON(pURL) { const tmpRC = this.restClient; return tmpRC ? new Promise((resolve) => tmpRC.getJSON(pURL, (pErr, pBody) => resolve(pErr ? null : pBody))) : Promise.resolve(null); }
72
+ _putJSON(pURL, pBody) { const tmpRC = this.restClient; return tmpRC ? new Promise((resolve) => tmpRC.putJSON(pURL, pBody, (pErr, pRes) => resolve({ error: pErr, result: pRes }))) : Promise.resolve({ error: 'no client' }); }
73
+ _postJSON(pURL, pBody) { const tmpRC = this.restClient; return tmpRC ? new Promise((resolve) => tmpRC.postJSON(pURL, pBody, (pErr, pRes) => resolve({ error: pErr, result: pRes }))) : Promise.resolve({ error: 'no client' }); }
74
+ _esc(pStr) { return String(pStr == null ? '' : pStr).replace(/[&<>"']/g, (pChar) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[pChar])); }
75
+
76
+ /** Shared display formatter — the currency/percent/number/id/date/bool switch every workspace overview used. */
77
+ _formatValue(pValue, pFormat)
78
+ {
79
+ if (pValue === null || pValue === undefined || pValue === '') { return '—'; }
80
+ switch (pFormat)
81
+ {
82
+ case 'currency': { const tmpN = Number(pValue); return isNaN(tmpN) ? String(pValue) : `$${tmpN.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; }
83
+ case 'percent': { const tmpN = Number(pValue); return isNaN(tmpN) ? String(pValue) : `${tmpN}%`; }
84
+ case 'number': { const tmpN = Number(pValue); return isNaN(tmpN) ? String(pValue) : tmpN.toLocaleString(); }
85
+ case 'date': { const tmpS = String(pValue); return (tmpS.indexOf('T') > -1) ? tmpS.slice(0, 10) : tmpS; }
86
+ case 'bool': return (pValue === true || pValue === 1 || pValue === '1') ? 'Yes' : 'No';
87
+ default: return String(pValue);
88
+ }
89
+ }
90
+
91
+ // --- permissions -----------------------------------------------------------------------------------------
92
+ get permissions() { return this.pict.fable.EffectivePermissionsService || (this.fable && this.fable.EffectivePermissionsService); }
93
+ can(pEntity, pAction)
94
+ {
95
+ const tmpSvc = this.permissions;
96
+ if (!tmpSvc || (typeof tmpSvc.can !== 'function')) { return true; }
97
+ return !!tmpSvc.can(pEntity, pAction);
98
+ }
99
+ /** Default: fail-open until the capability map is ready, then require <Entity>-Read. A config can override. */
100
+ canRead()
101
+ {
102
+ const tmpCfg = this.workspaceConfig.Permissions || {};
103
+ if (typeof tmpCfg.Read === 'function') { return !!tmpCfg.Read(this); }
104
+ const tmpSvc = this.permissions;
105
+ if (!tmpSvc || !tmpSvc.currentUserReady || !tmpSvc.currentUserReady()) { return true; }
106
+ return this.can(this.entityName, 'Read');
107
+ }
108
+
109
+ // --- load ------------------------------------------------------------------------------------------------
110
+ /** True in edit or create mode — used to swap the tab body for the configured EditContent (a CRUD form host). */
111
+ get isEditing() { return (this._mode === 'edit') || (this._mode === 'create'); }
112
+ get mode() { return this._mode; }
113
+
114
+ async loadAsync(pID, pTab, pMode)
115
+ {
116
+ this._id = parseInt(pID, 10) || 0;
117
+ this._mode = (pMode === 'edit') ? 'edit' : 'view';
118
+ const tmpState = this.state;
119
+ tmpState.Loading = true; tmpState.Message = '';
120
+ this._activeTab = this._normalizeTab(pTab);
121
+ tmpState.Tab = this._activeTab;
122
+ this.renderWorkspace();
123
+ try
124
+ {
125
+ const tmpRecord = await this._getJSON(this.readEndpoint(this._id));
126
+ if (!tmpRecord || !tmpRecord[this.idField]) { throw new Error(`${this.entityName} ${this._id} not found.`); }
127
+ this._record = tmpRecord;
128
+ this._resolvedCards = {};
129
+ if (typeof this['hydrateAsync'] === 'function') { await this['hydrateAsync'](tmpRecord); }
130
+ tmpState.Loading = false;
131
+ this.renderWorkspace();
132
+ this._resolveCardsAsync(); // best-effort; re-renders the overview when related names arrive
133
+ await this._enterTab(this.isEditing ? '__edit__' : this._activeTab);
134
+ }
135
+ catch (pError)
136
+ {
137
+ tmpState.Loading = false;
138
+ tmpState.Message = `Could not load the ${this.entityName.toLowerCase()}: ${(pError && pError.message) || pError}`;
139
+ this.renderWorkspace();
140
+ }
141
+ }
142
+
143
+ async openByGUIDAsync(pGUID, pMode)
144
+ {
145
+ const tmpState = this.state;
146
+ tmpState.Loading = true; tmpState.Message = '';
147
+ this._mode = (pMode === 'edit') ? 'edit' : 'view';
148
+ this.renderWorkspace();
149
+ const tmpRows = await this._getJSON(`${this.collection}/FilteredTo/FBV~${this.guidField}~EQ~${encodeURIComponent(pGUID)}`);
150
+ const tmpRecord = (Array.isArray(tmpRows) && tmpRows.length > 0) ? tmpRows[0] : null;
151
+ if (!tmpRecord)
152
+ {
153
+ this._record = null;
154
+ tmpState.Loading = false;
155
+ tmpState.Message = `No ${this.entityName.toLowerCase()} found for ${pGUID}.`;
156
+ this.renderWorkspace();
157
+ return;
158
+ }
159
+ return this.loadAsync(tmpRecord[this.idField], this._activeTab, this._mode);
160
+ }
161
+
162
+ /** Enter CREATE mode (no record). Subclasses implement hydrateCreateAsync(pArg) to seed the working form. */
163
+ async startCreateAsync(pArg)
164
+ {
165
+ this._id = 0;
166
+ this._mode = 'create';
167
+ this._record = {};
168
+ this._resolvedCards = {};
169
+ const tmpState = this.state;
170
+ tmpState.Loading = false; tmpState.Message = ''; tmpState.Tab = this._activeTab;
171
+ if (typeof this['hydrateCreateAsync'] === 'function') { await this['hydrateCreateAsync'](pArg); }
172
+ this.renderWorkspace();
173
+ await this._enterTab('__edit__');
174
+ }
175
+
176
+ // --- tabs ------------------------------------------------------------------------------------------------
177
+ get tabDefs() { return this.workspaceConfig.Tabs || []; }
178
+ /** Tab defs for RENDERING: resolves any function-valued Label against `this` (dynamic labels, e.g. "Projects (N)"). */
179
+ _resolvedTabDefs()
180
+ {
181
+ const fResolve = (pTab) =>
182
+ {
183
+ const tmpTab = Object.assign({}, pTab);
184
+ if (typeof tmpTab.Label === 'function') { try { tmpTab.Label = tmpTab.Label(this); } catch (pError) { tmpTab.Label = ''; } }
185
+ if (Array.isArray(tmpTab.Children)) { tmpTab.Children = tmpTab.Children.map(fResolve); }
186
+ return tmpTab;
187
+ };
188
+ return this.tabDefs.map(fResolve);
189
+ }
190
+ _leafTabs()
191
+ {
192
+ const tmpOut = [];
193
+ for (const tmpTab of this.tabDefs)
194
+ {
195
+ if (Array.isArray(tmpTab.Children) && tmpTab.Children.length) { tmpOut.push(...tmpTab.Children); }
196
+ else { tmpOut.push(tmpTab); }
197
+ }
198
+ return tmpOut;
199
+ }
200
+ validTabKeys() { return this._leafTabs().map((pTab) => pTab.Key); }
201
+ defaultTab() { const tmpLeaves = this._leafTabs(); return tmpLeaves.length ? tmpLeaves[0].Key : ''; }
202
+ _normalizeTab(pTab) { const tmpKeys = this.validTabKeys(); return (tmpKeys.indexOf(pTab) > -1) ? pTab : this.defaultTab(); }
203
+ _activeLeaf() { return this._leafTabs().find((pTab) => pTab.Key === this._activeTab) || null; }
204
+
205
+ navigateTab(pTab)
206
+ {
207
+ if (this._id && (typeof window !== 'undefined') && window.location) { window.location.hash = `#/${this.routePrefix}/Workspace/${this._id}/${pTab}`; }
208
+ else { this.setTab(pTab); }
209
+ }
210
+ openTabAsync(pID, pTab)
211
+ {
212
+ const tmpID = parseInt(pID, 10);
213
+ if (this._record && (Number(this._record[this.idField]) === tmpID) && !this.state.Loading) { return Promise.resolve(this.setTab(pTab)); }
214
+ return Promise.resolve(this.loadAsync(tmpID, pTab));
215
+ }
216
+ setTab(pTab)
217
+ {
218
+ this._activeTab = this._normalizeTab(pTab);
219
+ this.state.Tab = this._activeTab;
220
+ this.renderWorkspace();
221
+ return this._enterTab(this._activeTab);
222
+ }
223
+ async _enterTab(pTab)
224
+ {
225
+ // '__edit__' is the pseudo-tab for the edit/create body — its OnEnter lives on EditContent.
226
+ const tmpLeaf = (pTab === '__edit__') ? { Key: '__edit__', OnEnter: (this.workspaceConfig.EditContent || {}).OnEnter } : this._leafTabs().find((t) => t.Key === pTab);
227
+ if (tmpLeaf && tmpLeaf.OnEnter)
228
+ {
229
+ const tmpFn = (typeof tmpLeaf.OnEnter === 'function') ? tmpLeaf.OnEnter : this[tmpLeaf.OnEnter];
230
+ if (typeof tmpFn === 'function') { try { await tmpFn.call(this, this); } catch (pError) { this.pict.log.warn(`${this.identifier}: OnEnter(${pTab}) failed: ${(pError && pError.message) || pError}`); } }
231
+ }
232
+ }
233
+
234
+ // --- render ----------------------------------------------------------------------------------------------
235
+ renderWorkspace()
236
+ {
237
+ if (!this.view) { return; }
238
+ const tmpCfg = this.workspaceConfig;
239
+ const tmpState = this.state;
240
+ if (!this.canRead()) { this.pict.ContentAssignment.assignContent(this.containerSelector, `<div class="pw-wrap"><div class="pw-empty"><i class="fas fa-lock"></i><p>You don’t have access to ${this._esc(this.entityName)} records.</p></div></div>`); return; }
241
+ // Create mode has no record yet (the form is the body), so it renders even without one.
242
+ if (tmpState.Loading && !this._record && (this._mode !== 'create')) { this.pict.ContentAssignment.assignContent(this.containerSelector, `<div class="pw-wrap"><div class="pw-state"><span class="pw-spinner pw-spinner-inline"></span> Loading…</div></div>`); return; }
243
+ if (tmpState.Message && !this._record && (this._mode !== 'create')) { this.pict.ContentAssignment.assignContent(this.containerSelector, `<div class="pw-wrap"><div class="pw-state">${this._esc(tmpState.Message)}</div></div>`); return; }
244
+
245
+ const tmpRec = this.record;
246
+ const tmpHead = tmpCfg.Header || {};
247
+ const tmpTitle = tmpHead.Title ? tmpHead.Title(tmpRec, this) : (tmpRec.Name || `${this.entityName} ${this._id}`);
248
+ const tmpEyebrow = (typeof tmpHead.Eyebrow === 'function') ? tmpHead.Eyebrow(tmpRec, this) : (tmpHead.Eyebrow || this.entityName);
249
+ const tmpSubtitle = tmpHead.Subtitle ? tmpHead.Subtitle(tmpRec, this) : '';
250
+ const tmpExtra = (typeof tmpHead.Extra === 'function') ? (tmpHead.Extra(this) || '') : '';
251
+ // Edit/Create with a configured EditContent: no tab bar — the edit body (a form host) IS the whole panel.
252
+ const tmpEditBody = this.isEditing && this.workspaceConfig.EditContent;
253
+ const tmpTabs = tmpEditBody ? '' : libTabBar.renderTabsHTML(this._resolvedTabDefs(), this._activeTab,
254
+ (pTab) => `_Pict.providers.${this.identifier}.navigateTab('${pTab.Key}')`, { overflow: 'menu' });
255
+
256
+ const tmpHTML = `
257
+ <div class="pw-wrap">
258
+ <div class="pw-head">
259
+ <div class="pw-head-id">
260
+ <p class="pw-eyebrow">${this._esc(tmpEyebrow)}</p>
261
+ <h1 class="pw-title">${this._escTitle(tmpTitle)}</h1>
262
+ ${tmpSubtitle ? `<div class="pw-subtitle">${this._escTitle(tmpSubtitle)}</div>` : ''}
263
+ </div>
264
+ <div class="pw-head-actions">${this._headerActionsHTML()}${tmpExtra}</div>
265
+ </div>
266
+ ${tmpEditBody ? '' : `<div class="pw-tabnav">${tmpTabs}</div>`}
267
+ <div class="pw-panel${tmpEditBody ? ' pw-panel-standalone' : ''}" id="${this.identifier}-Body"></div>
268
+ </div>`;
269
+ this.pict.ContentAssignment.assignContent(this.containerSelector, tmpHTML);
270
+ libTabBar.layout(document);
271
+ // Post-header hook (e.g. mount a QR thumbnail canvas into a header slot painted by Header.Extra).
272
+ if (typeof tmpHead.AfterRender === 'function') { try { tmpHead.AfterRender(this); } catch (pError) { /* decorative */ } }
273
+ this._renderActiveBody();
274
+ }
275
+
276
+ /** Titles/subtitles may already contain intentional markup (a status pill); allow it through but escape plain strings. */
277
+ _escTitle(pValue) { return (typeof pValue === 'string' && /<[a-z]/i.test(pValue)) ? pValue : this._esc(pValue); }
278
+
279
+ _headerActionsHTML()
280
+ {
281
+ const tmpActions = (this.workspaceConfig.Header && this.workspaceConfig.Header.Actions) || [];
282
+ const tmpBtns = tmpActions.map((pAction) =>
283
+ {
284
+ if (pAction.When && !pAction.When(this)) { return ''; }
285
+ const tmpIcon = pAction.Icon ? `<i class="${this._esc(pAction.Icon)}"></i>` : '';
286
+ const tmpStyle = pAction.Style ? ` ${pAction.Style}` : '';
287
+ const tmpTag = /^https?:|^#\//.test(pAction.OnClick || '') ? 'a' : 'button';
288
+ const tmpAttr = (tmpTag === 'a') ? `href="${this._esc(pAction.OnClick)}"` : `onclick="_Pict.providers.${this.identifier}.${pAction.OnClick}"`;
289
+ return `<${tmpTag} class="pw-btn${tmpStyle}" ${tmpAttr}>${tmpIcon}<span>${this._esc(pAction.Label)}</span></${tmpTag}>`;
290
+ }).join('');
291
+ const tmpList = (this.workspaceConfig.Header && this.workspaceConfig.Header.ListRoute);
292
+ // The list Back link is a view-mode affordance — edit/create modes use their own Cancel action.
293
+ const tmpBack = (tmpList && !this.isEditing) ? `<a class="pw-back" href="${this._esc(tmpList)}"><i class="fas fa-arrow-left"></i> Back</a>` : '';
294
+ return `${tmpBtns}${tmpBack}`;
295
+ }
296
+
297
+ backToList() { const tmpList = (this.workspaceConfig.Header && this.workspaceConfig.Header.ListRoute); if (tmpList && typeof window !== 'undefined') { window.location.hash = tmpList; } }
298
+ /** Default Edit action — the generic PSRS edit form for entities without a bespoke edit workspace. */
299
+ goToEdit() { const tmpG = this.record[this.guidField]; if (tmpG && typeof window !== 'undefined') { window.location.hash = `#/PSRS/${this.entityName}/Edit/${tmpG}`; } }
300
+ goToList() { this.backToList(); }
301
+
302
+ /** Re-paint only the active tab body (used after a tab's own async load completes). */
303
+ repaintBody() { this._renderActiveBody(); }
304
+
305
+ _renderActiveBody()
306
+ {
307
+ const tmpBody = this.bodySelector;
308
+ // Edit/Create mode with a configured EditContent: the edit body replaces the tab body entirely.
309
+ const tmpLeaf = (this.isEditing && this.workspaceConfig.EditContent)
310
+ ? { Key: '__edit__', Content: this.workspaceConfig.EditContent }
311
+ : this._activeLeaf();
312
+ if (!tmpLeaf) { this.pict.ContentAssignment.assignContent(tmpBody, ''); return; }
313
+ const tmpContent = tmpLeaf.Content || { kind: 'overview' };
314
+ // Exit every embed view whose tab is not the active one, so a stale embed doesn't linger.
315
+ this._exitInactiveEmbeds(tmpLeaf);
316
+ switch (tmpContent.kind)
317
+ {
318
+ case 'overview': this._renderOverview(tmpBody); break;
319
+ case 'render': this._renderBespoke(tmpLeaf, tmpContent, tmpBody); break;
320
+ case 'embed': this._renderEmbed(tmpLeaf, tmpContent, tmpBody); break;
321
+ case 'assoc': this._renderAssoc(tmpLeaf, tmpContent, tmpBody); break;
322
+ case 'childlist': this._renderChildList(tmpLeaf, tmpContent, tmpBody); break;
323
+ default: this.pict.ContentAssignment.assignContent(tmpBody, `<div class="pw-empty"><p>Unknown tab.</p></div>`);
324
+ }
325
+ }
326
+
327
+ /** Resolve FieldGroups Card fields (FK → related record) to display names, then re-render the overview. */
328
+ async _resolveCardsAsync()
329
+ {
330
+ const tmpGroups = this.workspaceConfig.FieldGroups || [];
331
+ const tmpTargets = [];
332
+ for (const tmpGroup of tmpGroups)
333
+ {
334
+ for (const tmpField of (tmpGroup.Fields || []))
335
+ {
336
+ const tmpID = this._record ? this._record[tmpField.Key] : null;
337
+ if (tmpField.Card && (tmpID != null) && (Number(tmpID) > 0)) { tmpTargets.push({ Key: tmpField.Key, RecordSet: tmpField.Card, ID: tmpID }); }
338
+ }
339
+ }
340
+ if (!tmpTargets.length) { return; }
341
+ await Promise.all(tmpTargets.map(async (pTarget) =>
342
+ {
343
+ const tmpRec = await this._getJSON(`${pTarget.RecordSet}/${pTarget.ID}`);
344
+ if (tmpRec && !tmpRec.Error) { this._resolvedCards[pTarget.Key] = tmpRec.Name || tmpRec.DisplayName || tmpRec.Title || String(pTarget.ID); }
345
+ }));
346
+ if (this._activeLeaf() && (this._activeLeaf().Content || {}).kind === 'overview') { this._renderOverview(this.bodySelector); }
347
+ }
348
+
349
+ // --- content kind: overview (FieldGroups) ----------------------------------------------------------------
350
+ _renderOverview(pBodySel)
351
+ {
352
+ const tmpGroups = this.workspaceConfig.FieldGroups || [];
353
+ const tmpRec = this.record;
354
+ if (!tmpGroups.length) { this.pict.ContentAssignment.assignContent(pBodySel, `<div class="pw-empty"><i class="fas fa-circle-info"></i><p>No overview configured.</p></div>`); return; }
355
+ const tmpHTML = tmpGroups.map((pGroup) =>
356
+ {
357
+ const tmpFields = (pGroup.Fields || []).map((pField) =>
358
+ {
359
+ let tmpVal;
360
+ if (typeof pField.Value === 'function') { tmpVal = pField.Value(tmpRec, this); }
361
+ else if (pField.Card && this._resolvedCards && this._resolvedCards[pField.Key] !== undefined) { tmpVal = this._resolvedCards[pField.Key]; }
362
+ else { tmpVal = this._formatValue(tmpRec[pField.Key], pField.Format); }
363
+ const tmpCls = `pw-field${pField.Wide ? ' pw-field-wide' : ''}`;
364
+ const tmpValCls = `pw-field-value${pField.Mono ? ' pw-field-mono' : ''}`;
365
+ return `<div class="${tmpCls}"><span class="pw-field-label">${this._esc(pField.Label)}</span><div class="${tmpValCls}">${this._escTitle(tmpVal)}</div></div>`;
366
+ }).join('');
367
+ return `<div class="pw-group"><div class="pw-group-head"><span>${this._esc(pGroup.Name)}</span></div><div class="pw-grid">${tmpFields}</div></div>`;
368
+ }).join('');
369
+ this.pict.ContentAssignment.assignContent(pBodySel, tmpHTML);
370
+ }
371
+
372
+ // --- content kind: render (bespoke) ----------------------------------------------------------------------
373
+ _renderBespoke(pLeaf, pContent, pBodySel)
374
+ {
375
+ const tmpFn = (typeof pContent.ref === 'function') ? pContent.ref
376
+ : ((this.workspaceConfig.Renderers && this.workspaceConfig.Renderers[pContent.ref]) || this[pContent.ref]);
377
+ if (typeof tmpFn !== 'function') { this.pict.ContentAssignment.assignContent(pBodySel, `<div class="pw-empty"><p>Missing renderer: ${this._esc(pContent.ref)}</p></div>`); return; }
378
+ try
379
+ {
380
+ const tmpOut = tmpFn.call(this, this, pBodySel);
381
+ if (typeof tmpOut === 'string') { this.pict.ContentAssignment.assignContent(pBodySel, tmpOut); }
382
+ }
383
+ catch (pError) { this.pict.ContentAssignment.assignContent(pBodySel, `<div class="pw-error">Render failed: ${this._esc((pError && pError.message) || pError)}</div>`); }
384
+ }
385
+
386
+ // --- content kind: embed (host a full view via openInto) -------------------------------------------------
387
+ _embedRefs() { const tmpOut = []; for (const tmpLeaf of this._leafTabs()) { if (tmpLeaf.Content && tmpLeaf.Content.kind === 'embed') { tmpOut.push(tmpLeaf); } } return tmpOut; }
388
+ _embedTarget(pContent) { return (pContent.style === 'provider') ? this.pict.providers[pContent.ref] : this.pict.views[pContent.ref]; }
389
+ _exitInactiveEmbeds(pActiveLeaf)
390
+ {
391
+ for (const tmpLeaf of this._embedRefs())
392
+ {
393
+ if (tmpLeaf.Key === (pActiveLeaf && pActiveLeaf.Key)) { continue; }
394
+ const tmpTarget = this._embedTarget(tmpLeaf.Content);
395
+ if (tmpTarget && typeof tmpTarget.exitEmbed === 'function') { try { tmpTarget.exitEmbed(); } catch (pError) { /* best-effort */ } }
396
+ }
397
+ }
398
+ _renderEmbed(pLeaf, pContent, pBodySel)
399
+ {
400
+ const tmpHost = `${this.identifier}-Embed-${pLeaf.Key}`;
401
+ this.pict.ContentAssignment.assignContent(pBodySel, `<div id="${tmpHost}" class="pw-embed-host"></div>`);
402
+ const tmpTarget = this._embedTarget(pContent);
403
+ if (!tmpTarget || typeof tmpTarget.openInto !== 'function') { this.pict.ContentAssignment.assignContent(pBodySel, `<div class="pw-empty"><p>Embedded view “${this._esc(pContent.ref)}” is not available.</p></div>`); return; }
404
+ try
405
+ {
406
+ if (pContent.style === 'provider') { tmpTarget.openInto(...(pContent.args || []), this._id, `#${tmpHost}`); }
407
+ else { tmpTarget.openInto(this._id, `#${tmpHost}`); }
408
+ }
409
+ catch (pError) { this.pict.log.warn(`${this.identifier}: embed ${pContent.ref} failed: ${(pError && pError.message) || pError}`); }
410
+ }
411
+
412
+ // --- content kind: assoc (RecordSet association editor) --------------------------------------------------
413
+ _renderAssoc(pLeaf, pContent, pBodySel)
414
+ {
415
+ const tmpHost = `${this.identifier}-Assoc-${pLeaf.Key}`;
416
+ this.pict.ContentAssignment.assignContent(pBodySel, `<div id="${tmpHost}"></div>`);
417
+ const tmpManager = this.pict.providers.RecordSetAssociationManager;
418
+ if (!libRecordSetAssociationEditor || !tmpManager || (typeof tmpManager.getAssociation !== 'function') || !tmpManager.getAssociation(pContent.ref))
419
+ {
420
+ this.pict.ContentAssignment.assignContent(pBodySel, `<div class="pw-empty"><p>Association “${this._esc(pContent.ref)}” is not registered.</p></div>`); return;
421
+ }
422
+ const tmpHash = `${this.identifier}-AssocEditor-${pContent.ref}`;
423
+ let tmpEditor = this.pict.views[tmpHash];
424
+ if (!tmpEditor)
425
+ {
426
+ const tmpLib = pContent.editorLib ? (this.pict.__workspaceEditorLibs && this.pict.__workspaceEditorLibs[pContent.editorLib]) || libRecordSetAssociationEditor : libRecordSetAssociationEditor;
427
+ this.pict.addView(tmpHash, Object.assign({}, tmpLib.default_configuration,
428
+ { ViewIdentifier: tmpHash, AssociationHash: pContent.ref, ThisRecordSet: pContent.thisRecordSet || this.entityName, DefaultDestinationAddress: `#${tmpHost}`, PickerMode: pContent.pickerMode || 'single' },
429
+ pContent.editorOptions || {}), tmpLib);
430
+ tmpEditor = this.pict.views[tmpHash];
431
+ }
432
+ tmpEditor.options.ThisID = this._id;
433
+ tmpEditor.options.DefaultDestinationAddress = `#${tmpHost}`;
434
+ this._assocRenderQueue = this._assocRenderQueue
435
+ .then(() => tmpEditor.renderEditor())
436
+ .catch((pError) => { this.pict.log.warn(`${this.identifier}: assoc ${pContent.ref} render failed: ${(pError && pError.message) || pError}`); });
437
+ }
438
+
439
+ // --- content kind: childlist (FK children grid) ----------------------------------------------------------
440
+ async _renderChildList(pLeaf, pContent, pBodySel)
441
+ {
442
+ this.pict.ContentAssignment.assignContent(pBodySel, `<div class="pw-state"><span class="pw-spinner pw-spinner-inline"></span> Loading…</div>`);
443
+ const tmpParentField = pContent.parentField || this.idField;
444
+ const tmpCollection = pContent.collection || `${pContent.ref}s`;
445
+ const tmpFilter = `${tmpCollection}/FilteredTo/FBV~${tmpParentField}~EQ~${this._id}~FBV~Deleted~EQ~0/0/${pContent.cap || 500}`;
446
+ const tmpRows = (await this._getJSON(tmpFilter)) || [];
447
+ if (this._activeTab !== pLeaf.Key) { return; } // user moved on
448
+ const tmpCols = pContent.columns || [ { Key: 'Name', Label: 'Name' } ];
449
+ if (!tmpRows.length) { this.pict.ContentAssignment.assignContent(pBodySel, `<div class="pw-empty"><i class="fas fa-inbox"></i><p>${this._esc(pContent.empty || `No ${(pContent.ref || 'record').toLowerCase()} records.`)}</p></div>`); return; }
450
+ const tmpHead = tmpCols.map((pCol) => `<th${pCol.Num ? ' class="pw-num"' : ''}>${this._esc(pCol.Label)}</th>`).join('');
451
+ const tmpBody = tmpRows.map((pRow) => `<tr>${tmpCols.map((pCol, pIdx) =>
452
+ {
453
+ const tmpVal = this._formatValue(pRow[pCol.Key], pCol.Format);
454
+ const tmpCls = (pIdx === 0) ? 'pw-cell-strong' : (pCol.Num ? 'pw-num' : (pCol.Muted ? 'pw-muted' : ''));
455
+ return `<td${tmpCls ? ` class="${tmpCls}"` : ''}>${this._escTitle(tmpVal)}</td>`;
456
+ }).join('')}</tr>`).join('');
457
+ this.pict.ContentAssignment.assignContent(pBodySel, `<div class="pw-tablewrap"><table class="pw-table"><thead><tr>${tmpHead}</tr></thead><tbody>${tmpBody}</tbody></table></div>`);
458
+ }
459
+ }
460
+
461
+ module.exports = PictSectionWorkspaceProvider;
@@ -0,0 +1,23 @@
1
+ 'use strict';
2
+
3
+ const libPictView = require('pict-view');
4
+
5
+ /**
6
+ * pict-section-workspace base view — a thin shell. It paints one container div and hands all rendering to the matching
7
+ * provider (same Identifier), which string-renders the header + tab bar + active tab body into it. The factory
8
+ * builds this view's default_configuration (Identifier, container template, shared + extra CSS).
9
+ */
10
+ class PictSectionWorkspaceView extends libPictView
11
+ {
12
+ get provider() { return this.pict.providers[this.options.ViewIdentifier]; }
13
+
14
+ onAfterRender(pRenderable)
15
+ {
16
+ this.pict.CSSMap.injectCSS();
17
+ const tmpProvider = this.provider;
18
+ if (tmpProvider && (typeof tmpProvider.renderWorkspace === 'function')) { tmpProvider.renderWorkspace(); }
19
+ return super.onAfterRender(pRenderable);
20
+ }
21
+ }
22
+
23
+ module.exports = PictSectionWorkspaceView;
@@ -0,0 +1,152 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * pict-section-workspace — a declarative, config-driven tabbed workspace for a single entity record.
5
+ *
6
+ * A workspace config:
7
+ * {
8
+ * Identifier, Ordinal,
9
+ * Entity: { Name, IDField?, Collection?, GUIDField?, ReadEndpoint?(id) },
10
+ * Header: { Eyebrow, Title(rec), Subtitle?(rec), Actions:[{Label,Icon?,Style?,OnClick,When?(prov)}], ListRoute },
11
+ * Permissions?: { Read?(prov) }, // default: <Entity>-Read, fail-open until ready
12
+ * Tabs: [ { Key, Label, Icon?, Pin?, Children?:[…leaf], Content:{kind,ref,…}, OnEnter? } ],
13
+ * FieldGroups?: [ { Name, Fields:[{Key,Label,Wide?,Mono?,Format?,Value?(rec,prov)}] } ], // for kind:'overview'
14
+ * Renderers?: { <ref>: (prov, hostSelector) => htmlString|void }, // for kind:'render'
15
+ * ProviderClass?, ViewClass?, // subclass overrides (bespoke methods live here)
16
+ * CSSExtra?, EditorLibs?: { <name>: assocEditorLib },
17
+ * Create?: { Route }, Edit?: { Route },
18
+ * }
19
+ *
20
+ * Content kinds per tab leaf:
21
+ * overview — FieldGroups grid (the base owns it)
22
+ * render — Content.ref is a Renderers[ref] fn or a provider method name; paints into the tab body
23
+ * embed — Content.ref is a ViewIdentifier hosted via openInto/exitEmbed (style:'provider' → a provider host,
24
+ * with Content.args prepended before the record id)
25
+ * assoc — Content.ref is a RecordSetAssociationManager association hash; editorLib names an EditorLibs entry
26
+ * childlist — Content.ref is the child Entity; {collection?, parentField?, columns:[{Key,Label,Num?,Format?}], cap?, empty?}
27
+ */
28
+
29
+ const PictSectionWorkspaceProvider = require('./Pict-Section-Workspace-Provider.js');
30
+ const PictSectionWorkspaceView = require('./Pict-Section-Workspace-View.js');
31
+ const { WORKSPACE_CSS } = require('./Pict-Section-Workspace-CSS.js');
32
+
33
+ // Where the workspace view mounts by default. Override per workspace via config `DestinationAddress`, or set a
34
+ // global default with the exported `setDefaultDestination()`.
35
+ let DEFAULT_DESTINATION = '#Pict-Application-Container';
36
+ function setDefaultDestination(pSelector) { if (pSelector) { DEFAULT_DESTINATION = pSelector; } }
37
+
38
+ /** Register a workspace's provider + view pair with a pict application. */
39
+ function createWorkspace(pPict, pConfig)
40
+ {
41
+ const tmpId = pConfig.Identifier;
42
+ const tmpDest = pConfig.DestinationAddress || DEFAULT_DESTINATION;
43
+ const tmpProviderClass = pConfig.ProviderClass || PictSectionWorkspaceProvider;
44
+ const tmpViewClass = pConfig.ViewClass || PictSectionWorkspaceView;
45
+
46
+ if (pConfig.EditorLibs) { pPict.__workspaceEditorLibs = Object.assign(pPict.__workspaceEditorLibs || {}, pConfig.EditorLibs); }
47
+
48
+ pPict.addProvider(tmpId,
49
+ { ProviderIdentifier: tmpId, AutoInitialize: true, AutoInitializeOrdinal: pConfig.Ordinal || 230, WorkspaceConfig: pConfig },
50
+ tmpProviderClass);
51
+
52
+ pPict.addView(tmpId,
53
+ {
54
+ ViewIdentifier: tmpId,
55
+ DefaultRenderable: `${tmpId}-Wrap`,
56
+ DefaultDestinationAddress: tmpDest,
57
+ AutoRender: false,
58
+ CSS: WORKSPACE_CSS + (pConfig.CSSExtra || ''),
59
+ // The container template, plus any ExtraTemplates a workspace needs registered so its bespoke
60
+ // `render` tabs can paint them via pict.parseTemplateByHash (e.g. a migrated workspace reusing its
61
+ // original per-tab templates + shared controller templates verbatim).
62
+ Templates: [ { Hash: `${tmpId}-Template`, Template: `<div id="${tmpId}-Container"></div>` } ].concat(pConfig.ExtraTemplates || []),
63
+ Renderables: [ { RenderableHash: `${tmpId}-Wrap`, TemplateHash: `${tmpId}-Template`, DestinationAddress: tmpDest, RenderMethod: 'replace' } ],
64
+ },
65
+ tmpViewClass);
66
+
67
+ return { Identifier: tmpId };
68
+ }
69
+
70
+ /**
71
+ * Register the standard workspace bridge routes on the app router. pRouter is the application's router provider
72
+ * instance (exposes .pictRouter, .currentView, ._renderRouteView, .pict). Emits:
73
+ * /<Entity>/Workspace/:ID /<Entity>/Workspace/:ID/:Tab
74
+ * /PSRS/<Entity>/View/:GUIDRecord (+ optional Edit/Create bridges)
75
+ * The :Tab whitelist is derived from the live config so it can never drift from the tab manifest.
76
+ */
77
+ function registerWorkspaceRoutes(pRouter, pConfig)
78
+ {
79
+ const tmpEntity = pConfig.Entity.Name;
80
+ const tmpPrefix = pConfig.RoutePrefix || tmpEntity; // workspace-route noun (defaults to the entity name)
81
+ const tmpId = pConfig.Identifier;
82
+ const tmpRouter = pRouter.pictRouter;
83
+ const fProvider = () => pRouter.pict.providers[tmpId];
84
+
85
+ tmpRouter.addRoute(`/${tmpPrefix}/Workspace/:ID`, (pRouteData) =>
86
+ {
87
+ pRouter.currentView = tmpId;
88
+ pRouter._renderRouteView(tmpId);
89
+ const tmpP = fProvider(); if (tmpP) { tmpP.loadAsync(parseInt(pRouteData.data.ID, 10)); }
90
+ });
91
+
92
+ tmpRouter.addRoute(`/${tmpPrefix}/Workspace/:ID/:Tab`, (pRouteData) =>
93
+ {
94
+ const tmpP = fProvider();
95
+ const tmpTargetID = parseInt(pRouteData.data.ID, 10);
96
+ const tmpMounted = (pRouter.currentView === tmpId) && tmpP && (Number(tmpP.idRecord) === tmpTargetID);
97
+ pRouter.currentView = tmpId;
98
+ if (!tmpMounted) { pRouter._renderRouteView(tmpId); }
99
+ if (tmpP) { tmpP.openTabAsync(tmpTargetID, pRouteData.data.Tab); }
100
+ });
101
+
102
+ tmpRouter.addRoute(`/PSRS/${tmpEntity}/View/:GUIDRecord`, (pRouteData) =>
103
+ {
104
+ pRouter.currentView = tmpId;
105
+ pRouter._renderRouteView(tmpId);
106
+ const tmpP = fProvider(); if (tmpP) { tmpP.openByGUIDAsync(pRouteData.data.GUIDRecord); }
107
+ });
108
+
109
+ // Edit → open the workspace (read for now) unless the config routes it elsewhere.
110
+ if (pConfig.Edit && pConfig.Edit.Route === 'workspace')
111
+ {
112
+ tmpRouter.addRoute(`/PSRS/${tmpEntity}/Edit/:GUIDRecord`, (pRouteData) =>
113
+ {
114
+ pRouter.currentView = tmpId;
115
+ pRouter._renderRouteView(tmpId);
116
+ const tmpP = fProvider(); if (tmpP) { tmpP.openByGUIDAsync(pRouteData.data.GUIDRecord, 'edit'); }
117
+ });
118
+ }
119
+
120
+ // Create → enter create mode (startCreateAsync). Optional CloneRoute passes a source GUID to clone from.
121
+ if (pConfig.Create && pConfig.Create.Route)
122
+ {
123
+ tmpRouter.addRoute(pConfig.Create.Route, () =>
124
+ {
125
+ pRouter.currentView = tmpId;
126
+ pRouter._renderRouteView(tmpId);
127
+ const tmpP = fProvider(); if (tmpP) { tmpP.startCreateAsync(null); }
128
+ });
129
+ if (pConfig.Create.CloneRoute)
130
+ {
131
+ tmpRouter.addRoute(pConfig.Create.CloneRoute, (pRouteData) =>
132
+ {
133
+ pRouter.currentView = tmpId;
134
+ pRouter._renderRouteView(tmpId);
135
+ const tmpP = fProvider(); if (tmpP) { tmpP.startCreateAsync((pRouteData.data && pRouteData.data.GUIDRecord) || null); }
136
+ });
137
+ }
138
+ }
139
+ }
140
+
141
+ module.exports = {
142
+ createWorkspace,
143
+ registerWorkspaceRoutes,
144
+ setDefaultDestination,
145
+ PictSectionWorkspaceProvider,
146
+ PictSectionWorkspaceView,
147
+ WORKSPACE_CSS,
148
+ // Legacy aliases (the framework was extracted from the Headlight config app) — kept so existing subclasses
149
+ // that `extends HeadlightWorkspaceBaseProvider` keep resolving after adopting the published package.
150
+ HeadlightWorkspaceBaseProvider: PictSectionWorkspaceProvider,
151
+ HeadlightWorkspaceBaseView: PictSectionWorkspaceView,
152
+ };
@@ -0,0 +1,8 @@
1
+ // The workspace onclick strings reference the global pict instance as `_Pict`; the runtime is a browser DOM.
2
+ declare global
3
+ {
4
+ var _Pict: any;
5
+ interface Window { _Pict?: any; }
6
+ }
7
+
8
+ export {};
@@ -0,0 +1,2 @@
1
+ export const WORKSPACE_CSS: string;
2
+ //# sourceMappingURL=Pict-Section-Workspace-CSS.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Pict-Section-Workspace-CSS.d.ts","sourceRoot":"","sources":["../source/Pict-Section-Workspace-CSS.js"],"names":[],"mappings":"AAQA,mCA2DE"}
@@ -0,0 +1,92 @@
1
+ export = PictSectionWorkspaceProvider;
2
+ /**
3
+ * pict-section-workspace base provider — owns everything every entity workspace shares: the REST + format helpers,
4
+ * the GUID-bridge loader, tab routing, permissions, the shell render (header + tab bar + body panel), and the four
5
+ * tab content-kind resolvers (render / embed / assoc / childlist). A concrete workspace is either the base + a
6
+ * declarative config (registered by the factory) or a thin subclass that adds bespoke `render:` tab methods.
7
+ *
8
+ * Config is read from `this.workspaceConfig` — the factory sets `options.WorkspaceConfig`; a subclass may override
9
+ * the getter. See Pict-Section-Workspace.js for the config schema.
10
+ */
11
+ declare class PictSectionWorkspaceProvider extends libPictProvider {
12
+ constructor(pFable: any, pOptions: any, pServiceHash: any);
13
+ _record: any;
14
+ _id: number;
15
+ _activeTab: any;
16
+ _mode: string;
17
+ _assocRenderQueue: Promise<void>;
18
+ get workspaceConfig(): any;
19
+ get identifier(): any;
20
+ get entityName(): any;
21
+ get idField(): any;
22
+ get collection(): any;
23
+ get guidField(): any;
24
+ readEndpoint(pID: any): any;
25
+ /** URL segment for the workspace routes (#/<prefix>/Workspace/…). Defaults to the entity name; a config may
26
+ * override it when the route noun differs from the entity (e.g. PhysicalAsset records under #/Asset/Workspace). */
27
+ get routePrefix(): any;
28
+ get containerSelector(): string;
29
+ get bodySelector(): string;
30
+ get view(): any;
31
+ get record(): any;
32
+ get idRecord(): number;
33
+ get state(): any;
34
+ /** Name of the fable service that speaks to the record API (getJSON/putJSON/postJSON). Override per workspace
35
+ * via config `RestClientName`; defaults to `RestClient`. Falls back to a couple of common names so a host that
36
+ * registered its client under a different name still resolves. */
37
+ get restClientName(): any;
38
+ get restClient(): any;
39
+ _getJSON(pURL: any): Promise<any>;
40
+ _putJSON(pURL: any, pBody: any): Promise<any>;
41
+ _postJSON(pURL: any, pBody: any): Promise<any>;
42
+ _esc(pStr: any): string;
43
+ /** Shared display formatter — the currency/percent/number/id/date/bool switch every workspace overview used. */
44
+ _formatValue(pValue: any, pFormat: any): string;
45
+ get permissions(): any;
46
+ can(pEntity: any, pAction: any): boolean;
47
+ /** Default: fail-open until the capability map is ready, then require <Entity>-Read. A config can override. */
48
+ canRead(): boolean;
49
+ /** True in edit or create mode — used to swap the tab body for the configured EditContent (a CRUD form host). */
50
+ get isEditing(): boolean;
51
+ get mode(): string;
52
+ loadAsync(pID: any, pTab: any, pMode: any): Promise<void>;
53
+ _resolvedCards: {};
54
+ openByGUIDAsync(pGUID: any, pMode: any): Promise<void>;
55
+ /** Enter CREATE mode (no record). Subclasses implement hydrateCreateAsync(pArg) to seed the working form. */
56
+ startCreateAsync(pArg: any): Promise<void>;
57
+ get tabDefs(): any;
58
+ /** Tab defs for RENDERING: resolves any function-valued Label against `this` (dynamic labels, e.g. "Projects (N)"). */
59
+ _resolvedTabDefs(): any;
60
+ _leafTabs(): any[];
61
+ validTabKeys(): any[];
62
+ defaultTab(): any;
63
+ _normalizeTab(pTab: any): any;
64
+ _activeLeaf(): any;
65
+ navigateTab(pTab: any): void;
66
+ openTabAsync(pID: any, pTab: any): Promise<void>;
67
+ setTab(pTab: any): Promise<void>;
68
+ _enterTab(pTab: any): Promise<void>;
69
+ renderWorkspace(): void;
70
+ /** Titles/subtitles may already contain intentional markup (a status pill); allow it through but escape plain strings. */
71
+ _escTitle(pValue: any): string;
72
+ _headerActionsHTML(): string;
73
+ backToList(): void;
74
+ /** Default Edit action — the generic PSRS edit form for entities without a bespoke edit workspace. */
75
+ goToEdit(): void;
76
+ goToList(): void;
77
+ /** Re-paint only the active tab body (used after a tab's own async load completes). */
78
+ repaintBody(): void;
79
+ _renderActiveBody(): void;
80
+ /** Resolve FieldGroups Card fields (FK → related record) to display names, then re-render the overview. */
81
+ _resolveCardsAsync(): Promise<void>;
82
+ _renderOverview(pBodySel: any): void;
83
+ _renderBespoke(pLeaf: any, pContent: any, pBodySel: any): void;
84
+ _embedRefs(): any[];
85
+ _embedTarget(pContent: any): any;
86
+ _exitInactiveEmbeds(pActiveLeaf: any): void;
87
+ _renderEmbed(pLeaf: any, pContent: any, pBodySel: any): void;
88
+ _renderAssoc(pLeaf: any, pContent: any, pBodySel: any): void;
89
+ _renderChildList(pLeaf: any, pContent: any, pBodySel: any): Promise<void>;
90
+ }
91
+ import libPictProvider = require("pict-provider");
92
+ //# sourceMappingURL=Pict-Section-Workspace-Provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Pict-Section-Workspace-Provider.d.ts","sourceRoot":"","sources":["../source/Pict-Section-Workspace-Provider.js"],"names":[],"mappings":";AAYA;;;;;;;;GAQG;AACH;IAEC,2DAUC;IALA,aAAmB;IACnB,YAAY;IACZ,gBAAsB;IACtB,cAAmB;IACnB,iCAA0C;IAI3C,2BAAoE;IACpE,sBAA4D;IAC5D,sBAA6D;IAC7D,mBAAuF;IACvF,sBAA4F;IAC5F,qBAA6F;IAC7F,4BAA6H;IAC7H;wHACoH;IACpH,uBAAiF;IAEjF,gCAAmE;IACnE,2BAAyD;IACzD,gBAAuD;IACvD,kBAA2C;IAC3C,uBAAmC;IAEnC,iBAKC;IAGD;;uEAEmE;IACnE,0BAAoF;IACpF,sBAIC;IACD,kCAAsL;IACtL,8CAA+N;IAC/N,+CAAiO;IACjO,wBAAwK;IAExK,gHAAgH;IAChH,gDAYC;IAGD,uBAAmI;IACnI,yCAKC;IACD,+GAA+G;IAC/G,mBAOC;IAGD,iHAAiH;IACjH,yBAAgF;IAChF,mBAAiC;IAEjC,0DA2BC;IAbC,mBAAwB;IAe1B,uDAiBC;IAED,6GAA6G;IAC7G,2CAWC;IAGD,mBAAyD;IACzD,uHAAuH;IACvH,wBAUC;IACD,mBASC;IACD,sBAAmE;IACnE,kBAAqG;IACrG,8BAA4H;IAC5H,mBAA+F;IAE/F,6BAIC;IACD,iDAKC;IACD,iCAMC;IACD,oCASC;IAGD,wBAuCC;IAED,0HAA0H;IAC1H,+BAAiH;IAEjH,6BAgBC;IAED,mBAA0L;IAC1L,sGAAsG;IACtG,iBAAwK;IACxK,iBAAiC;IAEjC,uFAAuF;IACvF,oBAA2C;IAE3C,0BAoBC;IAED,2GAA2G;IAC3G,oCAmBC;IAGD,qCAoBC;IAGD,+DAWC;IAGD,oBAAiL;IACjL,iCAAsI;IACtI,4CAQC;IACD,6DAYC;IAGD,6DAwBC;IAGD,0EAkBC;CACD"}
@@ -0,0 +1,12 @@
1
+ export = PictSectionWorkspaceView;
2
+ /**
3
+ * pict-section-workspace base view — a thin shell. It paints one container div and hands all rendering to the matching
4
+ * provider (same Identifier), which string-renders the header + tab bar + active tab body into it. The factory
5
+ * builds this view's default_configuration (Identifier, container template, shared + extra CSS).
6
+ */
7
+ declare class PictSectionWorkspaceView extends libPictView {
8
+ get provider(): any;
9
+ onAfterRender(pRenderable: any): boolean;
10
+ }
11
+ import libPictView = require("pict-view");
12
+ //# sourceMappingURL=Pict-Section-Workspace-View.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Pict-Section-Workspace-View.d.ts","sourceRoot":"","sources":["../source/Pict-Section-Workspace-View.js"],"names":[],"mappings":";AAIA;;;;GAIG;AACH;IAEC,oBAA2E;IAE3E,yCAMC;CACD"}
@@ -0,0 +1,18 @@
1
+ /** Register a workspace's provider + view pair with a pict application. */
2
+ export function createWorkspace(pPict: any, pConfig: any): {
3
+ Identifier: any;
4
+ };
5
+ /**
6
+ * Register the standard workspace bridge routes on the app router. pRouter is the application's router provider
7
+ * instance (exposes .pictRouter, .currentView, ._renderRouteView, .pict). Emits:
8
+ * /<Entity>/Workspace/:ID /<Entity>/Workspace/:ID/:Tab
9
+ * /PSRS/<Entity>/View/:GUIDRecord (+ optional Edit/Create bridges)
10
+ * The :Tab whitelist is derived from the live config so it can never drift from the tab manifest.
11
+ */
12
+ export function registerWorkspaceRoutes(pRouter: any, pConfig: any): void;
13
+ export function setDefaultDestination(pSelector: any): void;
14
+ import PictSectionWorkspaceProvider = require("./Pict-Section-Workspace-Provider.js");
15
+ import PictSectionWorkspaceView = require("./Pict-Section-Workspace-View.js");
16
+ import { WORKSPACE_CSS } from "./Pict-Section-Workspace-CSS.js";
17
+ export { PictSectionWorkspaceProvider, PictSectionWorkspaceView, WORKSPACE_CSS, PictSectionWorkspaceProvider as HeadlightWorkspaceBaseProvider, PictSectionWorkspaceView as HeadlightWorkspaceBaseView };
18
+ //# sourceMappingURL=Pict-Section-Workspace.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Pict-Section-Workspace.d.ts","sourceRoot":"","sources":["../source/Pict-Section-Workspace.js"],"names":[],"mappings":"AAqCA,2EAA2E;AAC3E;;EA6BC;AAED;;;;;;GAMG;AACH,0EA8DC;AAvGD,4DAAiG"}