@mmlogic/components 0.1.30 → 0.3.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.
@@ -2,13 +2,17 @@
2
2
  APP STATE
3
3
  ===================================================================== */
4
4
 
5
- let _selectedTenant = null;
6
- let _selectedType = null; // { name, pluralName }
7
- let _relationMeta = {}; // relatedClass / mostSignificantClass → { name, mostSignificantClass }
8
- let _tableDataHref = null; // base href for the current table view (without ?page=)
9
- let _locale = navigator.language || 'nl-NL';
10
- let _dashboard = null; // full dashboard response — used for view switching
11
- let _currentViewKey = null; // key of the view currently displayed in the table
5
+ let _selectedTenant = null;
6
+ let _selectedType = null; // { name, pluralName }
7
+ let _relationMeta = {}; // relatedClass / mostSignificantClass → { name, mostSignificantClass }
8
+ let _baseHref = null; // tenant base href: /data/{tenant}
9
+ let _locale = navigator.language || 'nl-NL';
10
+ let _dashboardData = null; // full dashboard response
11
+ let _dashboardRecord = null; // data-object voor object/form dashboards
12
+ let _activeLayoutIndex = 0;
13
+ let _dashboardType = 'class'; // 'class' | 'general' | 'navigation' | 'object' | 'form'
14
+ let _sectionGeneration = 0; // incremented on each renderSection(); prevents stale fetches
15
+ let _navHistory = []; // stack van { dashboardData, dashboardRecord, activeLayoutIndex }
12
16
 
13
17
  /* =====================================================================
14
18
  UTILITIES
@@ -21,6 +25,29 @@ function escHtml(str) {
21
25
  .replace(/"/g, '"').replace(/'/g, ''');
22
26
  }
23
27
 
28
+ function formatJson(value) {
29
+ try {
30
+ const parsed = typeof value === 'string' ? JSON.parse(value) : value;
31
+ const json = JSON.stringify(parsed, null, 2);
32
+ return json.replace(
33
+ /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
34
+ match => {
35
+ let style = 'color:#2aa198';
36
+ if (/^"/.test(match)) {
37
+ style = /:$/.test(match) ? 'color:#81a1c1' : 'color:#a3be8c';
38
+ } else if (/true|false/.test(match)) {
39
+ style = 'color:#ebcb8b';
40
+ } else if (/null/.test(match)) {
41
+ style = 'color:#bf616a';
42
+ }
43
+ return `<span style="${style}">${match}</span>`;
44
+ },
45
+ );
46
+ } catch {
47
+ return escHtml(String(value));
48
+ }
49
+ }
50
+
24
51
  function httpStatusText(code) {
25
52
  const map = {
26
53
  200: 'OK', 201: 'Created', 204: 'No Content',
@@ -31,9 +58,43 @@ function httpStatusText(code) {
31
58
  return map[code] || '';
32
59
  }
33
60
 
61
+ const EVENT_LOG_MAX = 200;
62
+
34
63
  function logEvent(type, data) {
35
- document.getElementById('event-log').textContent =
36
- '[' + type + ']\n' + JSON.stringify(data, null, 2);
64
+ const log = document.getElementById('event-log');
65
+
66
+ const empty = log.querySelector('.event-log-empty');
67
+ if (empty) empty.remove();
68
+
69
+ // Trim oldest entries when the log grows too large
70
+ while (log.children.length >= EVENT_LOG_MAX) log.removeChild(log.firstChild);
71
+
72
+ const now = new Date();
73
+ const time = now.toTimeString().slice(0, 8) + '.' + String(now.getMilliseconds()).padStart(3, '0');
74
+ const json = data !== undefined ? JSON.stringify(data, null, 2) : null;
75
+
76
+ const entry = document.createElement('div');
77
+ entry.className = 'event-log-entry';
78
+ entry.dataset.type = type;
79
+ entry.innerHTML = `
80
+ <div class="event-log-entry-head">
81
+ <span class="event-log-time">${escHtml(time)}</span>
82
+ <span class="event-log-type">${escHtml(type)}</span>
83
+ ${json ? '<span class="event-log-arrow">▶</span>' : ''}
84
+ </div>
85
+ ${json ? `<pre class="event-log-detail">${escHtml(json)}</pre>` : ''}`;
86
+
87
+ entry.querySelector('.event-log-entry-head')?.addEventListener('click', () => {
88
+ if (json) entry.classList.toggle('open');
89
+ });
90
+
91
+ log.appendChild(entry);
92
+ log.scrollTop = log.scrollHeight;
93
+ }
94
+
95
+ function clearEventLog() {
96
+ const log = document.getElementById('event-log');
97
+ log.innerHTML = '<span class="event-log-empty">Wacht op events…</span>';
37
98
  }
38
99
 
39
100
  /* =====================================================================
@@ -139,293 +200,339 @@ function showCompanyList() {
139
200
  ===================================================================== */
140
201
 
