Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions components/engine/engine-intent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -461,6 +468,8 @@ UI-test manifest, and its perspective in the generated Harmonia SPA + the shared
generates, postings.
- **Cross-model schedule SOURCE** - a schedule's `entity` must be local (the generate target may
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,50 @@ private static Map<String, Object> 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<String, Object> model = EdmIntentGenerator.buildModelJsonForTest(IntentParser.parse(yaml), "work");
List<Map<String, Object>> 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<String, Object> 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<String, Object> primary = entityByName(entities, "Meeting");
assertEquals("MANAGE_CALENDAR", primary.get("layoutType"));
assertEquals(null, primary.get("detailCalendar"));
}

private static List<Map<String, Object>> entities(Map<String, Object> modelJson) {
return (List<Map<String, Object>>) ((Map<String, Object>) modelJson.get("model")).get("entities");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 }

Expand Down Expand Up @@ -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';
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,15 @@
<div x-show="state === 'loading'"><div x-h-spinner></div></div>
<div x-show="state === 'error'" x-h-text.muted x-text="error"></div>
<div x-show="state === 'empty'" x-h-text.muted x-text="T('$projectName:${tprefix}.messages.noData', 'No records yet.')"></div>
<!-- A calendar detail (intent view: calendar on the composition child): the same
master-filtered rows as events - event-click edits, empty-day click adds with the
master FK + the clicked date preset. -->
<template x-if="def.calendar">
<div x-show="state === 'default'" style="min-height: 420px">
<div x-h-calendar="calCfg" style="height: 420px" @event-click="onEventClick" @date-click="onDateClick"></div>
</div>
</template>
<template x-if="!def.calendar">
<div x-show="state === 'default'" x-h-table-container.scroll style="max-height: 320px">
<table x-h-table data-borders="rows">
<thead x-h-table-header>
Expand Down Expand Up @@ -248,6 +257,7 @@
</tbody>
</table>
</div>
</template>
</div>

<!-- Detail delete confirmation -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,15 @@
<div x-show="state === 'loading'"><div x-h-spinner></div></div>
<div x-show="state === 'error'" x-h-text.muted x-text="error"></div>
<div x-show="state === 'empty'" x-h-text.muted x-text="T('$projectName:${tprefix}.messages.noData', 'No records yet.')"></div>
<!-- A calendar detail (intent view: calendar on the composition child) renders the same
master-filtered rows as events on an embedded calendar: event-click edits the child,
empty-day click creates one with the master FK + the clicked date preset. -->
<template x-if="def.calendar">
<div x-show="state === 'default'" style="min-height: 420px">
<div x-h-calendar="calCfg" style="height: 420px" @event-click="onEventClick" @date-click="onDateClick"></div>
</div>
</template>
<template x-if="!def.calendar">
<div x-show="state === 'default'" x-h-table-container.scroll style="max-height: 320px">
<table x-h-table data-borders="rows">
<thead x-h-table-header>
Expand Down Expand Up @@ -201,6 +210,7 @@
</tbody>
</table>
</div>
</template>
</div>
</div>
</template>
Expand Down
Loading