@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.
- 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 +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
|
@@ -1,272 +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
|
-
// Save/remove just pass through to the backend. The UI refresh is NOT
|
|
62
|
-
// triggered here: the store plugin emits a sys:browser-store,changed:group
|
|
63
|
-
// message on the write, and <vg-entity-admin> re-lists reactively in
|
|
64
|
-
// response (see connectedCallback). This decouples "who mutated" from
|
|
65
|
-
// "who must refresh".
|
|
66
|
-
seneca.add('cmp:entity_admin,save:item', function (msg, reply) {
|
|
67
|
-
this.act({ ...msgFor(msg.canon, 'save'), item: msg.item },
|
|
68
|
-
function (err, out) {
|
|
69
|
-
reply(err ? { ok: false, why: err.message } : out)
|
|
70
|
-
})
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
seneca.add('cmp:entity_admin,remove:item', function (msg, reply) {
|
|
74
|
-
this.act({ ...msgFor(msg.canon, 'remove'), id: msg.id },
|
|
75
|
-
function (err, out) {
|
|
76
|
-
reply(err ? { ok: false, why: err.message } : out)
|
|
77
|
-
})
|
|
78
|
-
})
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
function inputFor(fname, fdef, value) {
|
|
83
|
-
const kind = (fdef && fdef.kind) || 'String'
|
|
84
|
-
const val = null == value ? '' : value
|
|
85
|
-
if ('Boolean' === kind) {
|
|
86
|
-
return `<input name="${fname}" type="checkbox" ${true === value ? 'checked' : ''} />`
|
|
40
|
+
current(tok) {
|
|
41
|
+
return tok === this._tok
|
|
87
42
|
}
|
|
88
|
-
if ('Number' === kind) {
|
|
89
|
-
return `<input name="${fname}" type="number" value="${val}" />`
|
|
90
|
-
}
|
|
91
|
-
return `<input name="${fname}" type="text" value="${String(val).replace(/"/g, '"')}" />`
|
|
92
|
-
}
|
|
93
43
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
this.canon = null
|
|
99
|
-
this.editing = null // item being edited, or {} for new
|
|
44
|
+
navigate(canon, id) {
|
|
45
|
+
if (this.onNavigate) {
|
|
46
|
+
this.onNavigate(canon, id)
|
|
47
|
+
}
|
|
100
48
|
}
|
|
101
49
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
bus.sub('sys:browser-store,changed:all', () => {
|
|
113
|
-
if (this.isConnected && this.canon) {
|
|
114
|
-
this.renderList()
|
|
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]
|
|
115
60
|
}
|
|
116
|
-
|
|
117
|
-
|
|
61
|
+
maps[r.field] = { target: r.target, labels }
|
|
62
|
+
}
|
|
63
|
+
return maps
|
|
118
64
|
}
|
|
119
65
|
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
if (
|
|
123
|
-
|
|
124
|
-
|
|
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>`
|
|
125
75
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
this.canon = res.entities[0].canon
|
|
76
|
+
if ('Boolean' === fdef.kind) {
|
|
77
|
+
return value ? '✓' : '<span class="vg-muted">✗</span>'
|
|
129
78
|
}
|
|
79
|
+
return esc(value)
|
|
80
|
+
}
|
|
130
81
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
data-canon="${e.canon}">${e.canon}</a>`).join(' ')
|
|
134
|
-
|
|
135
|
-
this.innerHTML = `
|
|
136
|
-
<div class="vg-admin">
|
|
137
|
-
<nav>${nav}</nav>
|
|
138
|
-
<div id="vg-list"></div>
|
|
139
|
-
<div id="vg-form"></div>
|
|
140
|
-
</div>`
|
|
141
|
-
|
|
142
|
-
for (const a of this.querySelectorAll('.vg-nav')) {
|
|
82
|
+
wireRefLinks(root) {
|
|
83
|
+
for (const a of root.querySelectorAll('.vg-ref')) {
|
|
143
84
|
a.onclick = (ev) => {
|
|
144
85
|
ev.preventDefault()
|
|
145
|
-
this.canon
|
|
146
|
-
this.editing = null
|
|
147
|
-
this.render()
|
|
86
|
+
this.navigate(a.dataset.canon, a.dataset.id)
|
|
148
87
|
}
|
|
149
88
|
}
|
|
150
|
-
|
|
151
|
-
await this.renderList()
|
|
152
|
-
this.renderForm()
|
|
153
89
|
}
|
|
154
90
|
|
|
155
|
-
|
|
156
|
-
return this.entities.find((e) => e.canon === this.canon)
|
|
157
|
-
}
|
|
91
|
+
// ---- list ----
|
|
158
92
|
|
|
159
|
-
async
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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)) {
|
|
163
108
|
return
|
|
164
109
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
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)
|
|
170
115
|
|
|
171
|
-
const head = fields.map((f) => `<th>${f}</th>`).join('')
|
|
172
116
|
const rows = items.map((item) => `
|
|
173
|
-
<tr
|
|
174
|
-
${fields.map((f) => `<td>${
|
|
175
|
-
<td>
|
|
176
|
-
|
|
177
|
-
<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>
|
|
178
124
|
</td>
|
|
179
125
|
</tr>`).join('')
|
|
180
126
|
|
|
181
|
-
this.
|
|
182
|
-
<
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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>`
|
|
140
|
+
|
|
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)
|
|
193
146
|
}
|
|
194
147
|
for (const b of this.querySelectorAll('.vg-edit')) {
|
|
195
|
-
b.onclick = () =>
|
|
196
|
-
this.editing = this.items.find((i) => i.id === b.dataset.id)
|
|
197
|
-
this.renderForm()
|
|
198
|
-
}
|
|
148
|
+
b.onclick = () => this.showForm(b.dataset.id)
|
|
199
149
|
}
|
|
200
150
|
for (const b of this.querySelectorAll('.vg-del')) {
|
|
201
151
|
b.onclick = async () => {
|
|
202
|
-
await
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
this.editing = null
|
|
206
|
-
this.renderForm()
|
|
152
|
+
await Api.remove(canon, b.dataset.id)
|
|
153
|
+
this.afterMutation()
|
|
154
|
+
this.showList()
|
|
207
155
|
}
|
|
208
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 })
|
|
209
160
|
}
|
|
210
161
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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>`
|
|
170
|
+
return
|
|
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)) {
|
|
215
207
|
return
|
|
216
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
|
+
}
|
|
217
230
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
${fields.map((f) => `
|
|
226
|
-
<label>${(def.fields[f] && def.fields[f].label) || f}
|
|
227
|
-
${inputFor(f, def.fields[f], this.editing[f])}
|
|
228
|
-
</label>`).join('')}
|
|
229
|
-
<button type="submit">Save</button>
|
|
230
|
-
<button type="button" id="vg-cancel">Cancel</button>
|
|
231
|
-
<div id="vg-form-err"></div>
|
|
232
|
-
</form>`
|
|
233
|
-
|
|
234
|
-
box.querySelector('#vg-cancel').onclick = () => {
|
|
235
|
-
this.editing = null
|
|
236
|
-
this.renderForm()
|
|
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
|
+
}
|
|
237
238
|
}
|
|
239
|
+
return preset
|
|
240
|
+
}
|
|
238
241
|
|
|
239
|
-
|
|
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
|
+
}
|
|
264
|
+
|
|
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('')
|
|
287
|
+
|
|
288
|
+
if (!this.current(tok)) {
|
|
289
|
+
return
|
|
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>`
|
|
304
|
+
|
|
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) => {
|
|
240
308
|
ev.preventDefault()
|
|
241
|
-
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
|
+
}
|
|
242
314
|
for (const f of fields) {
|
|
243
|
-
const
|
|
244
|
-
const
|
|
245
|
-
if ('Boolean' === kind) {
|
|
246
|
-
|
|
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
|
|
247
319
|
}
|
|
248
|
-
else if ('' ===
|
|
249
|
-
// Blank optional field: leave unset (don't send an empty string;
|
|
250
|
-
// an optional/Skip field rejects '').
|
|
320
|
+
else if ('' === el.value) {
|
|
251
321
|
continue
|
|
252
322
|
}
|
|
253
323
|
else {
|
|
254
|
-
|
|
324
|
+
data[f] = 'Number' === fdef.kind ? Number(el.value) : el.value
|
|
255
325
|
}
|
|
256
326
|
}
|
|
257
|
-
|
|
258
|
-
|
|
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)
|
|
259
330
|
if (!res.ok) {
|
|
260
|
-
|
|
261
|
-
|
|
331
|
+
this.querySelector('#vg-form-err').textContent = 'Save failed: ' + (res.why || '')
|
|
332
|
+
return
|
|
262
333
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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', {})
|
|
267
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', {})
|
|
268
347
|
}
|
|
269
348
|
}
|
|
270
349
|
}
|
|
271
350
|
|
|
272
|
-
|
|
351
|
+
|
|
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
|
+
|