feat: move unfinished tickets between releases
- Bulk editing gets «Release…» (open releases or none), and the list view a release filter, also reachable from each release («Ver en la lista», ?release=). - Archiving a release asks where its unfinished tickets go, as closing a sprint does: ReleaseArchivedEvent carries `moveTo` (an open release of the same project, or none) and the saga moves them there; finished ones stay. In GraphQL, REST and the MCP's archive_release. - Chapter 10 lists the next features and the ideas to weigh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
@@ -105,6 +106,10 @@ const numberOf = (key: string) => Number(key.split('-').pop());
|
||||
[options]="assigneeFilterOptions()"
|
||||
[(value)]="assignee"
|
||||
/>
|
||||
<pi-select-field
|
||||
[options]="releaseFilterOptions()"
|
||||
[(value)]="releaseFilter"
|
||||
/>
|
||||
@if (facade.loaded()) {
|
||||
<span class="ts-muted ts-mono ts-list__count">
|
||||
{{ facade.total() }} tickets{{
|
||||
@@ -430,6 +435,8 @@ const numberOf = (key: string) => Number(key.split('-').pop());
|
||||
`,
|
||||
})
|
||||
export class ListPage {
|
||||
/** `?release=<id>`: opens already filtered by that release. */
|
||||
readonly release = input<string | undefined>(undefined);
|
||||
protected readonly facade = inject(ListFacade);
|
||||
protected readonly project = inject(ProjectFacade);
|
||||
protected readonly NOBODY = NOBODY;
|
||||
@@ -455,6 +462,11 @@ export class ListPage {
|
||||
protected readonly type = signal<string | null>(ALL);
|
||||
protected readonly priority = signal<string | null>(ALL);
|
||||
protected readonly assignee = signal<string | null>(ALL);
|
||||
protected readonly releaseFilter = signal<string | null>(ALL);
|
||||
private readonly releaseFromUrl = effect(() => {
|
||||
const id = this.release();
|
||||
if (id) untracked(() => this.releaseFilter.set(id));
|
||||
});
|
||||
protected readonly scopeOptions: PiSelectFieldOption<string>[] = [
|
||||
{ value: 'open', label: 'Abiertos' },
|
||||
{ value: 'closed', label: 'Terminados o cancelados' },
|
||||
@@ -483,6 +495,24 @@ export class ListPage {
|
||||
.members()
|
||||
.map((m) => ({ value: m.user.id, label: m.user.name })),
|
||||
]);
|
||||
/** Planned ones first: those are the ones tickets move between. */
|
||||
protected readonly releaseFilterOptions = computed<
|
||||
PiSelectFieldOption<string>[]
|
||||
>(() => [
|
||||
{ value: ALL, label: 'Todas las releases' },
|
||||
...[...this.project.releases()]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(b.status === 'PLANNED') - Number(a.status === 'PLANNED'),
|
||||
)
|
||||
.map((r) => ({
|
||||
value: r.id,
|
||||
label:
|
||||
r.status === 'PLANNED'
|
||||
? r.name
|
||||
: `${r.name} (${r.status === 'RELEASED' ? 'publicada' : 'archivada'})`,
|
||||
})),
|
||||
]);
|
||||
private readonly criteria = computed<ListCriteria>(() => {
|
||||
const scope = this.scope();
|
||||
const assignee = this.assignee();
|
||||
@@ -496,6 +526,7 @@ export class ListPage {
|
||||
: undefined,
|
||||
unassigned: assignee === NOBODY || undefined,
|
||||
assigneeId: assignee && assignee !== NOBODY ? assignee : undefined,
|
||||
releaseId: this.releaseFilter() || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
PiActionButtonComponent,
|
||||
PiEmptyStateComponent,
|
||||
PiLabelDirective,
|
||||
PiSelectFieldComponent,
|
||||
PiSelectFieldOption,
|
||||
PiTextareaComponent,
|
||||
PiTextFieldComponent,
|
||||
} from '@pi/ui';
|
||||
@@ -23,6 +25,9 @@ import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||
import { Release, ReleaseStatus } from '@task-stream/web/tracker/domain';
|
||||
import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
|
||||
|
||||
/** The «no release» choice of a select (an empty value). */
|
||||
const NO_RELEASE = '';
|
||||
|
||||
@Component({
|
||||
selector: 'ts-releases-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@@ -33,6 +38,7 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
|
||||
PiActionButtonComponent,
|
||||
PiEmptyStateComponent,
|
||||
PiLabelDirective,
|
||||
PiSelectFieldComponent,
|
||||
PiTextFieldComponent,
|
||||
PiTextareaComponent,
|
||||
],
|
||||
@@ -103,6 +109,12 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
|
||||
[queryParams]="{ release: release.id }"
|
||||
>Ver en el tablero →</a
|
||||
>
|
||||
<a
|
||||
class="ts-release__link"
|
||||
[routerLink]="['../list']"
|
||||
[queryParams]="{ release: release.id }"
|
||||
>Ver en la lista →</a
|
||||
>
|
||||
<span class="ts-spacer"></span>
|
||||
@if (release.notes) {
|
||||
<pi-action-button
|
||||
@@ -179,6 +191,28 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
|
||||
</form>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #archiveForm>
|
||||
<form
|
||||
class="ts-form"
|
||||
(submit)="$event.preventDefault(); confirmArchive()"
|
||||
novalidate
|
||||
>
|
||||
<p class="ts-muted">
|
||||
{{ unfinished() }} ticket(s) sin terminar. Los terminados se quedan en
|
||||
esta release como registro de lo hecho.
|
||||
</p>
|
||||
<pi-select-field [options]="moveToOptions()" [(value)]="moveTo">
|
||||
<pi-label>Lo pendiente va a</pi-label>
|
||||
</pi-select-field>
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
text="Archivar release"
|
||||
(execute)="confirmArchive()"
|
||||
/>
|
||||
</form>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #createForm>
|
||||
<form
|
||||
class="ts-form"
|
||||
@@ -353,6 +387,8 @@ export class ReleasesPage {
|
||||
viewChild.required<TemplateRef<unknown>>('createForm');
|
||||
private readonly notesForm =
|
||||
viewChild.required<TemplateRef<unknown>>('notesForm');
|
||||
private readonly archiveForm =
|
||||
viewChild.required<TemplateRef<unknown>>('archiveForm');
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
@@ -457,7 +493,44 @@ export class ReleasesPage {
|
||||
.catch(() => this.toast.error('No se pudieron copiar'));
|
||||
}
|
||||
|
||||
/* Archive: where its unfinished tickets go, as when closing a sprint. */
|
||||
private archiving: Release | null = null;
|
||||
protected readonly unfinished = signal(0);
|
||||
protected readonly moveTo = signal<string | null>(NO_RELEASE);
|
||||
protected readonly moveToOptions = signal<PiSelectFieldOption<string>[]>([]);
|
||||
|
||||
protected archive(release: Release): void {
|
||||
this.facade.archiveRelease(release.id);
|
||||
this.archiving = release;
|
||||
// The next one first: the nearest target date, then the oldest planned
|
||||
// (the list comes newest first).
|
||||
const open = this.facade
|
||||
.releases()
|
||||
.filter((r) => r.status === ReleaseStatus.PLANNED && r.id !== release.id)
|
||||
.map((r, i) => ({ r, i }))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.r.targetDate ?? '9999').localeCompare(b.r.targetDate ?? '9999') ||
|
||||
b.i - a.i,
|
||||
)
|
||||
.map(({ r }) => r);
|
||||
this.unfinished.set(release.progress.total - release.progress.finished);
|
||||
this.moveToOptions.set([
|
||||
{ value: NO_RELEASE, label: 'Ninguna release' },
|
||||
...open.map((r) => ({ value: r.id, label: r.name })),
|
||||
]);
|
||||
this.moveTo.set(open[0]?.id ?? NO_RELEASE);
|
||||
// Nothing to place: no need to ask.
|
||||
if (this.unfinished() === 0) {
|
||||
this.confirmArchive();
|
||||
return;
|
||||
}
|
||||
this.drawer.open(`Archivar ${release.name}`, this.archiveForm());
|
||||
}
|
||||
|
||||
protected confirmArchive(): void {
|
||||
if (!this.archiving) return;
|
||||
this.facade.archiveRelease(this.archiving.id, this.moveTo() || null);
|
||||
this.archiving = null;
|
||||
this.drawer.closeAll();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
type PiSelectFieldOption,
|
||||
} from '@pi/ui';
|
||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||
import { BulkChange, IssuePriority } from '@task-stream/web/tracker/domain';
|
||||
import {
|
||||
BulkChange,
|
||||
IssuePriority,
|
||||
ReleaseStatus,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { options, PRIORITY } from './copy';
|
||||
|
||||
/** "Nobody" / "no sprint" in the selects. */
|
||||
@@ -62,6 +66,9 @@ const NOBODY = '__none__';
|
||||
@case ('sprint') {
|
||||
<pi-select-field [options]="sprintOptions()" [(value)]="value" />
|
||||
}
|
||||
@case ('release') {
|
||||
<pi-select-field [options]="releaseOptions()" [(value)]="value" />
|
||||
}
|
||||
@case ('priority') {
|
||||
<pi-select-field [options]="priorityOptions" [(value)]="value" />
|
||||
}
|
||||
@@ -112,6 +119,7 @@ export class BulkBarComponent {
|
||||
{ value: 'status', label: 'Mover a…' },
|
||||
{ value: 'assignee', label: 'Asignar a…' },
|
||||
{ value: 'sprint', label: 'Sprint…' },
|
||||
{ value: 'release', label: 'Release…' },
|
||||
{ value: 'priority', label: 'Prioridad…' },
|
||||
{ value: 'label', label: 'Añadir etiqueta…' },
|
||||
];
|
||||
@@ -141,6 +149,16 @@ export class BulkBarComponent {
|
||||
.map((s) => ({ value: s.id, label: s.name })),
|
||||
],
|
||||
);
|
||||
/** Only open releases take tickets. */
|
||||
protected readonly releaseOptions = computed<PiSelectFieldOption<string>[]>(
|
||||
() => [
|
||||
{ value: NOBODY, label: 'Ninguna release' },
|
||||
...this.project
|
||||
.releases()
|
||||
.filter((r) => r.status === ReleaseStatus.PLANNED)
|
||||
.map((r) => ({ value: r.id, label: r.name })),
|
||||
],
|
||||
);
|
||||
protected readonly priorityOptions = options(PRIORITY);
|
||||
protected readonly canApply = computed(() =>
|
||||
this.field() === 'label' ? !!this.label().trim() : !!this.value(),
|
||||
@@ -159,6 +177,9 @@ export class BulkBarComponent {
|
||||
case 'sprint':
|
||||
change.sprintId = value === NOBODY ? null : value;
|
||||
break;
|
||||
case 'release':
|
||||
change.releaseId = value === NOBODY ? null : value;
|
||||
break;
|
||||
case 'priority':
|
||||
if (value) change.priority = value as IssuePriority;
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user