feat: release notes
Each release can carry notes in Markdown, written before or after it ships. A draft is generated from its finished tickets grouped by type, to edit and copy from the releases page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
inject,
|
||||
signal,
|
||||
TemplateRef,
|
||||
@@ -15,7 +16,8 @@ import {
|
||||
PiTextareaComponent,
|
||||
PiTextFieldComponent,
|
||||
} from '@pi/ui';
|
||||
import { DrawerService } from '@task-stream/web/shared/ui';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { DrawerService, ToastService } from '@task-stream/web/shared/ui';
|
||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||
import { Release, ReleaseStatus } from '@task-stream/web/tracker/domain';
|
||||
import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
|
||||
@@ -83,6 +85,12 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
|
||||
{{ release.progress.percent }}%</span
|
||||
>
|
||||
</div>
|
||||
@if (release.notes) {
|
||||
<details class="ts-release__notes">
|
||||
<summary>Notas de la versión</summary>
|
||||
<pre class="ts-release__notes-text">{{ release.notes }}</pre>
|
||||
</details>
|
||||
}
|
||||
<div class="ts-release__actions">
|
||||
<a
|
||||
class="ts-release__link"
|
||||
@@ -91,6 +99,24 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
|
||||
>Ver en el tablero →</a
|
||||
>
|
||||
<span class="ts-spacer"></span>
|
||||
@if (release.notes) {
|
||||
<pi-action-button
|
||||
size="small"
|
||||
appearance="ghost"
|
||||
color="grey"
|
||||
text="Copiar notas"
|
||||
(execute)="copyNotes(release)"
|
||||
/>
|
||||
}
|
||||
@if (facade.canManage() && release.status !== 'ARCHIVED') {
|
||||
<pi-action-button
|
||||
size="small"
|
||||
appearance="outline"
|
||||
color="grey"
|
||||
[text]="release.notes ? 'Editar notas' : 'Escribir notas'"
|
||||
(execute)="openNotes(release)"
|
||||
/>
|
||||
}
|
||||
@if (facade.canManage() && release.status === 'PLANNED') {
|
||||
<pi-action-button
|
||||
size="small"
|
||||
@@ -118,6 +144,39 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
|
||||
}
|
||||
</ol>
|
||||
|
||||
<ng-template #notesForm>
|
||||
<form
|
||||
class="ts-form"
|
||||
(submit)="$event.preventDefault(); saveNotes()"
|
||||
novalidate
|
||||
>
|
||||
<p class="ts-muted">
|
||||
Lo que trae la versión, contado para quien la usa (Markdown). El
|
||||
borrador sale de los tickets terminados, agrupados por tipo.
|
||||
</p>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
appearance="outline"
|
||||
color="grey"
|
||||
[text]="drafting() ? 'Generando…' : 'Generar borrador'"
|
||||
[disabled]="drafting()"
|
||||
(execute)="draftNotes()"
|
||||
/>
|
||||
<pi-textarea
|
||||
placeholder="## Novedades - …"
|
||||
[(value)]="notes"
|
||||
>
|
||||
<pi-label>Notas</pi-label>
|
||||
</pi-textarea>
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
text="Guardar notas"
|
||||
(execute)="saveNotes()"
|
||||
/>
|
||||
</form>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #createForm>
|
||||
<form
|
||||
class="ts-form"
|
||||
@@ -144,6 +203,20 @@ import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
|
||||
</ng-template>
|
||||
`,
|
||||
styles: `
|
||||
.ts-release__notes {
|
||||
margin-top: 0.25rem;
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
}
|
||||
.ts-release__notes-text {
|
||||
margin: 0.5rem 0 0;
|
||||
white-space: pre-wrap;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
/* A timeline: one node per version on a vertical rail. */
|
||||
.ts-releases {
|
||||
position: relative;
|
||||
@@ -276,6 +349,15 @@ export class ReleasesPage {
|
||||
private readonly drawer = inject(DrawerService);
|
||||
private readonly createForm =
|
||||
viewChild.required<TemplateRef<unknown>>('createForm');
|
||||
private readonly notesForm =
|
||||
viewChild.required<TemplateRef<unknown>>('notesForm');
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
/* Release notes */
|
||||
private writing: Release | null = null;
|
||||
protected readonly notes = signal('');
|
||||
protected readonly drafting = signal(false);
|
||||
|
||||
private readonly model = signal({
|
||||
name: '',
|
||||
@@ -327,6 +409,52 @@ export class ReleasesPage {
|
||||
this.facade.publishRelease(release.id);
|
||||
}
|
||||
|
||||
protected openNotes(release: Release): void {
|
||||
this.writing = release;
|
||||
this.notes.set(release.notes ?? '');
|
||||
this.drawer.open(`Notas de ${release.name}`, this.notesForm(), '40rem');
|
||||
if (!release.notes) this.draftNotes();
|
||||
}
|
||||
|
||||
protected draftNotes(): void {
|
||||
if (!this.writing) return;
|
||||
// Replacing what someone wrote needs a yes.
|
||||
if (
|
||||
this.notes().trim() &&
|
||||
this.notes() !== this.writing.notes &&
|
||||
!confirm('¿Sustituir lo escrito por el borrador?')
|
||||
)
|
||||
return;
|
||||
this.drafting.set(true);
|
||||
this.facade
|
||||
.releaseNotesDraft(this.writing.id)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (draft) => {
|
||||
this.notes.set(draft);
|
||||
this.drafting.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.drafting.set(false);
|
||||
this.toast.error('No se pudo generar el borrador');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected saveNotes(): void {
|
||||
if (!this.writing) return;
|
||||
this.facade.writeReleaseNotes(this.writing.id, this.notes());
|
||||
this.writing = null;
|
||||
this.drawer.closeAll();
|
||||
}
|
||||
|
||||
protected copyNotes(release: Release): void {
|
||||
navigator.clipboard
|
||||
?.writeText(release.notes ?? '')
|
||||
.then(() => this.toast.success('Notas copiadas'))
|
||||
.catch(() => this.toast.error('No se pudieron copiar'));
|
||||
}
|
||||
|
||||
protected archive(release: Release): void {
|
||||
this.facade.archiveRelease(release.id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user