Skip to content

Commit 4aab2e4

Browse files
delchevclaude
andauthored
feat(harmonia): a view: calendar child renders as an embedded calendar on the DOCUMENT surface too - power and personal (#6364)
The embedded calendar for a composition child (intent view: calendar) existed on the master layout's detail panels and the my-form child panels, but a DOCUMENT master rendered the same child as a plain table in its secondary panels - and the personal document did not render non-item children at all. The concrete case: a day-allocation calendar on a timesheet document. - perspective/document: the secondary detail panels honor def.calendar exactly like the master layout (embedded x-h-calendar; event-click edits the child, empty-day click creates one with the document FK + clicked date preset) - the shared detailPanel component already carried the whole behavior. - my-document: renders the non-item scope-inheriting children below the totals (the SAME panels the my-form renders - calendar for a view: calendar child, table otherwise), reading through the children's scoped My controllers. - embedded event titles naming a RELATION resolve to the referenced label (the raw-FK-id class, completing the standalone-calendar fix): detailPanel reuses its existing column lookups and re-renders events when maps arrive; the my-form/my-document child calendars fetch the title lookup emitted into the child config (parameterUtils), raw value as the fail-soft fallback. IntentEmissionCoverageIT: the fixture gains TicketVisit (view: calendar composition child of the personal chat Document, relation title) and asserts the power document's calendar branch, the detail registration's calendar + lookup config, and the personal document's children loading, titleLookup and panel markup. Ran green locally (1/1). Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent d608107 commit 4aab2e4

7 files changed

Lines changed: 259 additions & 6 deletions

File tree

components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/detailPanel.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ function detailPanel(def, masterId) {
6767
}
6868
}
6969
this.lookups = all;
70+
// A calendar def re-renders its events once the maps arrive - a title naming a relation
71+
// column resolves to the referenced label instead of the raw FK id.
72+
if (this.def.calendar) this.calCfg = { view: this.calCfg.view, events: this.buildEvents() };
7073
this.refreshIcons();
7174
},
7275

