@voxgig/build 4.4.0 → 4.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +51 -2
  2. package/build.ts +20 -0
  3. package/dist/api/api_gen.d.ts +7 -0
  4. package/dist/api/api_gen.js +370 -0
  5. package/dist/api/api_gen.js.map +1 -0
  6. package/dist/build.d.ts +16 -1
  7. package/dist/build.js +17 -1
  8. package/dist/build.js.map +1 -1
  9. package/dist/doc/doc_gen.d.ts +8 -0
  10. package/dist/doc/doc_gen.js +397 -0
  11. package/dist/doc/doc_gen.js.map +1 -0
  12. package/dist/env/web/web_gen.js +159 -1
  13. package/dist/env/web/web_gen.js.map +1 -1
  14. package/dist/shape/ent.js +4 -2
  15. package/dist/shape/ent.js.map +1 -1
  16. package/env/web/web_gen.ts +174 -1
  17. package/package.json +6 -9
  18. package/tm/web/backend/env/shared/seed.ts.frag +23 -0
  19. package/tm/web/backend/env/web/api.ts.frag +157 -0
  20. package/tm/web/backend/env/web/web.ts.frag +37 -4
  21. package/tm/web/backend/srv/api/api-srv.ts.frag +4 -0
  22. package/tm/web/backend/srv/api/expose.ts.frag +33 -0
  23. package/tm/web/backend/srv/api/get_info.ts.frag +6 -0
  24. package/tm/web/backend/srv/api/on_ent.ts.frag +124 -0
  25. package/tm/web/backend/srv/auth/apikey_util.ts.frag +10 -0
  26. package/tm/web/backend/srv/auth/auth-srv.ts.frag +1 -0
  27. package/tm/web/backend/srv/auth/change_pass.ts.frag +12 -0
  28. package/tm/web/backend/srv/auth/create_apikey.ts.frag +33 -0
  29. package/tm/web/backend/srv/auth/get_info.ts.frag +1 -0
  30. package/tm/web/backend/srv/auth/list_apikey.ts.frag +15 -0
  31. package/tm/web/backend/srv/auth/load_auth.ts.frag +10 -1
  32. package/tm/web/backend/srv/auth/remind_pass.ts.frag +35 -0
  33. package/tm/web/backend/srv/auth/revoke_apikey.ts.frag +23 -0
  34. package/tm/web/backend/srv/auth/signin_user.ts.frag +12 -2
  35. package/tm/web/backend/srv/auth/signout_user.ts.frag +11 -1
  36. package/tm/web/backend/srv/auth/update_user.ts.frag +15 -0
  37. package/tm/web/backend/srv/auth/user_util.ts.frag +21 -0
  38. package/tm/web/backend/srv/auth/web_change_pass.ts.frag +16 -0
  39. package/tm/web/backend/srv/auth/web_create_apikey.ts.frag +15 -0
  40. package/tm/web/backend/srv/auth/web_list_apikey.ts.frag +11 -0
  41. package/tm/web/backend/srv/auth/web_load_auth.ts.frag +1 -0
  42. package/tm/web/backend/srv/auth/web_remind_pass.ts.frag +7 -0
  43. package/tm/web/backend/srv/auth/web_revoke_apikey.ts.frag +14 -0
  44. package/tm/web/backend/srv/auth/web_signin_user.ts.frag +1 -0
  45. package/tm/web/backend/srv/auth/web_signout_user.ts.frag +11 -3
  46. package/tm/web/backend/srv/auth/web_update_user.ts.frag +11 -0
  47. package/tm/web/backend/srv/ent/access.ts.frag +179 -0
  48. package/tm/web/backend/srv/ent/cmd_list.ts.frag +86 -0
  49. package/tm/web/backend/srv/ent/cmd_load.ts.frag +56 -0
  50. package/tm/web/backend/srv/ent/cmd_remove.ts.frag +60 -0
  51. package/tm/web/backend/srv/ent/cmd_save.ts.frag +141 -0
  52. package/tm/web/backend/srv/ent/ent-srv.ts.frag +4 -0
  53. package/tm/web/backend/srv/ent/get_info.ts.frag +6 -0
  54. package/tm/web/backend/srv/ent/web_cmd_list.ts.frag +17 -0
  55. package/tm/web/backend/srv/ent/web_cmd_load.ts.frag +17 -0
  56. package/tm/web/backend/srv/ent/web_cmd_remove.ts.frag +17 -0
  57. package/tm/web/backend/srv/ent/web_cmd_save.ts.frag +17 -0
  58. package/tm/web/backend/test/unit/env/web/api-router.test.ts.frag +315 -0
  59. package/tm/web/backend/test/unit/env/web/surface.test.ts.frag +109 -0
  60. package/tm/web/backend/test/unit/srv/api/api.setup.ts.frag +162 -0
  61. package/tm/web/backend/test/unit/srv/api/api.test.ts.frag +225 -0
  62. package/tm/web/backend/test/unit/srv/auth/apikey.test.ts.frag +152 -0
  63. package/tm/web/backend/test/unit/srv/auth/auth.setup.ts.frag +40 -0
  64. package/tm/web/backend/test/unit/srv/auth/session.test.ts.frag +189 -0
  65. package/tm/web/backend/test/unit/srv/ent/ent.setup.ts.frag +53 -0
  66. package/tm/web/backend/test/unit/srv/ent/proxy.test.ts.frag +109 -0
  67. package/tm/web/docs/explanation/web-architecture.md.frag +50 -0
  68. package/tm/web/docs/how-to/add-a-custom-entity-view.md.frag +53 -0
  69. package/tm/web/docs/how-to/change-the-theme.md.frag +63 -0
  70. package/tm/web/docs/how-to/customise-the-web-app.md.frag +68 -0
  71. package/tm/web/docs/how-to/use-the-api.md.frag +53 -0
  72. package/tm/web/docs/reference/web-app.md.frag +98 -0
  73. package/tm/web/web/AGENTS.md.frag +52 -0
  74. package/tm/web/web/e2e/smoke.spec.js.frag +13 -11
  75. package/tm/web/web/index.html.frag +1 -0
  76. package/tm/web/web/package.json.frag +1 -0
  77. package/tm/web/web/playwright.config.js.frag +2 -1
  78. package/tm/web/web/src/api.js.frag +92 -0
  79. package/tm/web/web/src/bus.js.frag +14 -8
  80. package/tm/web/web/src/cmp/admin.js.frag +293 -212
  81. package/tm/web/web/src/cmp/admin.md.frag +51 -0
  82. package/tm/web/web/src/cmp/app.js.frag +12 -15
  83. package/tm/web/web/src/cmp/app.md.frag +28 -0
  84. package/tm/web/web/src/cmp/auth.js.frag +69 -41
  85. package/tm/web/web/src/cmp/auth.md.frag +36 -0
  86. package/tm/web/web/src/cmp/public.js.frag +58 -0
  87. package/tm/web/web/src/cmp/public.md.frag +26 -0
  88. package/tm/web/web/src/cmp/settings.js.frag +178 -0
  89. package/tm/web/web/src/cmp/settings.md.frag +30 -0
  90. package/tm/web/web/src/cmp/shell.js.frag +231 -0
  91. package/tm/web/web/src/cmp/shell.md.frag +49 -0
  92. package/tm/web/web/src/cmp/view/custom-view.js.frag +47 -0
  93. package/tm/web/web/src/cmp/view/custom-view.md.frag +31 -0
  94. package/tm/web/web/src/custom.css.frag +8 -0
  95. package/tm/web/web/src/customise.js.frag +30 -0
  96. package/tm/web/web/src/hooks.js.frag +79 -0
  97. package/tm/web/web/src/main.js.frag +17 -4
  98. package/tm/web/web/src/model.js.frag +162 -0
  99. package/tm/web/web/src/style.css.frag +135 -21
  100. package/tm/web/web/src/theme.js.frag +82 -0
