@voxgig/build 4.4.0 → 4.12.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/README.md +51 -2
- package/build.ts +20 -0
- package/dist/api/api_gen.d.ts +7 -0
- package/dist/api/api_gen.js +370 -0
- package/dist/api/api_gen.js.map +1 -0
- package/dist/build.d.ts +16 -1
- package/dist/build.js +17 -1
- package/dist/build.js.map +1 -1
- package/dist/doc/doc_gen.d.ts +8 -0
- package/dist/doc/doc_gen.js +397 -0
- package/dist/doc/doc_gen.js.map +1 -0
- package/dist/env/web/web_gen.js +197 -11
- package/dist/env/web/web_gen.js.map +1 -1
- package/dist/shape/ent.js +4 -2
- package/dist/shape/ent.js.map +1 -1
- package/env/web/web_gen.ts +219 -13
- package/package.json +6 -9
- package/tm/web/backend/env/shared/seed.ts.frag +23 -0
- package/tm/web/backend/env/web/api.ts.frag +157 -0
- package/tm/web/backend/env/web/web.ts.frag +43 -8
- package/tm/web/backend/srv/api/api-srv.ts.frag +4 -0
- package/tm/web/backend/srv/api/expose.ts.frag +33 -0
- package/tm/web/backend/srv/api/get_info.ts.frag +6 -0
- package/tm/web/backend/srv/api/on_ent.ts.frag +124 -0
- package/tm/web/backend/srv/auth/apikey_util.ts.frag +10 -0
- package/tm/web/backend/srv/auth/auth-srv.ts.frag +1 -0
- package/tm/web/backend/srv/auth/change_pass.ts.frag +12 -0
- package/tm/web/backend/srv/auth/create_apikey.ts.frag +33 -0
- package/tm/web/backend/srv/auth/get_info.ts.frag +1 -0
- package/tm/web/backend/srv/auth/list_apikey.ts.frag +15 -0
- package/tm/web/backend/srv/auth/load_auth.ts.frag +10 -1
- package/tm/web/backend/srv/auth/remind_pass.ts.frag +35 -0
- package/tm/web/backend/srv/auth/revoke_apikey.ts.frag +23 -0
- package/tm/web/backend/srv/auth/signin_user.ts.frag +12 -2
- package/tm/web/backend/srv/auth/signout_user.ts.frag +11 -1
- package/tm/web/backend/srv/auth/update_user.ts.frag +15 -0
- package/tm/web/backend/srv/auth/user_util.ts.frag +21 -0
- package/tm/web/backend/srv/auth/web_change_pass.ts.frag +16 -0
- package/tm/web/backend/srv/auth/web_create_apikey.ts.frag +15 -0
- package/tm/web/backend/srv/auth/web_list_apikey.ts.frag +11 -0
- package/tm/web/backend/srv/auth/web_load_auth.ts.frag +1 -0
- package/tm/web/backend/srv/auth/web_remind_pass.ts.frag +7 -0
- package/tm/web/backend/srv/auth/web_revoke_apikey.ts.frag +14 -0
- package/tm/web/backend/srv/auth/web_signin_user.ts.frag +1 -0
- package/tm/web/backend/srv/auth/web_signout_user.ts.frag +11 -3
- package/tm/web/backend/srv/auth/web_update_user.ts.frag +11 -0
- package/tm/web/backend/srv/ent/access.ts.frag +179 -0
- package/tm/web/backend/srv/ent/cmd_list.ts.frag +86 -0
- package/tm/web/backend/srv/ent/cmd_load.ts.frag +56 -0
- package/tm/web/backend/srv/ent/cmd_remove.ts.frag +60 -0
- package/tm/web/backend/srv/ent/cmd_save.ts.frag +141 -0
- package/tm/web/backend/srv/ent/ent-srv.ts.frag +4 -0
- package/tm/web/backend/srv/ent/get_info.ts.frag +6 -0
- package/tm/web/backend/srv/ent/web_cmd_list.ts.frag +17 -0
- package/tm/web/backend/srv/ent/web_cmd_load.ts.frag +17 -0
- package/tm/web/backend/srv/ent/web_cmd_remove.ts.frag +17 -0
- package/tm/web/backend/srv/ent/web_cmd_save.ts.frag +17 -0
- package/tm/web/backend/test/unit/env/web/api-router.test.ts.frag +315 -0
- package/tm/web/backend/test/unit/env/web/surface.test.ts.frag +109 -0
- package/tm/web/backend/test/unit/srv/api/api.setup.ts.frag +162 -0
- package/tm/web/backend/test/unit/srv/api/api.test.ts.frag +225 -0
- package/tm/web/backend/test/unit/srv/auth/apikey.test.ts.frag +152 -0
- package/tm/web/backend/test/unit/srv/auth/auth.setup.ts.frag +40 -0
- package/tm/web/backend/test/unit/srv/auth/session.test.ts.frag +189 -0
- package/tm/web/backend/test/unit/srv/ent/ent.setup.ts.frag +53 -0
- package/tm/web/backend/test/unit/srv/ent/proxy.test.ts.frag +109 -0
- package/tm/web/docs/explanation/web-architecture.md.frag +50 -0
- package/tm/web/docs/how-to/add-a-custom-entity-view.md.frag +53 -0
- package/tm/web/docs/how-to/change-the-theme.md.frag +63 -0
- package/tm/web/docs/how-to/customise-the-web-app.md.frag +68 -0
- package/tm/web/docs/how-to/use-the-api.md.frag +53 -0
- package/tm/web/docs/reference/web-app.md.frag +98 -0
- package/tm/web/web/AGENTS.md.frag +52 -0
- package/tm/web/web/e2e/smoke.spec.js.frag +13 -11
- package/tm/web/web/index.html.frag +1 -0
- package/tm/web/web/package.json.frag +1 -0
- package/tm/web/web/playwright.config.js.frag +2 -1
- package/tm/web/web/src/api.js.frag +92 -0
- package/tm/web/web/src/bus.js.frag +14 -8
- package/tm/web/web/src/cmp/admin.js.frag +293 -212
- package/tm/web/web/src/cmp/admin.md.frag +51 -0
- package/tm/web/web/src/cmp/app.js.frag +12 -15
- package/tm/web/web/src/cmp/app.md.frag +28 -0
- package/tm/web/web/src/cmp/auth.js.frag +69 -41
- package/tm/web/web/src/cmp/auth.md.frag +36 -0
- package/tm/web/web/src/cmp/public.js.frag +58 -0
- package/tm/web/web/src/cmp/public.md.frag +26 -0
- package/tm/web/web/src/cmp/settings.js.frag +178 -0
- package/tm/web/web/src/cmp/settings.md.frag +30 -0
- package/tm/web/web/src/cmp/shell.js.frag +231 -0
- package/tm/web/web/src/cmp/shell.md.frag +49 -0
- package/tm/web/web/src/cmp/view/custom-view.js.frag +47 -0
- package/tm/web/web/src/cmp/view/custom-view.md.frag +31 -0
- package/tm/web/web/src/custom.css.frag +8 -0
- package/tm/web/web/src/customise.js.frag +30 -0
- package/tm/web/web/src/hooks.js.frag +79 -0
- package/tm/web/web/src/main.js.frag +17 -4
- package/tm/web/web/src/model.js.frag +162 -0
- package/tm/web/web/src/style.css.frag +135 -21
- 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 & 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
|
+
({ '&': '&', '<': '<', '>': '>', '"': '"' }[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
|
+
$$fe$$/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,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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
+
|