feat: a public notes page per release, for clients
On the releases page, «Página pública» (owners and admins) opens a
secret link to a page with the release's notes, readable without an
account, also for private projects: the notes written, or the draft
from its finished tickets while there are none (without repeating the
title the page already shows). The link can be copied, renewed (the old
one stops working) or withdrawn. Stored in Mongo (`release_note_shares`).
API: shareReleaseNotes / unshareReleaseNotes / releaseNotesShare,
publicReleaseNotes(token); GET/PUT/DELETE /api/releases/{id}/public-notes,
GET /api/shared/release-notes/{token}. Web: /notes/:token.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,3 +39,26 @@ export interface SharedDashboard {
|
||||
| null;
|
||||
activity: { day: string; count: number }[] | null;
|
||||
}
|
||||
|
||||
/** A release's public notes link (owners and admins). */
|
||||
export interface ReleaseNotesShare {
|
||||
releaseId: string;
|
||||
/** The secret of the link: /notes/{token}. */
|
||||
token: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** A release's notes, for anyone with its link. */
|
||||
export interface PublicReleaseNotes {
|
||||
projectName: string;
|
||||
projectKey: string;
|
||||
releaseName: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
targetDate: string | null;
|
||||
releasedAt: string | null;
|
||||
/** Markdown. */
|
||||
notes: string;
|
||||
/** False: drawn from its finished tickets (none written yet). */
|
||||
written: boolean;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Home } from '../models/home';
|
||||
import { PortfolioProject, ProjectInsights } from '../models/insights';
|
||||
import {
|
||||
DashboardShare,
|
||||
PublicReleaseNotes,
|
||||
ReleaseNotesShare,
|
||||
SharedBlock,
|
||||
SharedDashboard,
|
||||
} from '../models/sharing';
|
||||
@@ -474,6 +476,20 @@ export abstract class SharingGateway {
|
||||
abstract remove(projectId: string): Observable<void>;
|
||||
/** What a link shows, for anyone (null: no such link). */
|
||||
abstract shared(token: string): Observable<SharedDashboard | null>;
|
||||
/** A release's public notes link, if any (owners and admins). */
|
||||
abstract releaseNotesShare(
|
||||
releaseId: string,
|
||||
): Observable<ReleaseNotesShare | null>;
|
||||
/** Turns it on; `renew` makes a new link. */
|
||||
abstract shareReleaseNotes(
|
||||
releaseId: string,
|
||||
renew: boolean,
|
||||
): Observable<ReleaseNotesShare>;
|
||||
abstract unshareReleaseNotes(releaseId: string): Observable<void>;
|
||||
/** A release's notes for anyone with the link (null: no such link). */
|
||||
abstract publicReleaseNotes(
|
||||
token: string,
|
||||
): Observable<PublicReleaseNotes | null>;
|
||||
}
|
||||
|
||||
export abstract class NotificationsGateway {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import {
|
||||
DashboardShare,
|
||||
PublicReleaseNotes,
|
||||
ReleaseNotesShare,
|
||||
SharedBlock,
|
||||
SharedDashboard,
|
||||
SharingGateway,
|
||||
@@ -8,7 +10,11 @@ import {
|
||||
import { map, Observable } from 'rxjs';
|
||||
import {
|
||||
DashboardShareDocument,
|
||||
PublicReleaseNotesDocument,
|
||||
ReleaseNotesShareDocument,
|
||||
ShareDashboardDocument,
|
||||
ShareReleaseNotesDocument,
|
||||
UnshareReleaseNotesDocument,
|
||||
SharedDashboardDocument,
|
||||
UnshareDashboardDocument,
|
||||
} from '../graphql/generated';
|
||||
@@ -45,4 +51,33 @@ export class GraphqlSharingGateway extends SharingGateway {
|
||||
.query(SharedDashboardDocument, { token })
|
||||
.pipe(map((d) => (d.sharedDashboard as SharedDashboard | null) ?? null));
|
||||
}
|
||||
|
||||
releaseNotesShare(releaseId: string): Observable<ReleaseNotesShare | null> {
|
||||
return this.gql
|
||||
.query(ReleaseNotesShareDocument, { releaseId })
|
||||
.pipe(map((d) => d.releaseNotesShare ?? null));
|
||||
}
|
||||
|
||||
shareReleaseNotes(
|
||||
releaseId: string,
|
||||
renew: boolean,
|
||||
): Observable<ReleaseNotesShare> {
|
||||
return this.gql
|
||||
.mutate(ShareReleaseNotesDocument, { releaseId, renew })
|
||||
.pipe(map((d) => d.shareReleaseNotes));
|
||||
}
|
||||
|
||||
unshareReleaseNotes(releaseId: string): Observable<void> {
|
||||
return this.gql
|
||||
.mutate(UnshareReleaseNotesDocument, { releaseId })
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
|
||||
publicReleaseNotes(token: string): Observable<PublicReleaseNotes | null> {
|
||||
return this.gql
|
||||
.query(PublicReleaseNotesDocument, { token })
|
||||
.pipe(
|
||||
map((d) => (d.publicReleaseNotes as PublicReleaseNotes | null) ?? null),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3010,6 +3010,57 @@ export type SharedDashboardQuery = {
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type ReleaseNotesShareFieldsFragment = {
|
||||
releaseId: string;
|
||||
token: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type ReleaseNotesShareQueryVariables = Exact<{
|
||||
releaseId: string;
|
||||
}>;
|
||||
|
||||
export type ReleaseNotesShareQuery = {
|
||||
releaseNotesShare: {
|
||||
releaseId: string;
|
||||
token: string;
|
||||
createdAt: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type ShareReleaseNotesMutationVariables = Exact<{
|
||||
releaseId: string;
|
||||
renew: boolean;
|
||||
}>;
|
||||
|
||||
export type ShareReleaseNotesMutation = {
|
||||
shareReleaseNotes: { releaseId: string; token: string; createdAt: string };
|
||||
};
|
||||
|
||||
export type UnshareReleaseNotesMutationVariables = Exact<{
|
||||
releaseId: string;
|
||||
}>;
|
||||
|
||||
export type UnshareReleaseNotesMutation = { unshareReleaseNotes: boolean };
|
||||
|
||||
export type PublicReleaseNotesQueryVariables = Exact<{
|
||||
token: string;
|
||||
}>;
|
||||
|
||||
export type PublicReleaseNotesQuery = {
|
||||
publicReleaseNotes: {
|
||||
projectName: string;
|
||||
projectKey: string;
|
||||
releaseName: string;
|
||||
description: string | null;
|
||||
status: ReleaseStatus;
|
||||
targetDate: string | null;
|
||||
releasedAt: string | null;
|
||||
notes: string;
|
||||
written: boolean;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type SprintFieldsFragment = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
@@ -4824,6 +4875,27 @@ export const DashboardShareFieldsFragmentDoc = {
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<DashboardShareFieldsFragment, unknown>;
|
||||
export const ReleaseNotesShareFieldsFragmentDoc = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'ReleaseNotesShareFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'ReleaseNotesShare' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'releaseId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'token' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<ReleaseNotesShareFieldsFragment, unknown>;
|
||||
export const SprintFieldsFragmentDoc = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -18184,6 +18256,275 @@ export const SharedDashboardDocument = {
|
||||
SharedDashboardQuery,
|
||||
SharedDashboardQueryVariables
|
||||
>;
|
||||
export const ReleaseNotesShareDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
name: { kind: 'Name', value: 'ReleaseNotesShare' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'releaseId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'releaseNotesShare' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'releaseId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'releaseId' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'ReleaseNotesShareFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'ReleaseNotesShareFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'ReleaseNotesShare' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'releaseId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'token' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
ReleaseNotesShareQuery,
|
||||
ReleaseNotesShareQueryVariables
|
||||
>;
|
||||
export const ShareReleaseNotesDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'ShareReleaseNotes' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'releaseId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'renew' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'shareReleaseNotes' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'releaseId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'releaseId' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'renew' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'renew' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'ReleaseNotesShareFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'ReleaseNotesShareFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'ReleaseNotesShare' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'releaseId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'token' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
ShareReleaseNotesMutation,
|
||||
ShareReleaseNotesMutationVariables
|
||||
>;
|
||||
export const UnshareReleaseNotesDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'UnshareReleaseNotes' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'releaseId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'unshareReleaseNotes' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'releaseId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'releaseId' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
UnshareReleaseNotesMutation,
|
||||
UnshareReleaseNotesMutationVariables
|
||||
>;
|
||||
export const PublicReleaseNotesDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
name: { kind: 'Name', value: 'PublicReleaseNotes' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'token' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'String' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'publicReleaseNotes' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'token' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'token' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'projectName' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'projectKey' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'releaseName' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'targetDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'releasedAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'notes' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'written' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
PublicReleaseNotesQuery,
|
||||
PublicReleaseNotesQueryVariables
|
||||
>;
|
||||
export const SprintsDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
|
||||
@@ -72,3 +72,39 @@ query SharedDashboard($token: String!) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment ReleaseNotesShareFields on ReleaseNotesShare {
|
||||
releaseId
|
||||
token
|
||||
createdAt
|
||||
}
|
||||
|
||||
query ReleaseNotesShare($releaseId: ID!) {
|
||||
releaseNotesShare(releaseId: $releaseId) {
|
||||
...ReleaseNotesShareFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation ShareReleaseNotes($releaseId: ID!, $renew: Boolean!) {
|
||||
shareReleaseNotes(releaseId: $releaseId, renew: $renew) {
|
||||
...ReleaseNotesShareFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation UnshareReleaseNotes($releaseId: ID!) {
|
||||
unshareReleaseNotes(releaseId: $releaseId)
|
||||
}
|
||||
|
||||
query PublicReleaseNotes($token: String!) {
|
||||
publicReleaseNotes(token: $token) {
|
||||
projectName
|
||||
projectKey
|
||||
releaseName
|
||||
description
|
||||
status
|
||||
targetDate
|
||||
releasedAt
|
||||
notes
|
||||
written
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { PiEmptyStateComponent, PiSkeletonComponent } from '@pi/ui';
|
||||
import {
|
||||
PublicReleaseNotes,
|
||||
SharingGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { MarkdownComponent } from '../ui/markdown';
|
||||
|
||||
/**
|
||||
* /notes/:token — a release's notes for its clients, without an account:
|
||||
* the notes written, or the ones drawn from its finished tickets.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ts-public-release-notes-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PiEmptyStateComponent, PiSkeletonComponent, MarkdownComponent],
|
||||
template: `
|
||||
@if (missing()) {
|
||||
<pi-empty-state
|
||||
icon="lock"
|
||||
title="Este enlace ya no funciona"
|
||||
description="Puede que lo hayan renovado o retirado. Pide uno nuevo a quien te lo dio."
|
||||
/>
|
||||
} @else if (notes(); as release) {
|
||||
<article class="ts-notes ts-panel">
|
||||
<header>
|
||||
<p class="ts-eyebrow">{{ release.projectName }}</p>
|
||||
<h1>{{ release.releaseName }}</h1>
|
||||
<p class="ts-muted">{{ when(release) }}</p>
|
||||
@if (release.description) {
|
||||
<p class="ts-notes__description">{{ release.description }}</p>
|
||||
}
|
||||
</header>
|
||||
<ts-markdown [text]="release.notes" />
|
||||
</article>
|
||||
<p class="ts-notes__footer">Hecho con TaskStream</p>
|
||||
} @else if (failed()) {
|
||||
<pi-empty-state
|
||||
icon="exclamation"
|
||||
title="No se pudo cargar"
|
||||
description="Prueba de nuevo dentro de un rato."
|
||||
/>
|
||||
} @else {
|
||||
<pi-skeleton shape="block" height="16rem" />
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
max-width: 46rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.ts-notes {
|
||||
padding: clamp(1.25rem, 4vw, 2.5rem);
|
||||
header {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1.25rem;
|
||||
border-bottom: 1px solid var(--ts-line);
|
||||
}
|
||||
h1 {
|
||||
margin: 0.25rem 0;
|
||||
font-size: clamp(1.75rem, 1.2rem + 1.6vw, 2.5rem);
|
||||
font-weight: 750;
|
||||
line-height: 1.1;
|
||||
}
|
||||
header p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
.ts-notes__description {
|
||||
margin-top: 0.75rem !important;
|
||||
font-size: 1.0625rem;
|
||||
}
|
||||
.ts-notes__footer {
|
||||
margin: 0;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class PublicReleaseNotesPage {
|
||||
/** Route param (`withComponentInputBinding`). */
|
||||
readonly token = input.required<string>();
|
||||
private readonly sharing = inject(SharingGateway);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
protected readonly notes = signal<PublicReleaseNotes | null>(null);
|
||||
protected readonly missing = signal(false);
|
||||
protected readonly failed = signal(false);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const token = this.token();
|
||||
untracked(() =>
|
||||
this.sharing
|
||||
.publicReleaseNotes(token)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (notes) => {
|
||||
this.notes.set(notes);
|
||||
this.missing.set(!notes);
|
||||
},
|
||||
error: () => this.failed.set(true),
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
protected when(release: PublicReleaseNotes): string {
|
||||
const date = (day: string) =>
|
||||
new Date(`${day.slice(0, 10)}T00:00:00Z`).toLocaleDateString('es', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
if (release.releasedAt) return `Publicada el ${date(release.releasedAt)}`;
|
||||
if (release.targetDate)
|
||||
return `Prevista para el ${date(release.targetDate)}`;
|
||||
return 'En preparación';
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,12 @@ import {
|
||||
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,
|
||||
ReleaseNotesShare,
|
||||
ReleaseStatus,
|
||||
SharingGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { RELEASE_STATUS, releaseWhen } from '../ui/copy';
|
||||
|
||||
/** The «no release» choice of a select (an empty value). */
|
||||
@@ -130,6 +135,15 @@ const NO_RELEASE = '';
|
||||
(execute)="copyNotes(release)"
|
||||
/>
|
||||
}
|
||||
@if (facade.canManage()) {
|
||||
<pi-action-button
|
||||
size="small"
|
||||
appearance="ghost"
|
||||
color="grey"
|
||||
text="Página pública"
|
||||
(execute)="openPublicNotes(release)"
|
||||
/>
|
||||
}
|
||||
@if (facade.canManage() && release.status !== 'ARCHIVED') {
|
||||
<pi-action-button
|
||||
size="small"
|
||||
@@ -166,6 +180,65 @@ const NO_RELEASE = '';
|
||||
}
|
||||
</ol>
|
||||
|
||||
<ng-template #publicNotesForm>
|
||||
<div class="ts-form">
|
||||
<p class="ts-muted">
|
||||
Una página con las notas de esta versión para tus clientes, sin
|
||||
cuenta. Enseña las notas escritas o, mientras no las haya, las que
|
||||
salen de sus tickets terminados.
|
||||
</p>
|
||||
@if (publicShare(); as share) {
|
||||
<input
|
||||
class="ts-input ts-public-notes__url"
|
||||
readonly
|
||||
aria-label="Enlace de la página pública"
|
||||
[value]="publicUrl(share.token)"
|
||||
(focus)="$any($event.target).select()"
|
||||
/>
|
||||
<div class="ts-public-notes__actions">
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="primary"
|
||||
text="Copiar"
|
||||
(execute)="copyLink(publicUrl(share.token))"
|
||||
/>
|
||||
<a
|
||||
class="ts-release__link"
|
||||
[href]="publicUrl(share.token)"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>Abrir</a
|
||||
>
|
||||
<span class="ts-spacer"></span>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
appearance="ghost"
|
||||
color="grey"
|
||||
text="Renovar"
|
||||
(execute)="sharePublicNotes(true)"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
appearance="ghost"
|
||||
color="grey"
|
||||
text="Retirar"
|
||||
(execute)="unsharePublicNotes()"
|
||||
/>
|
||||
</div>
|
||||
<p class="ts-muted">
|
||||
Renovar hace un enlace nuevo y el anterior deja de funcionar.
|
||||
</p>
|
||||
} @else if (publicLoaded()) {
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
text="Crear el enlace"
|
||||
(execute)="sharePublicNotes(false)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #notesForm>
|
||||
<form
|
||||
class="ts-form"
|
||||
@@ -244,6 +317,16 @@ const NO_RELEASE = '';
|
||||
</ng-template>
|
||||
`,
|
||||
styles: `
|
||||
.ts-public-notes__url {
|
||||
width: 100%;
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.ts-public-notes__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.ts-release__notes {
|
||||
margin-top: 0.25rem;
|
||||
summary {
|
||||
@@ -392,6 +475,13 @@ export class ReleasesPage {
|
||||
viewChild.required<TemplateRef<unknown>>('createForm');
|
||||
private readonly notesForm =
|
||||
viewChild.required<TemplateRef<unknown>>('notesForm');
|
||||
private readonly publicNotesForm =
|
||||
viewChild.required<TemplateRef<unknown>>('publicNotesForm');
|
||||
private readonly sharing = inject(SharingGateway);
|
||||
/** The release whose public page is open in the drawer, and its link. */
|
||||
private sharingRelease: Release | null = null;
|
||||
protected readonly publicShare = signal<ReleaseNotesShare | null>(null);
|
||||
protected readonly publicLoaded = signal(false);
|
||||
private readonly archiveForm =
|
||||
viewChild.required<TemplateRef<unknown>>('archiveForm');
|
||||
private readonly toast = inject(ToastService);
|
||||
@@ -491,6 +581,68 @@ export class ReleasesPage {
|
||||
this.drawer.closeAll();
|
||||
}
|
||||
|
||||
protected openPublicNotes(release: Release): void {
|
||||
this.sharingRelease = release;
|
||||
this.publicShare.set(null);
|
||||
this.publicLoaded.set(false);
|
||||
this.sharing
|
||||
.releaseNotesShare(release.id)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (share) => {
|
||||
this.publicShare.set(share);
|
||||
this.publicLoaded.set(true);
|
||||
},
|
||||
error: () => this.toast.error('No se pudo leer el enlace'),
|
||||
});
|
||||
this.drawer.open(
|
||||
`Página pública de ${release.name}`,
|
||||
this.publicNotesForm(),
|
||||
);
|
||||
}
|
||||
|
||||
protected sharePublicNotes(renew: boolean): void {
|
||||
const release = this.sharingRelease;
|
||||
if (!release) return;
|
||||
this.sharing
|
||||
.shareReleaseNotes(release.id, renew)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (share) => {
|
||||
this.publicShare.set(share);
|
||||
this.toast.success(renew ? 'Enlace renovado' : 'Enlace creado');
|
||||
},
|
||||
error: () => this.toast.error('No se pudo crear el enlace'),
|
||||
});
|
||||
}
|
||||
|
||||
protected unsharePublicNotes(): void {
|
||||
const release = this.sharingRelease;
|
||||
if (!release) return;
|
||||
this.sharing
|
||||
.unshareReleaseNotes(release.id)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.publicShare.set(null);
|
||||
this.toast.success('Enlace retirado');
|
||||
},
|
||||
error: () => this.toast.error('No se pudo retirar'),
|
||||
});
|
||||
}
|
||||
|
||||
/** The web app and the API share the origin. */
|
||||
protected publicUrl(token: string): string {
|
||||
return `${location.origin}/notes/${token}`;
|
||||
}
|
||||
|
||||
protected copyLink(url: string): void {
|
||||
navigator.clipboard
|
||||
?.writeText(url)
|
||||
.then(() => this.toast.success('Enlace copiado'))
|
||||
.catch(() => this.toast.error('No se pudo copiar'));
|
||||
}
|
||||
|
||||
protected copyNotes(release: Release): void {
|
||||
navigator.clipboard
|
||||
?.writeText(release.notes ?? '')
|
||||
|
||||
@@ -54,6 +54,15 @@ export const trackerRoutes: Routes = [
|
||||
(m) => m.SharedDashboardPage,
|
||||
),
|
||||
},
|
||||
{
|
||||
// A release's notes for its clients: the token is the access.
|
||||
path: 'notes/:token',
|
||||
title: 'Notas de la versión · TaskStream',
|
||||
loadComponent: () =>
|
||||
import('./pages/public-release-notes.page').then(
|
||||
(m) => m.PublicReleaseNotesPage,
|
||||
),
|
||||
},
|
||||
// The Spanish paths, from links shared before.
|
||||
{ path: 'cartera', redirectTo: 'portfolio' },
|
||||
{ path: 'explorar', redirectTo: 'explore' },
|
||||
|
||||
Reference in New Issue
Block a user