@@ -160,8 +163,17 @@ function detailPanel(def, masterId) {
160163
eventTitle(row) {
161164
const cal = this.def.calendar;
162165
if (cal.title) {
163-
const t = row[cal.title];
164-
if (t !== undefined && t !== null && String(t) !== '') return String(t);
166+
const v = row[cal.title];
167+
// A title naming a RELATION column resolves to its referenced label, exactly like the
168+
// table cell does; the raw value stays the fallback for dangling FKs / unloaded maps.
169+
const col = (this.def.columns || []).find(c => c.name === cal.title && c.lookup);
170+
if (col) {
171+
const m = this.lookups[cal.title];
172+
const ref = m ? m[v] : undefined;
173+
const t = ref ? ref[col.lookup.text] : undefined;
174+
if (t !== undefined && t !== null && String(t) !== '') return String(t);
175+
}
176+
if (v !== undefined && v !== null && String(v) !== '') return String(v);
165177
}
166178
return this.def.label + ' #' + row[this.def.primaryKey];
167179
},

components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-document-page.js.template

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ document.addEventListener('alpine:init', () => {
9292
#end
9393
this.itemsEnabled = true;
9494
await this.loadItems();
95+
if (this.loadChildren) this.loadChildren();
9596
this.state = 'default';
9697
} catch (e) {
9798
this.error = (e && e.message) || 'Could not load the document.';
@@ -334,5 +335,121 @@ document.addEventListener('alpine:init', () => {
334335
},
335336

336337
goBack() { this.navigate('/my/${name}'); },
338+
#set($docChildren = [])
339+
#if($myChildren)
340+
#foreach($child in $myChildren)
341+
#if($child.name != "$!{documentItemsEntity}")
342+
#set($ignore = $docChildren.add($child))
343+
#end
344+
#end
345+
#end
346+
#if($docChildren.size() > 0)
347+
348+
// --- non-item children inheriting the personal scope (panels below the document) --------------
349+
// The SAME panels the my-form renders: an embedded calendar for a view: calendar child (e.g. a
350+
// day-allocation calendar on a timesheet document), a table otherwise.
351+
children: [
352+
#foreach($child in $docChildren)
353+
{ name: '${child.name}', label: '${child.label}', fkProperty: '${child.fkProperty}',
354+
apiPath: '${child.apiPath}', rows: [], state: 'loading',
355+
calendar: #if($child.calendar){ start: '${child.calendar.start}', end: #if($child.calendar.end)'${child.calendar.end}'#{else}null#end, title: #if($child.calendar.title)'${child.calendar.title}'#{else}null#end, titleLookup: #if($child.calendar.titleLookup){ url: '${child.calendar.titleLookup.url}', key: '${child.calendar.titleLookup.key}', value: '${child.calendar.titleLookup.value}' }#{else}null#end, view: '${child.calendar.view}' }#{else}null#end,
356+
calCfg: { view: '#if($child.calendar)${child.calendar.view}#{else}month#end', events: [] },
357+
columns: [#foreach($col in $child.columns){ name: '${col.name}', label: '${col.label}'#if($col.number), number: true#end#if($col.date), date: true#end }#if($foreach.hasNext), #end#end] },
358+
#end
359+
],
360+
361+
async loadChildren() {
362+
for (const child of this.children) {
363+
try {
364+
child.rows = await App.services.api.get(child.apiPath + '?' + encodeURIComponent(child.fkProperty) + '=' + encodeURIComponent(this.id)) || [];
365+
if (child.calendar) {
366+
// a title naming a RELATION resolves to its referenced label (fail-soft: raw value stays)
367+
if (child.calendar.titleLookup && !child.titleMap) {
368+
try {
369+
const rows = await App.services.api.getAll(child.calendar.titleLookup.url, { baseUrl: '' });
370+
const m = {};
371+
(rows || []).forEach(r => { m[r[child.calendar.titleLookup.key]] = r[child.calendar.titleLookup.value]; });
372+
child.titleMap = m;
373+
} catch (e) { child.titleMap = {}; }
374+
}
375+
child.calCfg = { view: child.calendar.view, events: this.buildChildEvents(child) };
376+
}
377+
child.state = 'default';
378+
} catch (e) {
379+
child.state = 'error';
380+
}
381+
}
382+
this.refreshIcons && this.refreshIcons();
383+
},
384+
385+
// Row -> event mapping, the same conventions as the shared detailPanel (Jackson java.time
386+
// arrays / epoch seconds / ISO strings normalize via childToISO).
387+
buildChildEvents(child) {
388+
return (child.rows || []).map(row => {
389+
const start = this.childToISO(row[child.calendar.start]);
390+
if (!start) return null;
391+
const ev = { id: row.Id, title: this.childEventTitle(child, row), start: start, allDay: true };
392+
if (child.calendar.end) {
393+
const end = this.childToISO(row[child.calendar.end]);
394+
if (end) ev.end = end;
395+
}
396+
return ev;
397+
}).filter(Boolean);
398+
},
399+
childEventTitle(child, row) {
400+
if (child.calendar.title) {
401+
const v = row[child.calendar.title];
402+
const m = child.titleMap;
403+
const resolved = m ? m[v] : undefined;
404+
if (resolved !== undefined && resolved !== null && String(resolved) !== '') return String(resolved);
405+
if (v !== undefined && v !== null && String(v) !== '') return String(v);
406+
}
407+
return child.label + ' #' + row.Id;
408+
},
409+
childToISO(v) {
410+
if (v === undefined || v === null || v === '') return '';
411+
if (Array.isArray(v)) {
412+
const p = n => String(n).padStart(2, '0');
413+
const date = v[0] + '-' + p(v[1]) + '-' + p(v[2]);
414+
if (v.length <= 3) return date;
415+
return date + 'T' + p(v[3] || 0) + ':' + p(v[4] || 0) + ':' + p(v[5] || 0);
416+
}
417+
if (typeof v === 'number') {
418+
const ms = v < 1e12 ? v * 1000 : v;
419+
try { return new Date(ms).toISOString(); } catch (e) { return ''; }
420+
}
421+
return String(v);
422+
},
423+
displayChild(child, row, col) {
424+
const v = row[col.name];
425+
if (v == null || v === '') return '—';
426+
if (col.date) return this.childToISO(v).split('T')[0];
427+
return String(v);
428+
},
429+
430+
addChild(child) {
431+
this.navigate('/my/' + child.name + '/create?' + encodeURIComponent(child.fkProperty) + '='
432+
+ encodeURIComponent(this.id) + '&returnTo=' + encodeURIComponent('/my/${name}/' + this.id + '/edit'));
433+
},
434+
editChild(child, id) {
435+
this.navigate('/my/' + child.name + '/' + id + '/edit?returnTo=' + encodeURIComponent('/my/${name}/' + this.id + '/edit'));
436+
},
437+
onChildEventClick(child, e) {
438+
const id = e && e.detail && e.detail.event ? e.detail.event.id : null;
439+
if (id) this.editChild(child, id);
440+
},
441+
onChildDateClick(child, e) {
442+
let q = '?' + encodeURIComponent(child.fkProperty) + '=' + encodeURIComponent(this.id)
443+
+ '&returnTo=' + encodeURIComponent('/my/${name}/' + this.id + '/edit');
444+
const d = e && e.detail ? e.detail.date : null;
445+
if (d instanceof Date && !isNaN(d.getTime())) {
446+
const p = n => String(n).padStart(2, '0');
447+
let val = d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate());
448+
if (e.detail.time) val += 'T' + e.detail.time;
449+
q += '&' + encodeURIComponent(child.calendar.start) + '=' + encodeURIComponent(val);
450+
}
451+
this.navigate('/my/' + child.name + '/create' + q);
452+
},
453+
#end
337454
}));
338455
});

