Skip to content

Commit 945fcd0

Browse files
delchevclaude
andauthored
feat(harmonia): Attachments / Snapshot files panel (function:Attachment UI) (#6373)
* feat(intent): function: Attachment — file-attachment composition child (end-to-end) A `function: Attachment` entity is a first-class composition child of its master (native 1:n, master-detail wiring unchanged), turning file attachments into a modeled, type-safe detail rather than a bespoke subsystem. Builds on the merged CMS Attachments SDK (#6370). Intent/EDM (engine-intent): - `attachment` added to IntentParser ENTITY_FUNCTIONS; EntityIntent.isAttachment(). - EdmIntentGenerator injects the standard file-metadata columns (FileName [major], ContentType, FileSize [BIGINT], StoragePath, Uuid — all read-only, upload-set) + implicit audit, and marks the entity attachmentEntity="true". The author declares no primary key on an attachment child, so a generated integer Id is synthesized (otherwise the entity/ controller would have no PK). Controller (template-application-rest-java): - Gated on attachmentEntity, the generated controller gains POST /upload (multipart → stores each file in the tenant CMS at /Attachments/<Master>/<yyyy>/<MM>/<uuid>/<file>, persists one row per file with the reference + metadata), GET /{id}/download (permission- scoped stream, never a raw documents?path=), and deleteById also removes the CMS file. SDK (api-modules-java): - Attachments.storeUploads(master) reads the servlet parts Spring's multipart resolver has already parsed (the controller is served by a Spring @PostMapping, which consumes the body before commons-fileupload could), so the generated controller only ever sees Attachment. Test: EdmIntentGeneratorTest asserts the injected metadata + Id PK; RecordAttachmentIT drives the full HTTP round-trip (multipart upload → verbatim download → delete → gone) through the real multipart servlet path and tenant CMS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(harmonia): Attachments / Snapshot files panel for function:Attachment children Renders a composition child declared `function: Attachment` (or, forthcoming, `function: Snapshot`) as a purpose-built Files panel instead of a generic row table — the UI half of the record-attachments feature (backend in #6371, storage SDK in #6370). Shared runtime (application-core): - detailPanel gains a `files` mode (parallel to the existing `calendar` mode): lists the master-filtered files with per-file Download (a plain browser GET to the controller's `/{id}/download`), and — unless read-only — an Upload control (multipart POST to `/upload`) and per-file Remove (reuses the detail delete, which also drops the CMS file). Like a calendar, a files panel always renders (its empty state is meaningful). - api client: request() now sends a FormData body as-is (browser sets the multipart boundary) instead of JSON-encoding it, so the upload goes through the same client + auth/error path. Harmonia templates: - detail-register emits `files: { readOnly: <attachmentReadOnly> }` for an `attachmentEntity` child (read-only flips it to download-only — the generated Snapshot case). - form-view (editable), document-view (editable), master-view (read-only browse) each render the files branch: an x-h-info-page empty state + file cards (name/size/Download[/Remove]), gated on `def.files`; the row table + Add button are suppressed for a files detail. $refs is ${dollar}-escaped so Velocity leaves the Alpine ref alone. Editable path verified end-to-end on a generated CompanyAttachment (upload → master-filtered list → verbatim download → remove). Read-only (Snapshot) branch is in place, activated once the `attachmentReadOnly` marker lands with `function: Snapshot`. Depends on #6371 (function: Attachment backend + controller verbs). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f8a78aa commit 945fcd0

6 files changed

Lines changed: 196 additions & 7 deletions

File tree

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@
2727
* table: the same master-filtered rows become events; event-click edits the child, date-click
2828
* creates one with the master FK AND the clicked date preset. An empty month is meaningful, so a
2929
* calendar panel shows the calendar even with zero rows.
30+
*
31+
* A def carrying `files: { readOnly }` (a composition child declared `function: Attachment` or
32+
* `function: Snapshot`) renders as a Files panel instead of the table: the master-filtered rows are
33+
* uploaded files, each downloadable via the controller's `/{id}/download` route. Editable (Attachment):
34+
* an Upload control adds files (multipart POST to `/upload`) and each row can be removed. Read-only
35+
* (Snapshot): download only — no upload, no delete (copies are generated server-side, e.g. on issue).
36+
* Like a calendar, a files panel always renders (its empty state is meaningful), never the shared
37+
* "no records" line.
3038
*/
3139
function detailPanel(def, masterId) {
3240
return {
@@ -39,6 +47,8 @@ function detailPanel(def, masterId) {
3947
deleteOpen: false,
4048
deleteTarget: null,
4149
deleteBusy: false,
50+
uploading: false, // files defs only
51+
fileError: null,
4252
// Reactive config for the embedded x-h-calendar (calendar defs only); rebuilt on every load.
4353
calCfg: { view: (def.calendar && def.calendar.view) || 'month', events: [] },
4454

@@ -112,6 +122,10 @@ function detailPanel(def, masterId) {
112122
if (this.def.calendar) {
113123
this.calCfg = { view: this.def.calendar.view || 'month', events: this.buildEvents() };
114124
this.state = 'default';
125+
} else if (this.def.files) {
126+
// A files panel always renders (its empty state carries the upload prompt / "generated on
127+
// issue" note), so it never falls back to the shared "no records" line.
128+
this.state = 'default';
115129
} else {
116130
this.state = this.rows.length === 0 ? 'empty' : 'default';
117131
}
@@ -225,6 +239,51 @@ function detailPanel(def, masterId) {
225239
window.PineconeRouter.navigate('/' + this.def.entity + '/create' + q);
226240
},
227241

242+
// --- files panel (files defs only) ----------------------------------------------------------
243+
// Read-only (Snapshot): download only. Editable (Attachment): upload + remove.
244+
get filesReadOnly() { return !!(this.def.files && this.def.files.readOnly); },
245+
246+
// Absolute URL of the controller's download route (a plain browser GET, not the fetch client):
247+
// apiPath is relative to restBase, so prepend it once. The row's own id keys the file.
248+
downloadHref(row) {
249+
const base = (App.config && App.config.restBase) || '';
250+
return base + this.def.apiPath + '/' + encodeURIComponent(row[this.def.primaryKey]) + '/download';
251+
},
252+
253+
// Human-readable size from the injected FileSize column (bytes).
254+
fileSizeText(row) {
255+
const n = Number(row.FileSize);
256+
if (!isFinite(n) || n <= 0) return '';
257+
const units = ['B', 'KB', 'MB', 'GB'];
258+
let v = n, i = 0;
259+
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
260+
return (i === 0 ? v : v.toFixed(1)) + ' ' + units[i];
261+
},
262+
263+
// Multipart upload of the picked files to the controller's /upload route (master FK query),
264+
// then reload. FormData bodies are sent as-is by the api client (browser sets the boundary).
265+
async uploadFiles(fileList) {
266+
const files = Array.from(fileList || []);
267+
if (!files.length || this.masterId == null) return;
268+
this.uploading = true;
269+
this.fileError = null;
270+
try {
271+
const fd = new FormData();
272+
files.forEach(f => fd.append('file', f, f.name));
273+
const q = '?' + encodeURIComponent(this.def.masterEntityId) + '=' + encodeURIComponent(this.masterId);
274+
await App.services.api.post(this.def.apiPath + '/upload' + q, fd);
275+
await this.load();
276+
} catch (e) {
277+
this.fileError = App.services.apiErrors.messageFor(e, 'Upload failed.');
278+
} finally {
279+
this.uploading = false;
280+
}
281+
},
282+
onFilePick(e) {
283+
this.uploadFiles(e.target.files);
284+
e.target.value = ''; // allow re-picking the same file
285+
},
286+
228287
askDelete(row) { this.deleteTarget = row; this.deleteOpen = true; },
229288
async confirmDelete() {
230289
if (!this.deleteTarget) return;

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,15 @@ App.services.api = {
8989
async request(method, url, body, opts = {}) {
9090
const baseUrl = this.resolveBaseUrl(opts);
9191

92+
// A FormData body is a multipart upload: send it as-is and let the browser set the
93+
// Content-Type (with its boundary) — never JSON-encode it or override the header.
94+
const isForm = (typeof FormData !== 'undefined') && (body instanceof FormData);
95+
9296
// X-Requested-With marks the call as programmatic for browsers without Sec-Fetch-Mode: the
9397
// server then answers an expired session with a PLAIN 401 (no Basic challenge), so the
9498
// browser's native login dialog never pops over a background poll.
95-
const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' };
99+
const headers = { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' };
100+
if (!isForm) headers['Content-Type'] = 'application/json';
96101
const language = this.language();
97102
if (language) headers['Accept-Language'] = language;
98103

@@ -101,7 +106,7 @@ App.services.api = {
101106
r = await fetch(`${baseUrl}${url}`, {
102107
method,
103108
headers,
104-
body: body ? JSON.stringify(body) : undefined,
109+
body: body ? (isForm ? body : JSON.stringify(body)) : undefined,
105110
credentials: 'same-origin'
106111
});
107112
} catch (e) {

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: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@
290290
<div x-data="detailPanel({...d, returnTo: '/${name}/' + id + (isPreview ? '/preview' : '/edit')}, id)" x-h-card class="w-full">
291291
<div x-h-card-header>
292292
<h3 x-h-card-title x-text="T(d.tkey, d.label)"></h3>
293-
<div x-h-card-action x-show="masterId && !isPreview">
293+
<div x-h-card-action x-show="masterId && !isPreview && !def.files">
294294
<button x-h-button data-variant="primary" data-size="sm" @click="addRow()"><i role="img" x-h-lucide data-lucide="plus"></i><span x-text="T('$projectName:${tprefix}.defaults.add', 'Add')"></span></button>
295295
</div>
296296
</div>
@@ -306,7 +306,52 @@
306306
<div x-h-calendar="calCfg" style="height: 420px" @event-click="onEventClick" @date-click="onDateClick"></div>
307307
</div>
308308
</template>
309-
<template x-if="!def.calendar">
309+
#set($dollar = '$')
310+
<!-- A files detail (function: Attachment / Snapshot): download always; upload + remove only
311+
when editable (not preview) and not a read-only Snapshot. ${dollar}refs is escaped so
312+
Velocity leaves the Alpine ref alone. -->
313+
<template x-if="def.files">
314+
<div x-show="state === 'default'" class="vbox gap-3">
315+
<div x-show="fileError" x-h-alert data-variant="negative" role="alert"><div x-h-alert-description x-text="fileError"></div></div>
316+
<template x-if="masterId &amp;&amp; !isPreview &amp;&amp; !filesReadOnly">
317+
<div class="hbox items-center gap-2">
318+
<input type="file" multiple class="hidden" x-ref="fileInput" @change="onFilePick(${dollar}event)" :disabled="uploading" />
319+
<button type="button" x-h-button data-variant="outline" data-size="sm" :disabled="uploading" @click="${dollar}refs.fileInput.click()">
320+
<span x-show="uploading" x-h-spinner></span>
321+
<i x-show="!uploading" role="img" x-h-lucide data-lucide="upload"></i>
322+
<span x-text="uploading ? T('$projectName:${tprefix}.defaults.uploading', 'Uploading…') : T('$projectName:${tprefix}.defaults.upload', 'Upload')"></span>
323+
</button>
324+
</div>
325+
</template>
326+
<template x-if="rows.length === 0">
327+
<div x-h-info-page>
328+
<div x-h-info-page-header>
329+
<div x-h-info-page-media.icon><i role="img" x-h-lucide data-lucide="paperclip"></i></div>
330+
<div x-h-info-page-title x-text="filesReadOnly ? T('$projectName:${tprefix}.messages.noCopies', 'No copies yet') : T('$projectName:${tprefix}.messages.noFiles', 'No attachments')"></div>
331+
<div x-h-info-page-description x-text="filesReadOnly ? T('$projectName:${tprefix}.messages.noCopiesHint', 'A copy is stored automatically when the document is issued.') : T('$projectName:${tprefix}.messages.noFilesHint', 'Upload files to attach them to this document.')"></div>
332+
</div>
333+
</div>
334+
</template>
335+
<template x-if="rows.length > 0">
336+
<div class="vbox gap-2">
337+
<template x-for="row in rows" :key="row[def.primaryKey]">
338+
<div x-h-card class="hbox items-center gap-3 p-3">
339+
<i role="img" x-h-lucide data-lucide="file-text" class="shrink-0"></i>
340+
<div class="vbox gap-0 grow min-w-0">
341+
<span class="text-sm truncate" x-text="row.FileName"></span>
342+
<span class="text-xs text-muted-foreground" x-text="fileSizeText(row)"></span>
343+
</div>
344+
<a x-h-button data-variant="transparent" data-size="sm" :href="downloadHref(row)" target="_blank" rel="noopener" aria-label="Download"><i role="img" x-h-lucide data-lucide="download"></i></a>
345+
<template x-if="!isPreview &amp;&amp; !filesReadOnly">
346+
<button type="button" x-h-button data-variant="transparent" data-size="sm" @click="askDelete(row)" aria-label="Remove"><i role="img" x-h-lucide data-lucide="trash-2"></i></button>
347+
</template>
348+
</div>
349+
</template>
350+
</div>
351+
</template>
352+
</div>
353+
</template>
354+
<template x-if="!def.calendar &amp;&amp; !def.files">
310355
<div x-show="state === 'default'" x-h-table-container.scroll style="max-height: 320px">
311356
<table x-h-table data-borders="rows">
312357
<thead x-h-table-header>

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

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@
218218
<div x-data="detailPanel({...d, returnTo: '/${name}/' + id + (isPreview ? '/preview' : '/edit')}, id)" x-h-card>
219219
<div x-h-card-header>
220220
<h3 x-h-card-title x-text="d.label"></h3>
221-
<div x-h-card-action x-show="!isPreview">
221+
<div x-h-card-action x-show="!isPreview && !def.files">
222222
<button type="button" x-h-button data-variant="primary" data-size="sm" @click="addRow()"><i role="img" x-h-lucide data-lucide="plus"></i><span x-text="T('$projectName:${tprefix}.defaults.add', 'Add')"></span></button>
223223
</div>
224224
</div>
@@ -234,7 +234,52 @@
234234
<div x-h-calendar="calCfg" style="height: 420px" @event-click="onEventClick" @date-click="onDateClick"></div>
235235
</div>
236236
</template>
237-
<template x-if="!def.calendar">
237+
#set($dollar = '$')
238+
<!-- A files detail (function: Attachment / Snapshot): download always; upload + remove
239+
only when editable (not preview) and not a read-only Snapshot (copies are generated
240+
server-side). $refs is escaped (${dollar}refs) so Velocity leaves the Alpine ref alone. -->
241+
<template x-if="def.files">
242+
<div x-show="state === 'default'" class="vbox gap-3">
243+
<div x-show="fileError" x-h-alert data-variant="negative" role="alert"><div x-h-alert-description x-text="fileError"></div></div>
244+
<template x-if="!isPreview &amp;&amp; !filesReadOnly">
245+
<div class="hbox items-center gap-2">
246+
<input type="file" multiple class="hidden" x-ref="fileInput" @change="onFilePick(${dollar}event)" :disabled="uploading" />
247+
<button type="button" x-h-button data-variant="outline" data-size="sm" :disabled="uploading" @click="${dollar}refs.fileInput.click()">
248+
<span x-show="uploading" x-h-spinner></span>
249+
<i x-show="!uploading" role="img" x-h-lucide data-lucide="upload"></i>
250+
<span x-text="uploading ? T('$projectName:${tprefix}.defaults.uploading', 'Uploading…') : T('$projectName:${tprefix}.defaults.upload', 'Upload')"></span>
251+
</button>
252+
</div>
253+
</template>
254+
<template x-if="rows.length === 0">
255+
<div x-h-info-page>
256+
<div x-h-info-page-header>
257+
<div x-h-info-page-media.icon><i role="img" x-h-lucide data-lucide="paperclip"></i></div>
258+
<div x-h-info-page-title x-text="filesReadOnly ? T('$projectName:${tprefix}.messages.noCopies', 'No copies yet') : T('$projectName:${tprefix}.messages.noFiles', 'No attachments')"></div>
259+
<div x-h-info-page-description x-text="filesReadOnly ? T('$projectName:${tprefix}.messages.noCopiesHint', 'A copy is stored automatically when the document is issued.') : T('$projectName:${tprefix}.messages.noFilesHint', 'Upload files to attach them to this record.')"></div>
260+
</div>
261+
</div>
262+
</template>
263+
<template x-if="rows.length > 0">
264+
<div class="vbox gap-2">
265+
<template x-for="row in rows" :key="row[def.primaryKey]">
266+
<div x-h-card class="hbox items-center gap-3 p-3">
267+
<i role="img" x-h-lucide data-lucide="file-text" class="shrink-0"></i>
268+
<div class="vbox gap-0 grow min-w-0">
269+
<span class="text-sm truncate" x-text="row.FileName"></span>
270+
<span class="text-xs text-muted-foreground" x-text="fileSizeText(row)"></span>
271+
</div>
272+
<a x-h-button data-variant="transparent" data-size="sm" :href="downloadHref(row)" target="_blank" rel="noopener" aria-label="Download"><i role="img" x-h-lucide data-lucide="download"></i></a>
273+
<template x-if="!isPreview &amp;&amp; !filesReadOnly">
274+
<button type="button" x-h-button data-variant="transparent" data-size="sm" @click="askDelete(row)" aria-label="Remove"><i role="img" x-h-lucide data-lucide="trash-2"></i></button>
275+
</template>
276+
</div>
277+
</template>
278+
</div>
279+
</template>
280+
</div>
281+
</template>
282+
<template x-if="!def.calendar &amp;&amp; !def.files">
238283
<div x-show="state === 'default'" x-h-table-container.scroll style="max-height: 320px">
239284
<table x-h-table data-borders="rows">
240285
<thead x-h-table-header>

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ App.registerDetail('${masterEntity}', {
3535
// Rendered as an embedded x-h-calendar panel on the master page (intent view: calendar on a
3636
// composition child); the shared detailPanel maps the rows to events by these properties.
3737
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 },
38+
#end
39+
#if($attachmentEntity)
40+
// Rendered as a Files panel (function: Attachment / Snapshot): the shared detailPanel lists the
41+
// uploaded files with download, and — unless read-only — an upload control + per-file remove.
42+
// Read-only (a generated Snapshot child) shows download only. See detailPanel `files`.
43+
files: { readOnly: #if($attachmentReadOnly)true#{else}false#end },
3844
#end
3945
columns: [
4046
#foreach($property in $properties)

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,36 @@
186186
<div x-h-calendar="calCfg" style="height: 420px" @event-click="onEventClick" @date-click="onDateClick"></div>
187187
</div>
188188
</template>
189-
<template x-if="!def.calendar">
189+
<!-- A files detail (function: Attachment / Snapshot) on the read-only browse pane: the
190+
files are downloadable here; upload/remove happen on the record's Edit form. -->
191+
<template x-if="def.files">
192+
<div x-show="state === 'default'" class="vbox gap-3">
193+
<template x-if="rows.length === 0">
194+
<div x-h-info-page>
195+
<div x-h-info-page-header>
196+
<div x-h-info-page-media.icon><i role="img" x-h-lucide data-lucide="paperclip"></i></div>
197+
<div x-h-info-page-title x-text="filesReadOnly ? T('$projectName:${tprefix}.messages.noCopies', 'No copies yet') : T('$projectName:${tprefix}.messages.noFiles', 'No attachments')"></div>
198+
<div x-h-info-page-description x-text="filesReadOnly ? T('$projectName:${tprefix}.messages.noCopiesHint', 'A copy is stored automatically when the document is issued.') : T('$projectName:${tprefix}.messages.noFilesHint', 'Files are managed from the record’s Edit form.')"></div>
199+
</div>
200+
</div>
201+
</template>
202+
<template x-if="rows.length > 0">
203+
<div class="vbox gap-2">
204+
<template x-for="row in rows" :key="row[def.primaryKey]">
205+
<div x-h-card class="hbox items-center gap-3 p-3">
206+
<i role="img" x-h-lucide data-lucide="file-text" class="shrink-0"></i>
207+
<div class="vbox gap-0 grow min-w-0">
208+
<span class="text-sm truncate" x-text="row.FileName"></span>
209+
<span class="text-xs text-muted-foreground" x-text="fileSizeText(row)"></span>
210+
</div>
211+
<a x-h-button data-variant="transparent" data-size="sm" :href="downloadHref(row)" target="_blank" rel="noopener" aria-label="Download"><i role="img" x-h-lucide data-lucide="download"></i></a>
212+
</div>
213+
</template>
214+
</div>
215+
</template>
216+
</div>
217+
</template>
218+
<template x-if="!def.calendar &amp;&amp; !def.files">
190219
<div x-show="state === 'default'" x-h-table-container.scroll style="max-height: 320px">
191220
<table x-h-table data-borders="rows">
192221
<thead x-h-table-header>

0 commit comments

Comments
 (0)