@voxgig/build 4.3.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.
- 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 +159 -1
- 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 +174 -1
- 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 +37 -4
- 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 +3 -0
- package/tm/web/web/package.json.frag +4 -1
- package/tm/web/web/playwright.config.js.frag +6 -1
- package/tm/web/web/src/api.js.frag +92 -0
- package/tm/web/web/src/bus.js.frag +46 -23
- package/tm/web/web/src/cmp/admin.js.frag +292 -193
- 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
|
@@ -1,254 +1,353 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
1
|
+
// The generic entity admin: list / detail / form for ANY entity in the
|
|
2
|
+
// model, driven entirely by /model.json. Reference fields (a `ref` target
|
|
3
|
+
// canon) render as pickers in forms and clickable links in lists; a detail
|
|
4
|
+
// view shows an entity plus inline lists of everything that references it
|
|
5
|
+
// (inverse relationships), so you can navigate the whole graph.
|
|
6
|
+
//
|
|
7
|
+
// Backed by the ONE generic backend service (aim:ent,cmd:*) via api.js.
|
|
8
|
+
//
|
|
9
|
+
// Properties set by the shell: canon, projectId, detailId, onNavigate(canon,id).
|
|
10
|
+
|
|
11
|
+
import { emit } from '../bus.js'
|
|
12
|
+
import * as Model from '../model.js'
|
|
13
|
+
import * as Api from '../api.js'
|
|
14
|
+
import * as Hooks from '../hooks.js'
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
function esc(s) {
|
|
18
|
+
return String(null == s ? '' : s).replace(/[&<>"]/g, (c) =>
|
|
19
|
+
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))
|
|
20
|
+
}
|
|
16
21
|
|
|
17
|
-
let model = null
|
|
18
22
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
for (const name of Object.keys(ent[zone])) {
|
|
28
|
-
out.push({
|
|
29
|
-
zone,
|
|
30
|
-
name,
|
|
31
|
-
canon: zone + '/' + name,
|
|
32
|
-
fields: ent[zone][name].field || {},
|
|
33
|
-
})
|
|
34
|
-
}
|
|
23
|
+
class VgEntityAdmin extends HTMLElement {
|
|
24
|
+
reload() {
|
|
25
|
+
if (this.detailId) {
|
|
26
|
+
this.showDetail(this.detailId)
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
this.showList()
|
|
35
30
|
}
|
|
36
|
-
return out
|
|
37
31
|
}
|
|
38
32
|
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
33
|
+
// Render token: async render methods capture it up-front and only commit
|
|
34
|
+
// to the DOM if they are still the latest, so a slow/stale render can't
|
|
35
|
+
// overwrite a newer one (fixes overlapping list/detail/form renders).
|
|
36
|
+
begin() {
|
|
37
|
+
return (this._tok = (this._tok || 0) + 1)
|
|
43
38
|
}
|
|
44
39
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return reply({ ok: true, entities: entities() })
|
|
48
|
-
}
|
|
49
|
-
fetch('/model.json').then((r) => r.json()).then((m) => {
|
|
50
|
-
model = m
|
|
51
|
-
reply({ ok: true, entities: entities() })
|
|
52
|
-
}).catch((e) => reply({ ok: false, why: String(e) }))
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
seneca.add('cmp:entity_admin,list:item', function (msg, reply) {
|
|
56
|
-
this.act(msgFor(msg.canon, 'list'), function (err, out) {
|
|
57
|
-
reply(err ? { ok: false, why: err.message } : out)
|
|
58
|
-
})
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
seneca.add('cmp:entity_admin,save:item', function (msg, reply) {
|
|
62
|
-
this.act({ ...msgFor(msg.canon, 'save'), item: msg.item },
|
|
63
|
-
function (err, out) {
|
|
64
|
-
if (!err && out.ok) {
|
|
65
|
-
emit('entity', { canon: msg.canon })
|
|
66
|
-
}
|
|
67
|
-
reply(err ? { ok: false, why: err.message } : out)
|
|
68
|
-
})
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
seneca.add('cmp:entity_admin,remove:item', function (msg, reply) {
|
|
72
|
-
this.act({ ...msgFor(msg.canon, 'remove'), id: msg.id },
|
|
73
|
-
function (err, out) {
|
|
74
|
-
if (!err && out.ok) {
|
|
75
|
-
emit('entity', { canon: msg.canon })
|
|
76
|
-
}
|
|
77
|
-
reply(err ? { ok: false, why: err.message } : out)
|
|
78
|
-
})
|
|
79
|
-
})
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
function inputFor(fname, fdef, value) {
|
|
84
|
-
const kind = (fdef && fdef.kind) || 'String'
|
|
85
|
-
const val = null == value ? '' : value
|
|
86
|
-
if ('Boolean' === kind) {
|
|
87
|
-
return `<input name="${fname}" type="checkbox" ${true === value ? 'checked' : ''} />`
|
|
40
|
+
current(tok) {
|
|
41
|
+
return tok === this._tok
|
|
88
42
|
}
|
|
89
|
-
if ('Number' === kind) {
|
|
90
|
-
return `<input name="${fname}" type="number" value="${val}" />`
|
|
91
|
-
}
|
|
92
|
-
return `<input name="${fname}" type="text" value="${String(val).replace(/"/g, '"')}" />`
|
|
93
|
-
}
|
|
94
43
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
this.canon = null
|
|
100
|
-
this.editing = null // item being edited, or {} for new
|
|
44
|
+
navigate(canon, id) {
|
|
45
|
+
if (this.onNavigate) {
|
|
46
|
+
this.onNavigate(canon, id)
|
|
47
|
+
}
|
|
101
48
|
}
|
|
102
49
|
|
|
103
|
-
|
|
104
|
-
|
|
50
|
+
// Map each reference field to { targetCanon, labels:{id->label} } so refs
|
|
51
|
+
// render as human labels/links instead of raw ids.
|
|
52
|
+
async refMaps(canon) {
|
|
53
|
+
const maps = {}
|
|
54
|
+
for (const r of Model.refsOf(canon)) {
|
|
55
|
+
const rows = await Api.list(r.target)
|
|
56
|
+
const lf = Model.labelField(r.target)
|
|
57
|
+
const labels = {}
|
|
58
|
+
for (const row of rows) {
|
|
59
|
+
labels[row.id] = row[lf]
|
|
60
|
+
}
|
|
61
|
+
maps[r.field] = { target: r.target, labels }
|
|
62
|
+
}
|
|
63
|
+
return maps
|
|
105
64
|
}
|
|
106
65
|
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
66
|
+
cell(canon, field, value, maps) {
|
|
67
|
+
const fdef = Model.fieldsOf(canon)[field] || {}
|
|
68
|
+
if (fdef.ref) {
|
|
69
|
+
if (null == value) {
|
|
70
|
+
return '<span class="vg-muted">—</span>'
|
|
71
|
+
}
|
|
72
|
+
const m = maps[field] || { labels: {} }
|
|
73
|
+
const label = m.labels[value] || value
|
|
74
|
+
return `<a href="#" class="vg-ref" data-canon="${fdef.ref}" data-id="${esc(value)}">${esc(label)}</a>`
|
|
112
75
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
this.canon = res.entities[0].canon
|
|
76
|
+
if ('Boolean' === fdef.kind) {
|
|
77
|
+
return value ? '✓' : '<span class="vg-muted">✗</span>'
|
|
116
78
|
}
|
|
79
|
+
return esc(value)
|
|
80
|
+
}
|
|
117
81
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
data-canon="${e.canon}">${e.canon}</a>`).join(' ')
|
|
121
|
-
|
|
122
|
-
this.innerHTML = `
|
|
123
|
-
<div class="vg-admin">
|
|
124
|
-
<nav>${nav}</nav>
|
|
125
|
-
<div id="vg-list"></div>
|
|
126
|
-
<div id="vg-form"></div>
|
|
127
|
-
</div>`
|
|
128
|
-
|
|
129
|
-
for (const a of this.querySelectorAll('.vg-nav')) {
|
|
82
|
+
wireRefLinks(root) {
|
|
83
|
+
for (const a of root.querySelectorAll('.vg-ref')) {
|
|
130
84
|
a.onclick = (ev) => {
|
|
131
85
|
ev.preventDefault()
|
|
132
|
-
this.canon
|
|
133
|
-
this.editing = null
|
|
134
|
-
this.render()
|
|
86
|
+
this.navigate(a.dataset.canon, a.dataset.id)
|
|
135
87
|
}
|
|
136
88
|
}
|
|
137
|
-
|
|
138
|
-
await this.renderList()
|
|
139
|
-
this.renderForm()
|
|
140
89
|
}
|
|
141
90
|
|
|
142
|
-
|
|
143
|
-
return this.entities.find((e) => e.canon === this.canon)
|
|
144
|
-
}
|
|
91
|
+
// ---- list ----
|
|
145
92
|
|
|
146
|
-
async
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
.
|
|
93
|
+
async showList() {
|
|
94
|
+
const tok = this.begin()
|
|
95
|
+
const canon = this.canon
|
|
96
|
+
const pf = Model.projectRefField(canon)
|
|
97
|
+
if ('proj/project' !== canon && pf && !this.projectId) {
|
|
98
|
+
this.innerHTML = `<div class="vg-empty">Select or create a project to manage ${esc(Model.labelOf(canon))}.</div>`
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const q = {}
|
|
103
|
+
if (pf && this.projectId) {
|
|
104
|
+
q[pf] = this.projectId
|
|
105
|
+
}
|
|
106
|
+
let [items, maps] = await Promise.all([Api.list(canon, q), this.refMaps(canon)])
|
|
107
|
+
if (!this.current(tok)) {
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
// Hook: transform the item set (sort/filter/augment) and the columns.
|
|
111
|
+
items = Hooks.filter('admin:list:items', items, { canon })
|
|
112
|
+
const fields = Hooks.filter('admin:list:columns',
|
|
113
|
+
Model.displayFields(canon).filter((f) => f !== pf), { canon })
|
|
114
|
+
const labelName = Model.labelOf(canon)
|
|
152
115
|
|
|
153
|
-
const head = fields.map((f) => `<th>${f}</th>`).join('')
|
|
154
116
|
const rows = items.map((item) => `
|
|
155
|
-
<tr
|
|
156
|
-
${fields.map((f) => `<td>${
|
|
157
|
-
<td>
|
|
158
|
-
|
|
159
|
-
<button class="vg-
|
|
117
|
+
<tr>
|
|
118
|
+
${fields.map((f) => `<td>${this.cell(canon, f, item[f], maps)}</td>`).join('')}
|
|
119
|
+
<td class="vg-actions">
|
|
120
|
+
${Hooks.html('admin:row:actions', { canon, item })}
|
|
121
|
+
<button class="vg-open" data-id="${item.id}">Open</button>
|
|
122
|
+
<button class="vg-edit" data-id="${item.id}">Edit</button>
|
|
123
|
+
<button class="vg-del" data-id="${item.id}">Delete</button>
|
|
160
124
|
</td>
|
|
161
125
|
</tr>`).join('')
|
|
162
126
|
|
|
163
|
-
this.
|
|
164
|
-
<
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
127
|
+
this.innerHTML = `
|
|
128
|
+
<div class="vg-entity">
|
|
129
|
+
<div class="vg-entity-head">
|
|
130
|
+
<h2>${esc(labelName)}</h2>
|
|
131
|
+
${Hooks.html('admin:list:toolbar', { canon })}
|
|
132
|
+
<button class="vg-primary" id="vg-new">New ${esc(labelName)}</button>
|
|
133
|
+
</div>
|
|
134
|
+
<table class="vg-table">
|
|
135
|
+
<thead><tr>${fields.map((f) => `<th>${esc(Model.titleize(f))}</th>`).join('')}<th></th></tr></thead>
|
|
136
|
+
<tbody>${rows || `<tr><td colspan="${fields.length + 1}" class="vg-muted">No ${esc(labelName)} yet.</td></tr>`}</tbody>
|
|
137
|
+
</table>
|
|
138
|
+
<p id="vg-count" class="vg-muted">${items.length} item${1 === items.length ? '' : 's'}</p>
|
|
139
|
+
</div>`
|
|
170
140
|
|
|
171
|
-
this.
|
|
172
|
-
this.querySelector('#vg-new').onclick = () =>
|
|
173
|
-
|
|
174
|
-
|
|
141
|
+
this.wireRefLinks(this)
|
|
142
|
+
this.querySelector('#vg-new').onclick = () => this.showForm(null)
|
|
143
|
+
for (const b of this.querySelectorAll('.vg-open')) {
|
|
144
|
+
// Route through the shell so it can update project context.
|
|
145
|
+
b.onclick = () => this.navigate(canon, b.dataset.id)
|
|
175
146
|
}
|
|
176
147
|
for (const b of this.querySelectorAll('.vg-edit')) {
|
|
177
|
-
b.onclick = () =>
|
|
178
|
-
this.editing = this.items.find((i) => i.id === b.dataset.id)
|
|
179
|
-
this.renderForm()
|
|
180
|
-
}
|
|
148
|
+
b.onclick = () => this.showForm(b.dataset.id)
|
|
181
149
|
}
|
|
182
150
|
for (const b of this.querySelectorAll('.vg-del')) {
|
|
183
151
|
b.onclick = async () => {
|
|
184
|
-
await
|
|
185
|
-
|
|
186
|
-
this.
|
|
187
|
-
await this.renderList()
|
|
188
|
-
this.renderForm()
|
|
152
|
+
await Api.remove(canon, b.dataset.id)
|
|
153
|
+
this.afterMutation()
|
|
154
|
+
this.showList()
|
|
189
155
|
}
|
|
190
156
|
}
|
|
157
|
+
// Hook: wire up any custom markup injected by admin:row:actions /
|
|
158
|
+
// admin:list:toolbar (the root element + rendered items are provided).
|
|
159
|
+
Hooks.action('admin:list:after', { root: this, canon, items })
|
|
191
160
|
}
|
|
192
161
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
162
|
+
// ---- detail (relationship navigation) ----
|
|
163
|
+
|
|
164
|
+
async showDetail(id) {
|
|
165
|
+
const tok = this.begin()
|
|
166
|
+
const canon = this.canon
|
|
167
|
+
const item = await Api.load(canon, id)
|
|
168
|
+
if (!item) {
|
|
169
|
+
this.innerHTML = `<div class="vg-empty">Not found.</div>`
|
|
197
170
|
return
|
|
198
171
|
}
|
|
172
|
+
const maps = await this.refMaps(canon)
|
|
173
|
+
const fields = Model.displayFields(canon)
|
|
174
|
+
const label = item[Model.labelField(canon)] || id
|
|
175
|
+
|
|
176
|
+
const rowsHtml = fields.map((f) => `
|
|
177
|
+
<tr><th>${esc(Model.titleize(f))}</th><td>${this.cell(canon, f, item[f], maps)}</td></tr>`).join('')
|
|
178
|
+
|
|
179
|
+
// Inverse relationships: everything that references THIS entity.
|
|
180
|
+
const children = Model.inverseRefs(canon)
|
|
181
|
+
const childSections = []
|
|
182
|
+
for (const c of children) {
|
|
183
|
+
const kids = await Api.list(c.canon, { [c.field]: id })
|
|
184
|
+
const kmaps = await this.refMaps(c.canon)
|
|
185
|
+
const kfields = Model.displayFields(c.canon).filter((x) => x !== c.field)
|
|
186
|
+
childSections.push(`
|
|
187
|
+
<section class="vg-children" data-canon="${c.canon}" data-parent-field="${c.field}">
|
|
188
|
+
<div class="vg-entity-head">
|
|
189
|
+
<h3>${esc(c.label)}</h3>
|
|
190
|
+
<button class="vg-primary vg-child-new" data-canon="${c.canon}">New ${esc(c.label)}</button>
|
|
191
|
+
</div>
|
|
192
|
+
<table class="vg-table">
|
|
193
|
+
<thead><tr>${kfields.map((f) => `<th>${esc(Model.titleize(f))}</th>`).join('')}<th></th></tr></thead>
|
|
194
|
+
<tbody>${kids.map((k) => `
|
|
195
|
+
<tr>
|
|
196
|
+
${kfields.map((f) => `<td>${this.cell(c.canon, f, k[f], kmaps)}</td>`).join('')}
|
|
197
|
+
<td class="vg-actions">
|
|
198
|
+
<button class="vg-child-open" data-canon="${c.canon}" data-id="${k.id}">Open</button>
|
|
199
|
+
</td>
|
|
200
|
+
</tr>`).join('') || `<tr><td colspan="${kfields.length + 1}" class="vg-muted">None yet.</td></tr>`}
|
|
201
|
+
</tbody>
|
|
202
|
+
</table>
|
|
203
|
+
</section>`)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!this.current(tok)) {
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
this.innerHTML = `
|
|
210
|
+
<div class="vg-entity">
|
|
211
|
+
<div class="vg-entity-head">
|
|
212
|
+
<button class="vg-link" id="vg-back">‹ ${esc(Model.labelOf(canon))}</button>
|
|
213
|
+
<h2>${esc(label)}</h2>
|
|
214
|
+
<button class="vg-edit" id="vg-edit-detail" data-id="${id}">Edit</button>
|
|
215
|
+
</div>
|
|
216
|
+
<table class="vg-detail"><tbody>${rowsHtml}</tbody></table>
|
|
217
|
+
${childSections.join('')}
|
|
218
|
+
</div>`
|
|
219
|
+
|
|
220
|
+
this.wireRefLinks(this)
|
|
221
|
+
this.querySelector('#vg-back').onclick = () => { this.detailId = null; this.showList() }
|
|
222
|
+
this.querySelector('#vg-edit-detail').onclick = () => this.showForm(id)
|
|
223
|
+
for (const b of this.querySelectorAll('.vg-child-open')) {
|
|
224
|
+
b.onclick = () => this.navigate(b.dataset.canon, b.dataset.id)
|
|
225
|
+
}
|
|
226
|
+
for (const b of this.querySelectorAll('.vg-child-new')) {
|
|
227
|
+
b.onclick = () => this.showForm(null, { canon: b.dataset.canon, preset: this.presetFor(b.dataset.canon, canon, id) })
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Preset the parent reference (and inherited project) when creating a child.
|
|
232
|
+
presetFor(childCanon, parentCanon, parentId) {
|
|
233
|
+
const preset = {}
|
|
234
|
+
for (const r of Model.refsOf(childCanon)) {
|
|
235
|
+
if (r.target === parentCanon) {
|
|
236
|
+
preset[r.field] = parentId
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return preset
|
|
240
|
+
}
|
|
199
241
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
242
|
+
// ---- form ----
|
|
243
|
+
|
|
244
|
+
async showForm(id, childCtx) {
|
|
245
|
+
const tok = this.begin()
|
|
246
|
+
const canon = (childCtx && childCtx.canon) || this.canon
|
|
247
|
+
const preset = (childCtx && childCtx.preset) || {}
|
|
248
|
+
const item = id ? (await Api.load(canon, id)) || {} : Object.assign({}, preset)
|
|
249
|
+
const pf = Model.projectRefField(canon)
|
|
250
|
+
// Hook: transform the editable field list.
|
|
251
|
+
const fields = Hooks.filter('admin:form:fields',
|
|
252
|
+
Model.displayFields(canon).filter((f) => f !== pf), { canon, id })
|
|
253
|
+
|
|
254
|
+
// Populate reference pickers.
|
|
255
|
+
const refOptions = {}
|
|
256
|
+
for (const r of Model.refsOf(canon)) {
|
|
257
|
+
if (r.field === pf) {
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
const rows = await Api.list(r.target)
|
|
261
|
+
const lf = Model.labelField(r.target)
|
|
262
|
+
refOptions[r.field] = rows.map((row) => ({ id: row.id, label: row[lf] || row.id }))
|
|
263
|
+
}
|
|
203
264
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
265
|
+
const inputs = fields.map((f) => {
|
|
266
|
+
const fdef = Model.fieldsOf(canon)[f] || {}
|
|
267
|
+
const val = item[f]
|
|
268
|
+
let control
|
|
269
|
+
if (fdef.ref) {
|
|
270
|
+
const opts = refOptions[f] || []
|
|
271
|
+
control = `<select name="${f}">
|
|
272
|
+
<option value="">— none —</option>
|
|
273
|
+
${opts.map((o) => `<option value="${esc(o.id)}"${o.id === val ? ' selected' : ''}>${esc(o.label)}</option>`).join('')}
|
|
274
|
+
</select>`
|
|
275
|
+
}
|
|
276
|
+
else if ('Boolean' === fdef.kind) {
|
|
277
|
+
control = `<input name="${f}" type="checkbox"${val ? ' checked' : ''} />`
|
|
278
|
+
}
|
|
279
|
+
else if ('Number' === fdef.kind) {
|
|
280
|
+
control = `<input name="${f}" type="number" value="${null == val ? '' : esc(val)}" />`
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
control = `<input name="${f}" type="text" value="${null == val ? '' : esc(val)}" />`
|
|
284
|
+
}
|
|
285
|
+
return `<label>${esc(fdef.label || Model.titleize(f))} ${control}</label>`
|
|
286
|
+
}).join('')
|
|
215
287
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
this.renderForm()
|
|
288
|
+
if (!this.current(tok)) {
|
|
289
|
+
return
|
|
219
290
|
}
|
|
291
|
+
this.innerHTML = `
|
|
292
|
+
<div class="vg-entity">
|
|
293
|
+
<form class="vg-entity-form">
|
|
294
|
+
<h3>${id ? 'Edit' : 'New'} ${esc(Model.labelOf(canon))}</h3>
|
|
295
|
+
${inputs}
|
|
296
|
+
${Hooks.html('admin:form:extra', { canon, id, item })}
|
|
297
|
+
<div class="vg-form-actions">
|
|
298
|
+
<button type="submit" class="vg-primary">Save</button>
|
|
299
|
+
<button type="button" class="vg-link" id="vg-cancel">Cancel</button>
|
|
300
|
+
</div>
|
|
301
|
+
<div class="vg-form-err" id="vg-form-err"></div>
|
|
302
|
+
</form>
|
|
303
|
+
</div>`
|
|
220
304
|
|
|
221
|
-
|
|
305
|
+
Hooks.action('admin:form:after', { root: this, canon, id, item })
|
|
306
|
+
this.querySelector('#vg-cancel').onclick = () => this.reload()
|
|
307
|
+
this.querySelector('form').onsubmit = async (ev) => {
|
|
222
308
|
ev.preventDefault()
|
|
223
|
-
const
|
|
309
|
+
const data = Object.assign({}, id ? { id } : {}, preset)
|
|
310
|
+
// Project-scoped entities inherit the current project.
|
|
311
|
+
if (pf && this.projectId) {
|
|
312
|
+
data[pf] = this.projectId
|
|
313
|
+
}
|
|
224
314
|
for (const f of fields) {
|
|
225
|
-
const
|
|
226
|
-
const
|
|
227
|
-
if ('Boolean' === kind) {
|
|
228
|
-
|
|
315
|
+
const fdef = Model.fieldsOf(canon)[f] || {}
|
|
316
|
+
const el = ev.target.querySelector(`[name="${f}"]`)
|
|
317
|
+
if ('Boolean' === fdef.kind) {
|
|
318
|
+
data[f] = el.checked
|
|
229
319
|
}
|
|
230
|
-
else if ('' ===
|
|
231
|
-
// Blank optional field: leave unset (don't send an empty string;
|
|
232
|
-
// an optional/Skip field rejects '').
|
|
320
|
+
else if ('' === el.value) {
|
|
233
321
|
continue
|
|
234
322
|
}
|
|
235
323
|
else {
|
|
236
|
-
|
|
324
|
+
data[f] = 'Number' === fdef.kind ? Number(el.value) : el.value
|
|
237
325
|
}
|
|
238
326
|
}
|
|
239
|
-
|
|
240
|
-
|
|
327
|
+
// Hook: transform the payload just before saving.
|
|
328
|
+
const payload = Hooks.filter('admin:save:data', data, { canon, id })
|
|
329
|
+
const res = await Api.save(canon, payload)
|
|
241
330
|
if (!res.ok) {
|
|
242
|
-
|
|
243
|
-
|
|
331
|
+
this.querySelector('#vg-form-err').textContent = 'Save failed: ' + (res.why || '')
|
|
332
|
+
return
|
|
244
333
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
334
|
+
// Hook: react to a successful save.
|
|
335
|
+
Hooks.action('admin:save:after', { canon, id, item: res.item, res })
|
|
336
|
+
if ('proj/project' === canon) {
|
|
337
|
+
emit('projects-changed', {})
|
|
249
338
|
}
|
|
339
|
+
// Return to wherever we were.
|
|
340
|
+
this.reload()
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
afterMutation() {
|
|
345
|
+
if ('proj/project' === this.canon) {
|
|
346
|
+
emit('projects-changed', {})
|
|
250
347
|
}
|
|
251
348
|
}
|
|
252
349
|
}
|
|
253
350
|
|
|
351
|
+
|
|
254
352
|
customElements.define('vg-entity-admin', VgEntityAdmin)
|
|
353
|
+
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Component: vg-entity-admin (`cmp/admin.js`)
|
|
2
|
+
|
|
3
|
+
The generic, model-driven entity admin: list, detail (with relationship
|
|
4
|
+
drill-down), and form for ANY entity — columns, fields, ref pickers and
|
|
5
|
+
child lists all derive from the model at runtime. One component serves
|
|
6
|
+
the whole entity graph.
|
|
7
|
+
|
|
8
|
+
## View flow
|
|
9
|
+
|
|
10
|
+
```mermaid
|
|
11
|
+
flowchart LR
|
|
12
|
+
list[list view] -->|row click| detail[detail view]
|
|
13
|
+
list -->|New| form[form view]
|
|
14
|
+
detail -->|Edit| form
|
|
15
|
+
detail -->|child list row| detail
|
|
16
|
+
form -->|save| list
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
- **List**: columns from `Model.displayFields`; ref fields render as
|
|
20
|
+
links (`onNavigate` to the target entity); project-scoped entities
|
|
21
|
+
filter by the shell's current project.
|
|
22
|
+
- **Detail**: the entity's fields plus an inline child list per inverse
|
|
23
|
+
relationship (entities whose `ref` points here) — the drill-down.
|
|
24
|
+
- **Form**: inputs by field kind; `ref` fields become `select` pickers
|
|
25
|
+
loaded from the target entity; parent refs preset when creating from a
|
|
26
|
+
detail view.
|
|
27
|
+
|
|
28
|
+
## Messages
|
|
29
|
+
|
|
30
|
+
All data via `api.js` → generic entity service:
|
|
31
|
+
`aim:ent,cmd:list|load|save|remove, ent:<canon>`. Emits
|
|
32
|
+
`projects-changed` after project writes.
|
|
33
|
+
|
|
34
|
+
## Async rendering
|
|
35
|
+
|
|
36
|
+
Renders are async (data fetches) and can overlap; a **render token**
|
|
37
|
+
(`begin()` / `current(tok)`) discards stale renders — capture the token
|
|
38
|
+
at method start, bail before writing `innerHTML` if superseded. Keep
|
|
39
|
+
this pattern in any new async render path.
|
|
40
|
+
|
|
41
|
+
## Customisation
|
|
42
|
+
|
|
43
|
+
| Hook point | Kind |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `admin:list:toolbar` / `admin:row:actions` / `admin:form:extra` | html |
|
|
46
|
+
| `admin:list:items` / `admin:list:columns` / `admin:form:fields` / `admin:save:data` | filter |
|
|
47
|
+
| `admin:list:after` / `admin:form:after` / `admin:save:after` | action |
|
|
48
|
+
|
|
49
|
+
To replace this UI wholesale for one entity, use a custom view
|
|
50
|
+
(`ux:{view:'custom'}` in the model).
|
|
51
|
+
|
|
@@ -1,30 +1,27 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Top-level router: signed-out visitors see the public content site; signed-in
|
|
2
|
+
// users see the enterprise app shell. Driven by bus auth events.
|
|
3
3
|
|
|
4
4
|
import { bus, onEvent } from '../bus.js'
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
class VgApp extends HTMLElement {
|
|
8
8
|
connectedCallback() {
|
|
9
|
-
this.
|
|
10
|
-
|
|
11
|
-
<main id="vg-main"></main>`
|
|
12
|
-
|
|
13
|
-
onEvent('auth', ({ user }) => this.setMain(user))
|
|
14
|
-
|
|
9
|
+
this.signedIn = undefined
|
|
10
|
+
onEvent('auth', ({ user }) => this.route(user))
|
|
15
11
|
// Resolve the cookie session on load.
|
|
16
12
|
bus.post('cmp:auth,load:state')
|
|
17
13
|
}
|
|
18
14
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
else {
|
|
25
|
-
main.innerHTML = '<p class="vg-hint">Sign in to manage your data.</p>'
|
|
15
|
+
route(user) {
|
|
16
|
+
// Avoid needless re-mounts when the auth state hasn't actually changed.
|
|
17
|
+
const signedIn = !!user
|
|
18
|
+
if (this.signedIn === signedIn) {
|
|
19
|
+
return
|
|
26
20
|
}
|
|
21
|
+
this.signedIn = signedIn
|
|
22
|
+
this.innerHTML = signedIn ? '<vg-shell></vg-shell>' : '<vg-public></vg-public>'
|
|
27
23
|
}
|
|
28
24
|
}
|
|
29
25
|
|
|
30
26
|
customElements.define('vg-app', VgApp)
|
|
27
|
+
|