141
202
  async function loadTenants() {
142
- const panel = document.getElementById('panel-tenant');
143
- panel.classList.remove('hidden');
144
- const grid = document.getElementById('tenant-grid');
145
- grid.innerHTML = '<span class="spinner"></span>';
203
+ document.getElementById('panel-controls').classList.remove('hidden');
204
+ const sel = document.getElementById('tenant-select-compact');
205
+ sel.innerHTML = '<option value="">⏳ laden...</option>';
146
206
 
147
207
  try {
148
208
  const tenants = await apiFetchTenants(authGetToken());
149
- renderTenants(tenants);
150
- } catch (err) {
151
- grid.innerHTML = `<span style="color:var(--mrd-color-danger)">❌ ${escHtml(err.message)}</span>`;
152
- }
153
- }
154
-
155
- function renderTenants(tenants) {
156
- const grid = document.getElementById('tenant-grid');
157
- if (!tenants || tenants.length === 0) {
158
- grid.innerHTML = '<em style="color:var(--mrd-color-neutral-500)">Geen tenants gevonden.</em>';
159
- return;
160
- }
161
-
162
- grid.innerHTML = tenants.map((t, i) => {
163
- const code = t.tenantCode || t.code || t.id || String(i);
164
- const name = t.name || code;
165
- const desc = t.description || '';
166
- return `
167
- <div class="tenant-card">
168
- <input type="radio" name="tenant" id="tenant-${escHtml(code)}" value="${escHtml(code)}"
169
- onchange="onTenantChange('${escHtml(code)}', '${escHtml(name)}')">
170
- <label for="tenant-${escHtml(code)}">
171
- ${escHtml(name)}
172
- ${desc ? `<small>${escHtml(desc)}</small>` : ''}
173
- </label>
174
- </div>`;
175
- }).join('');
176
-
177
- const savedTenant = localStorage.getItem('last_tenant');
178
- if (savedTenant) {
179
- const radio = document.getElementById(`tenant-${savedTenant}`);
180
- if (radio) {
181
- radio.checked = true;
182
- const label = document.querySelector(`label[for="tenant-${savedTenant}"]`);
183
- const name = label ? (label.firstChild?.textContent?.trim() || savedTenant) : savedTenant;
184
- onTenantChange(savedTenant, name);
209
+ if (!tenants || tenants.length === 0) {
210
+ sel.innerHTML = '<option value="">— geen tenants gevonden —</option>';
211
+ return;
212
+ }
213
+ sel.innerHTML = '<option value="">— selecteer tenant —</option>' +
214
+ tenants.map(t => {
215
+ const code = t.tenantCode || t.code || t.id;
216
+ return `<option value="${escHtml(code)}">${escHtml(t.name || code)}</option>`;
217
+ }).join('');
218
+
219
+ const saved = localStorage.getItem('last_tenant');
220
+ if (saved) {
221
+ sel.value = saved;
222
+ onCompactTenantChange(saved);
185
223
  }
224
+ } catch (err) {
225
+ sel.innerHTML = `<option value="">❌ ${escHtml(err.message)}</option>`;
186
226
  }
187
227
  }
188
228
 
189
- async function onTenantChange(code, name) {
229
+ function onCompactTenantChange(code) {
190
230
  _selectedTenant = code;
231
+ _baseHref = `/data/${code}`;
191
232
  localStorage.setItem('last_tenant', code);
192
- _selectedType = null;
193
- document.getElementById('panel-table').classList.add('hidden');
194
- document.getElementById('panel-form').classList.add('hidden');
195
- document.getElementById('panel-response').innerHTML = '';
233
+ if (_dashboardType === 'class' && code) loadCompactTypes(code);
234
+ }
196
235
 
197
- const typePanel = document.getElementById('panel-type');
198
- typePanel.classList.remove('hidden');
199
- const sel = document.getElementById('type-select');
236
+ async function loadCompactTypes(tenantCode) {
237
+ const sel = document.getElementById('type-select-compact');
200
238
  sel.innerHTML = '<option value="">⏳ laden...</option>';
201
239
  sel.disabled = true;
202
-
203
240
  try {
204
- const types = await apiFetchTypes(authGetToken(), code);
205
- renderTypes(types);
241
+ const types = await apiFetchTypes(authGetToken(), tenantCode);
242
+ if (!types || types.length === 0) {
243
+ sel.innerHTML = '<option value="">— geen types —</option>';
244
+ return;
245
+ }
246
+ sel.innerHTML = '<option value="">— selecteer type —</option>' +
247
+ types.map(t => {
248
+ const plural = t.pluralName || t.name;
249
+ return `<option value="${escHtml(plural)}">${escHtml(t.name)}</option>`;
250
+ }).join('');
251
+ sel.disabled = false;
252
+
253
+ const saved = localStorage.getItem('last_type');
254
+ if (saved && sel.querySelector(`option[value="${saved}"]`)) {
255
+ sel.value = saved;
256
+ }
206
257
  } catch (err) {
207
- sel.innerHTML = '<option value="">— fout bij laden —</option>';
258
+ sel.innerHTML = `<option value="">❌ ${escHtml(err.message)}</option>`;
208
259
  sel.disabled = false;
209
- typePanel.querySelector('h3').insertAdjacentHTML('afterend',
210
- `<p style="color:var(--mrd-color-danger);font-size:.875rem;margin:0 0 .75rem">❌ ${escHtml(err.message)}</p>`);
211
260
  }
212
261
  }
213
262
 
214
- function renderTypes(types) {
215
- const sel = document.getElementById('type-select');
216
- if (!types || types.length === 0) {
217
- sel.innerHTML = '<option value="">— geen types gevonden —</option>';
218
- sel.disabled = true;
219
- return;
220
- }
221
- sel.innerHTML = '<option value="">— selecteer type —</option>' +
222
- types.map(t => {
223
- const plural = t.pluralName || t.name;
224
- return `<option value="${escHtml(plural)}" data-name="${escHtml(t.name)}">${escHtml(t.name)}</option>`;
225
- }).join('');
226
- sel.disabled = false;
263
+ function onDashboardTypeChange(type) {
264
+ _dashboardType = type;
265
+ document.getElementById('controls-class-group').classList.toggle('hidden', type !== 'class');
266
+ document.getElementById('controls-object-group').classList.toggle('hidden', type !== 'object' && type !== 'form');
267
+ if (type === 'class' && _selectedTenant) loadCompactTypes(_selectedTenant);
227
268
  }
228
269
 
229
270
  /* =====================================================================
230
- TABLE
271
+ DASHBOARD — laad + render layout sections
231
272
  ===================================================================== */
232
273
 
233
- async function loadTable() {
234
- const sel = document.getElementById('type-select');
235
- const pluralName = sel.value;
236
- if (!pluralName) return;
237
-
238
- const opt = sel.options[sel.selectedIndex];
239
- _selectedType = { name: opt.dataset.name || pluralName, pluralName };
274
+ async function loadDashboard() {
275
+ const name = document.getElementById('dashboard-name-input').value.trim() || undefined;
276
+ const token = authGetToken();
240
277
 
241
- const tablePanel = document.getElementById('panel-table');
242
- tablePanel.classList.remove('hidden');
243
- tablePanel.innerHTML = '<span class="spinner"></span> Lijst laden...';
244
- document.getElementById('panel-form').classList.add('hidden');
278
+ document.getElementById('sections-container').innerHTML =
279
+ '<span class="spinner"></span> Dashboard laden...';
280
+ document.getElementById('panel-sections').classList.remove('hidden');
245
281
  document.getElementById('panel-response').innerHTML = '';
246
- _tableDataHref = null;
247
282
 
248
283
  try {
249
- const dashboard = await apiFetchDashboard(authGetToken(), _selectedTenant, pluralName);
250
- _dashboard = dashboard;
251
-
252
- const layoutItem = dashboard.layouts?.[0]?.items?.[0];
253
- const viewKey = layoutItem?.name;
254
- const view = viewKey && dashboard.views?.[viewKey];
255
- const dataHref = viewKey && dashboard._links?.[viewKey]?.href;
256
- if (!view || !dataHref) throw new Error('Geen geldige view gevonden in dashboard response.');
257
-
258
- _currentViewKey = viewKey;
259
-
260
- // Strip any ?page= from href so we control pagination ourselves
261
- _tableDataHref = dataHref.replace(/([?&])page=\d+/, '').replace(/\?$/, '');
262
-
263
- const columns = (view.values ?? []).map(mapApiItem);
264
- const defaultSort = view.defaultSort ?? '';
265
- const viewLabel = layoutItem?.label ?? viewKey ?? '';
266
- const alternativeViews = layoutItem?.alternativeViews ?? [];
267
- const viewFilter = view.filter ?? [];
268
-
269
- // Fetch page 0 for totalElements + first rows (including defaultSort + view filters)
270
- const page0 = await apiFetchPage(authGetToken(), applyViewFilter(_tableDataHref, viewFilter), 0, defaultSort);
271
- const meta = page0.page ?? {};
272
- const total = meta.totalElements ?? 0;
273
- const pageSize = meta.size ?? 20;
274
- const embedded = page0._embedded ?? {};
275
- const rows0 = Object.values(embedded)[0] ?? [];
276
-
277
- renderTable(columns, total, pageSize, rows0, _tableDataHref, defaultSort, viewLabel, alternativeViews, viewFilter);
284
+ let dashboard;
285
+ let recordData = null;
286
+
287
+ if (_dashboardType === 'class') {
288
+ const pluralName = document.getElementById('type-select-compact').value;
289
+ if (!pluralName) throw new Error('Selecteer een type');
290
+ const opt = document.getElementById('type-select-compact').selectedOptions[0];
291
+ _selectedType = { name: opt.text, pluralName };
292
+ localStorage.setItem('last_type', pluralName);
293
+ dashboard = await apiFetchClassDashboard(token, _selectedTenant, pluralName, name);
294
+
295
+ } else if (_dashboardType === 'general') {
296
+ dashboard = await apiFetchGeneralDashboard(token, _selectedTenant, name);
297
+
298
+ } else if (_dashboardType === 'navigation') {
299
+ dashboard = await apiFetchNavigationPane(token, _selectedTenant, name);
300
+
301
+ } else {
302
+ // 'object' of 'form': fetch record → gebruik _links.metadata.href of _links.form.href
303
+ const objectHref = document.getElementById('object-href-input').value.trim();
304
+ if (!objectHref) throw new Error('Vul een Object URL in');
305
+ const recResp = await apiRequest('GET', objectHref, token);
306
+ if (!recResp.ok) throw new Error(`${recResp.status}: kon object niet ophalen`);
307
+ recordData = recResp.body;
308
+
309
+ const linkKey = _dashboardType === 'form' ? 'form' : 'metadata';
310
+ const dashHref = recordData._links?.[linkKey]?.href;
311
+ if (!dashHref) throw new Error(`Geen _links.${linkKey} gevonden in object`);
312
+
313
+ const dashResp = await apiRequest('GET', dashHref, token);
314
+ if (!dashResp.ok) throw new Error(`${dashResp.status}: dashboard ophalen mislukt`);
315
+ dashboard = dashResp.body;
316
+ }
317
+
318
+ _dashboardData = dashboard;
319
+ _dashboardRecord = recordData;
320
+ _activeLayoutIndex = 0;
321
+ _navHistory = [];
322
+
323
+ renderSectionTabs(dashboard.layouts ?? []);
324
+ renderSection(0);
325
+ updateBackButton();
278
326
  } catch (err) {
279
- tablePanel.innerHTML = `<span style="color:var(--mrd-color-danger)">❌ ${escHtml(err.message)}</span>`;
327
+ document.getElementById('sections-container').innerHTML =
328
+ `<span style="color:var(--mrd-color-danger)">❌ ${escHtml(err.message)}</span>`;
280
329
  }
281
330
  }
282
331
 
283
- function renderTable(columns, totalElements, pageSize, page0Rows, dataHref, defaultSort = '', initialViewLabel = '', initialAlternativeViews = [], initialViewFilter = []) {
284
- const tablePanel = document.getElementById('panel-table');
285
- tablePanel.innerHTML = '<mrd-table id="live-table"></mrd-table>';
286
-
287
- customElements.whenDefined('mrd-table').then(async () => {
288
- const table = document.getElementById('live-table');
289
-
290
- // Mutable closure state — reassigned on view switch so subsequent handlers see new values
291
- let _activeFilters = [];
292
- let _pageLinks = {}; // _links from the most recent page 0 response (self, excel, etc.)
293
- let viewLabel = initialViewLabel;
294
- let alternativeViews = initialAlternativeViews;
295
- let viewFilter = initialViewFilter; // static filters from view definition
296
-
297
- _tableDataHref = dataHref;
298
-
299
- table.columns = columns;
300
- table.locale = _locale;
301
- table.pageSize = pageSize;
302
- table.tableHeight = 500;
303
- table.totalElements = totalElements;
304
- table.defaultSort = defaultSort;
305
- table.viewLabel = viewLabel;
306
- table.alternativeViews = alternativeViews;
307
- table.actions = [
308
- { action: 'create', label: 'Nieuw record', icon: 'dev/sprites.svg#icon-plus', variant: 'primary' },
309
- { action: 'export', label: 'Exporteer naar Excel', icon: 'dev/sprites.svg#icon-file-excel' },
310
- ];
311
-
312
- function buildParams(sort) {
313
- const params = new URLSearchParams();
314
- if (sort) params.set('sort', sort);
315
- // Static view-level filters (from view.filter in dashboard response)
316
- viewFilter.forEach(f => { if (f.name && f.value != null) params.set(f.name, String(f.value)); });
317
- // User-applied column filters
318
- _activeFilters.forEach(f => {
319
- if (f.operator === 'isEmpty') { params.set(f.field, ''); return; }
320
- if (f.operator === 'isNotEmpty') { params.set(f.field + '_notempty', 'true'); return; }
321
- if (f.values?.length) { params.set(f.field, f.values.join(',')); return; }
322
- if (f.value != null && f.value !== '') params.set(f.field, String(f.value));
323
- if (f.from != null && f.from !== '') params.set(f.field + '_from', String(f.from));
324
- if (f.to != null && f.to !== '') params.set(f.field + '_to', String(f.to));
325
- });
326
- return params;
332
+ function renderSectionTabs(layouts) {
333
+ const bar = document.getElementById('sections-tab-bar');
334
+ bar.innerHTML = '';
335
+ if (layouts.length <= 1) return;
336
+
337
+ layouts.forEach((layout, i) => {
338
+ const label = layout.label ?? layout.type ?? `Sectie ${i + 1}`;
339
+ const btn = document.createElement('button');
340
+ btn.className = 'section-tab-btn' + (i === 0 ? ' active' : '');
341
+ btn.textContent = label;
342
+ btn.onclick = () => renderSection(i);
343
+ bar.appendChild(btn);
344
+ });
345
+ }
346
+
347
+ function renderSection(index) {
348
+ _activeLayoutIndex = index;
349
+ const generation = ++_sectionGeneration; // snapshot for stale-fetch detection
350
+
351
+ document.querySelectorAll('.section-tab-btn').forEach((btn, i) =>
352
+ btn.classList.toggle('active', i === index));
353
+
354
+ const layout = _dashboardData.layouts[index];
355
+ document.getElementById('json-viewer').innerHTML = formatJson(layout);
356
+
357
+ const container = document.getElementById('sections-container');
358
+ container.innerHTML = '';
359
+
360
+ const section = document.createElement('mrd-layout-section');
361
+ section.items = layout.items;
362
+ section.data = _dashboardRecord ?? { _links: _dashboardData._links ?? {} };
363
+ section.locale = _locale;
364
+
365
+ section.addEventListener('mrdLoadViewPage', async (e) => {
366
+ if (generation !== _sectionGeneration) return;
367
+ const { name, page, path, qs } = e.detail;
368
+ logEvent('mrdLoadViewPage', e.detail);
369
+ try {
370
+ const url = `${_baseHref}${path}${qs ? '?' + qs : ''}`;
371
+ const result = await apiFetchPage(authGetToken(), url, page);
372
+ const rows = Object.values(result._embedded ?? {})[0] ?? [];
373
+ const total = result.page?.totalElements;
374
+ await section.setViewPage(name, page, rows, total);
375
+ } catch (err) {
376
+ console.error('[mrdLoadViewPage] mislukt', name, err);
327
377
  }
378
+ });
328
379
 
329
- // Register mrdLoadPage before init() so scroll events are caught immediately
330
- table.addEventListener('mrdLoadPage', async (e) => {
331
- const { page, sort } = e.detail;
332
- try {
333
- const params = buildParams(sort);
334
- const qs = params.toString();
335
- const url = qs ? `${_tableDataHref}?${qs}` : _tableDataHref;
380
+ section.addEventListener('mrdLoadViewAggregations', async (e) => {
381
+ if (generation !== _sectionGeneration) return;
382
+ const { name, path, qs: filterQs, sum, avg, count } = e.detail;
383
+ logEvent('mrdLoadViewAggregations', e.detail);
384
+ try {
385
+ const aggUrl = `${_baseHref}${path}/aggregations`;
386
+ const p = new URLSearchParams(filterQs ?? '');
387
+ p.delete('page');
388
+ p.delete('sort');
389
+ if (sum?.length) p.set('sum', sum.join(','));
390
+ if (avg?.length) p.set('avg', avg.join(','));
391
+ if (count?.length) p.set('count', count.join(','));
392
+ const qs = p.toString();
393
+ const result = await apiRequest('GET', qs ? `${aggUrl}?${qs}` : aggUrl, authGetToken());
394
+ if (result.ok) await section.setViewAggregations(name, result.body);
395
+ } catch (err) {
396
+ console.error('[mrdLoadViewAggregations] mislukt', name, err);
397
+ }
398
+ });
336
399
 
337
- const result = await apiFetchPage(authGetToken(), url, page);
338
- const embedded = result._embedded ?? {};
339
- const rows = Object.values(embedded)[0] ?? [];
340
- await table.setPage(page, rows);
341
- } catch (err) {
342
- console.error('[mrd-table] pagina laden mislukt', page, err);
343
- }
344
- });
400
+ section.addEventListener('mrdViewAction', async (e) => {
401
+ logEvent('mrdViewAction', e.detail);
402
+ });
345
403
 
346
- // Open form with existing record on row click
347
- table.addEventListener('mrdRowClick', async (e) => {
348
- await loadRecord(e.detail);
349
- });
404
+ section.addEventListener('mrdNavigate', async (e) => {
405
+ if (generation !== _sectionGeneration) return;
406
+ await handleNavigate(e.detail);
407
+ });
408
+ section.addEventListener('mrdDownload', (e) => logEvent('mrdDownload', e.detail));
409
+ section.addEventListener('mrdSearch', (e) => logEvent('mrdSearch', e.detail));
350
410
 
351
- // Table-level action buttons (toolbar)
352
- table.addEventListener('mrdAction', (e) => {
353
- logEvent('mrdAction', e.detail);
354
- if (e.detail.action === 'create') loadForm();
355
- });
411
+ container.appendChild(section);
412
+ }
356
413
 
357
- // Store filters; mrdLoadPage will pick them up automatically
358
- table.addEventListener('mrdFilter', (e) => {
359
- logEvent('mrdFilter', e.detail);
360
- _activeFilters = e.detail.filters;
361
- });
362
414
 
363
- // Fetch aggregation totals from /aggregations endpoint with same filter params
364
- table.addEventListener('mrdLoadAggregations', async (e) => {
365
- const { sum, avg, count } = e.detail;
366
- try {
367
- const aggUrl = _tableDataHref.split('?')[0] + '/aggregations';
368
- const params = buildParams('');
369
- if (sum?.length) params.set('sum', sum.join(','));
370
- if (avg?.length) params.set('avg', avg.join(','));
371
- if (count?.length) params.set('count', count.join(','));
372
- const qs = params.toString();
373
- const result = await apiRequest('GET', qs ? `${aggUrl}?${qs}` : aggUrl, authGetToken());
374
- if (result.ok) await table.setAggregations(result.body);
375
- } catch (err) {
376
- console.error('[mrdLoadAggregations] mislukt', err);
377
- }
378
- });
415
+ function toggleJsonViewer() {
416
+ const viewer = document.getElementById('json-viewer');
417
+ const btn = document.getElementById('btn-json-toggle');
418
+ const open = !viewer.classList.toggle('hidden');
419
+ btn.classList.toggle('active', open);
420
+ }
421
+
422
+ function updateBackButton() {
423
+ const btn = document.getElementById('btn-nav-back');
424
+ if (btn) btn.classList.toggle('hidden', _navHistory.length === 0);
425
+ }
379
426
 
380
- // Switch to an alternative view: swap current and clicked view
381
- table.addEventListener('mrdSwitchView', async (e) => {
382
- const newKey = e.detail.name;
383
- const newView = _dashboard?.views?.[newKey];
384
- const newHref = _dashboard?._links?.[newKey]?.href;
385
- if (!newView || !newHref) { console.error('[mrdSwitchView] view niet gevonden:', newKey); return; }
386
-
387
- const clickedAlt = alternativeViews.find(av => av.name === newKey);
388
- const newViewLabel = clickedAlt?.label ?? newKey;
389
- const layoutItem = _dashboard.layouts?.[0]?.items?.[0];
390
-
391
- // Old current becomes an alternative; clicked alternative becomes current
392
- const newAlts = [
393
- { name: _currentViewKey, label: viewLabel, class: layoutItem?.class ?? '' },
394
- ...alternativeViews.filter(av => av.name !== newKey),
395
- ];
396
-
397
- _currentViewKey = newKey;
398
- _tableDataHref = newHref.replace(/([?&])page=\d+/, '').replace(/\?$/, '');
399
- viewLabel = newViewLabel;
400
- alternativeViews = newAlts;
401
- viewFilter = newView.filter ?? [];
402
- _activeFilters = [];
427
+ function syncDashboardTypeUI(type, objectHref) {
428
+ _dashboardType = type;
429
+ document.getElementById('dashboard-type-select').value = type;
430
+ const isObject = type === 'object' || type === 'form';
431
+ document.getElementById('controls-class-group').classList.toggle('hidden', type !== 'class');
432
+ document.getElementById('controls-object-group').classList.toggle('hidden', !isObject);
433
+ if (isObject && objectHref) {
434
+ document.getElementById('object-href-input').value = objectHref;
435
+ }
436
+ }
403
437
 
404
- try {
405
- const newDefaultSort = newView.defaultSort ?? '';
406
- const page0 = await apiFetchPage(authGetToken(), applyViewFilter(_tableDataHref, viewFilter), 0, newDefaultSort);
407
- const meta = page0.page ?? {};
408
- const rows0 = Object.values(page0._embedded ?? {})[0] ?? [];
409
-
410
- table.columns = (newView.values ?? []).map(mapApiItem);
411
- table.defaultSort = newDefaultSort;
412
- table.totalElements = meta.totalElements ?? 0;
413
- table.pageSize = meta.size ?? 20;
414
- table.viewLabel = viewLabel;
415
- table.alternativeViews = alternativeViews;
416
-
417
- await table.init();
418
- await table.setPage(0, rows0);
419
- } catch (err) {
420
- console.error('[mrdSwitchView] laden mislukt', err);
421
- }
422
- });
438
+ function navigateBack() {
439
+ if (_navHistory.length === 0) return;
440
+ const prev = _navHistory.pop();
441
+ _dashboardData = prev.dashboardData;
442
+ _dashboardRecord = prev.dashboardRecord;
443
+ _activeLayoutIndex = prev.activeLayoutIndex;
444
+ syncDashboardTypeUI(prev.dashboardType ?? _dashboardType, prev.objectHref ?? '');
445
+ renderSectionTabs(_dashboardData.layouts ?? []);
446
+ renderSection(_activeLayoutIndex);
447
+ updateBackButton();
448
+ }
423
449
 
424
- await table.init();
425
- await table.setPage(0, page0Rows); // inject pre-fetched page 0 — no extra request
450
+ function pushHistory() {
451
+ _navHistory.push({
452
+ dashboardData: _dashboardData,
453
+ dashboardRecord: _dashboardRecord,
454
+ activeLayoutIndex: _activeLayoutIndex,
455
+ dashboardType: _dashboardType,
456
+ objectHref: document.getElementById('object-href-input').value,
426
457
  });
427
458
  }
428
459
 
460
+ async function navigateToObjectDashboard(selfHref) {
461
+ if (!selfHref) return;
462
+
463
+ document.getElementById('sections-container').innerHTML =
464
+ '<span class="spinner"></span> Object laden...';
465
+ document.getElementById('panel-sections').classList.remove('hidden');
466
+
467
+ try {
468
+ const recResp = await apiRequest('GET', selfHref, authGetToken());
469
+ if (!recResp.ok) throw new Error(`${recResp.status}: object ophalen mislukt`);
470
+ const record = recResp.body;
471
+
472
+ const dashHref = record._links?.metadata?.href;
473
+ if (!dashHref) throw new Error('Geen _links.metadata gevonden in object');
474
+
475
+ const dashResp = await apiRequest('GET', dashHref, authGetToken());
476
+ if (!dashResp.ok) throw new Error(`${dashResp.status}: dashboard ophalen mislukt`);
477
+ const dashboard = dashResp.body;
478
+
479
+ pushHistory();
480
+ _dashboardData = dashboard;
481
+ _dashboardRecord = record;
482
+ _activeLayoutIndex = 0;
483
+
484
+ const displayHref = selfHref.startsWith('http') ? selfHref : API_BASE + selfHref;
485
+ syncDashboardTypeUI('object', displayHref);
486
+ renderSectionTabs(dashboard.layouts ?? []);
487
+ renderSection(0);
488
+ updateBackButton();
489
+ } catch (err) {
490
+ document.getElementById('sections-container').innerHTML =
491
+ `<span style="color:var(--mrd-color-danger)">❌ ${escHtml(err.message)}</span>`;
492
+ }
493
+ }
494
+
495
+ async function navigateToClassDashboard(pluralName) {
496
+ if (!_selectedTenant || !pluralName) return;
497
+
498
+ document.getElementById('sections-container').innerHTML =
499
+ '<span class="spinner"></span> Dashboard laden...';
500
+ document.getElementById('panel-sections').classList.remove('hidden');
501
+
502
+ try {
503
+ const dashboard = await apiFetchClassDashboard(authGetToken(), _selectedTenant, pluralName);
504
+
505
+ pushHistory();
506
+ _dashboardData = dashboard;
507
+ _dashboardRecord = null;
508
+ _activeLayoutIndex = 0;
509
+
510
+ syncDashboardTypeUI('class', '');
511
+ const sel = document.getElementById('type-select-compact');
512
+ if (sel.querySelector(`option[value="${pluralName}"]`)) sel.value = pluralName;
513
+
514
+ renderSectionTabs(dashboard.layouts ?? []);
515
+ renderSection(0);
516
+ updateBackButton();
517
+ } catch (err) {
518
+ document.getElementById('sections-container').innerHTML =
519
+ `<span style="color:var(--mrd-color-danger)">❌ ${escHtml(err.message)}</span>`;
520
+ }
521
+ }
522
+
523
+ async function handleNavigate(detail) {
524
+ const { href, label, navigate } = detail;
525
+ if (href) {
526
+ logEvent('mrdNavigate', { href, label });
527
+ await navigateToObjectDashboard(href);
528
+ } else if (navigate?.dataClass) {
529
+ logEvent('mrdNavigate', { dataClass: navigate.dataClass, navigationType: navigate.navigationType, label });
530
+ await navigateToClassDashboard(navigate.dataClass);
531
+ } else {
532
+ logEvent('mrdNavigate', detail);
533
+ }
534
+ }
535
+
429
536
  /* =====================================================================
430
537
  FORM — LOAD & RENDER
431
538
  ===================================================================== */
@@ -438,37 +545,29 @@ function renderTable(columns, totalElements, pageSize, page0Rows, dataHref, defa
438
545
  async function loadRecord(row) {
439
546
  const selfHref = row?._links?.self?.href;
440
547
  if (!selfHref) { console.warn('[loadRecord] geen _links.self.href in rij', row); return; }
441
-
442
- const sel = document.getElementById('type-select');
443
- if (!sel.value) return;
548
+ if (!_selectedType) return;
444
549
 
445
550
  const formPanel = document.getElementById('panel-form');
446
551
  formPanel.classList.remove('hidden');
447
552
  formPanel.innerHTML = '<span class="spinner"></span> Record laden...';
448
- document.getElementById('panel-table').classList.add('hidden');
449
553
  document.getElementById('panel-response').innerHTML = '';
450
554
 
451
555
  try {
452
- // Fetch the record first so we can read _links.form.href for the exact layout URL
453
556
  const recordResp = await apiRequest('GET', selfHref, authGetToken());
454
557
  if (!recordResp.ok) throw new Error(`${recordResp.status}: kon record niet ophalen`);
455
558
 
456
559
  const formHref = recordResp.body?._links?.form?.href ?? null;
457
560
  const layout = await apiFetchForm(authGetToken(), _selectedTenant, _selectedType.pluralName, formHref);
458
- renderForm(layout, recordResp.body, selfHref);
561
+ await renderForm(layout, recordResp.body, selfHref);
459
562
  } catch (err) {
460
563
  formPanel.innerHTML = `<span style="color:var(--mrd-color-danger)">❌ ${escHtml(err.message)}</span>`;
461
564
  }
462
565
  }
463
566
 
464
567
  async function loadForm() {
465
- const sel = document.getElementById('type-select');
466
- const pluralName = sel.value;
568
+ const pluralName = _selectedType?.pluralName;
467
569
  if (!pluralName) return;
468
570
 
469
- const opt = sel.options[sel.selectedIndex];
470
- _selectedType = { name: opt.dataset.name || pluralName, pluralName };
471
-
472
571
  const formPanel = document.getElementById('panel-form');
473
572
  formPanel.classList.remove('hidden');
474
573
  formPanel.innerHTML = '<span class="spinner"></span> Formulier laden...';
@@ -476,7 +575,7 @@ async function loadForm() {
476
575
 
477
576
  try {
478
577
  const layout = await apiFetchForm(authGetToken(), _selectedTenant, pluralName);
479
- renderForm(layout);
578
+ await renderForm(layout);
480
579
  } catch (err) {
481
580
  formPanel.innerHTML = `<span style="color:var(--mrd-color-danger)">❌ ${escHtml(err.message)}</span>`;
482
581
  }
@@ -489,21 +588,21 @@ async function loadForm() {
489
588
  * @param record - existing record for edit mode (null = new record)
490
589
  * @param selfHref - absolute URL for PATCH; null = POST (new record)
491
590
  */
492
- function renderForm(layout, record = null, selfHref = null) {
591
+ async function renderForm(layout, record = null, selfHref = null) {
493
592
  // Build relation metadata map keyed by both relatedClass and mostSignificantClass
494
593
  // so lookups work regardless of which value arrives in events (mrdSearch uses mostSignificantClass).
495
594
  _relationMeta = {};
496
595
  function collectRelations(items) {
497
596
  if (!Array.isArray(items)) return;
498
597
  items.forEach(item => {
499
- if (item.type === 'RELATION' && item.relation) {
598
+ if (item.type === 'RELATION' && item.relatedClass) {
500
599
  const meta = {
501
- name: item.relation.name,
502
- mostSignificantClass: item.relation.mostSignificantClass,
600
+ name: item.name,
601
+ mostSignificantClass: item.mostSignificantClass,
503
602
  };
504
- _relationMeta[item.relation.relatedClass] = meta;
505
- if (item.relation.mostSignificantClass) {
506
- _relationMeta[item.relation.mostSignificantClass] = meta;
603
+ _relationMeta[item.relatedClass] = meta;
604
+ if (item.mostSignificantClass) {
605
+ _relationMeta[item.mostSignificantClass] = meta;
507
606
  }
508
607
  }
509
608
  if (Array.isArray(item.items)) collectRelations(item.items);
@@ -532,14 +631,21 @@ function renderForm(layout, record = null, selfHref = null) {
532
631
  }
533
632
 
534
633
  const formPanel = document.getElementById('panel-form');
535
- formPanel.innerHTML = `<mrd-form id="live-form" locale="${_locale}"></mrd-form>`;
634
+ formPanel.innerHTML = `<mrd-form id="live-form" locale="${escHtml(_locale)}"></mrd-form>`;
536
635
 
537
- customElements.whenDefined('mrd-form').then(() => {
538
- const form = document.getElementById('live-form');
539
- form.layout = layout;
540
- if (record) form.values = initialValues;
636
+ const form = document.getElementById('live-form');
637
+ // Wait for Stencil to fully initialize this specific instance before setting props.
638
+ // componentOnReady() resolves after componentDidLoad — more reliable than customElements.whenDefined.
639
+ if (typeof form.componentOnReady === 'function') {
640
+ await form.componentOnReady();
641
+ } else {
642
+ await customElements.whenDefined('mrd-form');
643
+ }
541
644
 
542
- // Upload files immediately on selection; write binary URI back via setFieldValue
645
+ form.layout = layout;
646
+ if (record) form.values = initialValues;
647
+
648
+ // Upload files immediately on selection; write binary URI back via setFieldValue
543
649
  form.addEventListener('mrdChange', async (e) => {
544
650
  const { name, value } = e.detail;
545
651
  if (!(value instanceof File)) return;
@@ -645,7 +751,6 @@ function renderForm(layout, record = null, selfHref = null) {
645
751
  console.error('[mrdFetchAll] failed:', err);
646
752
  }
647
753
  });
648
- });
649
754
  }
650
755
 
651
756
  /* =====================================================================
@@ -695,15 +800,13 @@ customElements.whenDefined('mrd-form').then(() => {
695
800
 
696
801
  document.getElementById('locale-select').addEventListener('change', (e) => {
697
802
  _locale = e.target.value;
698
- // Update embedded demo form
699
803
  const demoForm = document.getElementById('demo-form');
700
804
  if (demoForm) demoForm.locale = _locale;
701
- // Update live table if present
702
- const liveTable = document.getElementById('live-table');
703
- if (liveTable) liveTable.locale = _locale;
704
- // Update live form if present
705
805
  const liveForm = document.getElementById('live-form');
706
806
  if (liveForm) liveForm.locale = _locale;
807
+ // Update actieve section als aanwezig
808
+ const section = document.querySelector('#sections-container mrd-layout-section');
809
+ if (section) section.locale = _locale;
707
810
  });
708
811
 
709
812
  document.getElementById('btn-inject-results').addEventListener('click', () => {