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
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ document.addEventListener('alpine:init', () => {
itemMode: 'create',
draft: {},
itemOptions: {},
#if($documentItemsLayout == "chat")
// Chat composer state (documentItemsLayout: chat) - the personal document renders the same
// conversation thread the power document does, through the personal items controller.
chatDraft: '',
chatSending: false,
#if($chatInternalProperty)
chatInternal: false,
#end
#end

// The item column metadata, reused from detail-register (App.detailsFor). Resolved lazily: the
// detail-register script may run after this component's init, so re-resolve until it is present.
Expand Down Expand Up @@ -252,6 +261,35 @@ document.addEventListener('alpine:init', () => {
this.itemsError = (e && e.message) || 'Could not delete the line item.';
}
},
#if($documentItemsLayout == "chat")

// Append a chat message through the PERSONAL items controller - ownership of the parent
// document is enforced server-side (requireMyParent), so a foreign document cannot be
// written to even by hand-crafting the request.
async sendMessage(value) {
const body = (value || '').trim();
if (!body || this.chatSending || !this.def() || this.id == null) return;
this.chatSending = true;
this.itemsError = null;
try {
const payload = { '${chatBodyProperty}': body };
payload[this.def().masterEntityId] = this.id;
#if($chatInternalProperty)
payload['${chatInternalProperty}'] = !!this.chatInternal;
#end
await App.services.api.post(this.itemsApiPath, payload);
this.chatDraft = '';
#if($chatInternalProperty)
this.chatInternal = false;
#end
await this.loadItems();
} catch (e) {
this.itemsError = (e && e.message) || 'Could not send the message.';
} finally {
this.chatSending = false;
}
},
#end

/** After an item change the server recomputes the header aggregates - re-read them. */
async reloadHeaderTotals() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,50 @@
#end
</div>

#if($documentItemsLayout == "chat")
<!-- Conversation thread (chat items) - the personal document renders the SAME chat the power
document does (bubbles + composer through the personal items controller), never the generic
line-items table. Own vs other alignment keys on the audit CreatedBy vs the logged-in user;
an internal memo is tinted distinctly. Composed from shipped Harmonia primitives (inline
styles + theme vars - see the power document view for why); the x-h-chat component family
will own this styling once it ships. -->
<div class="vbox gap-2" style="max-width: 900px" x-show="itemsEnabled">
<div x-h-toolbar data-variant="transparent" data-size="sm">
<span x-h-toolbar-title class="shrink-0" x-text="itemsDef ? T(itemsDef.tkey, '${documentItemsLabel}') : '${documentItemsLabel}'"></span>
</div>
<div x-show="itemsError" x-h-alert data-variant="negative" role="alert"><div x-h-alert-description x-text="itemsError"></div></div>
<div class="vbox gap-3 w-full rounded-control border border-input bg-input-inner p-4" style="max-height: 28rem; overflow-y: auto" role="log" aria-live="polite">
<template x-for="(row, idx) in items" :key="row[itemsDef.primaryKey] != null ? row[itemsDef.primaryKey] : idx">
<div class="vbox gap-1" style="max-width: 85%"
:style="row.CreatedBy === $store.currentUser.name ? 'align-self:flex-end;align-items:flex-end' : 'align-self:flex-start;align-items:flex-start'">
<div class="hbox items-baseline gap-2">
<span class="text-foreground text-xs font-medium" x-text="row.CreatedBy || T('$projectName:${tprefix}.defaults.system', 'System')"></span>
<span class="text-muted-foreground text-xs" x-text="window.HarmoniaFormat.value(row.CreatedAt, true)"></span>
#if($chatInternalProperty)
<span x-show="row.${chatInternalProperty}" x-h-badge data-variant="warning" x-text="T('$projectName:${tprefix}.defaults.internal', 'Internal')"></span>
#end
</div>
<div class="text-sm" style="border-radius: 0.5rem; padding: 0.5rem 0.75rem; white-space: pre-wrap; overflow-wrap: anywhere"
:style="#if($chatInternalProperty)row.${chatInternalProperty} ? 'background:var(--muted);color:var(--foreground)' : (#end(row.CreatedBy === $store.currentUser.name ? 'background:var(--primary);color:var(--primary-foreground)' : 'background:var(--secondary);color:var(--secondary-foreground)')#if($chatInternalProperty))#end"
x-text="row.${chatBodyProperty}"></div>
</div>
</template>
<div x-show="items.length === 0" x-h-text.muted class="p-2" x-text="T('$projectName:${tprefix}.messages.noData', 'No messages yet.')"></div>
</div>
<div class="vbox gap-2">
#if($chatInternalProperty)
<label class="hbox items-center gap-2 text-sm">
<span x-h-switch data-size="sm"><input type="checkbox" x-model="chatInternal" /></span>
<span x-text="T('$projectName:${tprefix}.defaults.internalNote', 'Internal note (not visible to the requester)')"></span>
</label>
#end
<div class="hbox items-end gap-2 w-full">
<textarea x-h-textarea rows="1" class="grow" x-model="chatDraft" @keydown.enter.prevent="sendMessage(chatDraft)" :disabled="chatSending" :aria-label="T('$projectName:${tprefix}.defaults.writeMessage', 'Write a message')" :placeholder="T('$projectName:${tprefix}.defaults.writeMessage', 'Write a message...')"></textarea>
<button type="button" x-h-button data-variant="primary" @click="sendMessage(chatDraft)" :disabled="chatSending || !chatDraft.trim()" :aria-label="T('$projectName:${tprefix}.defaults.send', 'Send')"><i role="img" x-h-lucide data-lucide="send"></i></button>
</div>
</div>
</div>
#else
<!-- Line items (only once the document exists). Add/edit via a dialog; delete inline. -->
<div class="vbox gap-2" style="max-width: 900px" x-show="itemsEnabled">
<div x-h-toolbar data-variant="transparent" data-size="sm">
Expand Down Expand Up @@ -114,18 +158,21 @@
</table>
</div>
</div>
#end

<!-- Totals footer (aggregate fields, read-only). -->
<!-- Totals footer (aggregate fields, read-only). A SENSITIVE aggregate is omitted entirely: the
personal controller nulls it on the wire, so rendering the row would only show a permanently
empty value the owner is not meant to see. -->
#set($hasAgg = false)
#foreach($property in $properties)
#if($property.aggregate == "true")#set($hasAgg = true)#end
#if($property.aggregate == "true" && !$property.sensitiveProperty)#set($hasAgg = true)#end
#end
#if($hasAgg)
<div class="hbox justify-end" style="max-width: 900px" x-show="itemsEnabled">
<div x-h-card class="min-w-3xs">
<div x-h-card-content class="vbox gap-1">
#foreach($property in $properties)
#if($property.aggregate == "true")
#if($property.aggregate == "true" && !$property.sensitiveProperty)
<div class="hbox items-baseline justify-between gap-4">
<span class="text-sm text-secondary-foreground" x-text="T('$projectName:${tprefix}.t.${property.dataName}', '#if($property.widgetLabel)${property.widgetLabel}#else${property.name}#end')"></span>
<span class="text-sm tabular-nums" x-text="window.HarmoniaFormat ? window.HarmoniaFormat.number(form.${property.name}) : form.${property.name}"></span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,12 +190,17 @@ class IntentEmissionCoverageIT extends IntegrationTest {
# documentItemsLayout: chat - the document master's line-items child renders as a
# conversation thread (x-h-chat bubbles + a composer) instead of the editable table;
# the body maps to the messageBody field, author/timestamp to the child's audit columns.
# The personal owner makes it a personal root too: the PERSONAL document must render
# the SAME chat thread (never the generic items table), through the personal items
# controller.
- name: Ticket
function: Document
documentItemsLayout: chat
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: subject, type: string, length: 200 }
relations:
- { name: Agent, kind: manyToOne, to: Person, personal: true }
- name: TicketMessage
function: DocumentItem
audit: true
Expand Down Expand Up @@ -585,6 +590,16 @@ private void assertEmission() {
"documentItemsLayout: chat must emit the message composer into the document view");
String ticketPage = contentOf("gen/emission/js/components/pages/Ticket/TicketDocumentPage.js");
assertTrue(ticketPage.contains("sendMessage"), "the chat document page must emit the append-message composer handler");
// The PERSONAL document of a chat entity renders the SAME thread + composer (never the
// generic line-items table), writing through the personal items controller so ownership is
// enforced server-side (the my-document chat parity class).
String myTicketDoc = contentOf("gen/emission/views/my/Ticket-document.html");
assertTrue(myTicketDoc.contains("role=\"log\""),
"the personal document of a chat entity must render the conversation thread, not the items table");
assertTrue(myTicketDoc.contains("x-model=\"chatDraft\""), "the personal document must carry the message composer");
String myTicketPage = contentOf("gen/emission/js/components/pages/my/TicketMyDocumentPage.js");
assertTrue(myTicketPage.contains("sendMessage") && myTicketPage.contains("TicketMessageMyController"),
"the personal chat composer must append through the personal items controller");

// 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
Expand Down
Loading