From c62722eb7275de6ba5d66ba86ae71659fb3bdb86 Mon Sep 17 00:00:00 2001 From: delchev Date: Fri, 10 Jul 2026 16:55:29 +0300 Subject: [PATCH] feat(intent): embedded calendar panel for a composition child inside its master page view: calendar (or function: Calendar / view: range) on a COMPOSITION CHILD no longer requires remodeling the child as PRIMARY: the child stays a detail of its master (MANAGE_DETAILS layout, the detail registry, the master-filtered controller, its form pages) and the calendar becomes HOW the master renders its panel - an embedded x-h-calendar in place of the detail table, on both the master browse page and the record's edit form. Event-click edits the child; an empty-day click creates one with the master FK AND the clicked date preset (the shared form presets matching create query params). An empty month renders as a calendar, not a 'no records' note. EdmIntentGenerator branches: dependent + isCalendar -> detailCalendar marker + the calendar* properties (no MANAGE_CALENDAR layout, no standalone page); detail-register carries the calendar meta into the registration; the shared detailPanel maps master-filtered rows to events (same Jackson java.time conventions as the standalone calendar page); master-view + form-view render the x-h-calendar branch. Verified on a generated project: the registration carries calendar: { start: 'Day', title: 'Note', view: 'month', range: false }, both views carry the branch, the served shared panel has the event mapping, and the child keeps its detail contract. Unit: a dependent calendar child keeps MANAGE_DETAILS + the meta while a primary keeps MANAGE_CALENDAR. Suite 114/114. Co-Authored-By: Claude Fable 5 --- components/engine/engine-intent/README.md | 9 +- .../generator/edm/EdmIntentGenerator.java | 11 ++- .../generator/edm/EdmIntentGeneratorTest.java | 44 +++++++++ .../shell/js/components/detailPanel.js | 93 ++++++++++++++++++- .../manage/form-view.html.template | 10 ++ .../master/detail-register.js.template | 5 + .../master/master-view.html.template | 10 ++ 7 files changed, 178 insertions(+), 4 deletions(-) diff --git a/components/engine/engine-intent/README.md b/components/engine/engine-intent/README.md index dfd944f0dec..3ce376b57f2 100644 --- a/components/engine/engine-intent/README.md +++ b/components/engine/engine-intent/README.md @@ -186,6 +186,13 @@ included. For logic beyond an expression, a hand-written `CalculatedField` compo slots: { start: startTime } ``` +`view: calendar` on a **composition child** renders an **embedded calendar panel inside its master's +page** (and the master's edit form) instead of the detail table: the same master-filtered rows become +events, event-click edits the child, an empty-day click creates one with the master FK and the +clicked date preset. The child keeps everything a detail has (registry, filtered controller, form +pages) - the calendar is just how its panel renders. `range` works the same way; `slots` stays +primary-only. + ## uses - cross-model references Entities owned by another intent model are referenced read-only (a PROJECTION + FK + dropdown - no @@ -460,8 +467,6 @@ UI-test manifest, and its perspective in the generated Harmonia SPA + the shared be cross-model). - **`generates` completion hook** - flipping the SOURCE record's status after creating the target (`onDone`-style) is not yet expressible. -- **Embedded calendar panel for a DEPENDENT composition child** inside its master page - calendar - views require a PRIMARY entity today. - **Pipeline hardening follow-ups** (tracked on the emission-coverage IT): seed-row key validation at generate time, surfaced + retried CSVIM import failures, `checks:` violations mapped to 4xx, generator-version stamping of generated output. diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java index 81e111280ce..bfa733954af 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java @@ -190,7 +190,16 @@ private static EdmDocument buildDocument(IntentGenerationContext context, Intent // (and the Jackson-serialized controller row) the calendar page reads. if (entity.isCalendar()) { CalendarIntent cal = entity.getCalendar(); - entityMap.put("layoutType", "MANAGE_CALENDAR"); + if (dependent) { + // A composition child stays a DETAIL of its master (MANAGE_DETAILS layout, the + // detail registry, the master-filtered controller) - the calendar is HOW the + // master page renders this child's panel: an embedded x-h-calendar instead of + // the detail table. The flag + the calendar* properties ride the .model into + // detail-register.js; no standalone calendar page is generated. + entityMap.put("detailCalendar", "true"); + } else { + entityMap.put("layoutType", "MANAGE_CALENDAR"); + } if (cal != null) { if (notBlank(cal.getStart())) { entityMap.put("calendarStartProperty", IntentNaming.pascalCase(cal.getStart())); diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java index 5c6a4279786..76de4fd0baa 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java @@ -459,6 +459,50 @@ private static Map buildFromResource(String resource, String int } @SuppressWarnings("unchecked") + @Test + void dependentCalendarChildKeepsTheDetailLayoutAndCarriesTheCalendarMeta() { + String yaml = """ + name: work + entities: + - name: Timesheet + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: month, type: date } + relations: + - { name: days, kind: oneToMany, to: DayAllocation } + - name: DayAllocation + view: calendar + calendar: { start: day, title: note } + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: day, type: date } + - { name: note, type: string } + relations: + - { name: Timesheet, kind: manyToOne, to: Timesheet, composition: true, required: true } + - name: Meeting + view: calendar + calendar: { start: at } + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: at, type: timestamp } + """; + Map model = EdmIntentGenerator.buildModelJsonForTest(IntentParser.parse(yaml), "work"); + List> entities = entities(model); + + // A COMPOSITION CHILD with view: calendar stays a detail of its master (registry, filtered + // controller, form pages) - the calendar is HOW the master renders its panel. + Map child = entityByName(entities, "DayAllocation"); + assertEquals("MANAGE_DETAILS", child.get("layoutType")); + assertEquals("true", child.get("detailCalendar")); + assertEquals("Day", child.get("calendarStartProperty")); + assertEquals("Note", child.get("calendarTitleProperty")); + + // A PRIMARY calendar entity keeps the standalone calendar page as before. + Map primary = entityByName(entities, "Meeting"); + assertEquals("MANAGE_CALENDAR", primary.get("layoutType")); + assertEquals(null, primary.get("detailCalendar")); + } + private static List> entities(Map modelJson) { return (List>) ((Map) modelJson.get("model")).get("entities"); } diff --git a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/detailPanel.js b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/detailPanel.js index e736c5b8000..98fbb9095d2 100644 --- a/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/detailPanel.js +++ b/components/resources/application-core/src/main/resources/META-INF/dirigible/application-core/shell/js/components/detailPanel.js @@ -21,6 +21,12 @@ * `def` shape (from App.registerDetail): { entity, apiPath, masterEntityId, label, * columns: [{ name }], returnTo }. apiPath is relative to App.config.restBase (the api client * prepends it), so detail calls pass no baseUrl override. + * + * A def carrying `calendar: { start, end?, title?, color?, view, range }` (a composition child + * declared `view: calendar` in the intent) renders as an embedded x-h-calendar instead of the + * table: the same master-filtered rows become events; event-click edits the child, date-click + * creates one with the master FK AND the clicked date preset. An empty month is meaningful, so a + * calendar panel shows the calendar even with zero rows. */ function detailPanel(def, masterId) { return { @@ -33,6 +39,8 @@ function detailPanel(def, masterId) { deleteOpen: false, deleteTarget: null, deleteBusy: false, + // Reactive config for the embedded x-h-calendar (calendar defs only); rebuilt on every load. + calCfg: { view: (def.calendar && def.calendar.view) || 'month', events: [] }, lookups: {}, // relationship column name -> { fkValue: referencedRow } @@ -98,7 +106,12 @@ function detailPanel(def, masterId) { // would silently cap a detail with more rows and hide the ones past the first page. const q = '?' + encodeURIComponent(this.def.masterEntityId) + '=' + encodeURIComponent(this.masterId); this.rows = await App.services.api.getAll(this.def.apiPath + q); - this.state = this.rows.length === 0 ? 'empty' : 'default'; + if (this.def.calendar) { + this.calCfg = { view: this.def.calendar.view || 'month', events: this.buildEvents() }; + this.state = 'default'; + } else { + this.state = this.rows.length === 0 ? 'empty' : 'default'; + } } catch (e) { this.error = App.services.apiErrors.messageFor(e, 'Could not load ' + this.def.label + '.'); this.state = 'error'; @@ -122,6 +135,84 @@ function detailPanel(def, masterId) { window.PineconeRouter.navigate('/' + this.def.entity + '/' + encodeURIComponent(row[this.def.primaryKey]) + '/preview' + q); }, + // --- embedded calendar (calendar defs only) ------------------------------------------------- + // Row -> event mapping, the same conventions as the standalone calendar page: Jackson java.time + // arrays / epoch seconds / ISO strings normalize via toISO; rows with no start are skipped. + buildEvents() { + const cal = this.def.calendar; + return (this.rows || []).map(row => { + const start = this.toISO(row[cal.start]); + if (!start) return null; + const ev = { + id: String(row[this.def.primaryKey]), + title: this.eventTitle(row), + start: start, + allDay: cal.range ? true : this.isDateOnly(row[cal.start]), + }; + if (cal.end) { + const end = this.toISO(row[cal.end]); + if (end) ev.end = end; + } + if (cal.color) ev.color = this.colorFor(row[cal.color]); + return ev; + }).filter(Boolean); + }, + eventTitle(row) { + const cal = this.def.calendar; + if (cal.title) { + const t = row[cal.title]; + if (t !== undefined && t !== null && String(t) !== '') return String(t); + } + return this.def.label + ' #' + row[this.def.primaryKey]; + }, + toISO(v) { + if (v === undefined || v === null || v === '') return ''; + if (Array.isArray(v)) { + const p = n => String(n).padStart(2, '0'); + const date = v[0] + '-' + p(v[1]) + '-' + p(v[2]); + if (v.length <= 3) return date; + return date + 'T' + p(v[3] || 0) + ':' + p(v[4] || 0) + ':' + p(v[5] || 0); + } + if (typeof v === 'number') { + // Jackson serializes Instant/Timestamp as epoch SECONDS; JS Date wants millis. + const ms = v < 1e12 ? v * 1000 : v; + try { return new Date(ms).toISOString(); } catch (e) { return ''; } + } + return String(v); + }, + isDateOnly(v) { + return Array.isArray(v) ? v.length <= 3 : (typeof v === 'string' && v.length <= 10); + }, + // Deterministic categorical colour from the Harmonia calendar palette. + colorFor(v) { + const palette = ['blue', 'green', 'purple', 'orange', 'teal', 'pink', 'indigo', 'yellow', 'red', 'gray']; + const key = (v === undefined || v === null) ? '' : String(v); + let h = 0; + for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0; + return palette[h % palette.length]; + }, + // Event click -> edit the child; empty-day click -> create one with the master FK AND the + // clicked date preset (the shared form presets any create query param whose name matches). + onEventClick(e) { + const id = e && e.detail && e.detail.event ? e.detail.event.id : null; + if (!id) return; + const q = '?returnTo=' + encodeURIComponent(this.def.returnTo); + window.PineconeRouter.navigate('/' + this.def.entity + '/' + encodeURIComponent(id) + '/edit' + q); + }, + onDateClick(e) { + const cal = this.def.calendar; + let q = '?' + encodeURIComponent(this.def.masterEntityId) + '=' + encodeURIComponent(this.masterId) + + '&returnTo=' + encodeURIComponent(this.def.returnTo); + const d = e && e.detail ? e.detail.date : null; + if (d instanceof Date && !isNaN(d.getTime())) { + const p = n => String(n).padStart(2, '0'); + let val = d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()); + if (e.detail.time) val += 'T' + e.detail.time; + q += '&' + encodeURIComponent(cal.start) + '=' + encodeURIComponent(val); + } + window.PineconeRouter.navigate('/' + this.def.entity + '/create' + q); + }, + askDelete(row) { this.deleteTarget = row; this.deleteOpen = true; }, async confirmDelete() { if (!this.deleteTarget) return; diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/form-view.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/form-view.html.template index 00cf0af4117..41119055378 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/form-view.html.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/manage/form-view.html.template @@ -218,6 +218,15 @@
+ + + diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/master/detail-register.js.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/master/detail-register.js.template index 425b5bb831a..07f373adaeb 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/master/detail-register.js.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/master/detail-register.js.template @@ -31,6 +31,11 @@ App.registerDetail('${masterEntity}', { masterEntityId: '${masterEntityId}', primaryKey: '${primaryKeysString}', returnTo: '/${masterEntity}', +#if($detailCalendar) + // Rendered as an embedded x-h-calendar panel on the master page (intent view: calendar on a + // composition child); the shared detailPanel maps the rows to events by these properties. + calendar: { start: '${calendarStartProperty}'#if($calendarEndProperty), end: '${calendarEndProperty}'#end#if($calendarTitleProperty), title: '${calendarTitleProperty}'#end#if($calendarColorProperty), color: '${calendarColorProperty}'#end, view: '${calendarInitialView}', range: #if($calendarRange)true#{else}false#end }, +#end columns: [ #foreach($property in $properties) #if(!$property.dataAutoIncrement && $property.widgetIsMajor && $property.name != $masterEntityId) diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/master/master-view.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/master/master-view.html.template index cf47c1bdae8..4b4bc0c4bf7 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/master/master-view.html.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/perspective/master/master-view.html.template @@ -168,6 +168,15 @@
+ + +