@design.estate/dees-catalog 12.4.0 → 13.0.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 +2603 -1201
- package/dist_bundle/bundle.js.map +1 -1
- package/dist_ts_web/00_commitinfo_data.js +3 -3
- package/dist_ts_web/elements/00group-chart/dees-chart-area/component.d.ts +26 -16
- package/dist_ts_web/elements/00group-chart/dees-chart-area/component.js +161 -72
- package/dist_ts_web/elements/00group-chart/dees-chart-area/realtime-updates.d.ts +2 -1
- package/dist_ts_web/elements/00group-chart/dees-chart-area/realtime-updates.js +3 -2
- package/dist_ts_web/elements/00group-chart/dees-chart-area/template.js +4 -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 +165 -1
- package/scripts/check-packed-consumer.mjs +1 -1
- package/ts_web/00_commitinfo_data.ts +3 -3
- package/ts_web/elements/00group-chart/dees-chart-area/component.ts +195 -79
- package/ts_web/elements/00group-chart/dees-chart-area/realtime-updates.ts +4 -2
- package/ts_web/elements/00group-chart/dees-chart-area/template.ts +3 -2
- 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,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
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { DeesElement, customElement, property, state, html, css } from '@design.estate/dees-element';
|
|
2
|
+
import type { IKanbanColumn, IKanbanComment, IKanbanPerson, IKanbanProvider, IKanbanTicket, IKanbanTicketDraft, TKanbanPriority } from '../interfaces.js';
|
|
3
|
+
import type { TReferenceContent, TReferenceSearch } from '../../00group-input/dees-input-reference/interfaces.js';
|
|
4
|
+
import { kanbanStyles } from '../kanban.styles.js';
|
|
5
|
+
import { kanbanEvent, sortKanbanTickets, ticketDraft, ticketLabel } from '../kanban.helpers.js';
|
|
6
|
+
import type { DeesInputText } from '../../00group-input/dees-input-text/dees-input-text.js';
|
|
7
|
+
import type { DeesInputTextarea } from '../../00group-input/dees-input-textarea/dees-input-textarea.js';
|
|
8
|
+
import '../../00group-input/dees-input-text/dees-input-text.js';
|
|
9
|
+
import '../../00group-input/dees-input-textarea/dees-input-textarea.js';
|
|
10
|
+
import '../../00group-input/dees-input-dropdown/dees-input-dropdown.js';
|
|
11
|
+
import '../dees-kanban-discussion/dees-kanban-discussion.js';
|
|
12
|
+
|
|
13
|
+
declare global { interface HTMLElementTagNameMap { 'dees-kanban-ticket-detail': DeesKanbanTicketDetail; } }
|
|
14
|
+
|
|
15
|
+
@customElement('dees-kanban-ticket-detail')
|
|
16
|
+
export class DeesKanbanTicketDetail extends DeesElement {
|
|
17
|
+
@property({ attribute: false }) accessor ticket: IKanbanTicket | undefined;
|
|
18
|
+
@property({ attribute: false }) accessor tickets: IKanbanTicket[] = [];
|
|
19
|
+
@property({ attribute: false }) accessor columns: IKanbanColumn[] = [];
|
|
20
|
+
@property({ attribute: false }) accessor people: IKanbanPerson[] = [];
|
|
21
|
+
@property({ attribute: false }) accessor provider: IKanbanProvider = {};
|
|
22
|
+
@property({ attribute: false }) accessor draft: IKanbanTicketDraft | undefined;
|
|
23
|
+
@property({ attribute: false }) accessor commentDraft: TReferenceContent = [];
|
|
24
|
+
@property({ attribute: false }) accessor comments: IKanbanComment[] = [];
|
|
25
|
+
@property({ attribute: false }) accessor searchTickets: TReferenceSearch | undefined;
|
|
26
|
+
@property({ type: Boolean }) accessor loadingComments = false;
|
|
27
|
+
@property({ type: String }) accessor commentsError = '';
|
|
28
|
+
@property({ type: String }) accessor error = '';
|
|
29
|
+
@property({ type: Boolean }) accessor saving = false;
|
|
30
|
+
@property({ type: Boolean }) accessor canGoBack = false;
|
|
31
|
+
@state() private accessor editing = false;
|
|
32
|
+
public static styles = [...kanbanStyles, css`
|
|
33
|
+
.layout { display: grid; grid-template-columns: minmax(0, 1fr) 230px; gap: 28px; }
|
|
34
|
+
.main { min-width: 0; }
|
|
35
|
+
.toolbar { margin-bottom: 16px; }
|
|
36
|
+
h2 { font-size: var(--dees-font-title2-size); line-height: 1.3; font-weight: 600; margin-bottom: 18px; overflow-wrap: anywhere; }
|
|
37
|
+
.description { white-space: pre-wrap; line-height: 1.65; overflow-wrap: anywhere; margin-bottom: 24px; }
|
|
38
|
+
aside { border-left: 1px solid var(--dees-color-border-subtle); padding-left: 22px; }
|
|
39
|
+
.fields { display: grid; gap: 16px; }
|
|
40
|
+
.fields label { display: block; margin-bottom: 6px; }
|
|
41
|
+
h3 { font-size: var(--dees-font-body-size); margin-bottom: 12px; }
|
|
42
|
+
.children { margin-bottom: 24px; }
|
|
43
|
+
.child { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--dees-color-border-subtle); }
|
|
44
|
+
.discussion { border-top: 1px solid var(--dees-color-border-subtle); padding-top: 22px; }
|
|
45
|
+
.edit { display: grid; gap: 14px; margin-bottom: 22px; }
|
|
46
|
+
@media (max-width: 700px) { .layout { grid-template-columns: minmax(0, 1fr); gap: 24px; } aside { grid-row: 1; border-left: 0; padding-left: 0; } .fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
47
|
+
`];
|
|
48
|
+
public updated(changed: Map<string, unknown>): void {
|
|
49
|
+
const previous = changed.get('ticket') as IKanbanTicket | undefined;
|
|
50
|
+
if (changed.has('ticket') && previous?.id !== this.ticket?.id) this.editing = Boolean(this.draft);
|
|
51
|
+
}
|
|
52
|
+
private changeDraft(change: Partial<IKanbanTicketDraft>): void {
|
|
53
|
+
this.draft = { ...(this.draft ?? ticketDraft(this.ticket!)), ...change };
|
|
54
|
+
kanbanEvent(this, 'kanban-ticket-draft', { ticketId: this.ticket!.id, draft: this.draft });
|
|
55
|
+
}
|
|
56
|
+
private addComment = (input: Parameters<NonNullable<IKanbanProvider['addComment']>>[0]) => this.provider.addComment!(input);
|
|
57
|
+
public render() {
|
|
58
|
+
const ticket = this.ticket;
|
|
59
|
+
if (!ticket) return html``;
|
|
60
|
+
const draft = this.draft ?? ticketDraft(ticket);
|
|
61
|
+
const parent = this.tickets.find(item => item.id === ticket.parentId);
|
|
62
|
+
const children = sortKanbanTickets(this.tickets.filter(item => item.parentId === ticket.id));
|
|
63
|
+
const editable = Boolean(this.provider.updateTicket) && ticket.permissions?.edit !== false;
|
|
64
|
+
const movable = Boolean(this.provider.moveTicket) && ticket.permissions?.move !== false;
|
|
65
|
+
const parents = this.tickets.filter(item => item.kind === (ticket.kind === 'subtask' ? 'task' : 'epic'));
|
|
66
|
+
return html`<div class="toolbar row">
|
|
67
|
+
${this.canGoBack ? html`<dees-button size="sm" type="ghost" icon="lucide:ArrowLeft" text="Back" @clicked=${() => kanbanEvent(this, 'kanban-detail-back', {})}></dees-button>` : ''}
|
|
68
|
+
<span class="badge">${ticket.kind}</span><span class="caption">${ticket.key}</span>
|
|
69
|
+
${parent ? html`<span class="caption">/</span><button class="link caption" @click=${() => kanbanEvent(this, 'kanban-ticket-open', { ticketId: parent.id })}>${parent.title}</button>` : ''}
|
|
70
|
+
<span class="spacer"></span>${editable && !this.editing ? html`<dees-button size="sm" type="ghost" text="Edit ticket" icon="lucide:Pencil" @clicked=${() => { this.editing = true; this.changeDraft({}); }}></dees-button>` : ''}
|
|
71
|
+
</div>
|
|
72
|
+
${this.error ? html`<p class="error" role="alert">${this.error}</p>` : ''}
|
|
73
|
+
<div class="layout"><div class="main">
|
|
74
|
+
${this.editing && editable ? html`<div class="edit">
|
|
75
|
+
<dees-input-text label="Title" .value=${draft.title} .disabled=${this.saving}
|
|
76
|
+
@input=${(event: Event) => this.changeDraft({ title: String((event.currentTarget as DeesInputText).value ?? '') })}></dees-input-text>
|
|
77
|
+
<dees-input-textarea label="Description" .value=${draft.description} .rows=${5} .disabled=${this.saving}
|
|
78
|
+
@input=${(event: Event) => this.changeDraft({ description: (event.currentTarget as DeesInputTextarea).value })}></dees-input-textarea>
|
|
79
|
+
<div class="row"><dees-button size="sm" type="accent" text="Save changes" .disabled=${this.saving || !draft.title.trim()} .status=${this.saving ? 'pending' : 'normal'}
|
|
80
|
+
@clicked=${() => kanbanEvent(this, 'kanban-ticket-save', { ticketId: ticket.id, draft })}></dees-button>
|
|
81
|
+
<dees-button size="sm" type="ghost" text="Cancel" .disabled=${this.saving} @clicked=${() => {
|
|
82
|
+
this.editing = false; this.draft = undefined; kanbanEvent(this, 'kanban-ticket-draft', { ticketId: ticket.id, draft: undefined });
|
|
83
|
+
}}></dees-button></div>
|
|
84
|
+
</div>` : html`<h2>${ticket.title}</h2><div class="description ${ticket.description ? '' : 'muted'}">${ticket.description || 'No description yet.'}</div>`}
|
|
85
|
+
${ticket.kind !== 'subtask' ? html`<section class="children"><div class="row"><h3>${ticket.kind === 'epic' ? 'Tasks' : 'Subtasks'}</h3><span class="spacer"></span>
|
|
86
|
+
${this.provider.createTicket && ticket.permissions?.createChild !== false ? html`<dees-button size="sm" type="ghost" icon="lucide:Plus" text=${ticket.kind === 'epic' ? 'Add task' : 'Add subtask'}
|
|
87
|
+
@clicked=${() => kanbanEvent(this, 'kanban-ticket-create', { kind: ticket.kind === 'epic' ? 'task' : 'subtask', parentId: ticket.id, columnId: this.columns[0]?.id ?? ticket.columnId })}></dees-button>` : ''}</div>
|
|
88
|
+
${children.map(child => html`<div class="child"><button class="link" @click=${() => kanbanEvent(this, 'kanban-ticket-open', { ticketId: child.id })}>${ticketLabel(child)}</button>
|
|
89
|
+
<span class="badge">${this.columns.find(column => column.id === child.columnId)?.title ?? child.columnId}</span></div>`)}
|
|
90
|
+
${!children.length ? html`<p class="caption">No ${ticket.kind === 'epic' ? 'tasks' : 'subtasks'} yet.</p>` : ''}</section>` : ''}
|
|
91
|
+
<dees-kanban-discussion class="discussion" .ticketId=${ticket.id} .comments=${this.comments} .draft=${this.commentDraft}
|
|
92
|
+
.loading=${this.loadingComments} .error=${this.commentsError} .readOnly=${ticket.permissions?.comment === false}
|
|
93
|
+
.searchTickets=${this.searchTickets} .addComment=${this.provider.addComment ? this.addComment : undefined}></dees-kanban-discussion>
|
|
94
|
+
</div><aside><div class="fields">
|
|
95
|
+
<dees-input-dropdown label="Status" .options=${this.columns.map(column => ({ key: column.id, option: column.title }))}
|
|
96
|
+
.value=${{ key: ticket.columnId, option: this.columns.find(column => column.id === ticket.columnId)?.title ?? ticket.columnId }}
|
|
97
|
+
.disabled=${!movable || this.saving} @selectedOption=${(event: CustomEvent<{ key: string }>) => kanbanEvent(this, 'kanban-ticket-move', { ticketId: ticket.id, columnId: event.detail.key })}></dees-input-dropdown>
|
|
98
|
+
${this.editing && editable ? html`
|
|
99
|
+
<dees-input-dropdown label="Priority" .disabled=${this.saving} .value=${{ key: draft.priority, option: draft.priority }}
|
|
100
|
+
.options=${['none', 'low', 'normal', 'high', 'urgent'].map(key => ({ key, option: key }))}
|
|
101
|
+
@selectedOption=${(event: CustomEvent<{ key: TKanbanPriority }>) => this.changeDraft({ priority: event.detail.key })}></dees-input-dropdown>
|
|
102
|
+
<dees-input-dropdown label="Assignee" .disabled=${this.saving} .value=${{ key: draft.assigneeId, option: this.people.find(person => person.id === draft.assigneeId)?.name ?? ticket.assignee?.name ?? 'Unassigned' }}
|
|
103
|
+
.options=${[{ key: '', option: 'Unassigned' }, ...this.people.map(person => ({ key: person.id, option: person.name }))]}
|
|
104
|
+
@selectedOption=${(event: CustomEvent<{ key: string }>) => this.changeDraft({ assigneeId: event.detail.key })}></dees-input-dropdown>
|
|
105
|
+
${ticket.kind !== 'epic' ? html`<dees-input-dropdown label=${ticket.kind === 'task' ? 'Epic' : 'Parent task'} .disabled=${this.saving}
|
|
106
|
+
.value=${{ key: draft.parentId, option: parents.find(item => item.id === draft.parentId)?.title ?? 'None' }}
|
|
107
|
+
.options=${[...(ticket.kind === 'task' ? [{ key: '', option: 'None' }] : []), ...parents.map(item => ({ key: item.id, option: ticketLabel(item) }))]}
|
|
108
|
+
@selectedOption=${(event: CustomEvent<{ key: string }>) => this.changeDraft({ parentId: event.detail.key })}></dees-input-dropdown>` : ''}
|
|
109
|
+
` : html`<div><label class="caption">Priority</label>${ticket.priority ?? 'none'}</div><div><label class="caption">Assignee</label>${ticket.assignee?.name ?? 'Unassigned'}</div>`}
|
|
110
|
+
${ticket.labels?.length ? html`<div><label class="caption">Labels</label><div class="row">${ticket.labels.map(label => html`<span class="badge">${label}</span>`)}</div></div>` : ''}
|
|
111
|
+
</div></aside></div>`;
|
|
112
|
+
}
|
|
113
|
+
public finishEditing(): void { this.editing = false; }
|
|
114
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export * from './interfaces.js';
|
|
2
|
+
export * from './dees-kanban/dees-kanban.js';
|
|
3
|
+
export * from './dees-kanban-card/dees-kanban-card.js';
|
|
4
|
+
export * from './dees-kanban-column/dees-kanban-column.js';
|
|
5
|
+
export * from './dees-kanban-ticket-detail/dees-kanban-ticket-detail.js';
|
|
6
|
+
export * from './dees-kanban-discussion/dees-kanban-discussion.js';
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { TReferenceContent } from '../00group-input/dees-input-reference/interfaces.js';
|
|
2
|
+
|
|
3
|
+
export type TKanbanTicketKind = 'epic' | 'task' | 'subtask';
|
|
4
|
+
export type TKanbanPriority = 'none' | 'low' | 'normal' | 'high' | 'urgent';
|
|
5
|
+
export interface IKanbanPerson { id: string; name: string; }
|
|
6
|
+
export interface IKanbanColumn { id: string; title: string; /** Marks completed subtasks. */ done?: boolean; }
|
|
7
|
+
export interface IKanbanTicket {
|
|
8
|
+
id: string;
|
|
9
|
+
key: string;
|
|
10
|
+
kind: TKanbanTicketKind;
|
|
11
|
+
title: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
/** Task -> epic, subtask -> task. Epics have no parent. */
|
|
14
|
+
parentId?: string;
|
|
15
|
+
columnId: string;
|
|
16
|
+
order: number;
|
|
17
|
+
priority?: TKanbanPriority;
|
|
18
|
+
assignee?: IKanbanPerson;
|
|
19
|
+
labels?: string[];
|
|
20
|
+
/** UI capabilities; the provider must enforce authorization independently. */
|
|
21
|
+
permissions?: { edit?: boolean; move?: boolean; comment?: boolean; createChild?: boolean };
|
|
22
|
+
}
|
|
23
|
+
export interface IKanbanComment {
|
|
24
|
+
id: string;
|
|
25
|
+
ticketId: string;
|
|
26
|
+
author: IKanbanPerson;
|
|
27
|
+
/** ISO 8601 timestamp. */
|
|
28
|
+
createdAt: string;
|
|
29
|
+
body: TReferenceContent;
|
|
30
|
+
}
|
|
31
|
+
export interface IKanbanTicketChanges {
|
|
32
|
+
title?: string;
|
|
33
|
+
description?: string;
|
|
34
|
+
priority?: TKanbanPriority;
|
|
35
|
+
assignee?: IKanbanPerson | null;
|
|
36
|
+
parentId?: string | null;
|
|
37
|
+
}
|
|
38
|
+
export interface IKanbanTicketDraft {
|
|
39
|
+
title: string;
|
|
40
|
+
description: string;
|
|
41
|
+
priority: TKanbanPriority;
|
|
42
|
+
assigneeId: string;
|
|
43
|
+
parentId: string;
|
|
44
|
+
}
|
|
45
|
+
export interface IKanbanMove {
|
|
46
|
+
ticketId: string;
|
|
47
|
+
columnId: string;
|
|
48
|
+
/** Omitted means append. Provider assigns ordering atomically with the move. */
|
|
49
|
+
beforeTicketId?: string;
|
|
50
|
+
}
|
|
51
|
+
export interface IKanbanCreate {
|
|
52
|
+
kind: TKanbanTicketKind;
|
|
53
|
+
title: string;
|
|
54
|
+
columnId: string;
|
|
55
|
+
parentId?: string;
|
|
56
|
+
}
|
|
57
|
+
/** Missing mutation callbacks make the corresponding controls read-only. AbortSignals cancel obsolete reads. */
|
|
58
|
+
export interface IKanbanProvider {
|
|
59
|
+
getTicket?: (id: string, signal: AbortSignal) => Promise<IKanbanTicket>;
|
|
60
|
+
searchTickets?: (query: string, signal: AbortSignal) => Promise<IKanbanTicket[]>;
|
|
61
|
+
loadComments?: (ticketId: string, signal: AbortSignal) => Promise<IKanbanComment[]>;
|
|
62
|
+
addComment?: (input: { ticketId: string; body: TReferenceContent }) => Promise<IKanbanComment>;
|
|
63
|
+
updateTicket?: (input: { ticketId: string; changes: IKanbanTicketChanges }) => Promise<IKanbanTicket>;
|
|
64
|
+
/** Return all records whose order or column changed. */
|
|
65
|
+
moveTicket?: (input: IKanbanMove) => Promise<IKanbanTicket[]>;
|
|
66
|
+
createTicket?: (input: IKanbanCreate) => Promise<IKanbanTicket>;
|
|
67
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { IKanbanColumn, IKanbanComment, IKanbanPerson, IKanbanProvider, IKanbanTicket } from './interfaces.js';
|
|
2
|
+
|
|
3
|
+
export const kanbanDemoPeople: IKanbanPerson[] = [{ id: 'mara', name: 'Mara Chen' }, { id: 'jonas', name: 'Jonas Berg' }, { id: 'alex', name: 'Alex Rivera' }];
|
|
4
|
+
export const kanbanDemoColumns: IKanbanColumn[] = [{ id: 'backlog', title: 'Backlog' }, { id: 'progress', title: 'In progress' }, { id: 'review', title: 'In review' }, { id: 'done', title: 'Done', done: true }];
|
|
5
|
+
export const createKanbanDemo = (): { tickets: IKanbanTicket[]; provider: IKanbanProvider } => {
|
|
6
|
+
let tickets: IKanbanTicket[] = [
|
|
7
|
+
{ id: 'epic-1', key: 'CAT-100', kind: 'epic', title: 'A calmer workspace', description: 'Make daily work feel focused, connected and easy to navigate.', columnId: 'progress', order: 0 },
|
|
8
|
+
{ id: 'epic-2', key: 'CAT-200', kind: 'epic', title: 'Built for teams', description: 'Keep everyone close to the work and the decisions behind it.', columnId: 'progress', order: 1 },
|
|
9
|
+
{ id: 'task-1', key: 'CAT-101', kind: 'task', title: 'Design the project overview', description: 'Give each project a clear home. Surface the next action, recent activity and open questions.', parentId: 'epic-1', columnId: 'backlog', order: 0, priority: 'normal', assignee: kanbanDemoPeople[0], labels: ['Design'] },
|
|
10
|
+
{ id: 'task-2', key: 'CAT-102', kind: 'task', title: 'Connect related tickets', description: 'Type #_ in a comment to search for another ticket. Keep the reference connected when its title changes.', parentId: 'epic-2', columnId: 'progress', order: 0, priority: 'high', assignee: kanbanDemoPeople[1], labels: ['Collaboration'] },
|
|
11
|
+
{ id: 'task-3', key: 'CAT-103', kind: 'task', title: 'Make every action keyboard accessible', description: 'Support focus, selection, navigation and moving tickets without a mouse.', parentId: 'epic-1', columnId: 'progress', order: 1, priority: 'normal', assignee: kanbanDemoPeople[2], labels: ['Accessibility'] },
|
|
12
|
+
{ id: 'task-4', key: 'CAT-104', kind: 'task', title: 'Review the ticket detail experience', description: 'Check hierarchy navigation, discussion drafts and small-screen layouts.', parentId: 'epic-2', columnId: 'review', order: 0, priority: 'low', assignee: kanbanDemoPeople[0] },
|
|
13
|
+
{ id: 'task-5', key: 'CAT-105', kind: 'task', title: 'Unify status and priority styles', description: 'Use shared catalog tokens in light and dark themes.', parentId: 'epic-1', columnId: 'done', order: 0, assignee: kanbanDemoPeople[2], labels: ['Foundations'] },
|
|
14
|
+
{ id: 'sub-1', key: 'CAT-106', kind: 'subtask', title: 'Anchor search to the cursor', parentId: 'task-2', columnId: 'done', order: 0, assignee: kanbanDemoPeople[1] },
|
|
15
|
+
{ id: 'sub-2', key: 'CAT-107', kind: 'subtask', title: 'Restore focus after selection', parentId: 'task-2', columnId: 'progress', order: 1, assignee: kanbanDemoPeople[1] },
|
|
16
|
+
{ id: 'sub-3', key: 'CAT-108', kind: 'subtask', title: 'Check arrow-key navigation', parentId: 'task-3', columnId: 'done', order: 0, assignee: kanbanDemoPeople[2] },
|
|
17
|
+
];
|
|
18
|
+
let comments: IKanbanComment[] = [
|
|
19
|
+
{ id: 'comment-1', ticketId: 'task-2', author: kanbanDemoPeople[0], createdAt: '2026-09-18T09:30:00Z', body: [{ type: 'text', text: 'The picker should feel like part of the conversation. Can we check this together with ' }, { type: 'reference', id: 'task-3', label: '#CAT-103 Keyboard accessibility' }, { type: 'text', text: '?' }] },
|
|
20
|
+
{ id: 'comment-2', ticketId: 'task-2', author: kanbanDemoPeople[1], createdAt: '2026-09-18T09:42:00Z', body: [{ type: 'text', text: 'Yes. Search receives focus immediately, and choosing a ticket returns you to exactly where you were writing.' }] },
|
|
21
|
+
];
|
|
22
|
+
let nextKey = 300;
|
|
23
|
+
// Demo-only in-memory provider. Applications persist through their own authorized APIs.
|
|
24
|
+
const provider: IKanbanProvider = {
|
|
25
|
+
getTicket: async id => {
|
|
26
|
+
const ticket = tickets.find(item => item.id === id);
|
|
27
|
+
if (!ticket) throw new Error('Ticket not found.');
|
|
28
|
+
return ticket;
|
|
29
|
+
},
|
|
30
|
+
searchTickets: async query => tickets.filter(ticket => `${ticket.key} ${ticket.title}`.toLowerCase().includes(query.toLowerCase())),
|
|
31
|
+
loadComments: async id => comments.filter(comment => comment.ticketId === id),
|
|
32
|
+
addComment: async input => {
|
|
33
|
+
const comment: IKanbanComment = { ...input, id: crypto.randomUUID(), author: kanbanDemoPeople[0], createdAt: new Date().toISOString() };
|
|
34
|
+
comments = [...comments, comment]; return comment;
|
|
35
|
+
},
|
|
36
|
+
updateTicket: async ({ ticketId, changes }) => {
|
|
37
|
+
const previous = tickets.find(ticket => ticket.id === ticketId)!;
|
|
38
|
+
const ticket: IKanbanTicket = { ...previous, ...changes, assignee: changes.assignee === null ? undefined : changes.assignee ?? previous.assignee,
|
|
39
|
+
parentId: changes.parentId === null ? undefined : changes.parentId ?? previous.parentId };
|
|
40
|
+
tickets = tickets.map(item => item.id === ticketId ? ticket : item); return ticket;
|
|
41
|
+
},
|
|
42
|
+
moveTicket: async ({ ticketId, columnId, beforeTicketId }) => {
|
|
43
|
+
const ticket = tickets.find(item => item.id === ticketId)!;
|
|
44
|
+
const destination = tickets.filter(item => item.id !== ticketId && item.columnId === columnId && item.kind === ticket.kind).sort((a, b) => a.order - b.order);
|
|
45
|
+
const index = beforeTicketId ? destination.findIndex(item => item.id === beforeTicketId) : destination.length;
|
|
46
|
+
destination.splice(index < 0 ? destination.length : index, 0, { ...ticket, columnId });
|
|
47
|
+
const changed = destination.map((item, order) => ({ ...item, order }));
|
|
48
|
+
tickets = tickets.map(item => changed.find(record => record.id === item.id) ?? item); return changed;
|
|
49
|
+
},
|
|
50
|
+
createTicket: async input => {
|
|
51
|
+
const ticket: IKanbanTicket = { ...input, id: crypto.randomUUID(), key: `CAT-${nextKey++}`, order: tickets.length };
|
|
52
|
+
tickets = [...tickets, ticket]; return ticket;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
return { tickets, provider };
|
|
56
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { IKanbanTicket, IKanbanTicketDraft } from './interfaces.js';
|
|
2
|
+
|
|
3
|
+
export const sortKanbanTickets = (tickets: readonly IKanbanTicket[]): IKanbanTicket[] =>
|
|
4
|
+
[...tickets].sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
|
|
5
|
+
export const ticketLabel = (ticket: IKanbanTicket): string => `#${ticket.key} ${ticket.title}`;
|
|
6
|
+
export const ticketDraft = (ticket: IKanbanTicket): IKanbanTicketDraft => ({
|
|
7
|
+
title: ticket.title, description: ticket.description ?? '', priority: ticket.priority ?? 'none',
|
|
8
|
+
assigneeId: ticket.assignee?.id ?? '', parentId: ticket.parentId ?? '',
|
|
9
|
+
});
|
|
10
|
+
export const kanbanEvent = <T>(owner: HTMLElement, name: string, detail: T): void => {
|
|
11
|
+
owner.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
|
|
12
|
+
};
|
|
13
|
+
export const kanbanError = (error: unknown): string => error instanceof Error ? error.message : String(error);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { css, cssManager } from '@design.estate/dees-element';
|
|
2
|
+
import { themeDefaultStyles } from '../00theme.js';
|
|
3
|
+
|
|
4
|
+
export const kanbanStyles = [themeDefaultStyles, cssManager.defaultStyles, css`
|
|
5
|
+
:host { display: block; min-width: 0; font-family: var(--dees-font-family); color: var(--dees-color-text-primary); font-size: var(--dees-font-body-size); }
|
|
6
|
+
* { box-sizing: border-box; }
|
|
7
|
+
h2, h3, p { margin: 0; }
|
|
8
|
+
.muted { color: var(--dees-color-text-secondary); }
|
|
9
|
+
.caption { font-size: var(--dees-font-footnote-size); color: var(--dees-color-text-secondary); }
|
|
10
|
+
.error { color: var(--dees-color-text-error); padding: 10px 0; font-size: var(--dees-font-footnote-size); }
|
|
11
|
+
.empty { padding: 24px 12px; text-align: center; color: var(--dees-color-text-secondary); font-size: var(--dees-font-footnote-size); }
|
|
12
|
+
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
13
|
+
.spacer { flex: 1; }
|
|
14
|
+
button.link { border: 0; background: transparent; font: inherit; color: var(--dees-color-link); padding: 2px 0; text-align: left; cursor: pointer; overflow-wrap: anywhere; }
|
|
15
|
+
button:focus-visible, summary:focus-visible { outline: 2px solid var(--dees-color-focus-ring); outline-offset: 3px; border-radius: var(--dees-radius-sm); }
|
|
16
|
+
dees-icon { font-size: 16px; }
|
|
17
|
+
.badge { display: inline-flex; align-items: center; gap: 4px; background: var(--dees-color-badge-default-bg); padding: 3px 7px; border-radius: var(--dees-radius-sm); font-size: var(--dees-font-caption1-size); color: var(--dees-color-text-secondary); }
|
|
18
|
+
`];
|
package/ts_web/elements/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export * from './00group-dataview/index.js';
|
|
|
10
10
|
export * from './00group-feedback/index.js';
|
|
11
11
|
export * from './00group-form/index.js';
|
|
12
12
|
export * from './00group-harness/index.js';
|
|
13
|
+
export * from './00group-kanban/index.js';
|
|
13
14
|
export * from './00group-input/index.js';
|
|
14
15
|
export * from './00group-layout/index.js';
|
|
15
16
|
export * from './00group-media/index.js';
|
|
@@ -101,6 +101,7 @@ export interface IEchartsBundle {
|
|
|
101
101
|
*/
|
|
102
102
|
export interface ITiptapBundle {
|
|
103
103
|
Editor: typeof Editor;
|
|
104
|
+
Node: typeof import('@tiptap/core').Node;
|
|
104
105
|
StarterKit: { configure: (options?: Partial<StarterKitOptions>) => any };
|
|
105
106
|
Underline: { configure: (options?: Partial<UnderlineOptions>) => any };
|
|
106
107
|
TextAlign: { configure: (options?: Partial<TextAlignOptions>) => any };
|
|
@@ -464,6 +465,7 @@ export class DeesServiceLibLoader {
|
|
|
464
465
|
|
|
465
466
|
this.tiptapLib = {
|
|
466
467
|
Editor: coreModule.Editor,
|
|
468
|
+
Node: coreModule.Node,
|
|
467
469
|
StarterKit: starterKitModule.default || starterKitModule.StarterKit,
|
|
468
470
|
Underline: underlineModule.default || underlineModule.Underline,
|
|
469
471
|
TextAlign: textAlignModule.default || textAlignModule.TextAlign,
|