@design.estate/dees-catalog 12.4.0 → 12.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_bundle/bundle.js +2455 -1133
- package/dist_bundle/bundle.js.map +1 -1
- package/dist_ts_web/00_commitinfo_data.js +3 -3
- package/dist_ts_web/elements/00group-input/dees-input-dropdown/dees-input-dropdown-popup.d.ts +13 -0
- package/dist_ts_web/elements/00group-input/dees-input-dropdown/dees-input-dropdown-popup.js +76 -7
- package/dist_ts_web/elements/00group-input/dees-input-reference/dees-input-reference.d.ts +42 -0
- package/dist_ts_web/elements/00group-input/dees-input-reference/dees-input-reference.demo.d.ts +1 -0
- package/dist_ts_web/elements/00group-input/dees-input-reference/dees-input-reference.demo.js +8 -0
- package/dist_ts_web/elements/00group-input/dees-input-reference/dees-input-reference.js +347 -0
- package/dist_ts_web/elements/00group-input/dees-input-reference/document.d.ts +5 -0
- package/dist_ts_web/elements/00group-input/dees-input-reference/document.js +45 -0
- package/dist_ts_web/elements/00group-input/dees-input-reference/index.d.ts +2 -0
- package/dist_ts_web/elements/00group-input/dees-input-reference/index.js +3 -0
- package/dist_ts_web/elements/00group-input/dees-input-reference/interfaces.d.ts +16 -0
- package/dist_ts_web/elements/00group-input/dees-input-reference/interfaces.js +2 -0
- package/dist_ts_web/elements/00group-input/index.d.ts +1 -0
- package/dist_ts_web/elements/00group-input/index.js +2 -1
- package/dist_ts_web/elements/00group-kanban/dees-kanban/dees-kanban.d.ts +58 -0
- package/dist_ts_web/elements/00group-kanban/dees-kanban/dees-kanban.demo.d.ts +1 -0
- package/dist_ts_web/elements/00group-kanban/dees-kanban/dees-kanban.demo.js +9 -0
- package/dist_ts_web/elements/00group-kanban/dees-kanban/dees-kanban.js +536 -0
- package/dist_ts_web/elements/00group-kanban/dees-kanban-card/dees-kanban-card.d.ts +20 -0
- package/dist_ts_web/elements/00group-kanban/dees-kanban-card/dees-kanban-card.js +157 -0
- package/dist_ts_web/elements/00group-kanban/dees-kanban-column/dees-kanban-column.d.ts +23 -0
- package/dist_ts_web/elements/00group-kanban/dees-kanban-column/dees-kanban-column.js +192 -0
- package/dist_ts_web/elements/00group-kanban/dees-kanban-discussion/dees-kanban-discussion.d.ts +24 -0
- package/dist_ts_web/elements/00group-kanban/dees-kanban-discussion/dees-kanban-discussion.js +211 -0
- package/dist_ts_web/elements/00group-kanban/dees-kanban-ticket-detail/dees-kanban-ticket-detail.d.ts +35 -0
- package/dist_ts_web/elements/00group-kanban/dees-kanban-ticket-detail/dees-kanban-ticket-detail.js +269 -0
- package/dist_ts_web/elements/00group-kanban/index.d.ts +6 -0
- package/dist_ts_web/elements/00group-kanban/index.js +7 -0
- package/dist_ts_web/elements/00group-kanban/interfaces.d.ts +84 -0
- package/dist_ts_web/elements/00group-kanban/interfaces.js +2 -0
- package/dist_ts_web/elements/00group-kanban/kanban.demodata.d.ts +7 -0
- package/dist_ts_web/elements/00group-kanban/kanban.demodata.js +60 -0
- package/dist_ts_web/elements/00group-kanban/kanban.helpers.d.ts +6 -0
- package/dist_ts_web/elements/00group-kanban/kanban.helpers.js +11 -0
- package/dist_ts_web/elements/00group-kanban/kanban.styles.d.ts +1 -0
- package/dist_ts_web/elements/00group-kanban/kanban.styles.js +18 -0
- package/dist_ts_web/elements/index.d.ts +1 -0
- package/dist_ts_web/elements/index.js +2 -1
- package/dist_ts_web/services/DeesServiceLibLoader.d.ts +1 -0
- package/dist_ts_web/services/DeesServiceLibLoader.js +2 -1
- package/package.json +1 -1
- package/readme.md +154 -0
- package/scripts/check-packed-consumer.mjs +1 -1
- package/ts_web/00_commitinfo_data.ts +3 -3
- package/ts_web/elements/00group-input/dees-input-dropdown/dees-input-dropdown-popup.ts +40 -5
- package/ts_web/elements/00group-input/dees-input-reference/dees-input-reference.demo.ts +9 -0
- package/ts_web/elements/00group-input/dees-input-reference/dees-input-reference.ts +221 -0
- package/ts_web/elements/00group-input/dees-input-reference/document.ts +38 -0
- package/ts_web/elements/00group-input/dees-input-reference/index.ts +2 -0
- package/ts_web/elements/00group-input/dees-input-reference/interfaces.ts +8 -0
- package/ts_web/elements/00group-input/index.ts +1 -0
- package/ts_web/elements/00group-kanban/dees-kanban/dees-kanban.demo.ts +9 -0
- package/ts_web/elements/00group-kanban/dees-kanban/dees-kanban.ts +315 -0
- package/ts_web/elements/00group-kanban/dees-kanban-card/dees-kanban-card.ts +58 -0
- package/ts_web/elements/00group-kanban/dees-kanban-column/dees-kanban-column.ts +60 -0
- package/ts_web/elements/00group-kanban/dees-kanban-discussion/dees-kanban-discussion.ts +81 -0
- package/ts_web/elements/00group-kanban/dees-kanban-ticket-detail/dees-kanban-ticket-detail.ts +114 -0
- package/ts_web/elements/00group-kanban/index.ts +6 -0
- package/ts_web/elements/00group-kanban/interfaces.ts +67 -0
- package/ts_web/elements/00group-kanban/kanban.demodata.ts +56 -0
- package/ts_web/elements/00group-kanban/kanban.helpers.ts +13 -0
- package/ts_web/elements/00group-kanban/kanban.styles.ts +18 -0
- package/ts_web/elements/index.ts +1 -0
- package/ts_web/services/DeesServiceLibLoader.ts +2 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { DeesElement, customElement, property, state, html, css } from '@design.estate/dees-element';
|
|
2
|
+
import type { IKanbanColumn, IKanbanComment, IKanbanCreate, IKanbanMove, IKanbanPerson, IKanbanProvider, IKanbanTicket, IKanbanTicketDraft } from '../interfaces.js';
|
|
3
|
+
import type { TReferenceContent, TReferenceSearch } from '../../00group-input/dees-input-reference/interfaces.js';
|
|
4
|
+
import type { DeesInputText } from '../../00group-input/dees-input-text/dees-input-text.js';
|
|
5
|
+
import { DeesModal } from '../../00group-overlay/dees-modal/dees-modal.js';
|
|
6
|
+
import { DeesContextmenu } from '../../00group-overlay/dees-contextmenu/dees-contextmenu.js';
|
|
7
|
+
import { DeesKanbanTicketDetail } from '../dees-kanban-ticket-detail/dees-kanban-ticket-detail.js';
|
|
8
|
+
import { kanbanStyles } from '../kanban.styles.js';
|
|
9
|
+
import { kanbanError, kanbanEvent, sortKanbanTickets, ticketLabel } from '../kanban.helpers.js';
|
|
10
|
+
import { demoFunc } from './dees-kanban.demo.js';
|
|
11
|
+
import '../dees-kanban-column/dees-kanban-column.js';
|
|
12
|
+
|
|
13
|
+
declare global { interface HTMLElementTagNameMap { 'dees-kanban': DeesKanban; } }
|
|
14
|
+
|
|
15
|
+
const interactionEvents = ['kanban-ticket-open', 'kanban-ticket-move', 'kanban-ticket-create', 'kanban-move-menu',
|
|
16
|
+
'kanban-ticket-save', 'kanban-ticket-draft', 'kanban-comment-draft', 'kanban-comment-added', 'kanban-comments-refresh',
|
|
17
|
+
'kanban-detail-back', 'kanban-drag-start', 'kanban-drag-end'] as const;
|
|
18
|
+
|
|
19
|
+
interface IKanbanInteractionMap {
|
|
20
|
+
'kanban-ticket-open': { ticketId: string };
|
|
21
|
+
'kanban-ticket-move': IKanbanMove;
|
|
22
|
+
'kanban-ticket-create': Omit<IKanbanCreate, 'title'>;
|
|
23
|
+
'kanban-move-menu': { ticketId: string; anchor: HTMLElement };
|
|
24
|
+
'kanban-ticket-save': { ticketId: string; draft: IKanbanTicketDraft };
|
|
25
|
+
'kanban-ticket-draft': { ticketId: string; draft?: IKanbanTicketDraft };
|
|
26
|
+
'kanban-comment-draft': { ticketId: string; body: TReferenceContent };
|
|
27
|
+
'kanban-comment-added': { comment: IKanbanComment; submittedBody: TReferenceContent };
|
|
28
|
+
'kanban-comments-refresh': { ticketId: string };
|
|
29
|
+
'kanban-detail-back': Record<string, never>;
|
|
30
|
+
'kanban-drag-start': { ticketId: string };
|
|
31
|
+
'kanban-drag-end': Record<string, never>;
|
|
32
|
+
}
|
|
33
|
+
type TKanbanInteraction = { [K in keyof IKanbanInteractionMap]: { type: K; detail: IKanbanInteractionMap[K] } }[keyof IKanbanInteractionMap];
|
|
34
|
+
|
|
35
|
+
@customElement('dees-kanban')
|
|
36
|
+
export class DeesKanban extends DeesElement {
|
|
37
|
+
public static demo = demoFunc;
|
|
38
|
+
public static demoGroups = ['Kanban'];
|
|
39
|
+
@property({ type: String }) accessor heading = 'Board';
|
|
40
|
+
@property({ attribute: false }) accessor tickets: IKanbanTicket[] = [];
|
|
41
|
+
@property({ attribute: false }) accessor columns: IKanbanColumn[] = [];
|
|
42
|
+
@property({ attribute: false }) accessor people: IKanbanPerson[] = [];
|
|
43
|
+
@property({ attribute: false }) accessor provider: IKanbanProvider = {};
|
|
44
|
+
@property({ type: Boolean }) accessor loading = false;
|
|
45
|
+
@property({ type: String }) accessor error = '';
|
|
46
|
+
@state() private accessor query = '';
|
|
47
|
+
@state() private accessor epicId = '';
|
|
48
|
+
@state() private accessor operationError = '';
|
|
49
|
+
@state() private accessor pendingTicketId = '';
|
|
50
|
+
@state() private accessor draggingTicketId = '';
|
|
51
|
+
@state() private accessor openingTicket = false;
|
|
52
|
+
private modal?: DeesModal;
|
|
53
|
+
private modalOpening?: Promise<DeesModal>;
|
|
54
|
+
private detail?: DeesKanbanTicketDetail;
|
|
55
|
+
private selectedId = '';
|
|
56
|
+
private history: string[] = [];
|
|
57
|
+
private openAbort?: AbortController;
|
|
58
|
+
private commentsAbort?: AbortController;
|
|
59
|
+
private commentCache = new Map<string, IKanbanComment[]>();
|
|
60
|
+
private commentRevisions = new Map<string, number>();
|
|
61
|
+
private commentDrafts = new Map<string, TReferenceContent>();
|
|
62
|
+
private ticketDrafts = new Map<string, IKanbanTicketDraft>();
|
|
63
|
+
private resolvedTickets = new Map<string, IKanbanTicket>();
|
|
64
|
+
|
|
65
|
+
public static styles = [...kanbanStyles, css`
|
|
66
|
+
:host { padding: 24px; background: var(--dees-color-bg-canvas); min-height: 100%; }
|
|
67
|
+
.heading { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 20px; }
|
|
68
|
+
h2 { font-size: var(--dees-font-title1-size); font-weight: 650; letter-spacing: -.02em; }
|
|
69
|
+
.filters { display: flex; align-items: end; gap: 16px; flex-wrap: wrap; margin-bottom: 24px; }
|
|
70
|
+
.filters dees-input-text { flex: 1; min-width: 200px; max-width: 360px; }
|
|
71
|
+
.filters dees-input-dropdown { min-width: 200px; max-width: 320px; flex: 1; }
|
|
72
|
+
.columns { display: flex; gap: 16px; overflow-x: auto; padding: 3px 3px 16px; align-items: stretch; }
|
|
73
|
+
@media (max-width: 600px) { :host { padding: 16px; } .filters { gap: 10px; } .columns { scroll-snap-type: x proximity; } dees-kanban-column { scroll-snap-align: start; flex-basis: 85vw; } }
|
|
74
|
+
`];
|
|
75
|
+
|
|
76
|
+
public async connectedCallback(): Promise<void> {
|
|
77
|
+
const connection = super.connectedCallback();
|
|
78
|
+
for (const name of interactionEvents) this.addEventListener(name, this.handleInteraction);
|
|
79
|
+
await connection;
|
|
80
|
+
}
|
|
81
|
+
public updated(): void { this.syncDetail(); }
|
|
82
|
+
public render() {
|
|
83
|
+
const epics = sortKanbanTickets(this.tickets.filter(ticket => ticket.kind === 'epic'));
|
|
84
|
+
const query = this.query.trim().toLocaleLowerCase();
|
|
85
|
+
const tasks = this.tickets.filter(ticket => ticket.kind === 'task' && (!this.epicId || ticket.parentId === this.epicId) &&
|
|
86
|
+
(!query || [ticket.key, ticket.title, ticket.assignee?.name, ...ticket.labels ?? [],
|
|
87
|
+
...this.tickets.filter(child => child.parentId === ticket.id).map(child => `${child.key} ${child.title}`)].join(' ').toLocaleLowerCase().includes(query)));
|
|
88
|
+
const epic = epics.find(ticket => ticket.id === this.epicId);
|
|
89
|
+
return html`<header class="heading"><h2>${this.heading}</h2><span class="caption">${tasks.length} tasks · ${epics.length} epics</span><span class="spacer"></span>
|
|
90
|
+
${this.provider.createTicket ? html`<dees-button type="ghost" size="sm" text="New epic" .disabled=${!this.columns.length}
|
|
91
|
+
@clicked=${() => { void this.createTicket({ kind: 'epic', columnId: this.columns[0].id }); }}></dees-button>
|
|
92
|
+
<dees-button type="accent" size="sm" icon="lucide:Plus" text="New task" .disabled=${!this.columns.length}
|
|
93
|
+
@clicked=${() => { void this.createTicket({ kind: 'task', columnId: this.columns[0].id, parentId: this.epicId || undefined }); }}></dees-button>` : ''}</header>
|
|
94
|
+
<div class="filters"><dees-input-text label="Search tickets" placeholder="Title, ticket number, assignee…" .value=${this.query}
|
|
95
|
+
@input=${(event: Event) => { this.query = String((event.currentTarget as DeesInputText).value ?? ''); }}></dees-input-text>
|
|
96
|
+
<dees-input-dropdown label="Epic" .value=${{ key: this.epicId, option: epic?.title ?? 'All epics' }}
|
|
97
|
+
.options=${[{ key: '', option: 'All epics' }, ...epics.map(ticket => ({ key: ticket.id, option: ticket.title }))]}
|
|
98
|
+
@selectedOption=${(event: CustomEvent<{ key: string }>) => { this.epicId = event.detail.key; }}></dees-input-dropdown>
|
|
99
|
+
${epic ? html`<dees-button type="ghost" size="sm" text="Open epic" @clicked=${() => { void this.openTicket(epic.id); }}></dees-button>` : ''}
|
|
100
|
+
</div>
|
|
101
|
+
${this.loading || this.openingTicket ? html`<p class="caption" role="status">${this.loading ? 'Loading board…' : 'Opening ticket…'}</p>` : ''}
|
|
102
|
+
${this.error || this.operationError ? html`<p class="error" role="alert">${this.error || this.operationError}</p>` : ''}
|
|
103
|
+
${!this.columns.length ? html`<p class="empty">No columns configured.</p>` : html`<div class="columns" aria-label=${this.heading}>
|
|
104
|
+
${this.columns.map(column => html`<dees-kanban-column .column=${column} .columns=${this.columns}
|
|
105
|
+
.tickets=${tasks.filter(ticket => ticket.columnId === column.id)} .allTickets=${this.tickets}
|
|
106
|
+
.movable=${Boolean(this.provider.moveTicket) && !this.pendingTicketId} .canCreate=${Boolean(this.provider.createTicket)}
|
|
107
|
+
.pendingTicketId=${this.pendingTicketId} .draggingTicketId=${this.draggingTicketId}></dees-kanban-column>`)}</div>`}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private findTicket(id: string): IKanbanTicket | undefined { return this.tickets.find(ticket => ticket.id === id) ?? this.resolvedTickets.get(id); }
|
|
111
|
+
private searchTickets: TReferenceSearch = async (query, signal) => {
|
|
112
|
+
const tickets = this.provider.searchTickets ? await this.provider.searchTickets(query, signal) :
|
|
113
|
+
this.tickets.filter(ticket => ticketLabel(ticket).toLocaleLowerCase().includes(query.toLocaleLowerCase()));
|
|
114
|
+
if (!signal.aborted) for (const ticket of tickets) this.resolvedTickets.set(ticket.id, ticket);
|
|
115
|
+
return tickets.map(ticket => ({ id: ticket.id, label: ticketLabel(ticket) }));
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** Open a task, epic, subtask or remotely resolved linked ticket in the board's detail modal. */
|
|
119
|
+
public async openTicket(id: string, addHistory = true): Promise<void> {
|
|
120
|
+
this.openAbort?.abort();
|
|
121
|
+
const controller = this.openAbort = new AbortController();
|
|
122
|
+
this.openingTicket = true;
|
|
123
|
+
this.operationError = '';
|
|
124
|
+
try {
|
|
125
|
+
const ticket = this.provider.getTicket ? await this.provider.getTicket(id, controller.signal) : this.findTicket(id);
|
|
126
|
+
if (controller.signal.aborted || !this.isConnected) return;
|
|
127
|
+
if (!ticket || ticket.id !== id) throw new Error('This ticket is unavailable or you do not have access.');
|
|
128
|
+
this.resolvedTickets.set(ticket.id, ticket);
|
|
129
|
+
this.tickets = this.tickets.map(item => item.id === ticket.id ? ticket : item);
|
|
130
|
+
if (addHistory && this.selectedId && this.selectedId !== id && this.modal) this.history.push(this.selectedId);
|
|
131
|
+
this.selectedId = id;
|
|
132
|
+
if (!this.detail) {
|
|
133
|
+
this.detail = new DeesKanbanTicketDetail();
|
|
134
|
+
for (const name of interactionEvents) this.detail.addEventListener(name, this.handleInteraction);
|
|
135
|
+
}
|
|
136
|
+
this.detail.ticket = ticket;
|
|
137
|
+
this.detail.error = '';
|
|
138
|
+
this.syncDetail();
|
|
139
|
+
if (!this.modal && !this.modalOpening) {
|
|
140
|
+
this.modalOpening = DeesModal.createAndShow({ heading: ticket.key, width: 1000, maxWidth: 1040,
|
|
141
|
+
mobileFullscreen: true, content: html`${this.detail}`, menuOptions: [] });
|
|
142
|
+
const modal = await this.modalOpening;
|
|
143
|
+
this.modalOpening = undefined;
|
|
144
|
+
if (!this.isConnected) { await modal.destroy(); return; }
|
|
145
|
+
this.modal = modal;
|
|
146
|
+
modal.addEventListener('modal-close', () => {
|
|
147
|
+
if (this.modal !== modal) return;
|
|
148
|
+
this.modal = undefined; this.selectedId = ''; this.history = [];
|
|
149
|
+
this.commentsAbort?.abort(); this.openAbort?.abort();
|
|
150
|
+
}, { once: true });
|
|
151
|
+
} else if (this.modalOpening) await this.modalOpening;
|
|
152
|
+
this.syncDetail();
|
|
153
|
+
if (controller.signal.aborted) return;
|
|
154
|
+
kanbanEvent(this, 'kanban-ticket-opened', { ticketId: id });
|
|
155
|
+
await this.refreshComments(id);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (!controller.signal.aborted) this.reportError(error);
|
|
158
|
+
} finally { if (this.openAbort === controller) this.openingTicket = false; }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private syncDetail(): void {
|
|
162
|
+
if (!this.detail || !this.selectedId) return;
|
|
163
|
+
const ticket = this.resolvedTickets.get(this.selectedId) ?? this.findTicket(this.selectedId);
|
|
164
|
+
if (!ticket) return;
|
|
165
|
+
// Host-fed updates take precedence for records on this board.
|
|
166
|
+
this.detail.ticket = this.tickets.find(item => item.id === this.selectedId) ?? ticket;
|
|
167
|
+
this.detail.tickets = [...this.tickets, ...[...this.resolvedTickets.values()].filter(item => !this.tickets.some(known => known.id === item.id))];
|
|
168
|
+
this.detail.columns = this.columns; this.detail.people = this.people;
|
|
169
|
+
this.detail.provider = this.provider; this.detail.searchTickets = this.searchTickets;
|
|
170
|
+
this.detail.comments = this.commentCache.get(this.selectedId) ?? [];
|
|
171
|
+
this.detail.commentDraft = this.commentDrafts.get(this.selectedId) ?? [];
|
|
172
|
+
this.detail.draft = this.ticketDrafts.get(this.selectedId);
|
|
173
|
+
this.detail.canGoBack = this.history.length > 0;
|
|
174
|
+
this.detail.saving = this.pendingTicketId === this.selectedId;
|
|
175
|
+
if (this.modal) this.modal.heading = this.detail.ticket.key;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Replace a ticket's discussion with an authoritative snapshot (for realtime updates). */
|
|
179
|
+
public setComments(ticketId: string, comments: IKanbanComment[]): void {
|
|
180
|
+
if (comments.some(comment => comment.ticketId !== ticketId)) throw new Error('Comment ticket IDs must match.');
|
|
181
|
+
this.commentCache.set(ticketId, [...comments]);
|
|
182
|
+
this.commentRevisions.set(ticketId, (this.commentRevisions.get(ticketId) ?? 0) + 1);
|
|
183
|
+
this.syncDetail();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
public async refreshComments(ticketId = this.selectedId): Promise<void> {
|
|
187
|
+
this.commentsAbort?.abort();
|
|
188
|
+
const controller = this.commentsAbort = new AbortController();
|
|
189
|
+
if (!ticketId || !this.provider.loadComments) { if (this.detail) { this.detail.loadingComments = false; this.detail.commentsError = ''; } return; }
|
|
190
|
+
const revision = this.commentRevisions.get(ticketId) ?? 0;
|
|
191
|
+
const existingIds = new Set((this.commentCache.get(ticketId) ?? []).map(comment => comment.id));
|
|
192
|
+
if (this.detail && ticketId === this.selectedId) { this.detail.loadingComments = true; this.detail.commentsError = ''; }
|
|
193
|
+
try {
|
|
194
|
+
const comments = await this.provider.loadComments(ticketId, controller.signal);
|
|
195
|
+
if (!controller.signal.aborted && (this.commentRevisions.get(ticketId) ?? 0) === revision) {
|
|
196
|
+
const postedWhileLoading = (this.commentCache.get(ticketId) ?? []).filter(comment => !existingIds.has(comment.id) && !comments.some(item => item.id === comment.id));
|
|
197
|
+
this.setComments(ticketId, [...comments, ...postedWhileLoading]);
|
|
198
|
+
}
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (!controller.signal.aborted && this.detail && ticketId === this.selectedId) this.detail.commentsError = kanbanError(error);
|
|
201
|
+
} finally {
|
|
202
|
+
if (!controller.signal.aborted && this.detail && ticketId === this.selectedId) this.detail.loadingComments = false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private mergeTickets(tickets: IKanbanTicket[], addToBoard = false): void {
|
|
207
|
+
for (const ticket of tickets) this.resolvedTickets.set(ticket.id, ticket);
|
|
208
|
+
this.tickets = this.tickets.map(ticket => tickets.find(item => item.id === ticket.id) ?? ticket);
|
|
209
|
+
if (addToBoard) this.tickets = [...this.tickets, ...tickets.filter(ticket => !this.tickets.some(item => item.id === ticket.id))];
|
|
210
|
+
this.syncDetail();
|
|
211
|
+
kanbanEvent(this, 'kanban-change', { tickets });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Provider commits the move and supplies the new ordering. Hierarchy is never part of a move request. */
|
|
215
|
+
public async moveTicket(input: IKanbanMove): Promise<void> {
|
|
216
|
+
const ticket = this.findTicket(input.ticketId);
|
|
217
|
+
if (!ticket || !this.provider.moveTicket || ticket.permissions?.move === false || this.pendingTicketId || input.beforeTicketId === ticket.id) return;
|
|
218
|
+
if (!this.columns.some(column => column.id === input.columnId)) { this.reportError(new Error('Unknown destination column.')); return; }
|
|
219
|
+
const before = input.beforeTicketId ? this.findTicket(input.beforeTicketId) : undefined;
|
|
220
|
+
if (input.beforeTicketId && (!before || before.columnId !== input.columnId || before.kind !== ticket.kind)) { this.reportError(new Error('Invalid insertion target.')); return; }
|
|
221
|
+
this.pendingTicketId = ticket.id; this.operationError = ''; this.draggingTicketId = '';
|
|
222
|
+
try {
|
|
223
|
+
const changed = await this.provider.moveTicket(input);
|
|
224
|
+
if (!changed.some(item => item.id === ticket.id)) throw new Error('The move provider did not return the moved ticket.');
|
|
225
|
+
this.mergeTickets(changed);
|
|
226
|
+
} catch (error) { this.reportError(error); }
|
|
227
|
+
finally { this.pendingTicketId = ''; this.syncDetail(); }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private async saveTicket(ticketId: string, draft: IKanbanTicketDraft): Promise<void> {
|
|
231
|
+
const ticket = this.findTicket(ticketId);
|
|
232
|
+
if (!ticket || !this.provider.updateTicket || ticket.permissions?.edit === false || this.pendingTicketId || !draft.title.trim()) return;
|
|
233
|
+
const parent = draft.parentId ? this.findTicket(draft.parentId) : undefined;
|
|
234
|
+
if ((ticket.kind === 'subtask' && parent?.kind !== 'task') || (ticket.kind === 'task' && draft.parentId && parent?.kind !== 'epic')) {
|
|
235
|
+
this.reportError(new Error('Choose a valid parent ticket.')); return;
|
|
236
|
+
}
|
|
237
|
+
const assignee = this.people.find(person => person.id === draft.assigneeId) ?? (ticket.assignee?.id === draft.assigneeId ? ticket.assignee : undefined);
|
|
238
|
+
if (draft.assigneeId && !assignee) { this.reportError(new Error('Choose an available assignee.')); return; }
|
|
239
|
+
this.pendingTicketId = ticketId; this.operationError = ''; if (this.detail) this.detail.error = '';
|
|
240
|
+
try {
|
|
241
|
+
const saved = await this.provider.updateTicket({ ticketId, changes: { title: draft.title.trim(), description: draft.description,
|
|
242
|
+
priority: draft.priority, assignee: assignee ?? null, parentId: ticket.kind === 'epic' ? null : draft.parentId || null } });
|
|
243
|
+
if (saved.id !== ticketId) throw new Error('The update provider returned a different ticket.');
|
|
244
|
+
this.ticketDrafts.delete(ticketId);
|
|
245
|
+
this.mergeTickets([saved]);
|
|
246
|
+
if (this.selectedId === ticketId) this.detail?.finishEditing();
|
|
247
|
+
} catch (error) { this.reportError(error); }
|
|
248
|
+
finally { this.pendingTicketId = ''; this.syncDetail(); }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private async createTicket(input: Omit<IKanbanCreate, 'title'>): Promise<void> {
|
|
252
|
+
if (!this.provider.createTicket || !this.columns.some(column => column.id === input.columnId)) return;
|
|
253
|
+
const parent = input.parentId ? this.findTicket(input.parentId) : undefined;
|
|
254
|
+
if (parent?.permissions?.createChild === false) return;
|
|
255
|
+
if ((input.kind === 'subtask' && parent?.kind !== 'task') || (input.kind === 'task' && parent && parent.kind !== 'epic')) return;
|
|
256
|
+
const parentId = input.parentId ?? (input.kind === 'task' ? this.epicId || undefined : undefined);
|
|
257
|
+
await DeesModal.prompt({ heading: `New ${input.kind}`, label: 'Title', confirmLabel: 'Create', onConfirm: async title => {
|
|
258
|
+
const ticket = await this.provider.createTicket!({ ...input, parentId, title });
|
|
259
|
+
this.mergeTickets([ticket], true);
|
|
260
|
+
} });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private async openMoveMenu(ticketId: string, anchor: HTMLElement): Promise<void> {
|
|
264
|
+
const ticket = this.findTicket(ticketId);
|
|
265
|
+
if (!ticket || !this.provider.moveTicket || ticket.permissions?.move === false || this.pendingTicketId) return;
|
|
266
|
+
const siblings = sortKanbanTickets(this.tickets.filter(item => item.columnId === ticket.columnId && item.kind === ticket.kind));
|
|
267
|
+
const index = siblings.findIndex(item => item.id === ticket.id);
|
|
268
|
+
const rect = anchor.getBoundingClientRect();
|
|
269
|
+
await DeesContextmenu.openContextMenuWithOptions(new MouseEvent('click', { clientX: rect.right, clientY: rect.bottom }), [
|
|
270
|
+
{ name: 'Move up', disabled: index <= 0, action: async () => { if (index > 0) await this.moveTicket({ ticketId, columnId: ticket.columnId, beforeTicketId: siblings[index - 1].id }); } },
|
|
271
|
+
{ name: 'Move down', disabled: index < 0 || index >= siblings.length - 1, action: async () => { if (index >= 0 && index < siblings.length - 1) await this.moveTicket({ ticketId, columnId: ticket.columnId, beforeTicketId: siblings[index + 2]?.id }); } },
|
|
272
|
+
{ divider: true }, ...this.columns.filter(column => column.id !== ticket.columnId).map(column => ({ name: `Move to ${column.title}`, action: async () => { await this.moveTicket({ ticketId, columnId: column.id }); } })),
|
|
273
|
+
], { ownerElement: anchor });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private reportError(error: unknown): void {
|
|
277
|
+
this.operationError = kanbanError(error);
|
|
278
|
+
if (this.detail && this.modal) this.detail.error = this.operationError;
|
|
279
|
+
kanbanEvent(this, 'kanban-error', { error, message: this.operationError });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private handleInteraction = (event: Event): void => {
|
|
283
|
+
event.stopPropagation();
|
|
284
|
+
const { type, detail } = event as unknown as TKanbanInteraction;
|
|
285
|
+
switch (type) {
|
|
286
|
+
case 'kanban-ticket-open': void this.openTicket(detail.ticketId); break;
|
|
287
|
+
case 'kanban-ticket-move': void this.moveTicket(detail); break;
|
|
288
|
+
case 'kanban-ticket-create': void this.createTicket(detail).catch(error => this.reportError(error)); break;
|
|
289
|
+
case 'kanban-move-menu': void this.openMoveMenu(detail.ticketId, detail.anchor).catch(error => this.reportError(error)); break;
|
|
290
|
+
case 'kanban-ticket-save': void this.saveTicket(detail.ticketId, detail.draft); break;
|
|
291
|
+
case 'kanban-ticket-draft':
|
|
292
|
+
if (detail.draft) this.ticketDrafts.set(detail.ticketId, detail.draft); else this.ticketDrafts.delete(detail.ticketId);
|
|
293
|
+
this.syncDetail(); break;
|
|
294
|
+
case 'kanban-comment-draft': this.commentDrafts.set(detail.ticketId, detail.body); break;
|
|
295
|
+
case 'kanban-comment-added': {
|
|
296
|
+
const { comment, submittedBody } = detail;
|
|
297
|
+
const comments = this.commentCache.get(comment.ticketId) ?? [];
|
|
298
|
+
this.commentCache.set(comment.ticketId, [...comments.filter(item => item.id !== comment.id), comment]);
|
|
299
|
+
if (JSON.stringify(this.commentDrafts.get(comment.ticketId) ?? []) === JSON.stringify(submittedBody)) this.commentDrafts.delete(comment.ticketId);
|
|
300
|
+
this.syncDetail(); kanbanEvent(this, 'kanban-comment-saved', { comment }); break;
|
|
301
|
+
}
|
|
302
|
+
case 'kanban-comments-refresh': void this.refreshComments(detail.ticketId); break;
|
|
303
|
+
case 'kanban-detail-back': { const previous = this.history.pop(); if (previous) void this.openTicket(previous, false); break; }
|
|
304
|
+
case 'kanban-drag-start': this.draggingTicketId = detail.ticketId; break;
|
|
305
|
+
case 'kanban-drag-end': this.draggingTicketId = ''; break;
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
public async disconnectedCallback(): Promise<void> {
|
|
310
|
+
for (const name of interactionEvents) this.removeEventListener(name, this.handleInteraction);
|
|
311
|
+
this.openAbort?.abort(); this.commentsAbort?.abort();
|
|
312
|
+
void this.modal?.destroy();
|
|
313
|
+
await super.disconnectedCallback();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { DeesElement, customElement, property, html, css } from '@design.estate/dees-element';
|
|
2
|
+
import type { IKanbanTicket, IKanbanColumn } from '../interfaces.js';
|
|
3
|
+
import { kanbanStyles } from '../kanban.styles.js';
|
|
4
|
+
import { kanbanEvent } from '../kanban.helpers.js';
|
|
5
|
+
import '../../00group-button/dees-button/dees-button.js';
|
|
6
|
+
import '../../00group-utility/dees-icon/dees-icon.js';
|
|
7
|
+
|
|
8
|
+
declare global { interface HTMLElementTagNameMap { 'dees-kanban-card': DeesKanbanCard; } }
|
|
9
|
+
|
|
10
|
+
@customElement('dees-kanban-card')
|
|
11
|
+
export class DeesKanbanCard extends DeesElement {
|
|
12
|
+
@property({ attribute: false }) accessor ticket: IKanbanTicket | undefined;
|
|
13
|
+
@property({ attribute: false }) accessor epic: IKanbanTicket | undefined;
|
|
14
|
+
@property({ attribute: false }) accessor subtasks: IKanbanTicket[] = [];
|
|
15
|
+
@property({ attribute: false }) accessor columns: IKanbanColumn[] = [];
|
|
16
|
+
@property({ type: Boolean }) accessor movable = false;
|
|
17
|
+
@property({ type: Boolean }) accessor pending = false;
|
|
18
|
+
public static styles = [...kanbanStyles, css`
|
|
19
|
+
article { padding: 14px; border: 1px solid var(--dees-color-border-default); border-radius: var(--dees-radius-lg); background: var(--dees-color-bg-primary); box-shadow: var(--dees-shadow-xs); }
|
|
20
|
+
article:hover { border-color: var(--dees-color-border-strong); }
|
|
21
|
+
article[aria-busy=true] { opacity: .65; }
|
|
22
|
+
.title { display: block; width: 100%; border: 0; background: transparent; padding: 8px 0 12px; color: inherit; text-align: left; cursor: pointer; font: inherit; font-weight: 550; line-height: 1.45; overflow-wrap: anywhere; }
|
|
23
|
+
.key { font-variant-numeric: tabular-nums; }
|
|
24
|
+
.link.epic { margin-bottom: 5px; max-width: 100%; font-size: var(--dees-font-caption1-size); }
|
|
25
|
+
.labels { margin-bottom: 10px; }
|
|
26
|
+
.priority[data-priority=urgent], .priority[data-priority=high] { color: var(--dees-color-text-warning); }
|
|
27
|
+
.avatar { width: 24px; height: 24px; border-radius: 50%; background: var(--dees-color-badge-default-bg); display: inline-grid; place-items: center; font-size: 10px; font-weight: 600; }
|
|
28
|
+
summary { cursor: pointer; padding: 10px 0 2px; font-size: var(--dees-font-footnote-size); color: var(--dees-color-text-secondary); }
|
|
29
|
+
.subtask { display: flex; gap: 6px; align-items: baseline; padding-top: 8px; font-size: var(--dees-font-footnote-size); }
|
|
30
|
+
`];
|
|
31
|
+
private open(ticketId: string): void { kanbanEvent(this, 'kanban-ticket-open', { ticketId }); }
|
|
32
|
+
public render() {
|
|
33
|
+
const ticket = this.ticket;
|
|
34
|
+
if (!ticket) return html``;
|
|
35
|
+
const completed = this.subtasks.filter(task => this.columns.some(column => column.id === task.columnId && column.done)).length;
|
|
36
|
+
return html`<article aria-busy=${String(this.pending)} draggable=${this.movable && !this.pending ? 'true' : 'false'}
|
|
37
|
+
@dragstart=${(event: DragEvent) => {
|
|
38
|
+
if (!this.movable || this.pending || !event.dataTransfer) { event.preventDefault(); return; }
|
|
39
|
+
event.dataTransfer.setData('application/x-dees-kanban-ticket', ticket.id);
|
|
40
|
+
event.dataTransfer.effectAllowed = 'move';
|
|
41
|
+
kanbanEvent(this, 'kanban-drag-start', { ticketId: ticket.id });
|
|
42
|
+
}} @dragend=${() => kanbanEvent(this, 'kanban-drag-end', {})}>
|
|
43
|
+
${this.epic ? html`<button class="link epic" @click=${() => this.open(this.epic!.id)}>${this.epic.title}</button>` : ''}
|
|
44
|
+
<div class="row caption"><span class="key">${ticket.key}</span><span class="spacer"></span>
|
|
45
|
+
${ticket.priority && ticket.priority !== 'none' ? html`<span class="priority" data-priority=${ticket.priority}>${ticket.priority}</span>` : ''}</div>
|
|
46
|
+
<button class="title" @click=${() => this.open(ticket.id)}>${ticket.title}</button>
|
|
47
|
+
${ticket.labels?.length ? html`<div class="row labels">${ticket.labels.map(label => html`<span class="badge">${label}</span>`)}</div>` : ''}
|
|
48
|
+
<div class="row caption"><span class="avatar" title=${ticket.assignee?.name ?? 'Unassigned'}>${ticket.assignee?.name.split(/\s+/).map(part => part[0]).join('').slice(0, 2) ?? '–'}</span>
|
|
49
|
+
<span>${ticket.assignee?.name ?? 'Unassigned'}</span><span class="spacer"></span>
|
|
50
|
+
${this.movable ? html`<dees-button size="icon" type="ghost" icon="lucide:Ellipsis" text="Move ticket" .disabled=${this.pending}
|
|
51
|
+
@clicked=${(event: Event) => kanbanEvent(this, 'kanban-move-menu', { ticketId: ticket.id, anchor: event.currentTarget as HTMLElement })}></dees-button>` : ''}
|
|
52
|
+
</div>
|
|
53
|
+
${this.subtasks.length ? html`<details><summary>${completed}/${this.subtasks.length} subtasks complete</summary>
|
|
54
|
+
${this.subtasks.map(task => html`<div class="subtask"><span>${this.columns.some(column => column.id === task.columnId && column.done) ? '✓' : '○'}</span>
|
|
55
|
+
<button class="link" @click=${() => this.open(task.id)}>${task.title}</button></div>`)}</details>` : ''}
|
|
56
|
+
</article>`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { DeesElement, customElement, property, state, html, css } from '@design.estate/dees-element';
|
|
2
|
+
import type { IKanbanColumn, IKanbanTicket } from '../interfaces.js';
|
|
3
|
+
import { kanbanStyles } from '../kanban.styles.js';
|
|
4
|
+
import { kanbanEvent, sortKanbanTickets } from '../kanban.helpers.js';
|
|
5
|
+
import '../dees-kanban-card/dees-kanban-card.js';
|
|
6
|
+
|
|
7
|
+
declare global { interface HTMLElementTagNameMap { 'dees-kanban-column': DeesKanbanColumn; } }
|
|
8
|
+
|
|
9
|
+
@customElement('dees-kanban-column')
|
|
10
|
+
export class DeesKanbanColumn extends DeesElement {
|
|
11
|
+
@property({ attribute: false }) accessor column: IKanbanColumn | undefined;
|
|
12
|
+
@property({ attribute: false }) accessor tickets: IKanbanTicket[] = [];
|
|
13
|
+
@property({ attribute: false }) accessor allTickets: IKanbanTicket[] = [];
|
|
14
|
+
@property({ attribute: false }) accessor columns: IKanbanColumn[] = [];
|
|
15
|
+
@property({ type: Boolean }) accessor movable = false;
|
|
16
|
+
@property({ type: Boolean }) accessor canCreate = false;
|
|
17
|
+
@property({ type: String }) accessor pendingTicketId = '';
|
|
18
|
+
@property({ type: String }) accessor draggingTicketId = '';
|
|
19
|
+
@state() private accessor dropBefore: string | undefined;
|
|
20
|
+
@state() private accessor draggingOver = false;
|
|
21
|
+
public static styles = [...kanbanStyles, css`
|
|
22
|
+
:host { display: flex; flex-direction: column; width: 300px; min-width: 270px; flex: 1 0 270px; }
|
|
23
|
+
section { min-height: 220px; height: 100%; border-radius: var(--dees-radius-lg); background: var(--dees-color-bg-secondary); padding: 10px; }
|
|
24
|
+
header { display: flex; align-items: center; gap: 8px; padding: 3px 3px 12px; }
|
|
25
|
+
h3 { font-size: var(--dees-font-body-size); font-weight: 600; }
|
|
26
|
+
.count { font-size: var(--dees-font-caption1-size); color: var(--dees-color-text-secondary); }
|
|
27
|
+
.stack { display: grid; gap: 10px; min-height: 80px; align-content: start; }
|
|
28
|
+
.target { position: relative; }
|
|
29
|
+
.target.before::before, .end-target.before::before { content: ''; display: block; height: 3px; background: var(--dees-color-accent-primary); border-radius: 2px; margin-bottom: 7px; }
|
|
30
|
+
.end-target { min-height: 20px; padding-top: 10px; }
|
|
31
|
+
section.over { outline: 2px solid var(--dees-color-focus-ring); outline-offset: 1px; }
|
|
32
|
+
`];
|
|
33
|
+
public updated(): void { if (!this.draggingTicketId) this.draggingOver = false; }
|
|
34
|
+
public render() {
|
|
35
|
+
if (!this.column) return html``;
|
|
36
|
+
return html`<section class=${this.draggingOver ? 'over' : ''} aria-label=${this.column.title}
|
|
37
|
+
@dragover=${(event: DragEvent) => { if (this.draggingTicketId && this.movable) { event.preventDefault(); this.draggingOver = true; if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'; } }}
|
|
38
|
+
@dragleave=${(event: DragEvent) => { if (!(event.relatedTarget instanceof Node) || !(event.currentTarget as Node).contains(event.relatedTarget)) this.draggingOver = false; }}
|
|
39
|
+
@drop=${(event: DragEvent) => {
|
|
40
|
+
event.preventDefault(); this.draggingOver = false;
|
|
41
|
+
if (this.movable && this.draggingTicketId && event.dataTransfer?.getData('application/x-dees-kanban-ticket') === this.draggingTicketId)
|
|
42
|
+
kanbanEvent(this, 'kanban-ticket-move', { ticketId: this.draggingTicketId, columnId: this.column!.id, beforeTicketId: this.dropBefore });
|
|
43
|
+
}}>
|
|
44
|
+
<header><h3>${this.column.title}</h3><span class="count">${this.tickets.length}</span><span class="spacer"></span>
|
|
45
|
+
${this.canCreate ? html`<dees-button type="ghost" size="icon" icon="lucide:Plus" text=${`Add task to ${this.column.title}`}
|
|
46
|
+
@clicked=${() => kanbanEvent(this, 'kanban-ticket-create', { kind: 'task', columnId: this.column!.id })}></dees-button>` : ''}</header>
|
|
47
|
+
<div class="stack">${sortKanbanTickets(this.tickets).map(ticket => html`
|
|
48
|
+
<div class="target ${this.draggingOver && this.dropBefore === ticket.id ? 'before' : ''}"
|
|
49
|
+
@dragover=${() => { this.dropBefore = ticket.id; }}>
|
|
50
|
+
<dees-kanban-card .ticket=${ticket} .columns=${this.columns}
|
|
51
|
+
.epic=${this.allTickets.find(item => item.id === ticket.parentId && item.kind === 'epic')}
|
|
52
|
+
.subtasks=${sortKanbanTickets(this.allTickets.filter(item => item.parentId === ticket.id && item.kind === 'subtask'))}
|
|
53
|
+
.movable=${this.movable && ticket.permissions?.move !== false} .pending=${this.pendingTicketId === ticket.id}></dees-kanban-card>
|
|
54
|
+
</div>`)}
|
|
55
|
+
${!this.tickets.length ? html`<div class="empty" @dragover=${() => { this.dropBefore = undefined; }}>No tasks</div>` : ''}
|
|
56
|
+
</div>
|
|
57
|
+
<div class="end-target ${this.draggingOver && !this.dropBefore ? 'before' : ''}" @dragover=${() => { this.dropBefore = undefined; }}></div>
|
|
58
|
+
</section>`;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { DeesElement, customElement, property, state, html, css } from '@design.estate/dees-element';
|
|
2
|
+
import type { IKanbanComment, IKanbanProvider } from '../interfaces.js';
|
|
3
|
+
import { kanbanStyles } from '../kanban.styles.js';
|
|
4
|
+
import { kanbanError, kanbanEvent } from '../kanban.helpers.js';
|
|
5
|
+
import { referenceText, type TReferenceContent, type TReferenceSearch } from '../../00group-input/dees-input-reference/index.js';
|
|
6
|
+
import '../../00group-button/dees-button/dees-button.js';
|
|
7
|
+
|
|
8
|
+
declare global { interface HTMLElementTagNameMap { 'dees-kanban-discussion': DeesKanbanDiscussion; } }
|
|
9
|
+
|
|
10
|
+
@customElement('dees-kanban-discussion')
|
|
11
|
+
export class DeesKanbanDiscussion extends DeesElement {
|
|
12
|
+
@property({ type: String }) accessor ticketId = '';
|
|
13
|
+
@property({ attribute: false }) accessor comments: IKanbanComment[] = [];
|
|
14
|
+
@property({ attribute: false }) accessor draft: TReferenceContent = [];
|
|
15
|
+
@property({ attribute: false }) accessor searchTickets: TReferenceSearch | undefined;
|
|
16
|
+
@property({ attribute: false }) accessor addComment: IKanbanProvider['addComment'];
|
|
17
|
+
@property({ type: Boolean }) accessor loading = false;
|
|
18
|
+
@property({ type: Boolean }) accessor readOnly = false;
|
|
19
|
+
@property({ type: String }) accessor error = '';
|
|
20
|
+
@state() private accessor pending = new Set<string>();
|
|
21
|
+
@state() private accessor failures = new Map<string, string>();
|
|
22
|
+
public static styles = [...kanbanStyles, css`
|
|
23
|
+
header { display: flex; align-items: center; margin-bottom: 16px; }
|
|
24
|
+
h3 { font-size: var(--dees-font-body-size); font-weight: 600; }
|
|
25
|
+
ol { list-style: none; padding: 0; margin: 0; }
|
|
26
|
+
li { padding: 0 0 20px; }
|
|
27
|
+
.author { font-weight: 550; font-size: var(--dees-font-footnote-size); }
|
|
28
|
+
time { font-size: var(--dees-font-caption1-size); color: var(--dees-color-text-secondary); }
|
|
29
|
+
.message { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.6; margin-top: 6px; }
|
|
30
|
+
.reference { display: inline; border: 0; border-radius: var(--dees-radius-sm); padding: 1px 4px; color: var(--dees-color-link); background: var(--dees-color-badge-default-bg); font: inherit; cursor: pointer; }
|
|
31
|
+
.compose { padding-top: 4px; }
|
|
32
|
+
.compose .row { padding-top: 8px; }
|
|
33
|
+
`];
|
|
34
|
+
public render() {
|
|
35
|
+
const pending = this.pending.has(this.ticketId);
|
|
36
|
+
const comments = this.comments.filter(comment => comment.ticketId === this.ticketId)
|
|
37
|
+
.slice().sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
|
|
38
|
+
return html`<header><h3>Discussion <span class="caption">${comments.length}</span></h3><span class="spacer"></span>
|
|
39
|
+
<dees-button size="sm" type="ghost" icon="lucide:RefreshCw" text="Refresh" .disabled=${this.loading}
|
|
40
|
+
@clicked=${() => kanbanEvent(this, 'kanban-comments-refresh', { ticketId: this.ticketId })}></dees-button></header>
|
|
41
|
+
${this.loading ? html`<p class="caption" role="status">Loading comments…</p>` : ''}
|
|
42
|
+
${this.error ? html`<p class="error" role="alert">${this.error}</p>` : ''}
|
|
43
|
+
<ol aria-label="Comments">${comments.map(comment => html`<li>
|
|
44
|
+
<div class="row"><span class="author">${comment.author.name}</span><time datetime=${comment.createdAt}>${new Date(comment.createdAt).toLocaleString()}</time></div>
|
|
45
|
+
<div class="message">${comment.body.map(part => part.type === 'text' ? part.text : html`<button class="reference"
|
|
46
|
+
@click=${() => kanbanEvent(this, 'kanban-ticket-open', { ticketId: part.id })}>${part.label}</button>`)}</div>
|
|
47
|
+
</li>`)}</ol>
|
|
48
|
+
${!comments.length && !this.loading && !this.error ? html`<p class="empty">Start the conversation.</p>` : ''}
|
|
49
|
+
${this.addComment && !this.readOnly ? html`<div class="compose">
|
|
50
|
+
<dees-input-reference label="Comment" placeholder="Write a comment…" .documentKey=${this.ticketId} .value=${this.draft} .disabled=${pending}
|
|
51
|
+
.searchReferences=${this.searchTickets}
|
|
52
|
+
@reference-input=${(event: CustomEvent<{ value: TReferenceContent }>) => {
|
|
53
|
+
this.draft = event.detail.value;
|
|
54
|
+
kanbanEvent(this, 'kanban-comment-draft', { ticketId: this.ticketId, body: this.draft });
|
|
55
|
+
}}
|
|
56
|
+
@reference-open=${(event: CustomEvent<{ id: string }>) => kanbanEvent(this, 'kanban-ticket-open', { ticketId: event.detail.id })}
|
|
57
|
+
@reference-submit=${() => { void this.submit(); }}></dees-input-reference>
|
|
58
|
+
<div class="row"><span class="caption">Type #_ to link a ticket · Ctrl/⌘ Enter to send</span><span class="spacer"></span>
|
|
59
|
+
<dees-button type="accent" size="sm" text="Comment" .disabled=${pending || !referenceText(this.draft).trim()}
|
|
60
|
+
.status=${pending ? 'pending' : 'normal'} @clicked=${() => { void this.submit(); }}></dees-button></div>
|
|
61
|
+
${this.failures.get(this.ticketId) ? html`<p class="error" role="alert">${this.failures.get(this.ticketId)}</p>` : ''}
|
|
62
|
+
</div>` : html`<p class="caption">Comments are read-only.</p>`}`;
|
|
63
|
+
}
|
|
64
|
+
public async submit(): Promise<void> {
|
|
65
|
+
const ticketId = this.ticketId;
|
|
66
|
+
if (!ticketId || !this.addComment || this.readOnly || this.pending.has(ticketId) || !referenceText(this.draft).trim()) return;
|
|
67
|
+
const body = this.draft.map(part => ({ ...part }));
|
|
68
|
+
this.pending = new Set(this.pending).add(ticketId);
|
|
69
|
+
this.failures = new Map(this.failures); this.failures.delete(ticketId);
|
|
70
|
+
try {
|
|
71
|
+
const comment = await this.addComment({ ticketId, body });
|
|
72
|
+
if (comment.ticketId !== ticketId) throw new Error('The saved comment belongs to a different ticket.');
|
|
73
|
+
if (this.ticketId === ticketId && JSON.stringify(this.draft) === JSON.stringify(body)) this.draft = [];
|
|
74
|
+
kanbanEvent(this, 'kanban-comment-added', { comment, submittedBody: body });
|
|
75
|
+
} catch (error) {
|
|
76
|
+
this.failures = new Map(this.failures).set(ticketId, kanbanError(error));
|
|
77
|
+
} finally {
|
|
78
|
+
this.pending = new Set(this.pending); this.pending.delete(ticketId);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|