feat: attachments on tickets and comments
Files are uploaded over REST, stored on disk (a volume in Docker) and recorded on the ticket as events; the bytes are written first, so no attachment ever points at a missing file. Only images, video, audio, PDF and plain text are shown inline, everything with nosniff and a sandbox CSP. The web shows thumbnails, uploads by drag and drop or by pasting a screenshot, and attaches files to new comments. Also adds `nx run api:contracts`, which writes schema.gql and openapi.json without starting the API or any database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,7 +29,21 @@ export const issuePageEvents = eventGroup({
|
||||
issueId: string;
|
||||
releaseId: string | null;
|
||||
}>(),
|
||||
commentAddRequested: type<{ issueId: string; body: string }>(),
|
||||
commentAddRequested: type<{
|
||||
issueId: string;
|
||||
body: string;
|
||||
/** Uploaded under the new comment once it exists. */
|
||||
files?: File[];
|
||||
}>(),
|
||||
attachRequested: type<{
|
||||
issueId: string;
|
||||
files: File[];
|
||||
commentId?: string | null;
|
||||
}>(),
|
||||
attachmentRemoveRequested: type<{
|
||||
issueId: string;
|
||||
attachmentId: string;
|
||||
}>(),
|
||||
commentEditRequested: type<{ commentId: string; body: string }>(),
|
||||
commentDeleteRequested: type<{ commentId: string }>(),
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Events } from '@ngrx/signals/events';
|
||||
import { AuthFacade } from '@task-stream/web/identity/application';
|
||||
import {
|
||||
ActivityGateway,
|
||||
AttachmentsGateway,
|
||||
CommentsGateway,
|
||||
IssuesGateway,
|
||||
Project,
|
||||
@@ -45,7 +46,15 @@ function gateways() {
|
||||
issue: vi.fn(() => of(issue)),
|
||||
changeStatus: vi.fn(() => of(ok)),
|
||||
},
|
||||
list: { list: vi.fn(() => of([])) },
|
||||
list: {
|
||||
list: vi.fn(() => of([])),
|
||||
add: vi.fn(() => of({ ...ok, id: 'c1' })),
|
||||
},
|
||||
attachments: {
|
||||
upload: vi.fn(() => of(ok)),
|
||||
download: vi.fn(() => of(new Blob(['x']))),
|
||||
remove: vi.fn(() => of(ok)),
|
||||
},
|
||||
realtime: {
|
||||
issueChanges: () => NEVER,
|
||||
commentChanges: () => NEVER,
|
||||
@@ -80,6 +89,7 @@ function setup() {
|
||||
{ provide: ReleasesGateway, useValue: fakes.list },
|
||||
{ provide: ActivityGateway, useValue: fakes.list },
|
||||
{ provide: CommentsGateway, useValue: fakes.list },
|
||||
{ provide: AttachmentsGateway, useValue: fakes.attachments },
|
||||
{ provide: UsersGateway, useValue: { search: () => of([]) } },
|
||||
{ provide: TrackerRealtimeGateway, useValue: fakes.realtime },
|
||||
{
|
||||
@@ -222,6 +232,24 @@ describe('IssueFacade', () => {
|
||||
reason: 'Falla en Safari',
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads files one by one, and under a new comment when sent with it', () => {
|
||||
const { issue, fakes } = setup();
|
||||
const a = new File(['a'], 'a.png', { type: 'image/png' });
|
||||
const b = new File(['b'], 'b.log', { type: 'text/plain' });
|
||||
|
||||
issue.open('WEB-1');
|
||||
issue.attach([a, b]);
|
||||
issue.addComment('Adjunto la traza', [b]);
|
||||
issue.removeAttachment('f1');
|
||||
|
||||
expect(fakes.attachments.upload.mock.calls).toEqual([
|
||||
['i1', a, null],
|
||||
['i1', b, null],
|
||||
['i1', b, 'c1'],
|
||||
]);
|
||||
expect(fakes.attachments.remove).toHaveBeenCalledWith('i1', 'f1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HomeFacade', () => {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { inject, Injectable, Provider } from '@angular/core';
|
||||
import { injectDispatch } from '@ngrx/signals/events';
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentsGateway,
|
||||
IssueLink,
|
||||
IssueLinkType,
|
||||
UpdateIssueInput,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { Observable } from 'rxjs';
|
||||
import { issuePageEvents } from '../events/issue.events';
|
||||
import { IssueStore } from '../stores/issue.store';
|
||||
|
||||
@@ -16,6 +19,7 @@ import { IssueStore } from '../stores/issue.store';
|
||||
export class IssueFacade {
|
||||
private readonly store = inject(IssueStore);
|
||||
private readonly dispatch = injectDispatch(issuePageEvents);
|
||||
private readonly files = inject(AttachmentsGateway);
|
||||
|
||||
readonly issue = this.store.issue;
|
||||
readonly comments = this.store.comments;
|
||||
@@ -74,12 +78,33 @@ export class IssueFacade {
|
||||
);
|
||||
}
|
||||
|
||||
addComment(body: string): void {
|
||||
/** `files` are attached under the new comment. */
|
||||
addComment(body: string, files: File[] = []): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.commentAddRequested({ issueId, body }),
|
||||
this.dispatch.commentAddRequested({ issueId, body, files }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Attaches files to the ticket (or under one of its comments). */
|
||||
attach(files: File[], commentId: string | null = null): void {
|
||||
if (!files.length) return;
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.attachRequested({ issueId, files, commentId }),
|
||||
);
|
||||
}
|
||||
|
||||
removeAttachment(attachmentId: string): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.attachmentRemoveRequested({ issueId, attachmentId }),
|
||||
);
|
||||
}
|
||||
|
||||
/** The file's bytes, to show an image or save the file. */
|
||||
fileOf(attachment: Attachment): Observable<Blob> {
|
||||
const issueId = this.store.issue()?.id ?? '';
|
||||
return this.files.download(issueId, attachment);
|
||||
}
|
||||
|
||||
editComment(commentId: string, body: string): void {
|
||||
this.dispatch.commentEditRequested({ commentId, body });
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { AuthFacade } from '@task-stream/web/identity/application';
|
||||
import {
|
||||
Activity,
|
||||
ActivityGateway,
|
||||
AttachmentsGateway,
|
||||
Comment,
|
||||
CommentsGateway,
|
||||
Issue,
|
||||
@@ -18,8 +19,11 @@ import {
|
||||
TrackerRealtimeGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import {
|
||||
concatMap,
|
||||
debounceTime,
|
||||
filter,
|
||||
from,
|
||||
last,
|
||||
map,
|
||||
merge,
|
||||
mergeMap,
|
||||
@@ -100,7 +104,18 @@ export const IssueStore = signalStore(
|
||||
activity = inject(ActivityGateway),
|
||||
realtime = inject(TrackerRealtimeGateway),
|
||||
notifications = inject(Notifications),
|
||||
attachments = inject(AttachmentsGateway),
|
||||
) => {
|
||||
/** One file after another: the ticket's version moves with each. */
|
||||
const uploadAll = (
|
||||
issueId: string,
|
||||
files: File[],
|
||||
commentId: string | null = null,
|
||||
) =>
|
||||
from(files).pipe(
|
||||
concatMap((file) => attachments.upload(issueId, file, commentId)),
|
||||
last(),
|
||||
);
|
||||
const fetchIssue = (idOrKey: string) =>
|
||||
issues.issue(idOrKey).pipe(
|
||||
retry({
|
||||
@@ -267,8 +282,48 @@ export const IssueStore = signalStore(
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
comments.add(payload.issueId, payload.body),
|
||||
'Comentario añadido',
|
||||
comments
|
||||
.add(payload.issueId, payload.body)
|
||||
.pipe(
|
||||
concatMap((comment) =>
|
||||
payload.files?.length
|
||||
? uploadAll(
|
||||
payload.issueId,
|
||||
payload.files,
|
||||
comment.id,
|
||||
)
|
||||
: [comment],
|
||||
),
|
||||
),
|
||||
payload.files?.length
|
||||
? 'Comentario y adjuntos añadidos'
|
||||
: 'Comentario añadido',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.attachRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
uploadAll(payload.issueId, payload.files, payload.commentId),
|
||||
payload.files.length === 1
|
||||
? `${payload.files[0].name} adjuntado`
|
||||
: `${payload.files.length} archivos adjuntados`,
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.attachmentRemoveRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
attachments.remove(payload.issueId, payload.attachmentId),
|
||||
'Adjunto eliminado',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
|
||||
@@ -65,6 +65,18 @@ export interface IssuePage {
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** A file on a ticket (or under one of its comments). */
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
/** Bytes. */
|
||||
size: number;
|
||||
uploadedBy: UserRef;
|
||||
uploadedAt: string;
|
||||
commentId: string | null;
|
||||
}
|
||||
|
||||
export interface Issue extends IssueSummary {
|
||||
description: string | null;
|
||||
reporter: UserRef;
|
||||
@@ -72,6 +84,7 @@ export interface Issue extends IssueSummary {
|
||||
storyPoints: number | null;
|
||||
dueDate: string | null;
|
||||
links: IssueLink[];
|
||||
attachments: Attachment[];
|
||||
allowedTransitions: string[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
Activity,
|
||||
Attachment,
|
||||
BoardColumn,
|
||||
Comment,
|
||||
CommandResult,
|
||||
@@ -93,6 +94,21 @@ export abstract class CommentsGateway {
|
||||
abstract remove(commentId: string): Observable<CommandResult>;
|
||||
}
|
||||
|
||||
/** A ticket's files. Uploads and downloads go through the REST API. */
|
||||
export abstract class AttachmentsGateway {
|
||||
abstract upload(
|
||||
issueId: string,
|
||||
file: File,
|
||||
commentId?: string | null,
|
||||
): Observable<CommandResult>;
|
||||
/** The file's bytes, to show (images) or save. */
|
||||
abstract download(issueId: string, attachment: Attachment): Observable<Blob>;
|
||||
abstract remove(
|
||||
issueId: string,
|
||||
attachmentId: string,
|
||||
): Observable<CommandResult>;
|
||||
}
|
||||
|
||||
export abstract class ReleasesGateway {
|
||||
abstract list(projectId: string): Observable<Release[]>;
|
||||
/** Planned releases of all my projects, soonest target date first. */
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import {
|
||||
AccessTokenSource,
|
||||
API_CONFIG,
|
||||
} from '@task-stream/web/shared/infrastructure';
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentsGateway,
|
||||
CommandResult,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { from, Observable, switchMap } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Files travel over REST (`/api/issues/:id/attachments`): GraphQL is not made
|
||||
* for bytes. The ticket's list of files comes with the ticket itself.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HttpAttachmentsGateway extends AttachmentsGateway {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly api = inject(API_CONFIG);
|
||||
private readonly tokens = inject(AccessTokenSource);
|
||||
|
||||
upload(
|
||||
issueId: string,
|
||||
file: File,
|
||||
commentId?: string | null,
|
||||
): Observable<CommandResult> {
|
||||
const body = new FormData();
|
||||
body.append('file', file, file.name);
|
||||
if (commentId) body.append('commentId', commentId);
|
||||
return this.authorized((headers) =>
|
||||
this.http.post<CommandResult>(this.url(issueId), body, { headers }),
|
||||
);
|
||||
}
|
||||
|
||||
download(issueId: string, attachment: Attachment): Observable<Blob> {
|
||||
return this.authorized((headers) =>
|
||||
this.http.get(this.url(issueId, attachment.id), {
|
||||
headers,
|
||||
responseType: 'blob',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
remove(issueId: string, attachmentId: string): Observable<CommandResult> {
|
||||
return this.authorized((headers) =>
|
||||
this.http.delete<CommandResult>(this.url(issueId, attachmentId), {
|
||||
headers,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private url(issueId: string, attachmentId?: string): string {
|
||||
const base = `${this.api.restUrl}/issues/${encodeURIComponent(issueId)}/attachments`;
|
||||
return attachmentId ? `${base}/${encodeURIComponent(attachmentId)}` : base;
|
||||
}
|
||||
|
||||
/** Public projects can be read without a session: no token, no header. */
|
||||
private authorized<T>(
|
||||
request: (headers: HttpHeaders) => Observable<T>,
|
||||
): Observable<T> {
|
||||
return from(this.tokens.accessToken()).pipe(
|
||||
switchMap((token) =>
|
||||
request(
|
||||
token
|
||||
? new HttpHeaders({ Authorization: `Bearer ${token}` })
|
||||
: new HttpHeaders(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,17 @@ fragment IssueFields on Issue {
|
||||
issueId
|
||||
issueKey
|
||||
}
|
||||
attachments {
|
||||
id
|
||||
fileName
|
||||
mimeType
|
||||
size
|
||||
uploadedAt
|
||||
commentId
|
||||
uploadedBy {
|
||||
...UserRefFields
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment CommentFields on Comment {
|
||||
|
||||
@@ -237,6 +237,15 @@ export type IssueFieldsFragment = {
|
||||
issueId: string;
|
||||
issueKey: string;
|
||||
}>;
|
||||
attachments: Array<{
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
uploadedAt: string;
|
||||
commentId: string | null;
|
||||
uploadedBy: { id: string; name: string };
|
||||
}>;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
};
|
||||
@@ -342,6 +351,15 @@ export type IssueQuery = {
|
||||
issueId: string;
|
||||
issueKey: string;
|
||||
}>;
|
||||
attachments: Array<{
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
uploadedAt: string;
|
||||
commentId: string | null;
|
||||
uploadedBy: { id: string; name: string };
|
||||
}>;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
};
|
||||
@@ -1294,6 +1312,34 @@ export const IssueFieldsFragmentDoc = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'attachments' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'fileName' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'mimeType' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'size' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'uploadedAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'commentId' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'uploadedBy' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -1877,6 +1923,34 @@ export const IssueDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'attachments' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'fileName' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'mimeType' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'size' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'uploadedAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'commentId' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'uploadedBy' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import {
|
||||
ActivityGateway,
|
||||
AttachmentsGateway,
|
||||
CommentsGateway,
|
||||
IssuesGateway,
|
||||
ProjectsGateway,
|
||||
@@ -18,10 +19,11 @@ import {
|
||||
GraphqlUsersGateway,
|
||||
} from './adapters/graphql-collaboration.gateways';
|
||||
import { GraphqlIssuesGateway } from './adapters/graphql-issues.gateway';
|
||||
import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway';
|
||||
import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway';
|
||||
import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway';
|
||||
|
||||
/** Ports → GraphQL adapters. */
|
||||
/** Ports → GraphQL adapters (and REST for files). */
|
||||
export function provideTrackerInfrastructure(): EnvironmentProviders {
|
||||
return makeEnvironmentProviders([
|
||||
{ provide: ProjectsGateway, useClass: GraphqlProjectsGateway },
|
||||
@@ -29,6 +31,7 @@ export function provideTrackerInfrastructure(): EnvironmentProviders {
|
||||
{ provide: CommentsGateway, useClass: GraphqlCommentsGateway },
|
||||
{ provide: ReleasesGateway, useClass: GraphqlReleasesGateway },
|
||||
{ provide: ActivityGateway, useClass: GraphqlActivityGateway },
|
||||
{ provide: AttachmentsGateway, useClass: HttpAttachmentsGateway },
|
||||
{ provide: UsersGateway, useClass: GraphqlUsersGateway },
|
||||
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
|
||||
]);
|
||||
|
||||
@@ -338,3 +338,10 @@ a.ts-key {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ts-brand);
|
||||
}
|
||||
|
||||
.ts-attachments {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
HostListener,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
@@ -13,6 +14,7 @@ import { form, FormField, required } from '@angular/forms/signals';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiFileUploadComponent,
|
||||
PiIconButtonComponent,
|
||||
PiIconComponent,
|
||||
PiLabelDirective,
|
||||
@@ -21,15 +23,17 @@ import {
|
||||
PiSkeletonComponent,
|
||||
PiTextareaComponent,
|
||||
PiTextFieldComponent,
|
||||
type PiFileRejection,
|
||||
type PiSelectFieldOption,
|
||||
} from '@pi/ui';
|
||||
import { DrawerService } from '@task-stream/web/shared/ui';
|
||||
import { DrawerService, ToastService } from '@task-stream/web/shared/ui';
|
||||
import {
|
||||
IssueFacade,
|
||||
ProjectFacade,
|
||||
provideIssueFacade,
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
Attachment,
|
||||
Comment,
|
||||
IssueLink,
|
||||
IssueLinkType,
|
||||
@@ -46,8 +50,11 @@ import {
|
||||
timeAgo,
|
||||
typeLook,
|
||||
} from '../ui/copy';
|
||||
import { AttachmentItem } from '../ui/attachments';
|
||||
|
||||
const NONE = '';
|
||||
/** Same ceiling as the API's ATTACHMENTS_MAX_MB default. */
|
||||
const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
@Component({
|
||||
selector: 'ts-issue-page',
|
||||
@@ -55,7 +62,9 @@ const NONE = '';
|
||||
imports: [
|
||||
RouterLink,
|
||||
FormField,
|
||||
AttachmentItem,
|
||||
PiActionButtonComponent,
|
||||
PiFileUploadComponent,
|
||||
PiIconComponent,
|
||||
PiIconButtonComponent,
|
||||
PiLabelDirective,
|
||||
@@ -134,6 +143,47 @@ const NONE = '';
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="ts-block">
|
||||
<h2 class="ts-section">
|
||||
Adjuntos
|
||||
<span class="ts-mono">{{ ticketFiles().length }}</span>
|
||||
</h2>
|
||||
@if (ticketFiles().length) {
|
||||
<div class="ts-attachments">
|
||||
@for (file of ticketFiles(); track file.id) {
|
||||
<ts-attachment
|
||||
[attachment]="file"
|
||||
[removable]="canRemove(file)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
} @else if (!project.canContribute()) {
|
||||
<p class="ts-muted">Sin adjuntos.</p>
|
||||
}
|
||||
@if (project.canContribute()) {
|
||||
<div class="ts-form">
|
||||
<pi-file-upload
|
||||
label="Capturas, logs, vídeos…"
|
||||
hint="Arrastra aquí, elige, o pega una captura con Ctrl+V. Hasta 25 MB por archivo."
|
||||
[multiple]="true"
|
||||
[maxFiles]="10"
|
||||
[maxSizeBytes]="maxFileBytes"
|
||||
[(value)]="pendingFiles"
|
||||
(rejected)="rejected($event)"
|
||||
/>
|
||||
@if (pendingFiles().length) {
|
||||
<div>
|
||||
<pi-action-button
|
||||
color="primary"
|
||||
[text]="'Adjuntar ' + pendingFiles().length"
|
||||
(execute)="attach()"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="ts-block">
|
||||
<h2 class="ts-section">Relaciones</h2>
|
||||
@for (
|
||||
@@ -238,6 +288,16 @@ const NONE = '';
|
||||
}
|
||||
</div>
|
||||
<p class="ts-prewrap">{{ comment.body }}</p>
|
||||
@if (filesOf(comment).length) {
|
||||
<div class="ts-attachments">
|
||||
@for (file of filesOf(comment); track file.id) {
|
||||
<ts-attachment
|
||||
[attachment]="file"
|
||||
[removable]="canRemove(file)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -251,6 +311,14 @@ const NONE = '';
|
||||
placeholder="Deja una decisión, una explicación, un paso para reproducir…"
|
||||
[(value)]="commentBody"
|
||||
/>
|
||||
<pi-file-upload
|
||||
label="Adjuntar al comentario"
|
||||
[multiple]="true"
|
||||
[maxFiles]="10"
|
||||
[maxSizeBytes]="maxFileBytes"
|
||||
[(value)]="commentFiles"
|
||||
(rejected)="rejected($event)"
|
||||
/>
|
||||
<div>
|
||||
<pi-action-button
|
||||
color="primary"
|
||||
@@ -436,6 +504,7 @@ export class IssuePage {
|
||||
protected readonly facade = inject(IssueFacade);
|
||||
protected readonly project = inject(ProjectFacade);
|
||||
private readonly drawer = inject(DrawerService);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly reasonForm =
|
||||
viewChild.required<TemplateRef<unknown>>('reasonForm');
|
||||
private readonly editForm =
|
||||
@@ -506,6 +575,21 @@ export class IssuePage {
|
||||
protected readonly linkType = signal<string | null>(IssueLinkType.RELATES_TO);
|
||||
protected readonly linkTarget = signal('');
|
||||
protected readonly commentBody = signal('');
|
||||
protected readonly commentFiles = signal<File[]>([]);
|
||||
protected readonly pendingFiles = signal<File[]>([]);
|
||||
protected readonly maxFileBytes = MAX_FILE_BYTES;
|
||||
|
||||
/** Files on the ticket itself; those under a comment are shown there. */
|
||||
protected readonly ticketFiles = computed(
|
||||
() => this.facade.issue()?.attachments.filter((a) => !a.commentId) ?? [],
|
||||
);
|
||||
protected filesOf = (comment: Comment): Attachment[] =>
|
||||
this.facade
|
||||
.issue()
|
||||
?.attachments.filter((a) => a.commentId === comment.id) ?? [];
|
||||
protected canRemove = (file: Attachment): boolean =>
|
||||
this.project.canContribute() &&
|
||||
(file.uploadedBy.id === this.facade.me() || this.project.canManage());
|
||||
|
||||
private pendingStatus: string | null = null;
|
||||
private readonly reasonModel = signal({ text: '' });
|
||||
@@ -590,8 +674,53 @@ export class IssuePage {
|
||||
protected addComment(): void {
|
||||
const body = this.commentBody().trim();
|
||||
if (!body) return;
|
||||
this.facade.addComment(body);
|
||||
this.facade.addComment(body, this.commentFiles());
|
||||
this.commentBody.set('');
|
||||
this.commentFiles.set([]);
|
||||
}
|
||||
|
||||
protected attach(): void {
|
||||
this.facade.attach(this.pendingFiles());
|
||||
this.pendingFiles.set([]);
|
||||
}
|
||||
|
||||
protected rejected(rejections: PiFileRejection[]): void {
|
||||
for (const { file, reason } of rejections) {
|
||||
this.toast.error(
|
||||
reason === 'size'
|
||||
? `${file.name} pasa de 25 MB`
|
||||
: reason === 'max-files'
|
||||
? 'Como mucho 10 archivos a la vez'
|
||||
: `${file.name} no es un tipo admitido`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** A screenshot pasted anywhere on the ticket is attached to it. */
|
||||
@HostListener('document:paste', ['$event'])
|
||||
protected pasted(event: ClipboardEvent): void {
|
||||
const target = event.target as HTMLElement | null;
|
||||
// Pasting text into a field is just typing.
|
||||
if (target?.closest('input, textarea, [contenteditable]')) return;
|
||||
if (!this.project.canContribute() || !this.facade.issue()) return;
|
||||
const files = [...(event.clipboardData?.files ?? [])].filter(
|
||||
(file) => file.size <= MAX_FILE_BYTES,
|
||||
);
|
||||
if (!files.length) return;
|
||||
event.preventDefault();
|
||||
this.facade.attach(
|
||||
files.map((file) =>
|
||||
file.name && file.name !== 'image.png'
|
||||
? file
|
||||
: new File(
|
||||
[file],
|
||||
`captura-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`,
|
||||
{
|
||||
type: file.type,
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
protected openEditComment(comment: Comment): void {
|
||||
|
||||
178
libs/web/tracker/presenters/src/lib/ui/attachments.ts
Normal file
178
libs/web/tracker/presenters/src/lib/ui/attachments.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { PiIconButtonComponent, PiIconComponent } from '@pi/ui';
|
||||
import { IssueFacade } from '@task-stream/web/tracker/application';
|
||||
import { Attachment } from '@task-stream/web/tracker/domain';
|
||||
import { timeAgo } from './copy';
|
||||
|
||||
/** Types the browser shows in a tab of its own; anything else is saved. */
|
||||
const OPENS_IN_TAB =
|
||||
/^(image\/(png|jpe?g|gif|webp|avif)|video\/(mp4|webm)|audio\/(mpeg|ogg|wav)|application\/pdf|text\/plain)$/;
|
||||
const PREVIEWED = /^image\/(png|jpe?g|gif|webp|avif)$/;
|
||||
|
||||
export function readableSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One file: a thumbnail for images, an icon otherwise. The bytes are fetched
|
||||
* with the session's token (a plain link could not send it) and shown from a
|
||||
* local object URL, released when the item goes.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ts-attachment',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PiIconComponent, PiIconButtonComponent],
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
class="ts-attachment__open"
|
||||
[attr.aria-label]="'Abrir ' + attachment().fileName"
|
||||
(click)="open()"
|
||||
>
|
||||
@if (thumbnail(); as src) {
|
||||
<img class="ts-attachment__thumb" [src]="src" alt="" />
|
||||
} @else {
|
||||
<span class="ts-attachment__icon" aria-hidden="true">
|
||||
<pi-icon [icon]="isImage() ? 'image' : 'document'" size="small" />
|
||||
</span>
|
||||
}
|
||||
<span class="ts-attachment__text">
|
||||
<span class="ts-attachment__name">{{ attachment().fileName }}</span>
|
||||
<span class="ts-attachment__meta"
|
||||
>{{ size() }} · {{ attachment().uploadedBy.name }} ·
|
||||
{{ ago(attachment().uploadedAt) }}</span
|
||||
>
|
||||
</span>
|
||||
</button>
|
||||
@if (removable()) {
|
||||
<pi-icon-button
|
||||
icon="trash"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
[attr.aria-label]="'Quitar ' + attachment().fileName"
|
||||
(execute)="remove()"
|
||||
/>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.ts-attachment__open {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding: 0.375rem;
|
||||
border: 1px solid var(--ts-line, rgba(127, 127, 127, 0.25));
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ts-attachment__open:hover,
|
||||
.ts-attachment__open:focus-visible {
|
||||
border-color: currentColor;
|
||||
}
|
||||
.ts-attachment__thumb,
|
||||
.ts-attachment__icon {
|
||||
flex: none;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 0.375rem;
|
||||
object-fit: cover;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(127, 127, 127, 0.12);
|
||||
}
|
||||
.ts-attachment__text {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
.ts-attachment__name {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ts-attachment__meta {
|
||||
font-size: 0.8125rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class AttachmentItem implements OnInit {
|
||||
readonly attachment = input.required<Attachment>();
|
||||
readonly removable = input(false);
|
||||
|
||||
private readonly facade = inject(IssueFacade);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private objectUrl: string | null = null;
|
||||
|
||||
protected readonly thumbnail = signal<string | null>(null);
|
||||
protected readonly isImage = computed(() =>
|
||||
PREVIEWED.test(this.attachment().mimeType),
|
||||
);
|
||||
protected readonly size = computed(() =>
|
||||
readableSize(this.attachment().size),
|
||||
);
|
||||
protected readonly ago = timeAgo;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.destroyRef.onDestroy(() => {
|
||||
if (this.objectUrl) URL.revokeObjectURL(this.objectUrl);
|
||||
});
|
||||
// Small images only: a 20 MB screenshot is not worth a thumbnail.
|
||||
if (this.isImage() && this.attachment().size <= 5 * 1024 * 1024) {
|
||||
this.withUrl((url) => this.thumbnail.set(url));
|
||||
}
|
||||
}
|
||||
|
||||
protected open(): void {
|
||||
this.withUrl((url) => {
|
||||
const { fileName, mimeType } = this.attachment();
|
||||
if (OPENS_IN_TAB.test(mimeType)) {
|
||||
window.open(url, '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.click();
|
||||
});
|
||||
}
|
||||
|
||||
protected remove(): void {
|
||||
if (confirm(`¿Quitar ${this.attachment().fileName}?`)) {
|
||||
this.facade.removeAttachment(this.attachment().id);
|
||||
}
|
||||
}
|
||||
|
||||
private withUrl(use: (url: string) => void): void {
|
||||
if (this.objectUrl) {
|
||||
use(this.objectUrl);
|
||||
return;
|
||||
}
|
||||
this.facade.fileOf(this.attachment()).subscribe((blob) => {
|
||||
this.objectUrl = URL.createObjectURL(blob);
|
||||
use(this.objectUrl);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user