components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-document-view.html.template

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,68 @@
184184
</div>
185185
#end
186186

187+
#set($docChildren = [])
188+
#if($myChildren)
189+
#foreach($child in $myChildren)
190+
#if($child.name != "$!{documentItemsEntity}")
191+
#set($ignore = $docChildren.add($child))
192+
#end
193+
#end
194+
#end
195+
#if($docChildren.size() > 0)
196+
197+
<!-- Non-item children inheriting the personal scope: an embedded calendar (view: calendar on
198+
the child) or a table - the SAME panels the my-form renders (e.g. the day-allocation
199+
calendar on a timesheet document). Only once the document exists. -->
200+
<template x-if="mode === 'edit'">
201+
<div class="vbox gap-4" style="max-width: 900px">
202+
<template x-for="child in children" :key="child.name">
203+
<div x-h-card>
204+
<div x-h-card-header class="hbox items-center">
205+
<span x-h-card-title x-text="child.label"></span>
206+
<div class="grow"></div>
207+
<button x-h-button data-variant="primary" data-size="sm" @click="addChild(child)">
208+
<i role="img" x-h-lucide data-lucide="plus"></i>
209+
<span x-text="T('$projectName:${tprefix}.defaults.add', 'Add')"></span>
210+
</button>
211+
</div>
212+
<div x-h-card-content>
213+
<div x-show="child.state === 'loading'"><div x-h-spinner></div></div>
214+
<template x-if="child.calendar">
215+
<div x-show="child.state === 'default'" style="min-height: 420px">
216+
<div x-h-calendar="child.calCfg" style="height: 420px"
217+
@event-click="onChildEventClick(child, $event)" @date-click="onChildDateClick(child, $event)"></div>
218+
</div>
219+
</template>
220+
<template x-if="!child.calendar">
221+
<div x-show="child.state === 'default'" x-h-table-container.scroll style="max-height: 320px">
222+
<table x-h-table data-borders="rows">
223+
<thead x-h-table-header>
224+
<tr x-h-table-row>
225+
<template x-for="col in child.columns" :key="col.name">
226+
<th x-h-table-head scope="col" :class="col.number ? 'text-right' : ''" x-text="col.label"></th>
227+
</template>
228+
</tr>
229+
</thead>
230+
<tbody x-h-table-body>
231+
<template x-for="row in child.rows" :key="row.Id">
232+
<tr x-h-table-row data-hoverable="true" class="cursor-pointer" @click="editChild(child, row.Id)">
233+
<template x-for="col in child.columns" :key="col.name">
234+
<td x-h-table-cell :class="col.number ? 'text-right' : ''" x-text="displayChild(child, row, col)"></td>
235+
</template>
236+
</tr>
237+
</template>
238+
</tbody>
239+
</table>
240+
</div>
241+
</template>
242+
</div>
243+
</div>
244+
</template>
245+
</div>
246+
</template>
247+
#end
248+
187249
<div class="hbox gap-2" style="max-width: 900px">
188250
<button x-h-button data-variant="negative" x-show="mode === 'edit'" @click="deleteOpen = true"
189251
x-text="T('$projectName:${tprefix}.defaults.delete', 'Delete')"></button>