@@ -0,0 +1,231 @@
1
+ // The authenticated enterprise app shell: a traditional layout with a top
2
+ // bar (brand, project selector, user menu top-right) and a collapsible left
3
+ // menu listing the model's entities. The main area hosts the generic entity
4
+ // admin (or settings). Everything is driven by the model — the entity menu
5
+ // and relationships come from /model.json, so it scales to any graph.
6
+
7
+ import { bus, onEvent } from '../bus.js'
8
+ import * as Model from '../model.js'
9
+ import * as Api from '../api.js'
10
+ import * as Hooks from '../hooks.js'
11
+ import * as Theme from '../theme.js'
12
+
13
+
14
+ class VgShell extends HTMLElement {
15
+ async connectedCallback() {
16
+ await Model.loadModel()
17
+ const state = await bus.post('cmp:auth,get:state')
18
+ this.user = state.user
19
+ this.projects = []
20
+ this.currentProjectId = null
21
+ this.currentCanon = null
22
+ this.view = 'entity'
23
+ this.collapsed = false
24
+
25
+ this.renderFrame()
26
+ await this.loadProjects()
27
+
28
+ // Land on projects if present, else the first entity.
29
+ const ents = Model.entities()
30
+ const start = ents.find((e) => 'proj/project' === e.canon) || ents[0]
31
+ if (start) {
32
+ this.openEntity(start.canon)
33
+ }
34
+
35
+ // The project selector refreshes when projects change (e.g. a new one is
36
+ // created). Guard with isConnected: bus.sub has no auto-unsubscribe, so a
37
+ // torn-down shell (sign-out→in re-mounts it) must not act on stale events.
38
+ onEvent('projects-changed', () => {
39
+ if (this.isConnected) {
40
+ this.loadProjects()
41
+ }
42
+ })
43
+ }
44
+
45
+ // ---- data ----
46
+
47
+ async loadProjects() {
48
+ this.projects = await Api.list('proj/project')
49
+ if (!this.currentProjectId && this.projects.length) {
50
+ this.currentProjectId = this.projects[0].id
51
+ }
52
+ this.renderProjectSelect()
53
+ // A project-scoped view may need re-listing once projects are known.
54
+ if ('entity' === this.view && this.admin && Model.isProjectScoped(this.currentCanon)) {
55
+ this.admin.projectId = this.currentProjectId
56
+ this.admin.reload()
57
+ }
58
+ }
59
+
60
+ // ---- navigation ----
61
+
62
+ openEntity(canon, detailId) {
63
+ // Opening a project's detail makes it the current project, so children
64
+ // created within it inherit the right project context.
65
+ if ('proj/project' === canon && detailId) {
66
+ this.currentProjectId = detailId
67
+ this.renderProjectSelect()
68
+ }
69
+ this.currentCanon = canon
70
+ this.view = 'entity'
71
+ this.setActiveNav()
72
+ this.mountAdmin(canon, detailId || null)
73
+ }
74
+
75
+ openSettings() {
76
+ this.view = 'settings'
77
+ this.setActiveNav()
78
+ const main = this.querySelector('#vg-main')
79
+ main.innerHTML = '<vg-settings></vg-settings>'
80
+ }
81
+
82
+ mountAdmin(canon, detailId) {
83
+ const main = this.querySelector('#vg-main')
84
+ // A custom view (ux:{view:'custom'}) replaces the generic admin.
85
+ const tag = Model.customViewTag(canon) || 'vg-entity-admin'
86
+ if (!this.admin || this.admin.parentNode !== main || this.admin.localName !== tag) {
87
+ main.innerHTML = ''
88
+ this.admin = document.createElement(tag)
89
+ this.admin.onNavigate = (c, id) => this.openEntity(c, id)
90
+ main.appendChild(this.admin)
91
+ }
92
+ this.admin.canon = canon
93
+ this.admin.projectId = Model.isProjectScoped(canon) ? this.currentProjectId : null
94
+ this.admin.detailId = detailId
95
+ this.admin.reload()
96
+ }
97
+
98
+ // ---- render ----
99
+
100
+ renderFrame() {
101
+ this.innerHTML = `
102
+ <div class="vg-shell${this.collapsed ? ' vg-collapsed' : ''}">
103
+ <header class="vg-topbar">
104
+ <button class="vg-icon-btn" id="vg-toggle" title="Menu">☰</button>
105
+ <span class="vg-brand">📋 $$Name$$</span>
106
+ <div class="vg-project-picker">
107
+ <label>Project</label>
108
+ <select id="vg-project"></select>
109
+ </div>
110
+ <div class="vg-spacer"></div>
111
+ ${Hooks.html('shell:topbar:right', { user: this.user })}
112
+ <div class="vg-usermenu" id="vg-usermenu">
113
+ <button class="vg-user-btn" id="vg-user-btn">
114
+ <span>${this.user ? this.user.email : ''}</span> <span class="vg-caret">▾</span>
115
+ </button>
116
+ <div class="vg-user-dropdown" id="vg-user-dropdown" hidden>
117
+ ${Theme.modes().length > 1
118
+ ? `<a href="#" id="vg-theme-toggle">Theme: ${esc(Theme.current())}</a>` : ''}
119
+ <a href="#" id="vg-nav-settings">Settings &amp; security</a>
120
+ <a href="#" id="vg-signout">Sign out</a>
121
+ </div>
122
+ </div>
123
+ </header>
124
+ <div class="vg-body">
125
+ <aside class="vg-sidebar">
126
+ ${Hooks.html('shell:sidebar:top', { user: this.user })}
127
+ <input class="vg-ent-filter" id="vg-ent-filter" placeholder="Filter…" />
128
+ <nav id="vg-entnav"></nav>
129
+ </aside>
130
+ <main class="vg-main" id="vg-main"></main>
131
+ </div>
132
+ </div>`
133
+
134
+ this.querySelector('#vg-toggle').onclick = () => {
135
+ this.collapsed = !this.collapsed
136
+ this.querySelector('.vg-shell').classList.toggle('vg-collapsed', this.collapsed)
137
+ }
138
+
139
+ const userBtn = this.querySelector('#vg-user-btn')
140
+ const dropdown = this.querySelector('#vg-user-dropdown')
141
+ userBtn.onclick = (ev) => {
142
+ ev.stopPropagation()
143
+ dropdown.hidden = !dropdown.hidden
144
+ }
145
+ document.addEventListener('click', () => { dropdown.hidden = true })
146
+
147
+ const themeToggle = this.querySelector('#vg-theme-toggle')
148
+ if (themeToggle) {
149
+ themeToggle.onclick = (ev) => {
150
+ ev.preventDefault()
151
+ ev.stopPropagation()
152
+ themeToggle.textContent = 'Theme: ' + Theme.nextMode()
153
+ }
154
+ }
155
+ this.querySelector('#vg-nav-settings').onclick = (ev) => {
156
+ ev.preventDefault()
157
+ dropdown.hidden = true
158
+ this.openSettings()
159
+ }
160
+ this.querySelector('#vg-signout').onclick = async (ev) => {
161
+ ev.preventDefault()
162
+ await bus.post('cmp:auth,signout:user')
163
+ }
164
+
165
+ this.querySelector('#vg-project').onchange = (ev) => {
166
+ this.currentProjectId = ev.target.value
167
+ if ('entity' === this.view && Model.isProjectScoped(this.currentCanon)) {
168
+ this.admin.projectId = this.currentProjectId
169
+ this.admin.reload()
170
+ }
171
+ }
172
+
173
+ const filter = this.querySelector('#vg-ent-filter')
174
+ filter.oninput = () => this.renderNav(filter.value)
175
+
176
+ this.renderNav('')
177
+ }
178
+
179
+ renderProjectSelect() {
180
+ const sel = this.querySelector('#vg-project')
181
+ if (!sel) {
182
+ return
183
+ }
184
+ sel.innerHTML = this.projects.length
185
+ ? this.projects.map((p) =>
186
+ `<option value="${p.id}"${p.id === this.currentProjectId ? ' selected' : ''}>${esc(p.name)}</option>`).join('')
187
+ : '<option value="">(no projects)</option>'
188
+ }
189
+
190
+ // The entity menu, grouped by zone, filterable (scales to many entities).
191
+ renderNav(filter) {
192
+ const nav = this.querySelector('#vg-entnav')
193
+ const f = (filter || '').toLowerCase()
194
+ // Hook: reorder/filter/relabel the entity menu.
195
+ const all = Hooks.filter('shell:nav:items', Model.entities(), {})
196
+ const ents = all.filter((e) => e.canon.toLowerCase().indexOf(f) >= 0)
197
+ const byZone = {}
198
+ for (const e of ents) {
199
+ (byZone[e.zone] = byZone[e.zone] || []).push(e)
200
+ }
201
+ nav.innerHTML = Object.keys(byZone).sort().map((zone) => `
202
+ <div class="vg-navgroup">
203
+ <div class="vg-navgroup-title">${esc(Model.titleize(zone))}</div>
204
+ ${byZone[zone].map((e) =>
205
+ `<a href="#" class="vg-navlink${e.canon === this.currentCanon ? ' vg-sel' : ''}"
206
+ data-canon="${e.canon}">${esc(e.label)}</a>`).join('')}
207
+ </div>`).join('')
208
+ for (const a of nav.querySelectorAll('.vg-navlink')) {
209
+ a.onclick = (ev) => {
210
+ ev.preventDefault()
211
+ this.openEntity(a.dataset.canon)
212
+ }
213
+ }
214
+ }
215
+
216
+ setActiveNav() {
217
+ for (const a of this.querySelectorAll('.vg-navlink')) {
218
+ a.classList.toggle('vg-sel', 'entity' === this.view && a.dataset.canon === this.currentCanon)
219
+ }
220
+ }
221
+ }
222
+
223
+
224
+ function esc(s) {
225
+ return String(null == s ? '' : s).replace(/[&<>"]/g, (c) =>
226
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]))
227
+ }
228
+
229
+
230
+ customElements.define('vg-shell', VgShell)
231
+
@@ -0,0 +1,49 @@
1
+ # Component: vg-shell (`cmp/shell.js`)
2
+
3
+ The authenticated enterprise app shell: top bar (brand, project
4
+ selector, user menu), collapsible left entity menu, and the main area
5
+ hosting the entity admin (generic or custom view) or settings. Entirely
6
+ model-driven — the entity menu and view routing come from `model.js`.
7
+
8
+ ## Structure
9
+
10
+ ```mermaid
11
+ flowchart TB
12
+ shell[vg-shell]
13
+ shell --> topbar[topbar: collapse, brand, project select, user menu]
14
+ shell --> sidebar[sidebar: filter + entity nav by zone]
15
+ shell --> main[main area]
16
+ main -->|generic entity| admin[vg-entity-admin]
17
+ main -->|ux view custom| custom[vg-view-zone-name]
18
+ main -->|settings| settings[vg-settings]
19
+ ```
20
+
21
+ ## Behaviour
22
+
23
+ - **Navigation**: `openEntity(canon, detailId?)` mounts the right
24
+ component for the entity (`Model.customViewTag(canon)` or the generic
25
+ admin), sets props (`canon`, `projectId`, `detailId`, `onNavigate`)
26
+ and calls `reload()`.
27
+ - **Project context**: the project `select` (or opening a project's
28
+ detail) sets `currentProjectId`; project-scoped entities re-list when
29
+ it changes.
30
+ - **Theme**: the user menu shows a mode toggle when the model declares
31
+ more than one theme mode (`theme.js`).
32
+
33
+ ## Messages
34
+
35
+ | Message | Direction | Purpose |
36
+ |---|---|---|
37
+ | `cmp:auth,get:state` | post | Current user for the user menu |
38
+ | `cmp:auth,signout:user` | post | Sign out |
39
+ | `aim:ent,cmd:list` (via `api.js`) | post | Project list for the selector |
40
+ | event `projects-changed` | sub | Refresh the project selector (guarded with `isConnected`) |
41
+
42
+ ## Customisation
43
+
44
+ | Hook point | Kind | Effect |
45
+ |---|---|---|
46
+ | `shell:topbar:right` | html | Markup before the user menu |
47
+ | `shell:sidebar:top` | html | Markup above the entity filter |
48
+ | `shell:nav:items` | filter | Reorder/filter/relabel the entity menu |
49
+
@@ -0,0 +1,47 @@
1
+ // CUSTOM VIEW for $$canon$$ (the model declares ux:{ view: 'custom' } for
2
+ // this entity). This file is HAND-CODED and CREATE-ONCE — regeneration will
3
+ // NOT overwrite it, so edit it freely. It replaces the generic entity admin
4
+ // for this entity.
5
+ //
6
+ // The app shell sets these properties before calling reload():
7
+ // - canon : this entity's canon ('$$canon$$')
8
+ // - projectId : the currently selected project (if project-scoped)
9
+ // - detailId : a specific row id when opened from elsewhere, else null
10
+ // - onNavigate(canon, id) : ask the shell to open another entity/detail
11
+ // Build your UI with the generic helpers in ../../api.js and ../../model.js.
12
+
13
+ import * as Api from '../../api.js'
14
+ import * as Model from '../../model.js'
15
+
16
+
17
+ class $$className$$ extends HTMLElement {
18
+ navigate(canon, id) {
19
+ if (this.onNavigate) {
20
+ this.onNavigate(canon, id)
21
+ }
22
+ }
23
+
24
+ async reload() {
25
+ const canon = '$$canon$$'
26
+ const q = {}
27
+ const pf = Model.projectRefField(canon)
28
+ if (pf && this.projectId) {
29
+ q[pf] = this.projectId
30
+ }
31
+ const items = await Api.list(canon, q)
32
+ const labelField = Model.labelField(canon)
33
+
34
+ // Starter UI: a simple list. Replace this with your custom view.
35
+ this.innerHTML = `
36
+ <div class="vg-entity">
37
+ <div class="vg-entity-head"><h2>$$Label$$</h2></div>
38
+ <ul>${items.map((it) =>
39
+ `<li>${String(null == it[labelField] ? it.id : it[labelField])}</li>`).join('')
40
+ || '<li class="vg-muted">Nothing yet.</li>'}</ul>
41
+ <p class="vg-hint">Hand-coded custom view — edit
42
+ web/src/cmp/view/$$zone$$_$$name$$.js to build it out.</p>
43
+ </div>`
44
+ }
45
+ }
46
+
47
+ customElements.define('$$tag$$', $$className$$)
@@ -0,0 +1,31 @@
1
+ # Component: $$tag$$ (`cmp/view/$$zone$$_$$name$$.js`)
2
+
3
+ Hand-coded custom view for the `$$canon$$` entity — mounted by the shell
4
+ INSTEAD of the generic `vg-entity-admin`, because the model declares
5
+ `ux: { view: 'custom' }` on this entity. The component file is
6
+ create-once and developer-owned; document your design decisions here.
7
+
8
+ ## Contract
9
+
10
+ ```mermaid
11
+ flowchart LR
12
+ shell[vg-shell] -->|props: canon, projectId, detailId, onNavigate| view[$$tag$$]
13
+ shell -->|method| reload[view.reload]
14
+ view -->|Api.list / load / save / remove| ent[aim:ent messages]
15
+ view -->|onNavigate canon,id| shell
16
+ ```
17
+
18
+ | Member | Meaning |
19
+ |---|---|
20
+ | `canon` | `'$$canon$$'` |
21
+ | `projectId` | Current project id when project-scoped, else `null` |
22
+ | `detailId` | Entity id to open in detail, or `null` |
23
+ | `onNavigate(canon, id)` | Navigate elsewhere in the app |
24
+ | `reload()` | Re-fetch and re-render |
25
+
26
+ ## Data
27
+
28
+ Use `Api` (`../../api.js`) — data flows through the generic
29
+ `aim:ent,cmd:*` messages, so membership scoping and validation apply
30
+ unchanged. Use `Model` (`../../model.js`) for labels, fields, and the
31
+ relationship graph.
@@ -0,0 +1,8 @@
1
+ /* Project CSS customisation (CREATE-ONCE — never overwritten). Override the
2
+ theme variables (defined in style.css) to restyle the whole app, and add
3
+ any rules you like. */
4
+
5
+ :root {
6
+ /* e.g. --vg-primary: #7c3aed; --vg-topbar-bg: #111827; */
7
+ }
8
+
@@ -0,0 +1,30 @@
1
+ // Project customisations (CREATE-ONCE — never overwritten by regeneration).
2
+ // Your entry point for tailoring the generated app WITHOUT editing the
3
+ // generated components. Register HTML / filter / action hooks here, and put
4
+ // any CSS in ./custom.css (imported below).
5
+ //
6
+ // See src/hooks.js for the API. Hook points exposed by the components include:
7
+ // shell:topbar:right, shell:sidebar:top, shell:nav:items
8
+ // admin:list:toolbar, admin:list:items, admin:list:columns, admin:row:actions,
9
+ // admin:list:after, admin:form:fields, admin:form:extra, admin:form:after,
10
+ // admin:save:data, admin:save:after
11
+ // public:sections, auth:form:footer, settings:sections
12
+
13
+ import './custom.css'
14
+ import * as Hooks from './hooks.js'
15
+
16
+
17
+ // Examples (uncomment and adapt to your model):
18
+ //
19
+ // Hooks.addHtml('shell:topbar:right', () => '<span class="vg-badge">Beta</span>')
20
+ //
21
+ // Hooks.addFilter('admin:list:items', (items, { canon }) =>
22
+ // 'my/entity' === canon ? items.slice().reverse() : items)
23
+ //
24
+ // Hooks.addHtml('admin:row:actions', ({ canon, item }) =>
25
+ // 'my/entity' === canon ? `<button data-do="${item.id}">Do</button>` : '')
26
+ //
27
+ // Hooks.addAction('admin:list:after', ({ root, canon }) => {
28
+ // // wire up any custom markup you injected above
29
+ // })
30
+
@@ -0,0 +1,79 @@
1
+ // Frontend customisation hooks. The backend lets you override ACTIONS with
2
+ // Seneca message priors; this is the analogue for the generated components —
3
+ // hooks for HTML, JS behaviour, and (via CSS variables + custom.css) styling.
4
+ //
5
+ // Register hooks from customise.js (a create-once file, so your changes are
6
+ // never overwritten). Every generated component exposes named hook points; a
7
+ // point is `<component>:<region>` (e.g. 'admin:row:actions', 'shell:topbar:right').
8
+ //
9
+ // Three kinds of hook:
10
+ // addHtml(point, ctx => 'markup') inject/append HTML at a region
11
+ // addFilter(point, (value, ctx) => value) transform a value (items, fields, actions)
12
+ // addAction(point, ctx => {...}) run side effects at a lifecycle point
13
+ //
14
+ // Components call html()/filter()/action() at their hook points. All are
15
+ // synchronous and failure-isolated (a throwing hook can't break a render).
16
+
17
+ const _html = {}
18
+ const _filter = {}
19
+ const _action = {}
20
+
21
+ function addHtml(point, fn) {
22
+ (_html[point] = _html[point] || []).push(fn)
23
+ }
24
+
25
+ function addFilter(point, fn) {
26
+ (_filter[point] = _filter[point] || []).push(fn)
27
+ }
28
+
29
+ function addAction(point, fn) {
30
+ (_action[point] = _action[point] || []).push(fn)
31
+ }
32
+
33
+ // Concatenate the HTML contributed by every hook at `point`.
34
+ function html(point, ctx) {
35
+ return (_html[point] || []).map((fn) => {
36
+ try {
37
+ return fn(ctx) || ''
38
+ }
39
+ catch (e) {
40
+ console.error('hook html failed:', point, e)
41
+ return ''
42
+ }
43
+ }).join('')
44
+ }
45
+
46
+ // Run `value` through every filter at `point`, in registration order.
47
+ function filter(point, value, ctx) {
48
+ return (_filter[point] || []).reduce((v, fn) => {
49
+ try {
50
+ return fn(v, ctx)
51
+ }
52
+ catch (e) {
53
+ console.error('hook filter failed:', point, e)
54
+ return v
55
+ }
56
+ }, value)
57
+ }
58
+
59
+ // Fire every action hook at `point` (side effects only).
60
+ function action(point, ctx) {
61
+ for (const fn of (_action[point] || [])) {
62
+ try {
63
+ fn(ctx)
64
+ }
65
+ catch (e) {
66
+ console.error('hook action failed:', point, e)
67
+ }
68
+ }
69
+ }
70
+
71
+ export {
72
+ addHtml,
73
+ addFilter,
74
+ addAction,
75
+ html,
76
+ filter,
77
+ action,
78
+ }
79
+
@@ -1,10 +1,23 @@
1
- // Todo SPA entry: web components on a Seneca service bus (experimental).
2
- // No framework: each component is a custom element backed by a seneca
3
- // plugin (state), and all data flows are bus messages - aim:* messages
4
- // travel to the backend gateway via the seneca-browser fetch transport.
1
+ // Todo SPA entry: web components on a Seneca service bus. No framework —
2
+ // each component is a custom element; all data flows are bus messages, with
3
+ // aim:* travelling to the backend gateway via the seneca-browser transport.
4
+ // The UI is model-driven (see model.js): navigation, forms and entity
5
+ // relationships are generated from /model.json.
5
6
 
