@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.
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 +3 -0
  76. package/tm/web/web/package.json.frag +4 -1
  77. package/tm/web/web/playwright.config.js.frag +6 -1
  78. package/tm/web/web/src/api.js.frag +92 -0
  79. package/tm/web/web/src/bus.js.frag +46 -23
  80. package/tm/web/web/src/cmp/admin.js.frag +292 -193
  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,254 +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, emit } 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
- 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, '&quot;')}" />`
93
- }
94
43
 
95
-
96
- class VgEntityAdmin extends HTMLElement {
97
- constructor() {
98
- super()
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
- connectedCallback() {
104
- this.render()
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
- async render() {
108
- const res = await bus.post('cmp:entity_admin,load:model')
109
- if (!res.ok) {
110
- this.innerHTML = `<p>model load failed: ${res.why}</p>`
111
- 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>`
112
75
  }
113
- this.entities = res.entities
114
- if (null == this.canon && 0 < res.entities.length) {
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
- const nav = res.entities.map((e) =>
119
- `<a href="#" class="vg-nav ${e.canon === this.canon ? 'sel' : ''}"
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 = a.dataset.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
- entdef() {
143
- return this.entities.find((e) => e.canon === this.canon)
144
- }
91
+ // ---- list ----
145
92
 
146
- async renderList() {
147
- const out = await bus.post('cmp:entity_admin,list:item',
148
- { canon: this.canon })
149
- const items = (out.ok && out.list) || []
150
- const fields = Object.keys(this.entdef().fields)
151
- .filter((f) => !SYS_FIELDS.includes(f))
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 data-id="${item.id}">
156
- ${fields.map((f) => `<td>${null == item[f] ? '' : item[f]}</td>`).join('')}
157
- <td>
158
- <button class="vg-edit" data-id="${item.id}">edit</button>
159
- <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>
160
124
  </td>
161
125
  </tr>`).join('')
162
126
 
163
- this.querySelector('#vg-list').innerHTML = `
164
- <h2>${this.canon} <button id="vg-new">new</button></h2>
165
- <table class="vg-table">
166
- <thead><tr>${head}<th></th></tr></thead>
167
- <tbody>${rows}</tbody>
168
- </table>
169
- <p id="vg-count">${items.length} item${1 === items.length ? '' : 's'}</p>`
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.items = items
172
- this.querySelector('#vg-new').onclick = () => {
173
- this.editing = {}
174
- this.renderForm()
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 bus.post('cmp:entity_admin,remove:item',
185
- { canon: this.canon, id: b.dataset.id })
186
- this.editing = null
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
- renderForm() {
194
- const box = this.querySelector('#vg-form')
195
- if (null == this.editing) {
196
- 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>`
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
- const def = this.entdef()
201
- const fields = Object.keys(def.fields)
202
- .filter((f) => !SYS_FIELDS.includes(f))
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
- box.innerHTML = `
205
- <form class="vg-entity-form">
206
- <h3>${this.editing.id ? 'Edit' : 'New'} ${this.canon}</h3>
207
- ${fields.map((f) => `
208
- <label>${(def.fields[f] && def.fields[f].label) || f}
209
- ${inputFor(f, def.fields[f], this.editing[f])}
210
- </label>`).join('')}
211
- <button type="submit">Save</button>
212
- <button type="button" id="vg-cancel">Cancel</button>
213
- <div id="vg-form-err"></div>
214
- </form>`
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
- box.querySelector('#vg-cancel').onclick = () => {
217
- this.editing = null
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
- box.querySelector('form').onsubmit = async (ev) => {
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 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
+ }
224
314
  for (const f of fields) {
225
- const input = box.querySelector(`[name="${f}"]`)
226
- const kind = (def.fields[f] && def.fields[f].kind) || 'String'
227
- if ('Boolean' === kind) {
228
- 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
229
319
  }
230
- else if ('' === input.value) {
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
- item[f] = 'Number' === kind ? Number(input.value) : input.value
324
+ data[f] = 'Number' === fdef.kind ? Number(el.value) : el.value
237
325
  }
238
326
  }
239
- const res = await bus.post('cmp:entity_admin,save:item',
240
- { 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)
241
330
  if (!res.ok) {
242
- box.querySelector('#vg-form-err').textContent =
243
- 'save failed: ' + (res.why || '')
331
+ this.querySelector('#vg-form-err').textContent = 'Save failed: ' + (res.why || '')
332
+ return
244
333
  }
245
- else {
246
- this.editing = null
247
- await this.renderList()
248
- 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', {})
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
- // App shell: shows the auth component always; the entity admin only when
2
- // signed in. Reacts to bus auth events.
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.innerHTML = `
10
- <header><h1>$$Name$$</h1><vg-auth></vg-auth></header>
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
- setMain(user) {
20
- const main = this.querySelector('#vg-main')
21
- if (user) {
22
- main.innerHTML = '<vg-entity-admin></vg-entity-admin>'
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
+