components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-form-page.js.template

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ document.addEventListener('alpine:init', () => {
182182
#foreach($child in $myChildren)
183183
{ name: '${child.name}', label: '${child.label}', fkProperty: '${child.fkProperty}',
184184
apiPath: '${child.apiPath}', rows: [], state: 'loading',
185-
calendar: #if($child.calendar){ start: '${child.calendar.start}', end: #if($child.calendar.end)'${child.calendar.end}'#{else}null#end, title: #if($child.calendar.title)'${child.calendar.title}'#{else}null#end, view: '${child.calendar.view}' }#{else}null#end,
185+
calendar: #if($child.calendar){ start: '${child.calendar.start}', end: #if($child.calendar.end)'${child.calendar.end}'#{else}null#end, title: #if($child.calendar.title)'${child.calendar.title}'#{else}null#end, titleLookup: #if($child.calendar.titleLookup){ url: '${child.calendar.titleLookup.url}', key: '${child.calendar.titleLookup.key}', value: '${child.calendar.titleLookup.value}' }#{else}null#end, view: '${child.calendar.view}' }#{else}null#end,
186186
calCfg: { view: '#if($child.calendar)${child.calendar.view}#{else}month#end', events: [] },
187187
columns: [#foreach($col in $child.columns){ name: '${col.name}', label: '${col.label}'#if($col.number), number: true#end#if($col.date), date: true#end }#if($foreach.hasNext), #end#end] },
188188
#end
@@ -192,7 +192,18 @@ document.addEventListener('alpine:init', () => {
192192
for (const child of this.children) {
193193
try {
194194
child.rows = await App.services.api.get(child.apiPath + '?' + encodeURIComponent(child.fkProperty) + '=' + encodeURIComponent(this.id)) || [];
195-
if (child.calendar) child.calCfg = { view: child.calendar.view, events: this.buildEvents(child) };
195+
if (child.calendar) {
196+
// a title naming a RELATION resolves to its referenced label (fail-soft: raw value stays)
197+
if (child.calendar.titleLookup && !child.titleMap) {
198+
try {
199+
const rows = await App.services.api.getAll(child.calendar.titleLookup.url, { baseUrl: '' });
200+
const m = {};
201+
(rows || []).forEach(r => { m[r[child.calendar.titleLookup.key]] = r[child.calendar.titleLookup.value]; });
202+
child.titleMap = m;
203+
} catch (e) { child.titleMap = {}; }
204+
}
205+
child.calCfg = { view: child.calendar.view, events: this.buildEvents(child) };
206+
}
196207
child.state = 'default';
197208
} catch (e) {
198209
child.state = 'error';
@@ -217,8 +228,11 @@ document.addEventListener('alpine:init', () => {
217228
},
218229
eventTitle(child, row) {
219230
if (child.calendar.title) {
220-
const t = row[child.calendar.title];
221-
if (t !== undefined && t !== null && String(t) !== '') return String(t);
231+
const v = row[child.calendar.title];
232+
const m = child.titleMap;
233+
const resolved = m ? m[v] : undefined;
234+
if (resolved !== undefined && resolved !== null && String(resolved) !== '') return String(resolved);
235+
if (v !== undefined && v !== null && String(v) !== '') return String(v);
222236
}
223237
return child.label + ' #' + row.Id;
224238
},

components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/document/document-view.html.template

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,15 @@
298298
<div x-show="state === 'loading'"><div x-h-spinner></div></div>
299299
<div x-show="state === 'error'" x-h-text.muted x-text="error"></div>
300300
<div x-show="state === 'empty'" x-h-text.muted x-text="T('$projectName:${tprefix}.messages.noData', 'No records.')"></div>
301+
<!-- A calendar detail (intent view: calendar on the composition child) renders the same
302+
document-filtered rows as events on an embedded calendar: event-click edits the child,
303+
empty-day click creates one with the document FK + the clicked date preset. -->
304+
<template x-if="def.calendar">
305+
<div x-show="state === 'default'" style="min-height: 420px">
306+
<div x-h-calendar="calCfg" style="height: 420px" @event-click="onEventClick" @date-click="onDateClick"></div>
307+
</div>
308+
</template>
309+
<template x-if="!def.calendar">
301310
<div x-show="state === 'default'" x-h-table-container.scroll style="max-height: 320px">
302311
<table x-h-table data-borders="rows">
303312
<thead x-h-table-header>
@@ -322,6 +331,7 @@
322331
</tbody>
323332
</table>
324333
</div>
334+
</template>
325335
</div>
326336

327337
<!-- Delete confirmation (manual corrective removal of a junction row → rollup recomputes). -->

components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/parameterUtils.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,14 @@ export function process(model, parameters) {
311311
start: c.calendarStartProperty || null,
312312
end: c.calendarEndProperty || null,
313313
title: c.calendarTitleProperty || null,
314+
// a title naming a RELATION resolves to its referenced label on the panel
315+
titleLookup: (() => {
316+
const tp = c.calendarTitleProperty;
317+
const p = tp && (c.properties || []).find(x => x.name === tp
318+
&& (x.widgetType === 'DROPDOWN' || x.widgetType === 'DOCUMENT_STATUS'));
319+
return p ? { url: p.widgetDropdownControllerUrl, key: p.widgetDropDownKey,
320+
value: p.widgetDropDownValue } : null;
321+
})(),
314322
view: c.calendarInitialView || 'month'
315323
} : null,
316324
columns: (c.properties || []).filter(cp => !cp.sensitiveProperty && !cp.dataAutoIncrement

tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,19 @@ class IntentEmissionCoverageIT extends IntegrationTest {
216216
relations:
217217
- { name: Ticket, kind: manyToOne, to: Ticket, composition: true, required: true }
218218
219+
# view: calendar on a NON-ITEM composition child of a personal DOCUMENT master - the
220+
# document surface renders it as an embedded calendar panel (power secondary panel +
221+
# my-document children), and the relation title resolves through a label lookup.
222+
- name: TicketVisit
223+
view: calendar
224+
calendar: { start: visitDate, title: Person }
225+
fields:
226+
- { name: id, type: integer, primaryKey: true, generated: true }
227+
- { name: visitDate, type: date, required: true }
228+
relations:
229+
- { name: Ticket, kind: manyToOne, to: Ticket, composition: true, required: true }
230+
- { name: Person, kind: manyToOne, to: Person }
231+
219232
# view: range + a personal owner - the PERSONAL surface must render the range
220233
# calendar (never the plain form+list), scoped to the MyController (U3 parity).
221234
- name: Leave
@@ -662,6 +675,23 @@ private void assertEmission() {
662675
assertTrue(myTicketPage.contains("sendMessage") && myTicketPage.contains("TicketMessageMyController"),
663676
"the personal chat composer must append through the personal items controller");
664677

678+
// view: calendar on a NON-ITEM composition child of a Document master - the document
679+
// surface renders the child as an embedded calendar panel on BOTH shells (the class where
680+
// the calendar was realized on the master layout but silently degraded to a table - or to
681+
// nothing - on the document layout), and a relation title resolves via the label lookup.
682+
String ticketDoc2 = contentOf("gen/emission/views/Ticket/Ticket-document.html");
683+
assertTrue(ticketDoc2.contains("def.calendar") && ticketDoc2.contains("x-h-calendar"),
684+
"the power document's secondary panels must render a calendar child as an embedded calendar");
685+
String visitRegister = contentOf("gen/emission/js/components/pages/Ticket/TicketVisit.detail.js");
686+
assertTrue(visitRegister.contains("calendar: {"), "the calendar child's detail registration must carry the calendar config");
687+
assertTrue(visitRegister.contains("lookup: {"),
688+
"the calendar child's relation columns must carry their label lookups (title resolution)");
689+
assertTrue(myTicketPage.contains("loadChildren") && myTicketPage.contains("TicketVisitMyController"),
690+
"the personal document must load its non-item children through their scoped controllers");
691+
assertTrue(myTicketPage.contains("titleLookup"),
692+
"the personal document's child calendar must resolve a relation title via the label lookup");
693+
assertTrue(myTicketDoc.contains("onChildEventClick"), "the personal document must render the child calendar panel markup");
694+
665695
// view: range/calendar + personal - the personal surface renders the calendar (never the
666696
// plain form+list), reads through the scoped controller, and /my/<Entity> lands on it.
667697
String myLeaveCalendar = contentOf("gen/emission/js/components/pages/my/LeaveMyCalendarPage.js");

0 commit comments

Comments
 (0)