@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
@@ -1,272 +1,353 @@
1
- // Entity admin: a django-admin-style generic CRUD UI, driven entirely by
2
- // the compiled model (fetched from /model.json). The entity_admin plugin
3
- // owns the model + per-entity message mapping (state); the
4
- // <vg-entity-admin> component renders nav / list / form and talks to the
5
- // plugin (and through it, the backend) only via bus messages.
6
-
7
- import { bus } from '../bus.js'
8
-
9
-
10
- // Fields managed by the system, not the form.
11
- const SYS_FIELDS = ['id', 'owner_id', 't_c', 't_m', 't_mh', 't_ch']
12
-
13
-
14
- bus.use(function entity_admin() {
15
- const seneca = this
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
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]))
20
+ }
16
21
 
17
- let model = null
18
22
 
19
- // Entities from the model: [{ zone, name, canon, fields }]
20
- function entities() {
21
- const out = []
22
- const ent = (model && model.main.ent) || {}
23
- for (const zone of Object.keys(ent)) {
24
- if ('sys' === zone) {
25
- continue
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
- // Message mapping convention: entity zone/name -> aim:<zone>,<verb>:<name>.
40
- function msgFor(canon, verb) {
41
- const [zone, name] = canon.split('/')
42
- return { aim: zone, [verb]: name }
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
- seneca.add('cmp:entity_admin,load:model', function (msg, reply) {
46
- if (model) {
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, '&quot;')}" />`
92
- }
93
43
 
94
-
95
- class VgEntityAdmin extends HTMLElement {
96
- constructor() {
97
- super()
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
- connectedCallback() {
103
- // Reactivity via Seneca messages: re-list whenever the store signals that
104
- // this entity's data changed - regardless of which component wrote it.
105
- // `sub` is Seneca's native fan-out (many observers per pattern), so this
106
- // is the same messaging primitive used everywhere else.
107
- bus.sub('sys:browser-store,changed:group', (msg) => {
108
- if (this.isConnected && msg.group === this.canon) {
109
- this.renderList()
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
- this.render()
61
+ maps[r.field] = { target: r.target, labels }
62
+ }
63
+ return maps
118
64
  }
119
65
 
120
- async render() {
121
- const res = await bus.post('cmp:entity_admin,load:model')
122
- if (!res.ok) {
123
- this.innerHTML = `<p>model load failed: ${res.why}</p>`
124
- return
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
- this.entities = res.entities
127
- if (null == this.canon && 0 < res.entities.length) {
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
- const nav = res.entities.map((e) =>
132
- `<a href="#" class="vg-nav ${e.canon === this.canon ? 'sel' : ''}"
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 = a.dataset.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
- entdef() {
156
- return this.entities.find((e) => e.canon === this.canon)
157
- }
91
+ // ---- list ----
158
92
 
159
- async renderList() {
160
- // May be invoked reactively (store change event) before the first full
161
- // render has built the DOM - skip until the list container exists.
162
- if (!this.entities || !this.querySelector('#vg-list')) {
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
- const out = await bus.post('cmp:entity_admin,list:item',
166
- { canon: this.canon })
167
- const items = (out.ok && out.list) || []
168
- const fields = Object.keys(this.entdef().fields)
169
- .filter((f) => !SYS_FIELDS.includes(f))
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 data-id="${item.id}">
174
- ${fields.map((f) => `<td>${null == item[f] ? '' : item[f]}</td>`).join('')}
175
- <td>
176
- <button class="vg-edit" data-id="${item.id}">edit</button>
177
- <button class="vg-del" data-id="${item.id}">delete</button>
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.querySelector('#vg-list').innerHTML = `
182
- <h2>${this.canon} <button id="vg-new">new</button></h2>
183
- <table class="vg-table">
184
- <thead><tr>${head}<th></th></tr></thead>
185
- <tbody>${rows}</tbody>
186
- </table>
187
- <p id="vg-count">${items.length} item${1 === items.length ? '' : 's'}</p>`
188
-
189
- this.items = items
190
- this.querySelector('#vg-new').onclick = () => {
191
- this.editing = {}
192
- this.renderForm()
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 bus.post('cmp:entity_admin,remove:item',
203
- { canon: this.canon, id: b.dataset.id })
204
- // The list refresh comes reactively from the store's changed event.
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
- renderForm() {
212
- const box = this.querySelector('#vg-form')
213
- if (null == this.editing) {
214
- box.innerHTML = ''
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
- const def = this.entdef()
219
- const fields = Object.keys(def.fields)
220
- .filter((f) => !SYS_FIELDS.includes(f))
221
-
222
- box.innerHTML = `
223
- <form class="vg-entity-form">
224
- <h3>${this.editing.id ? 'Edit' : 'New'} ${this.canon}</h3>
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
- box.querySelector('form').onsubmit = async (ev) => {
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 item = { ...(this.editing.id ? { id: this.editing.id } : {}) }
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 input = box.querySelector(`[name="${f}"]`)
244
- const kind = (def.fields[f] && def.fields[f].kind) || 'String'
245
- if ('Boolean' === kind) {
246
- item[f] = input.checked
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 ('' === input.value) {
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
- item[f] = 'Number' === kind ? Number(input.value) : input.value
324
+ data[f] = 'Number' === fdef.kind ? Number(el.value) : el.value
255
325
  }
256
326
  }
257
- const res = await bus.post('cmp:entity_admin,save:item',
258
- { canon: this.canon, item })
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
- box.querySelector('#vg-form-err').textContent =
261
- 'save failed: ' + (res.why || '')
331
+ this.querySelector('#vg-form-err').textContent = 'Save failed: ' + (res.why || '')
332
+ return
262
333
  }
263
- else {
264
- // The list refresh comes reactively from the store's changed event.
265
- this.editing = null
266
- this.renderForm()
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
- customElements.define('vg-entity-admin', VgEntityAdmin)
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
+