7
+ import './theme.css'
6
8
  import './style.css'
9
+ import './theme.js'
7
10
  import './bus.js'
8
11
  import './cmp/auth.js'
12
+ import './cmp/public.js'
9
13
  import './cmp/admin.js'
14
+ import './cmp/settings.js'
15
+ import './cmp/shell.js'
10
16
  import './cmp/app.js'
17
+
18
+ // Custom entity views (ux:{view:'custom'}) — generated index of hand-coded views.
19
+ import './views.js'
20
+
21
+ // Project customisations: hook registrations + custom.css (create-once).
22
+ import './customise.js'
23
+
@@ -0,0 +1,162 @@
1
+ // Client-side model helpers. The compiled model (/model.json) drives the
2
+ // entire UI: the entity menu, forms, and — via reference fields
3
+ // (kind:String + a `ref` target canon) — the relationship graph. Nothing
4
+ // here is entity-specific, so it works for any model.
5
+
6
+ let _model = null
7
+ let _loading = null
8
+
9
+ const SYS_FIELDS = ['id', 'owner_id', 't_c', 't_m', 't_mh', 't_ch']
10
+
11
+ async function loadModel() {
12
+ if (_model) {
13
+ return _model
14
+ }
15
+ if (!_loading) {
16
+ _loading = fetch('/model.json').then((r) => r.json()).then((m) => {
17
+ _model = m
18
+ return m
19
+ })
20
+ }
21
+ return _loading
22
+ }
23
+
24
+ function ent() {
25
+ return (_model && _model.main && _model.main.ent) || {}
26
+ }
27
+
28
+ function titleize(s) {
29
+ return String(s).charAt(0).toUpperCase() + String(s).slice(1).replace(/_/g, ' ')
30
+ }
31
+
32
+ // Browsable entities (the sys zone is internal — users are only reached via
33
+ // reference pickers, never the entity menu).
34
+ function entities() {
35
+ const out = []
36
+ const e = ent()
37
+ for (const zone of Object.keys(e)) {
38
+ if ('sys' === zone) {
39
+ continue
40
+ }
41
+ for (const name of Object.keys(e[zone])) {
42
+ // ux:{hide:true} keeps an entity out of the navigation entirely.
43
+ if (e[zone][name] && e[zone][name].ux && e[zone][name].ux.hide) {
44
+ continue
45
+ }
46
+ out.push({
47
+ zone,
48
+ name,
49
+ canon: zone + '/' + name,
50
+ label: titleize(name),
51
+ fields: e[zone][name].field || {},
52
+ })
53
+ }
54
+ }
55
+ return out
56
+ }
57
+
58
+ function fieldsOf(canon) {
59
+ const [z, n] = String(canon).split('/')
60
+ return (ent()[z] && ent()[z][n] && ent()[z][n].field) || {}
61
+ }
62
+
63
+ function labelOf(canon) {
64
+ return titleize(String(canon).split('/')[1] || canon)
65
+ }
66
+
67
+ // Reference fields on an entity: [{ field, target, label }].
68
+ function refsOf(canon) {
69
+ const f = fieldsOf(canon)
70
+ return Object.keys(f)
71
+ .filter((k) => f[k] && f[k].ref)
72
+ .map((k) => ({ field: k, target: f[k].ref, label: f[k].label || titleize(k) }))
73
+ }
74
+
75
+ // Entities that reference `canon` (inverse / has-many): [{ canon, field, label }].
76
+ function inverseRefs(canon) {
77
+ const out = []
78
+ const e = ent()
79
+ for (const zone of Object.keys(e)) {
80
+ if ('sys' === zone) {
81
+ continue
82
+ }
83
+ for (const name of Object.keys(e[zone])) {
84
+ const fc = zone + '/' + name
85
+ const f = e[zone][name].field || {}
86
+ for (const k of Object.keys(f)) {
87
+ if (f[k] && f[k].ref === canon) {
88
+ out.push({ canon: fc, field: k, label: titleize(name) })
89
+ }
90
+ }
91
+ }
92
+ }
93
+ return out
94
+ }
95
+
96
+ // The reference field pointing at proj/project (project scoping), or null.
97
+ function projectRefField(canon) {
98
+ const f = fieldsOf(canon)
99
+ for (const k of Object.keys(f)) {
100
+ if (f[k] && 'proj/project' === f[k].ref) {
101
+ return k
102
+ }
103
+ }
104
+ return null
105
+ }
106
+
107
+ function isProjectScoped(canon) {
108
+ return 'proj/project' === canon || null != projectRefField(canon)
109
+ }
110
+
111
+ // The field to show as an entity's human label (name/title, else first
112
+ // plain string field, else id).
113
+ function labelField(canon) {
114
+ const f = fieldsOf(canon)
115
+ if (f.name) {
116
+ return 'name'
117
+ }
118
+ if (f.title) {
119
+ return 'title'
120
+ }
121
+ for (const k of Object.keys(f)) {
122
+ if (SYS_FIELDS.indexOf(k) < 0 && !f[k].ref && 'String' === f[k].kind) {
123
+ return k
124
+ }
125
+ }
126
+ return 'id'
127
+ }
128
+
129
+ // Visible (editable/listed) fields: exclude system bookkeeping fields.
130
+ function displayFields(canon) {
131
+ const f = fieldsOf(canon)
132
+ return Object.keys(f).filter((k) => SYS_FIELDS.indexOf(k) < 0)
133
+ }
134
+
135
+ // Custom view: an entity may declare ux:{view:'custom'} to replace the
136
+ // generic entity admin with a hand-coded component. Returns that component's
137
+ // custom-element tag (vg-view-<zone>-<name>) or null for the generic admin.
138
+ function customViewTag(canon) {
139
+ const [z, n] = String(canon).split('/')
140
+ const def = ent()[z] && ent()[z][n]
141
+ if (def && def.ux && 'custom' === def.ux.view) {
142
+ return 'vg-view-' + z + '-' + n
143
+ }
144
+ return null
145
+ }
146
+
147
+ export {
148
+ loadModel,
149
+ entities,
150
+ fieldsOf,
151
+ refsOf,
152
+ inverseRefs,
153
+ projectRefField,
154
+ isProjectScoped,
155
+ labelField,
156
+ labelOf,
157
+ displayFields,
158
+ customViewTag,
159
+ titleize,
160
+ SYS_FIELDS,
161
+ }
162
+