diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/template/ui/my.js b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/template/ui/my.js index e0e04eecf06..95e8c8322b3 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/template/ui/my.js +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/template/ui/my.js @@ -18,14 +18,31 @@ export const getSources = () => [ action: "generate", engine: "velocity", rename: "gen/{{genFolderName}}/js/components/pages/my/{{name}}MyListPage.js", - collection: "personalRootModels" + collection: "personalListModels" }, { location: "/template-application-ui-harmonia-java/ui/my/my-list-view.html.template", action: "generate", engine: "velocity", rename: "gen/{{genFolderName}}/views/my/{{name}}-list.html", - collection: "personalRootModels" + collection: "personalListModels" + }, + { + // Personal CALENDAR/RANGE layout: a view: calendar|range personal root renders its own + // records as calendar events (scoped to the MyController), replacing the list - exactly + // like the power surface. date-click / event-click route to the personal form. + location: "/template-application-ui-harmonia-java/ui/my/my-calendar-page.js.template", + action: "generate", + engine: "velocity", + rename: "gen/{{genFolderName}}/js/components/pages/my/{{name}}MyCalendarPage.js", + collection: "personalCalendarModels" + }, + { + location: "/template-application-ui-harmonia-java/ui/my/my-calendar-view.html.template", + action: "generate", + engine: "velocity", + rename: "gen/{{genFolderName}}/views/my/{{name}}-calendar.html", + collection: "personalCalendarModels" }, { location: "/template-application-ui-harmonia-java/ui/my/my-form-page.js.template", diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-calendar-page.js.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-calendar-page.js.template new file mode 100644 index 00000000000..fadbb6df3d3 --- /dev/null +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-calendar-page.js.template @@ -0,0 +1,123 @@ +/* + * Generated by Eclipse Dirigible based on model and template. + * + * Do not modify the content as it may be re-generated again. + */ + +/* + * Personal (my) calendar page for ${name} - the logged-in user's OWN records rendered as events on + * a Harmonia x-h-calendar (the same view: calendar/range surface the power page has, scoped to the + * ${name}MyController - foreign rows and sensitive fields never arrive). date-click / event-click + * route to the personal form. + */ +document.addEventListener('alpine:init', () => { + Alpine.data('${name}MyCalendarPage', () => ({ + ...basePage(), + state: 'loading', + error: null, + items: [], + // Reactive config for x-h-calendar; repopulated after each load so the calendar re-renders. + calCfg: { view: '${calendarInitialView}', events: [] }, + + // Controller path for the PERSONAL surface, relative to App.config.restBase. + apiPath: '/${javaPerspectiveName}/${name}MyController', + + async init() { + await this.load(); + }, + + async load() { + this.state = 'loading'; + this.error = null; + try { + this.items = await App.services.api.get(this.apiPath) || []; + this.calCfg = { view: '${calendarInitialView}', events: this.buildEvents() }; + this.state = 'default'; + } catch (e) { + this.error = (e && e.message) || 'Could not load your ${menuLabel}.'; + this.state = 'error'; + } + this.refreshIcons(); + }, + + // Map each record to a calendar event. Rows with no start value are skipped. + buildEvents() { + return (this.items || []).map(row => { + const start = this.toISO(row.${calendarStartProperty}); + if (!start) return null; + const ev = { + id: String(row.${primaryKeysString}), + title: this.titleFor(row), + start: start, + allDay: #if($calendarRange)true#{else}this.isDateOnly(row.${calendarStartProperty})#end + }; +#if($calendarEndProperty) + const end = this.toISO(row.${calendarEndProperty}); + if (end) ev.end = end; +#end +#if($calendarColorProperty) + ev.color = this.colorFor(row.${calendarColorProperty}); +#end + return ev; + }).filter(Boolean); + }, + + titleFor(row) { +#if($calendarTitleProperty) + const t = row.${calendarTitleProperty}; + if (t !== undefined && t !== null && String(t) !== '') return String(t); +#end + return '${menuLabel} #' + row.${primaryKeysString}; + }, + + // Jackson serializes java.time as arrays (LocalDate [y,m,d]; LocalDateTime [y,m,d,h,mi,s,ns]) and + // Instant/Timestamp as a numeric epoch. Convert any of those (or a plain ISO string) to the ISO + // string x-h-calendar expects; '' when unset. + 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') { + 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]; + }, + + // Empty-cell click -> create your own record, prefilling the start field from the clicked day + // (the personal form presets any field whose name matches a query param on create). + onDateClick(e) { + const d = e && e.detail ? e.detail.date : null; + let qs = ''; + 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()); + const time = e.detail.time; + if (time) val += 'T' + time; + qs = '?${calendarStartProperty}=' + encodeURIComponent(val); + } + window.PineconeRouter.navigate('/my/${name}/create' + qs); + }, + onEventClick(e) { + const id = e && e.detail && e.detail.event ? e.detail.event.id : null; + if (id) window.PineconeRouter.navigate('/my/${name}/' + encodeURIComponent(id) + '/edit'); + }, + newEntity() { window.PineconeRouter.navigate('/my/${name}/create'); } + })); +}, { once: true }); diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-calendar-view.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-calendar-view.html.template new file mode 100644 index 00000000000..2077e0e155a --- /dev/null +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/my/my-calendar-view.html.template @@ -0,0 +1,26 @@ + +
+ +
+ +
+ +
+ +
+
+ + +
+
+
+ +
diff --git a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/shell/index.html.template b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/shell/index.html.template index 81f413b11e6..5b2dea19518 100644 --- a/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/shell/index.html.template +++ b/components/template/template-application-ui-harmonia-java/src/main/resources/META-INF/dirigible/template-application-ui-harmonia-java/ui/shell/index.html.template @@ -415,7 +415,10 @@ #foreach($entity in $models) -#if($entity.personalProperty) +#if($entity.personalProperty && $entity.layoutType == "MANAGE_CALENDAR") + + +#elseif($entity.personalProperty) #end #if($entity.personalProperty && $entity.layoutType == "MANAGE_DOCUMENT") @@ -524,7 +527,9 @@ #if($entity.type == "REPORT") #end -#if($entity.personalProperty) +#if($entity.personalProperty && $entity.layoutType == "MANAGE_CALENDAR") + +#elseif($entity.personalProperty) #end #if($entity.personalProperty && $entity.layoutType == "MANAGE_DOCUMENT") diff --git a/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js b/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js index 8702826034a..173b11ca80d 100644 --- a/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js +++ b/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js @@ -269,6 +269,11 @@ export function generateFiles(model, parameters, templateSources) { // Roots only (a DIRECT personal owner): these get list pages + shell perspectives; children // reach their forms through the parent's panels, never through navigation. const personalRootModels = model.entities.filter(e => e.personalProperty); + // A personal CALENDAR/RANGE root (view: calendar|range) lands on a personal calendar page + // instead of the list - exactly like the power surface, where the calendar replaces the table. + const personalCalendarModels = model.entities.filter(e => e.layoutType === "MANAGE_CALENDAR" && e.personalProperty); + // The personal LIST pair renders for every root EXCEPT a calendar root (replaced above). + const personalListModels = model.entities.filter(e => e.personalProperty && e.layoutType !== "MANAGE_CALENDAR"); // A personal document root (MANAGE_DOCUMENT + a direct personal owner) gets the personal DOCUMENT // layout (header form + inline items table + status pill + totals). It still gets a MyController // (personalModels, above) and a list + perspective (personalRootModels) - only its FORM is the @@ -367,6 +372,12 @@ export function generateFiles(model, parameters, templateSources) { case "personalDocumentModels": generatedFiles.push(...generateCollection(location, content, template, personalDocumentModels, parameters)); break; + case "personalListModels": + generatedFiles.push(...generateCollection(location, content, template, personalListModels, parameters)); + break; + case "personalCalendarModels": + generatedFiles.push(...generateCollection(location, content, template, personalCalendarModels, parameters)); + break; case "personalFormModels": generatedFiles.push(...generateCollection(location, content, template, personalFormModels, parameters)); break; diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java index bbdec29d96d..98d8ad5a27e 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java @@ -214,6 +214,18 @@ class IntentEmissionCoverageIT extends IntegrationTest { relations: - { name: Ticket, kind: manyToOne, to: Ticket, composition: true, required: true } + # view: range + a personal owner - the PERSONAL surface must render the range + # calendar (never the plain form+list), scoped to the MyController (U3 parity). + - name: Leave + view: range + calendar: { start: fromDate, end: toDate } + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: fromDate, type: date, required: true } + - { name: toDate, type: date, required: true } + relations: + - { name: Person, kind: manyToOne, to: Person, personal: true } + # partner: the EXTERNAL-partner mirror of personal - PartnerTicket is owned by a Person # (reusing identity: email; the admin seed maps the IT user), with a sensitive field. - name: PartnerTicket @@ -609,6 +621,17 @@ private void assertEmission() { assertTrue(myTicketPage.contains("sendMessage") && myTicketPage.contains("TicketMessageMyController"), "the personal chat composer must append through the personal items controller"); + // view: range/calendar + personal - the personal surface renders the calendar (never the + // plain form+list), reads through the scoped controller, and /my/ lands on it. + String myLeaveCalendar = contentOf("gen/emission/js/components/pages/my/LeaveMyCalendarPage.js"); + assertTrue(myLeaveCalendar.contains("LeaveMyController"), "the personal calendar must read through the scoped controller"); + String myLeaveView = contentOf("gen/emission/views/my/Leave-calendar.html"); + assertTrue(myLeaveView.contains("x-h-calendar"), + "the personal surface of a range/calendar root must render the calendar, not a plain list"); + String shellIndex = contentOf("gen/emission/index.html"); + assertTrue(shellIndex.contains("x-template.target.app=\"./views/my/Leave-calendar.html\""), + "/my/ must land on the personal calendar for a calendar root"); + // transitions: the server half is a controller that guards the source status + the when // guard (409) and flips ONLY the status column via the targeted updateProperty; the client // half is a custom-action contribution carrying the endpoint.