feat: formatted text, a list view, ticket details, public proposals and votes
Formatted text
- Descriptions, comments, replies and release notes render Markdown,
sanitised with DOMPurify (tested against scripts, handlers and
javascript: links). A pasted image is uploaded and written as
; the web fetches it with the reader's session,
so it works on private projects. Every text box has a preview. The
attachment upload now answers its attachmentId.
List view
- /p/:key/list: every ticket as rows, sortable by any column, filtered by
text, open/closed, type, priority and assignee; status, priority and
assignee change in place; bulk edit (the backlog's bar, now shared).
Ticket details
- Reactions on comments (GitHub's eight), for members and viewers.
- Duplicate a ticket: what it says, not where it stands.
- blockedBy: open tickets it waits for (BLOCKS / DEPENDS_ON), worked out
when reading with one query per board or page; shown on cards, list
rows and the ticket.
Public proposals
- A public project can take bug reports and ideas from anyone
(acceptsProposals): POST /api/explore/projects/{project}/proposals and
the form at /p/:key/proponer. They become tickets labelled «propuesta»,
opened by TaskStream; a honeypot field and 5 per address every 15
minutes keep spam out. No e-mail is asked: it would be public.
Votes
- Anyone signed in votes on a public project's tickets, members on a
private one (voteIssue, PUT/DELETE /api/issues/{issue}/vote): votes and
voted on each ticket, buttons on the ticket and the roadmap, a column
in the list. The showcase carries votes and the open proposals.
MCP: vote_issue, duplicate_issue, react_to_comment; listings show
blockers and votes. Docs, Word and PDF updated; chat notifications
(Discord, Slack, Telegram) noted for later.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,15 +10,11 @@ import {
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiEmptyStateComponent,
|
||||
PiIconButtonComponent,
|
||||
PiIconComponent,
|
||||
PiNoteComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiSkeletonComponent,
|
||||
PiTextFieldComponent,
|
||||
type PiSelectFieldOption,
|
||||
} from '@pi/ui';
|
||||
import {
|
||||
BacklogFacade,
|
||||
@@ -27,31 +23,26 @@ import {
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
BulkChange,
|
||||
IssuePriority,
|
||||
IssueSummary,
|
||||
sumPoints,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { initials, options, PRIORITY, statusLook, typeLook } from '../ui/copy';
|
||||
|
||||
/** "Nobody" / "no sprint" in the bulk selects. */
|
||||
const NOBODY = '__none__';
|
||||
import { BulkBarComponent } from '../ui/bulk-bar';
|
||||
import { initials, PRIORITY, statusLook, typeLook } from '../ui/copy';
|
||||
|
||||
/** /p/:key/backlog — what is still to start, in the order the team takes it. */
|
||||
@Component({
|
||||
selector: 'ts-backlog-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
BulkBarComponent,
|
||||
RouterLink,
|
||||
CdkDrag,
|
||||
CdkDropList,
|
||||
PiActionButtonComponent,
|
||||
PiEmptyStateComponent,
|
||||
PiIconButtonComponent,
|
||||
PiIconComponent,
|
||||
PiNoteComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiSkeletonComponent,
|
||||
PiTextFieldComponent,
|
||||
],
|
||||
providers: [provideBacklogFacade()],
|
||||
template: `
|
||||
@@ -85,60 +76,12 @@ const NOBODY = '__none__';
|
||||
<pi-skeleton shape="text" [lines]="8" />
|
||||
} @else if (facade.issues().length) {
|
||||
@if (project.canContribute()) {
|
||||
<div class="ts-bulk" role="toolbar" aria-label="Edición en bloque">
|
||||
<label class="ts-bulk__all">
|
||||
<input
|
||||
type="checkbox"
|
||||
[checked]="allSelected()"
|
||||
[indeterminate]="selected().size > 0 && !allSelected()"
|
||||
(change)="selectAll($any($event.target).checked)"
|
||||
/>
|
||||
{{
|
||||
selected().size
|
||||
? selected().size + ' seleccionados'
|
||||
: 'Seleccionar'
|
||||
}}
|
||||
</label>
|
||||
@if (selected().size) {
|
||||
<pi-select-field [options]="bulkFields" [(value)]="bulkField" />
|
||||
@switch (bulkField()) {
|
||||
@case ('status') {
|
||||
<pi-select-field
|
||||
[options]="statusOptions()"
|
||||
[(value)]="bulkValue"
|
||||
/>
|
||||
}
|
||||
@case ('assignee') {
|
||||
<pi-select-field
|
||||
[options]="assigneeOptions()"
|
||||
[(value)]="bulkValue"
|
||||
/>
|
||||
}
|
||||
@case ('sprint') {
|
||||
<pi-select-field
|
||||
[options]="sprintOptions()"
|
||||
[(value)]="bulkValue"
|
||||
/>
|
||||
}
|
||||
@case ('priority') {
|
||||
<pi-select-field
|
||||
[options]="priorityOptions"
|
||||
[(value)]="bulkValue"
|
||||
/>
|
||||
}
|
||||
@case ('label') {
|
||||
<pi-text-field placeholder="etiqueta" [(value)]="bulkLabel" />
|
||||
}
|
||||
}
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="primary"
|
||||
text="Aplicar"
|
||||
[disabled]="!canApply()"
|
||||
(execute)="applyBulk()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
<ts-bulk-bar
|
||||
[count]="selected().size"
|
||||
[allSelected]="allSelected()"
|
||||
(selectAll)="selectAll($event)"
|
||||
(apply)="applyBulk($event)"
|
||||
/>
|
||||
}
|
||||
<ol
|
||||
class="ts-backlog ts-panel"
|
||||
@@ -261,23 +204,6 @@ const NOBODY = '__none__';
|
||||
.ts-backlog__row.is-selected {
|
||||
background: color-mix(in srgb, var(--ts-brand) 8%, var(--ts-surface));
|
||||
}
|
||||
.ts-bulk {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
pi-select-field,
|
||||
pi-text-field {
|
||||
width: 13rem;
|
||||
}
|
||||
}
|
||||
.ts-bulk__all {
|
||||
display: inline-flex;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.ts-backlog__trash {
|
||||
margin: 1rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
@@ -342,46 +268,6 @@ export class BacklogPage {
|
||||
this.facade.issues().length > 0 &&
|
||||
this.facade.issues().every((i) => this.selected().has(i.id)),
|
||||
);
|
||||
protected readonly bulkFields: PiSelectFieldOption<string>[] = [
|
||||
{ value: 'status', label: 'Mover a…' },
|
||||
{ value: 'assignee', label: 'Asignar a…' },
|
||||
{ value: 'sprint', label: 'Sprint…' },
|
||||
{ value: 'priority', label: 'Prioridad…' },
|
||||
{ value: 'label', label: 'Añadir etiqueta…' },
|
||||
];
|
||||
protected readonly bulkField = signal<string | null>('status');
|
||||
protected readonly bulkValue = signal<string | null>(null);
|
||||
protected readonly bulkLabel = signal('');
|
||||
protected readonly statusOptions = computed<PiSelectFieldOption<string>[]>(
|
||||
() =>
|
||||
this.project
|
||||
.workflow()
|
||||
.settings.statuses.filter((s) => !s.archived)
|
||||
.map((s) => ({ value: s.id, label: s.name })),
|
||||
);
|
||||
protected readonly assigneeOptions = computed<PiSelectFieldOption<string>[]>(
|
||||
() => [
|
||||
{ value: NOBODY, label: 'Nadie (sin asignar)' },
|
||||
...this.project
|
||||
.members()
|
||||
.map((m) => ({ value: m.user.id, label: m.user.name })),
|
||||
],
|
||||
);
|
||||
protected readonly sprintOptions = computed<PiSelectFieldOption<string>[]>(
|
||||
() => [
|
||||
{ value: NOBODY, label: 'Backlog (sin sprint)' },
|
||||
...this.project
|
||||
.openSprints()
|
||||
.map((s) => ({ value: s.id, label: s.name })),
|
||||
],
|
||||
);
|
||||
protected readonly priorityOptions = options(PRIORITY);
|
||||
protected readonly canApply = computed(() =>
|
||||
this.bulkField() === 'label'
|
||||
? !!this.bulkLabel().trim()
|
||||
: !!this.bulkValue(),
|
||||
);
|
||||
|
||||
protected toggle(issueId: string, on: boolean): void {
|
||||
this.selected.update((set) => {
|
||||
const next = new Set(set);
|
||||
@@ -395,29 +281,9 @@ export class BacklogPage {
|
||||
this.selected.set(new Set(on ? this.facade.issues().map((i) => i.id) : []));
|
||||
}
|
||||
|
||||
protected applyBulk(): void {
|
||||
const value = this.bulkValue();
|
||||
const change: BulkChange = {};
|
||||
switch (this.bulkField()) {
|
||||
case 'status':
|
||||
if (value) change.status = value;
|
||||
break;
|
||||
case 'assignee':
|
||||
change.assigneeId = value === NOBODY ? null : value;
|
||||
break;
|
||||
case 'sprint':
|
||||
change.sprintId = value === NOBODY ? null : value;
|
||||
break;
|
||||
case 'priority':
|
||||
if (value) change.priority = value as IssuePriority;
|
||||
break;
|
||||
case 'label':
|
||||
change.addLabels = [this.bulkLabel().trim().toLowerCase()];
|
||||
break;
|
||||
}
|
||||
protected applyBulk(change: BulkChange): void {
|
||||
this.facade.bulkUpdate([...this.selected()], change);
|
||||
this.selected.set(new Set());
|
||||
this.bulkLabel.set('');
|
||||
}
|
||||
protected readonly points = computed(() => sumPoints(this.facade.issues()));
|
||||
protected readonly unestimated = computed(
|
||||
|
||||
Reference in New Issue
Block a user