first commit
This commit is contained in:
26
libs/web/tracker/application/README.md
Normal file
26
libs/web/tracker/application/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# web-tracker-application
|
||||
|
||||
Estado del tracker (NgRx Signals con eventos) detrás de facades.
|
||||
|
||||
- **Import**: `@task-stream/web/tracker/application`
|
||||
- **Capa**: `application` · **Tags**: `scope:web-tracker`, `type:application`
|
||||
|
||||
## Contenido
|
||||
|
||||
- Facades (lo que se exporta): `ProjectsFacade` (raíz) y `ProjectFacade`, `BoardFacade`, `IssueFacade` (de ruta, con
|
||||
`provideProjectFacade()`, `provideBoardFacade()`, `provideIssueFacade()`). Tests en `facades/facades.spec.ts`.
|
||||
- Interno: stores `ProjectsStore`, `ProjectStore`, `BoardStore`, `IssueStore` y eventos por origen
|
||||
(`*PageEvents`, `*ApiEvents`, `*RealtimeEvents`).
|
||||
- `board.reducers.ts`: transformaciones puras del tablero (movimiento optimista, cambios en vivo por versión).
|
||||
- Puerto `Notifications`.
|
||||
|
||||
Patrón de eventos: [Cliente web](../../../../docs/04-cliente-web.md#42-estado-ngrx-signals-con-eventos).
|
||||
|
||||
## Comandos
|
||||
|
||||
```sh
|
||||
npx nx lint web-tracker-application
|
||||
npx nx test web-tracker-application # Vitest
|
||||
```
|
||||
|
||||
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).
|
||||
34
libs/web/tracker/application/eslint.config.mjs
Normal file
34
libs/web/tracker/application/eslint.config.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import nx from '@nx/eslint-plugin';
|
||||
import baseConfig from '../../../../eslint.config.mjs';
|
||||
|
||||
export default [
|
||||
...nx.configs['flat/angular'],
|
||||
...nx.configs['flat/angular-template'],
|
||||
...baseConfig,
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
rules: {
|
||||
'@angular-eslint/directive-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'attribute',
|
||||
prefix: 'ts',
|
||||
style: 'camelCase',
|
||||
},
|
||||
],
|
||||
'@angular-eslint/component-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'element',
|
||||
prefix: 'ts',
|
||||
style: 'kebab-case',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.html'],
|
||||
// Override or add rules here
|
||||
rules: {},
|
||||
},
|
||||
];
|
||||
20
libs/web/tracker/application/project.json
Normal file
20
libs/web/tracker/application/project.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "web-tracker-application",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/web/tracker/application/src",
|
||||
"prefix": "ts",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:web-tracker", "type:application"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/vitest:test",
|
||||
"outputs": ["{options.reportsDirectory}"],
|
||||
"options": {
|
||||
"reportsDirectory": "coverage/libs/web/tracker/application"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
5
libs/web/tracker/application/src/index.ts
Normal file
5
libs/web/tracker/application/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './lib/notifications';
|
||||
export * from './lib/facades/projects.facade';
|
||||
export * from './lib/facades/project.facade';
|
||||
export * from './lib/facades/board.facade';
|
||||
export * from './lib/facades/issue.facade';
|
||||
43
libs/web/tracker/application/src/lib/events/board.events.ts
Normal file
43
libs/web/tracker/application/src/lib/events/board.events.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { type } from '@ngrx/signals';
|
||||
import { eventGroup } from '@ngrx/signals/events';
|
||||
import {
|
||||
BoardColumn,
|
||||
BoardFilter,
|
||||
CommandResult,
|
||||
CreateIssueInput,
|
||||
IssueChanged,
|
||||
IssueStatus,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
|
||||
export const boardPageEvents = eventGroup({
|
||||
source: 'Board Page',
|
||||
events: {
|
||||
opened: type<BoardFilter>(),
|
||||
filterChanged: type<BoardFilter>(),
|
||||
/** Optimistic: the card moves immediately and comes back if the API refuses. */
|
||||
issueMoved: type<{
|
||||
issueId: string;
|
||||
to: IssueStatus;
|
||||
reason?: string | null;
|
||||
}>(),
|
||||
issueCreateRequested: type<CreateIssueInput>(),
|
||||
},
|
||||
});
|
||||
|
||||
export const boardApiEvents = eventGroup({
|
||||
source: 'Board API',
|
||||
events: {
|
||||
loaded: type<BoardColumn[]>(),
|
||||
loadFailed: type<string>(),
|
||||
issueMoveFailed: type<string>(),
|
||||
issueCreated: type<CommandResult>(),
|
||||
issueCreateFailed: type<string>(),
|
||||
},
|
||||
});
|
||||
|
||||
export const boardRealtimeEvents = eventGroup({
|
||||
source: 'Board Realtime',
|
||||
events: {
|
||||
issueChanged: type<IssueChanged>(),
|
||||
},
|
||||
});
|
||||
57
libs/web/tracker/application/src/lib/events/issue.events.ts
Normal file
57
libs/web/tracker/application/src/lib/events/issue.events.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { type } from '@ngrx/signals';
|
||||
import { eventGroup } from '@ngrx/signals/events';
|
||||
import {
|
||||
Activity,
|
||||
ChangeStatusInput,
|
||||
Comment,
|
||||
CommentChanged,
|
||||
Issue,
|
||||
LinkIssuesInput,
|
||||
UpdateIssueInput,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
|
||||
export const issuePageEvents = eventGroup({
|
||||
source: 'Issue Page',
|
||||
events: {
|
||||
opened: type<{ idOrKey: string }>(),
|
||||
statusChangeRequested: type<ChangeStatusInput>(),
|
||||
assignRequested: type<{ issueId: string; assigneeId: string | null }>(),
|
||||
updateRequested: type<UpdateIssueInput>(),
|
||||
linkRequested: type<LinkIssuesInput>(),
|
||||
/** Relate by the other ticket's key (e.g. BE-3), resolved to its id first. */
|
||||
linkByKeyRequested: type<{
|
||||
issueId: string;
|
||||
type: LinkIssuesInput['type'];
|
||||
targetKey: string;
|
||||
}>(),
|
||||
unlinkRequested: type<LinkIssuesInput>(),
|
||||
releaseChangeRequested: type<{
|
||||
issueId: string;
|
||||
releaseId: string | null;
|
||||
}>(),
|
||||
commentAddRequested: type<{ issueId: string; body: string }>(),
|
||||
commentEditRequested: type<{ commentId: string; body: string }>(),
|
||||
commentDeleteRequested: type<{ commentId: string }>(),
|
||||
},
|
||||
});
|
||||
|
||||
export const issueApiEvents = eventGroup({
|
||||
source: 'Issue API',
|
||||
events: {
|
||||
loaded: type<Issue>(),
|
||||
loadFailed: type<string>(),
|
||||
commentsLoaded: type<Comment[]>(),
|
||||
historyLoaded: type<Activity[]>(),
|
||||
commandSucceeded: type<string>(),
|
||||
commandFailed: type<string>(),
|
||||
},
|
||||
});
|
||||
|
||||
export const issueRealtimeEvents = eventGroup({
|
||||
source: 'Issue Realtime',
|
||||
events: {
|
||||
/** Something about this ticket changed: refetch it. */
|
||||
issueTouched: type<{ issueId: string; version: number }>(),
|
||||
commentChanged: type<CommentChanged>(),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { type } from '@ngrx/signals';
|
||||
import { eventGroup } from '@ngrx/signals/events';
|
||||
import {
|
||||
Activity,
|
||||
CreateReleaseInput,
|
||||
MemberInput,
|
||||
Project,
|
||||
ProjectStats,
|
||||
Release,
|
||||
UserDirectoryEntry,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
|
||||
export const projectPageEvents = eventGroup({
|
||||
source: 'Project Page',
|
||||
events: {
|
||||
opened: type<{ idOrKey: string }>(),
|
||||
memberAddRequested: type<MemberInput>(),
|
||||
memberRoleChangeRequested: type<MemberInput>(),
|
||||
memberRemoveRequested: type<{ projectId: string; userId: string }>(),
|
||||
releaseCreateRequested: type<CreateReleaseInput>(),
|
||||
releasePublishRequested: type<{ releaseId: string; force: boolean }>(),
|
||||
releaseArchiveRequested: type<{ releaseId: string }>(),
|
||||
userSearchRequested: type<string>(),
|
||||
},
|
||||
});
|
||||
|
||||
export const projectApiEvents = eventGroup({
|
||||
source: 'Project API',
|
||||
events: {
|
||||
loaded: type<Project>(),
|
||||
loadFailed: type<string>(),
|
||||
statsLoaded: type<ProjectStats>(),
|
||||
releasesLoaded: type<Release[]>(),
|
||||
activityLoaded: type<Activity[]>(),
|
||||
commandSucceeded: type<string>(),
|
||||
commandFailed: type<string>(),
|
||||
usersFound: type<UserDirectoryEntry[]>(),
|
||||
},
|
||||
});
|
||||
|
||||
export const projectRealtimeEvents = eventGroup({
|
||||
source: 'Project Realtime',
|
||||
events: {
|
||||
projectChanged: type<Project>(),
|
||||
activityAdded: type<Activity>(),
|
||||
releaseChanged: type<Release>(),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { type } from '@ngrx/signals';
|
||||
import { eventGroup } from '@ngrx/signals/events';
|
||||
import {
|
||||
CommandResult,
|
||||
CreateProjectInput,
|
||||
Project,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
|
||||
export const projectsPageEvents = eventGroup({
|
||||
source: 'Projects Page',
|
||||
events: {
|
||||
opened: type<void>(),
|
||||
createRequested: type<CreateProjectInput>(),
|
||||
},
|
||||
});
|
||||
|
||||
export const projectsApiEvents = eventGroup({
|
||||
source: 'Projects API',
|
||||
events: {
|
||||
loaded: type<Project[]>(),
|
||||
loadFailed: type<string>(),
|
||||
created: type<CommandResult>(),
|
||||
createFailed: type<string>(),
|
||||
},
|
||||
});
|
||||
|
||||
export const projectsRealtimeEvents = eventGroup({
|
||||
source: 'Projects Realtime',
|
||||
events: {
|
||||
/** Joined, left or changed one of my projects. */
|
||||
projectChanged: type<{ project: Project; stillMember: boolean }>(),
|
||||
},
|
||||
});
|
||||
45
libs/web/tracker/application/src/lib/facades/board.facade.ts
Normal file
45
libs/web/tracker/application/src/lib/facades/board.facade.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { inject, Injectable, Provider } from '@angular/core';
|
||||
import { injectDispatch } from '@ngrx/signals/events';
|
||||
import {
|
||||
BoardFilter,
|
||||
CreateIssueInput,
|
||||
IssueStatus,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { boardPageEvents } from '../events/board.events';
|
||||
import { BoardStore } from '../stores/board.store';
|
||||
|
||||
/** Kanban board of a project, live. */
|
||||
@Injectable()
|
||||
export class BoardFacade {
|
||||
private readonly store = inject(BoardStore);
|
||||
private readonly dispatch = injectDispatch(boardPageEvents);
|
||||
|
||||
readonly columns = this.store.columns;
|
||||
readonly filter = this.store.filter;
|
||||
/** Tickets on the board (all columns). */
|
||||
readonly total = this.store.total;
|
||||
readonly loading = this.store.loading;
|
||||
readonly creating = this.store.creating;
|
||||
readonly error = this.store.error;
|
||||
|
||||
/** Shows the board with this filter: opens it for a new project, refilters otherwise. */
|
||||
show(filter: BoardFilter): void {
|
||||
if (this.store.filter()?.projectId === filter.projectId)
|
||||
this.dispatch.filterChanged(filter);
|
||||
else this.dispatch.opened(filter);
|
||||
}
|
||||
|
||||
/** Optimistic: the card moves at once and comes back if the API refuses. */
|
||||
moveIssue(issueId: string, to: IssueStatus, reason?: string | null): void {
|
||||
this.dispatch.issueMoved({ issueId, to, reason });
|
||||
}
|
||||
|
||||
createIssue(input: CreateIssueInput): void {
|
||||
this.dispatch.issueCreateRequested(input);
|
||||
}
|
||||
}
|
||||
|
||||
/** Store + facade of the board, for the board route's component. */
|
||||
export function provideBoardFacade(): Provider[] {
|
||||
return [BoardStore, BoardFacade];
|
||||
}
|
||||
159
libs/web/tracker/application/src/lib/facades/facades.spec.ts
Normal file
159
libs/web/tracker/application/src/lib/facades/facades.spec.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Events } from '@ngrx/signals/events';
|
||||
import { AuthFacade } from '@task-stream/web/identity/application';
|
||||
import {
|
||||
ActivityGateway,
|
||||
CommentsGateway,
|
||||
IssuesGateway,
|
||||
IssueStatus,
|
||||
Project,
|
||||
ProjectRole,
|
||||
ProjectsGateway,
|
||||
ReleasesGateway,
|
||||
TrackerRealtimeGateway,
|
||||
UsersGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { NEVER, of } from 'rxjs';
|
||||
import { boardPageEvents } from '../events/board.events';
|
||||
import { projectPageEvents } from '../events/project.events';
|
||||
import { Notifications } from '../notifications';
|
||||
import { BoardFacade, provideBoardFacade } from './board.facade';
|
||||
import { IssueFacade, provideIssueFacade } from './issue.facade';
|
||||
import { ProjectFacade, provideProjectFacade } from './project.facade';
|
||||
|
||||
const ok = { id: 'x', key: null, aggregateVersion: 1 };
|
||||
const project = { id: 'p1', key: 'WEB', members: [] } as unknown as Project;
|
||||
const issue = { id: 'i1', key: 'WEB-1', links: [] };
|
||||
|
||||
/** Every command succeeds, every query answers once, nothing arrives in real time. */
|
||||
function gateways() {
|
||||
return {
|
||||
projects: {
|
||||
project: vi.fn(() => of(project)),
|
||||
stats: vi.fn(() => NEVER),
|
||||
addMember: vi.fn(() => of(ok)),
|
||||
},
|
||||
issues: {
|
||||
board: vi.fn(() => of([])),
|
||||
issue: vi.fn(() => of(issue)),
|
||||
changeStatus: vi.fn(() => of(ok)),
|
||||
},
|
||||
list: { list: vi.fn(() => of([])) },
|
||||
realtime: {
|
||||
issueChanges: () => NEVER,
|
||||
commentChanges: () => NEVER,
|
||||
activity: () => NEVER,
|
||||
releaseChanges: () => NEVER,
|
||||
projectChanges: () => NEVER,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Facades are scoped to a route component, like in the app. */
|
||||
@Component({
|
||||
template: '',
|
||||
providers: [
|
||||
provideProjectFacade(),
|
||||
provideBoardFacade(),
|
||||
provideIssueFacade(),
|
||||
],
|
||||
})
|
||||
class Host {
|
||||
readonly project = inject(ProjectFacade);
|
||||
readonly board = inject(BoardFacade);
|
||||
readonly issue = inject(IssueFacade);
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const fakes = gateways();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: ProjectsGateway, useValue: fakes.projects },
|
||||
{ provide: IssuesGateway, useValue: fakes.issues },
|
||||
{ provide: ReleasesGateway, useValue: fakes.list },
|
||||
{ provide: ActivityGateway, useValue: fakes.list },
|
||||
{ provide: CommentsGateway, useValue: fakes.list },
|
||||
{ provide: UsersGateway, useValue: { search: () => of([]) } },
|
||||
{ provide: TrackerRealtimeGateway, useValue: fakes.realtime },
|
||||
{
|
||||
provide: Notifications,
|
||||
useValue: { success: vi.fn(), error: vi.fn() },
|
||||
},
|
||||
{ provide: AuthFacade, useValue: { user: signal({ id: 'me' }) } },
|
||||
],
|
||||
});
|
||||
const host = TestBed.createComponent(Host).componentInstance;
|
||||
return { ...host, fakes };
|
||||
}
|
||||
|
||||
describe('BoardFacade', () => {
|
||||
it('opens the board for a new project and refilters the same one', () => {
|
||||
const { board, fakes } = setup();
|
||||
const seen: string[] = [];
|
||||
TestBed.inject(Events)
|
||||
.on(boardPageEvents.opened, boardPageEvents.filterChanged)
|
||||
.subscribe((event) => seen.push(event.type));
|
||||
|
||||
board.show({ projectId: 'p1' });
|
||||
board.show({ projectId: 'p1', assigneeId: 'u2' });
|
||||
board.show({ projectId: 'p2' });
|
||||
|
||||
expect(seen).toEqual([
|
||||
boardPageEvents.opened.type,
|
||||
boardPageEvents.filterChanged.type,
|
||||
boardPageEvents.opened.type,
|
||||
]);
|
||||
expect(fakes.issues.board).toHaveBeenLastCalledWith({ projectId: 'p2' });
|
||||
expect(board.filter()).toEqual({ projectId: 'p2' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProjectFacade', () => {
|
||||
it('ignores commands until a project is open, then sends its id', () => {
|
||||
const { project, fakes } = setup();
|
||||
|
||||
project.addMember('u2');
|
||||
expect(fakes.projects.addMember).not.toHaveBeenCalled();
|
||||
|
||||
project.open('WEB');
|
||||
expect(project.project()?.id).toBe('p1');
|
||||
|
||||
project.addMember('u2', ProjectRole.VIEWER);
|
||||
expect(fakes.projects.addMember).toHaveBeenCalledWith({
|
||||
projectId: 'p1',
|
||||
userId: 'u2',
|
||||
role: ProjectRole.VIEWER,
|
||||
});
|
||||
});
|
||||
|
||||
it('searches the directory from two characters', () => {
|
||||
const { project } = setup();
|
||||
const seen: string[] = [];
|
||||
TestBed.inject(Events)
|
||||
.on(projectPageEvents.userSearchRequested)
|
||||
.subscribe(({ payload }) => seen.push(payload));
|
||||
|
||||
project.searchUsers(' a ');
|
||||
project.searchUsers(' an ');
|
||||
|
||||
expect(seen).toEqual(['an']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IssueFacade', () => {
|
||||
it('acts on the open ticket', () => {
|
||||
const { issue, fakes } = setup();
|
||||
|
||||
issue.changeStatus(IssueStatus.IN_PROGRESS);
|
||||
expect(fakes.issues.changeStatus).not.toHaveBeenCalled();
|
||||
|
||||
issue.open('WEB-1');
|
||||
issue.changeStatus(IssueStatus.QA_FAILED, 'Falla en Safari');
|
||||
expect(fakes.issues.changeStatus).toHaveBeenCalledWith({
|
||||
issueId: 'i1',
|
||||
status: IssueStatus.QA_FAILED,
|
||||
reason: 'Falla en Safari',
|
||||
});
|
||||
});
|
||||
});
|
||||
101
libs/web/tracker/application/src/lib/facades/issue.facade.ts
Normal file
101
libs/web/tracker/application/src/lib/facades/issue.facade.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { inject, Injectable, Provider } from '@angular/core';
|
||||
import { injectDispatch } from '@ngrx/signals/events';
|
||||
import {
|
||||
IssueLink,
|
||||
IssueLinkType,
|
||||
IssueStatus,
|
||||
UpdateIssueInput,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { issuePageEvents } from '../events/issue.events';
|
||||
import { IssueStore } from '../stores/issue.store';
|
||||
|
||||
/**
|
||||
* One ticket for presenters: details, comments and history, live. Commands act
|
||||
* on the open ticket, so pages don't pass its id.
|
||||
*/
|
||||
@Injectable()
|
||||
export class IssueFacade {
|
||||
private readonly store = inject(IssueStore);
|
||||
private readonly dispatch = injectDispatch(issuePageEvents);
|
||||
|
||||
readonly issue = this.store.issue;
|
||||
readonly comments = this.store.comments;
|
||||
readonly history = this.store.history;
|
||||
readonly outwardLinks = this.store.outwardLinks;
|
||||
readonly inwardLinks = this.store.inwardLinks;
|
||||
readonly loading = this.store.loading;
|
||||
readonly error = this.store.error;
|
||||
/** Signed-in user's id (authors can edit their comments). */
|
||||
readonly me = this.store.me;
|
||||
|
||||
/** Loads the ticket (by id or key, e.g. BE-3) and follows its changes. */
|
||||
open(idOrKey: string): void {
|
||||
this.dispatch.opened({ idOrKey });
|
||||
}
|
||||
|
||||
/** QA_FAILED and CANCELLED need a reason. */
|
||||
changeStatus(status: IssueStatus, reason?: string | null): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.statusChangeRequested({ issueId, status, reason }),
|
||||
);
|
||||
}
|
||||
|
||||
assign(assigneeId: string | null): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.assignRequested({ issueId, assigneeId }),
|
||||
);
|
||||
}
|
||||
|
||||
update(changes: Omit<UpdateIssueInput, 'issueId'>): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.updateRequested({ issueId, ...changes }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Relates this ticket with another one by its key (e.g. BE-3). */
|
||||
link(type: IssueLinkType, targetKey: string): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.linkByKeyRequested({ issueId, type, targetKey }),
|
||||
);
|
||||
}
|
||||
|
||||
unlink(link: IssueLink): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.unlinkRequested({
|
||||
issueId,
|
||||
type: link.type,
|
||||
targetIssueId: link.issueId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setRelease(releaseId: string | null): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.releaseChangeRequested({ issueId, releaseId }),
|
||||
);
|
||||
}
|
||||
|
||||
addComment(body: string): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.commentAddRequested({ issueId, body }),
|
||||
);
|
||||
}
|
||||
|
||||
editComment(commentId: string, body: string): void {
|
||||
this.dispatch.commentEditRequested({ commentId, body });
|
||||
}
|
||||
|
||||
deleteComment(commentId: string): void {
|
||||
this.dispatch.commentDeleteRequested({ commentId });
|
||||
}
|
||||
|
||||
private withIssue(command: (issueId: string) => void): void {
|
||||
const issueId = this.store.issue()?.id;
|
||||
if (issueId) command(issueId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Store + facade of a ticket, for the issue route's component. */
|
||||
export function provideIssueFacade(): Provider[] {
|
||||
return [IssueStore, IssueFacade];
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { inject, Injectable, Provider } from '@angular/core';
|
||||
import { injectDispatch } from '@ngrx/signals/events';
|
||||
import {
|
||||
CreateReleaseInput,
|
||||
ProjectRole,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { projectPageEvents } from '../events/project.events';
|
||||
import { ProjectStore } from '../stores/project.store';
|
||||
|
||||
/**
|
||||
* The open project for presenters: details, members, releases, stats and live
|
||||
* activity. Commands act on the open project, so pages don't pass its id.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ProjectFacade {
|
||||
private readonly store = inject(ProjectStore);
|
||||
private readonly dispatch = injectDispatch(projectPageEvents);
|
||||
|
||||
readonly project = this.store.project;
|
||||
readonly members = this.store.members;
|
||||
readonly stats = this.store.stats;
|
||||
readonly releases = this.store.releases;
|
||||
readonly activity = this.store.activity;
|
||||
/** Pireon directory results for `searchUsers()`. */
|
||||
readonly userResults = this.store.userResults;
|
||||
readonly loading = this.store.loading;
|
||||
readonly error = this.store.error;
|
||||
readonly myRole = this.store.myRole;
|
||||
/** Owner or admin. */
|
||||
readonly canManage = this.store.canManage;
|
||||
/** Any role but viewer. */
|
||||
readonly canContribute = this.store.canContribute;
|
||||
|
||||
/** Loads the project (by id or key) and follows its changes. */
|
||||
open(idOrKey: string): void {
|
||||
this.dispatch.opened({ idOrKey });
|
||||
}
|
||||
|
||||
/** Searches Pireon's directory (from 2 characters, debounced). */
|
||||
searchUsers(term: string): void {
|
||||
const query = term.trim();
|
||||
if (query.length >= 2) this.dispatch.userSearchRequested(query);
|
||||
}
|
||||
|
||||
addMember(userId: string, role: ProjectRole = ProjectRole.MEMBER): void {
|
||||
const projectId = this.projectId();
|
||||
if (projectId)
|
||||
this.dispatch.memberAddRequested({ projectId, userId, role });
|
||||
}
|
||||
|
||||
changeMemberRole(userId: string, role: ProjectRole): void {
|
||||
const projectId = this.projectId();
|
||||
if (projectId)
|
||||
this.dispatch.memberRoleChangeRequested({ projectId, userId, role });
|
||||
}
|
||||
|
||||
removeMember(userId: string): void {
|
||||
const projectId = this.projectId();
|
||||
if (projectId) this.dispatch.memberRemoveRequested({ projectId, userId });
|
||||
}
|
||||
|
||||
createRelease(release: Omit<CreateReleaseInput, 'projectId'>): void {
|
||||
const projectId = this.projectId();
|
||||
if (projectId)
|
||||
this.dispatch.releaseCreateRequested({ projectId, ...release });
|
||||
}
|
||||
|
||||
/** `force` publishes even with unfinished tickets. */
|
||||
publishRelease(releaseId: string, force = false): void {
|
||||
this.dispatch.releasePublishRequested({ releaseId, force });
|
||||
}
|
||||
|
||||
archiveRelease(releaseId: string): void {
|
||||
this.dispatch.releaseArchiveRequested({ releaseId });
|
||||
}
|
||||
|
||||
private projectId(): string | null {
|
||||
return this.store.project()?.id ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Store + facade of the open project, for the project route's component. */
|
||||
export function provideProjectFacade(): Provider[] {
|
||||
return [ProjectStore, ProjectFacade];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { injectDispatch } from '@ngrx/signals/events';
|
||||
import { CreateProjectInput } from '@task-stream/web/tracker/domain';
|
||||
import { projectsPageEvents } from '../events/projects.events';
|
||||
import { ProjectsStore } from '../stores/projects.store';
|
||||
|
||||
/** "My projects" for presenters: state as signals, intentions as methods. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProjectsFacade {
|
||||
private readonly store = inject(ProjectsStore);
|
||||
private readonly dispatch = injectDispatch(projectsPageEvents);
|
||||
|
||||
readonly projects = this.store.projects;
|
||||
/** By name. */
|
||||
readonly sorted = this.store.sorted;
|
||||
readonly loading = this.store.loading;
|
||||
readonly creating = this.store.creating;
|
||||
readonly error = this.store.error;
|
||||
|
||||
/** Loads the list and starts following membership changes. */
|
||||
load(): void {
|
||||
this.dispatch.opened();
|
||||
}
|
||||
|
||||
/** On success, notifies and navigates to the new project. */
|
||||
create(input: CreateProjectInput): void {
|
||||
this.dispatch.createRequested(input);
|
||||
}
|
||||
}
|
||||
25
libs/web/tracker/application/src/lib/notifications.ts
Normal file
25
libs/web/tracker/application/src/lib/notifications.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/** Outbound port: tell the user how a command went (implemented with toasts by the app). */
|
||||
export abstract class Notifications {
|
||||
abstract success(message: string): void;
|
||||
abstract error(message: string): void;
|
||||
}
|
||||
|
||||
/** Message of a failed GraphQL/HTTP call, for the user. */
|
||||
export function describeError(error: unknown): string {
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'errors' in error &&
|
||||
Array.isArray((error as { errors: unknown[] }).errors)
|
||||
) {
|
||||
return (error as { errors: { message: string }[] }).errors
|
||||
.map((e) => e.message)
|
||||
.join('\n');
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/** The read model is eventually consistent: right after a command, a query may 404 for a few ms. */
|
||||
export function isNotFound(error: unknown): boolean {
|
||||
return /not found/i.test(describeError(error));
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
BoardColumn,
|
||||
IssueStatus,
|
||||
IssueSummary,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { applyIssueChange, moveIssue } from './board.reducers';
|
||||
|
||||
const issue = (
|
||||
id: string,
|
||||
status: IssueStatus,
|
||||
version = 1,
|
||||
assignee: string | null = null,
|
||||
): IssueSummary =>
|
||||
({
|
||||
id,
|
||||
status,
|
||||
version,
|
||||
key: id,
|
||||
projectId: 'p',
|
||||
title: id,
|
||||
assignee: assignee ? { id: assignee, name: assignee } : null,
|
||||
release: null,
|
||||
area: null,
|
||||
labels: [],
|
||||
commentCount: 0,
|
||||
}) as unknown as IssueSummary;
|
||||
|
||||
const board = (): BoardColumn[] => [
|
||||
{
|
||||
status: IssueStatus.TODO,
|
||||
total: 1,
|
||||
issues: [issue('a', IssueStatus.TODO)],
|
||||
},
|
||||
{ status: IssueStatus.IN_PROGRESS, total: 0, issues: [] },
|
||||
];
|
||||
|
||||
describe('board reducers', () => {
|
||||
it('moves a card optimistically', () => {
|
||||
const next = moveIssue(board(), 'a', IssueStatus.IN_PROGRESS);
|
||||
expect(next[0]).toMatchObject({ total: 0, issues: [] });
|
||||
expect(next[1].issues[0]).toMatchObject({
|
||||
id: 'a',
|
||||
status: IssueStatus.IN_PROGRESS,
|
||||
});
|
||||
expect(next[1].total).toBe(1);
|
||||
});
|
||||
|
||||
it('applies newer realtime versions and ignores stale ones', () => {
|
||||
const moved = applyIssueChange(
|
||||
board(),
|
||||
issue('a', IssueStatus.IN_PROGRESS, 2),
|
||||
null,
|
||||
);
|
||||
expect(moved[1].issues.map((i) => i.id)).toEqual(['a']);
|
||||
|
||||
const stale = applyIssueChange(
|
||||
moved,
|
||||
issue('a', IssueStatus.TODO, 1),
|
||||
null,
|
||||
);
|
||||
expect(stale).toBe(moved);
|
||||
});
|
||||
|
||||
it('drops issues that no longer match the filter', () => {
|
||||
const next = applyIssueChange(
|
||||
board(),
|
||||
issue('a', IssueStatus.TODO, 2, 'bob'),
|
||||
{ projectId: 'p', assigneeId: 'ana' },
|
||||
);
|
||||
expect(next[0].issues).toEqual([]);
|
||||
});
|
||||
|
||||
it('adds new issues from other users', () => {
|
||||
const next = applyIssueChange(
|
||||
board(),
|
||||
issue('b', IssueStatus.TODO, 1),
|
||||
null,
|
||||
);
|
||||
expect(next[0].issues.map((i) => i.id)).toEqual(['b', 'a']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
BoardColumn,
|
||||
BoardFilter,
|
||||
IssueStatus,
|
||||
IssueSummary,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
|
||||
/** Pure board transformations (unit tested). */
|
||||
|
||||
export function findIssue(
|
||||
columns: BoardColumn[],
|
||||
issueId: string,
|
||||
): IssueSummary | undefined {
|
||||
for (const column of columns) {
|
||||
const issue = column.issues.find((i) => i.id === issueId);
|
||||
if (issue) return issue;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function matchesFilter(
|
||||
issue: IssueSummary,
|
||||
filter: BoardFilter | null,
|
||||
): boolean {
|
||||
if (!filter) return true;
|
||||
if (filter.assigneeId && issue.assignee?.id !== filter.assigneeId)
|
||||
return false;
|
||||
if (filter.releaseId && issue.release?.id !== filter.releaseId) return false;
|
||||
if (filter.area && issue.area !== filter.area) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Removes the issue wherever it is and puts `next` (if any) on top of its status column. */
|
||||
export function placeIssue(
|
||||
columns: BoardColumn[],
|
||||
issueId: string,
|
||||
next: IssueSummary | null,
|
||||
): BoardColumn[] {
|
||||
return columns.map((column) => {
|
||||
const had = column.issues.some((i) => i.id === issueId);
|
||||
let issues = had
|
||||
? column.issues.filter((i) => i.id !== issueId)
|
||||
: column.issues;
|
||||
let total = column.total - (had ? 1 : 0);
|
||||
if (next && next.status === column.status) {
|
||||
issues = [next, ...issues];
|
||||
total += 1;
|
||||
}
|
||||
return had || issues !== column.issues
|
||||
? { ...column, issues, total }
|
||||
: column;
|
||||
});
|
||||
}
|
||||
|
||||
/** Optimistic move. */
|
||||
export function moveIssue(
|
||||
columns: BoardColumn[],
|
||||
issueId: string,
|
||||
to: IssueStatus,
|
||||
): BoardColumn[] {
|
||||
const issue = findIssue(columns, issueId);
|
||||
return issue
|
||||
? placeIssue(columns, issueId, { ...issue, status: to })
|
||||
: columns;
|
||||
}
|
||||
|
||||
/** Merges a realtime change, ignoring stale versions and issues outside the filter. */
|
||||
export function applyIssueChange(
|
||||
columns: BoardColumn[],
|
||||
issue: IssueSummary,
|
||||
filter: BoardFilter | null,
|
||||
): BoardColumn[] {
|
||||
const current = findIssue(columns, issue.id);
|
||||
if (current && current.version > issue.version) return columns;
|
||||
return placeIssue(
|
||||
columns,
|
||||
issue.id,
|
||||
matchesFilter(issue, filter) ? issue : null,
|
||||
);
|
||||
}
|
||||
155
libs/web/tracker/application/src/lib/stores/board.store.ts
Normal file
155
libs/web/tracker/application/src/lib/stores/board.store.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { computed, inject } from '@angular/core';
|
||||
import { mapResponse } from '@ngrx/operators';
|
||||
import { signalStore, withComputed, withState } from '@ngrx/signals';
|
||||
import {
|
||||
Events,
|
||||
on,
|
||||
withEventHandlers,
|
||||
withReducer,
|
||||
} from '@ngrx/signals/events';
|
||||
import {
|
||||
BoardColumn,
|
||||
BoardFilter,
|
||||
IssuesGateway,
|
||||
TrackerRealtimeGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import {
|
||||
distinctUntilChanged,
|
||||
exhaustMap,
|
||||
map,
|
||||
merge,
|
||||
mergeMap,
|
||||
switchMap,
|
||||
tap,
|
||||
} from 'rxjs';
|
||||
import {
|
||||
boardApiEvents,
|
||||
boardPageEvents,
|
||||
boardRealtimeEvents,
|
||||
} from '../events/board.events';
|
||||
import { describeError, Notifications } from '../notifications';
|
||||
import { applyIssueChange, moveIssue } from './board.reducers';
|
||||
|
||||
interface BoardState {
|
||||
filter: BoardFilter | null;
|
||||
columns: BoardColumn[];
|
||||
loading: boolean;
|
||||
creating: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** Kanban board of a project, live. Scoped to the board route. */
|
||||
export const BoardStore = signalStore(
|
||||
withState<BoardState>({
|
||||
filter: null,
|
||||
columns: [],
|
||||
loading: false,
|
||||
creating: false,
|
||||
error: null,
|
||||
}),
|
||||
withComputed(({ columns }) => ({
|
||||
total: computed(() => columns().reduce((sum, c) => sum + c.total, 0)),
|
||||
})),
|
||||
withReducer(
|
||||
on(
|
||||
boardPageEvents.opened,
|
||||
boardPageEvents.filterChanged,
|
||||
({ payload }) => ({ filter: payload, loading: true, error: null }),
|
||||
),
|
||||
on(boardApiEvents.loaded, ({ payload }) => ({
|
||||
columns: payload,
|
||||
loading: false,
|
||||
})),
|
||||
on(boardApiEvents.loadFailed, ({ payload }) => ({
|
||||
loading: false,
|
||||
error: payload,
|
||||
})),
|
||||
on(boardPageEvents.issueMoved, ({ payload }, state) => ({
|
||||
columns: moveIssue(state.columns, payload.issueId, payload.to),
|
||||
})),
|
||||
on(boardRealtimeEvents.issueChanged, ({ payload }, state) => ({
|
||||
columns: applyIssueChange(state.columns, payload.issue, state.filter),
|
||||
})),
|
||||
on(boardPageEvents.issueCreateRequested, () => ({ creating: true })),
|
||||
on(boardApiEvents.issueCreated, boardApiEvents.issueCreateFailed, () => ({
|
||||
creating: false,
|
||||
})),
|
||||
),
|
||||
withEventHandlers(
|
||||
(
|
||||
store,
|
||||
events = inject(Events),
|
||||
issues = inject(IssuesGateway),
|
||||
realtime = inject(TrackerRealtimeGateway),
|
||||
notifications = inject(Notifications),
|
||||
) => {
|
||||
const load = (filter: BoardFilter) =>
|
||||
issues.board(filter).pipe(
|
||||
mapResponse({
|
||||
next: (columns) => boardApiEvents.loaded(columns),
|
||||
error: (error) => boardApiEvents.loadFailed(describeError(error)),
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
load$: events
|
||||
.on(boardPageEvents.opened, boardPageEvents.filterChanged)
|
||||
.pipe(switchMap(({ payload }) => load(payload))),
|
||||
live$: events.on(boardPageEvents.opened).pipe(
|
||||
map(({ payload }) => payload.projectId),
|
||||
distinctUntilChanged(),
|
||||
switchMap((projectId) => realtime.issueChanges(projectId)),
|
||||
map((change) => boardRealtimeEvents.issueChanged(change)),
|
||||
),
|
||||
move$: events.on(boardPageEvents.issueMoved).pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
issues
|
||||
.changeStatus({
|
||||
issueId: payload.issueId,
|
||||
status: payload.to,
|
||||
reason: payload.reason,
|
||||
})
|
||||
.pipe(
|
||||
mapResponse({
|
||||
next: () => null,
|
||||
error: (error) =>
|
||||
boardApiEvents.issueMoveFailed(describeError(error)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
/** The API refused the move: tell the user and put the card back. */
|
||||
rollback$: events.on(boardApiEvents.issueMoveFailed).pipe(
|
||||
tap(({ payload }) => notifications.error(payload)),
|
||||
switchMap(() => {
|
||||
const filter = store.filter();
|
||||
return filter ? load(filter) : [];
|
||||
}),
|
||||
),
|
||||
create$: events.on(boardPageEvents.issueCreateRequested).pipe(
|
||||
exhaustMap(({ payload }) =>
|
||||
issues.create(payload).pipe(
|
||||
mapResponse({
|
||||
next: (result) => boardApiEvents.issueCreated(result),
|
||||
error: (error) =>
|
||||
boardApiEvents.issueCreateFailed(describeError(error)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
feedback$: merge(
|
||||
events
|
||||
.on(boardApiEvents.issueCreated)
|
||||
.pipe(
|
||||
tap(({ payload }) =>
|
||||
notifications.success(`${payload.key} creado`),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(boardApiEvents.issueCreateFailed, boardApiEvents.loadFailed)
|
||||
.pipe(tap(({ payload }) => notifications.error(payload))),
|
||||
),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
21
libs/web/tracker/application/src/lib/stores/command.ts
Normal file
21
libs/web/tracker/application/src/lib/stores/command.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { mapResponse } from '@ngrx/operators';
|
||||
import { EventInstance } from '@ngrx/signals/events';
|
||||
import { Observable } from 'rxjs';
|
||||
import { describeError } from '../notifications';
|
||||
|
||||
type Creator = (message: string) => EventInstance<string, string>;
|
||||
|
||||
/** Runs a command and turns its outcome into a succeeded/failed event. */
|
||||
export function runCommand<T>(
|
||||
command$: Observable<T>,
|
||||
successMessage: string,
|
||||
succeeded: Creator,
|
||||
failed: Creator,
|
||||
) {
|
||||
return command$.pipe(
|
||||
mapResponse({
|
||||
next: () => succeeded(successMessage),
|
||||
error: (error) => failed(describeError(error)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
313
libs/web/tracker/application/src/lib/stores/issue.store.ts
Normal file
313
libs/web/tracker/application/src/lib/stores/issue.store.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { computed, inject } from '@angular/core';
|
||||
import { mapResponse } from '@ngrx/operators';
|
||||
import { signalStore, withComputed, withState } from '@ngrx/signals';
|
||||
import {
|
||||
Events,
|
||||
on,
|
||||
withEventHandlers,
|
||||
withReducer,
|
||||
} from '@ngrx/signals/events';
|
||||
import { AuthFacade } from '@task-stream/web/identity/application';
|
||||
import {
|
||||
Activity,
|
||||
ActivityGateway,
|
||||
Comment,
|
||||
CommentsGateway,
|
||||
Issue,
|
||||
IssuesGateway,
|
||||
TrackerRealtimeGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import {
|
||||
debounceTime,
|
||||
filter,
|
||||
map,
|
||||
merge,
|
||||
mergeMap,
|
||||
retry,
|
||||
switchMap,
|
||||
tap,
|
||||
throwError,
|
||||
timer,
|
||||
} from 'rxjs';
|
||||
import {
|
||||
issueApiEvents,
|
||||
issuePageEvents,
|
||||
issueRealtimeEvents,
|
||||
} from '../events/issue.events';
|
||||
import { describeError, isNotFound, Notifications } from '../notifications';
|
||||
import { runCommand } from './command';
|
||||
|
||||
interface IssueState {
|
||||
issue: Issue | null;
|
||||
comments: Comment[];
|
||||
history: Activity[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** One ticket: details, comments and history, live. Scoped to the issue view. */
|
||||
export const IssueStore = signalStore(
|
||||
withState<IssueState>({
|
||||
issue: null,
|
||||
comments: [],
|
||||
history: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
withComputed(({ issue }, auth = inject(AuthFacade)) => ({
|
||||
me: computed(() => auth.user()?.id ?? null),
|
||||
outwardLinks: computed(
|
||||
() => issue()?.links.filter((l) => l.direction === 'OUTWARD') ?? [],
|
||||
),
|
||||
inwardLinks: computed(
|
||||
() => issue()?.links.filter((l) => l.direction === 'INWARD') ?? [],
|
||||
),
|
||||
})),
|
||||
withReducer(
|
||||
on(issuePageEvents.opened, () => ({
|
||||
loading: true,
|
||||
error: null,
|
||||
issue: null,
|
||||
comments: [],
|
||||
history: [],
|
||||
})),
|
||||
on(issueApiEvents.loaded, ({ payload }) => ({
|
||||
issue: payload,
|
||||
loading: false,
|
||||
})),
|
||||
on(issueApiEvents.loadFailed, ({ payload }) => ({
|
||||
loading: false,
|
||||
error: payload,
|
||||
})),
|
||||
on(issueApiEvents.commentsLoaded, ({ payload }) => ({ comments: payload })),
|
||||
on(issueApiEvents.historyLoaded, ({ payload }) => ({ history: payload })),
|
||||
on(issueRealtimeEvents.commentChanged, ({ payload }, state) => {
|
||||
const others = state.comments.filter((c) => c.id !== payload.commentId);
|
||||
if (!payload.comment) return { comments: others };
|
||||
return {
|
||||
comments: [...others, payload.comment].sort((a, b) =>
|
||||
a.createdAt.localeCompare(b.createdAt),
|
||||
),
|
||||
};
|
||||
}),
|
||||
),
|
||||
withEventHandlers(
|
||||
(
|
||||
store,
|
||||
events = inject(Events),
|
||||
issues = inject(IssuesGateway),
|
||||
comments = inject(CommentsGateway),
|
||||
activity = inject(ActivityGateway),
|
||||
realtime = inject(TrackerRealtimeGateway),
|
||||
notifications = inject(Notifications),
|
||||
) => {
|
||||
const fetchIssue = (idOrKey: string) =>
|
||||
issues.issue(idOrKey).pipe(
|
||||
retry({
|
||||
count: 5,
|
||||
delay: (error) =>
|
||||
isNotFound(error) ? timer(300) : throwError(() => error),
|
||||
}),
|
||||
mapResponse({
|
||||
next: (issue) => issueApiEvents.loaded(issue),
|
||||
error: (error) => issueApiEvents.loadFailed(describeError(error)),
|
||||
}),
|
||||
);
|
||||
const loaded$ = events.on(issueApiEvents.loaded);
|
||||
const done = (message: string) =>
|
||||
issueApiEvents.commandSucceeded(message);
|
||||
const failed = issueApiEvents.commandFailed;
|
||||
|
||||
return {
|
||||
load$: events
|
||||
.on(issuePageEvents.opened)
|
||||
.pipe(switchMap(({ payload }) => fetchIssue(payload.idOrKey))),
|
||||
comments$: events.on(issuePageEvents.opened).pipe(
|
||||
switchMap(() => loaded$.pipe(map(({ payload }) => payload.id))),
|
||||
filter((id, index) => index === 0 || id !== store.issue()?.id),
|
||||
switchMap((issueId) =>
|
||||
comments
|
||||
.list(issueId)
|
||||
.pipe(map((list) => issueApiEvents.commentsLoaded(list))),
|
||||
),
|
||||
),
|
||||
history$: loaded$.pipe(
|
||||
debounceTime(100),
|
||||
switchMap(({ payload }) =>
|
||||
activity
|
||||
.list(payload.projectId, payload.id)
|
||||
.pipe(map((list) => issueApiEvents.historyLoaded(list))),
|
||||
),
|
||||
),
|
||||
/** Any change to this ticket by anyone: refetch the full details. */
|
||||
liveIssue$: events.on(issuePageEvents.opened).pipe(
|
||||
switchMap(() => loaded$.pipe(map(({ payload }) => payload))),
|
||||
filter(
|
||||
(issue, index) => index === 0 || issue.id !== store.issue()?.id,
|
||||
),
|
||||
switchMap((issue) =>
|
||||
realtime.issueChanges(issue.projectId).pipe(
|
||||
filter(
|
||||
(change) =>
|
||||
change.issue.id === issue.id &&
|
||||
change.version > (store.issue()?.version ?? 0),
|
||||
),
|
||||
map((change) =>
|
||||
issueRealtimeEvents.issueTouched({
|
||||
issueId: issue.id,
|
||||
version: change.version,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
refetch$: events.on(issueRealtimeEvents.issueTouched).pipe(
|
||||
debounceTime(150),
|
||||
switchMap(({ payload }) => fetchIssue(payload.issueId)),
|
||||
),
|
||||
liveComments$: events.on(issuePageEvents.opened).pipe(
|
||||
switchMap(() => loaded$.pipe(map(({ payload }) => payload.id))),
|
||||
filter((id, index) => index === 0 || id !== store.issue()?.id),
|
||||
switchMap((issueId) => realtime.commentChanges(issueId)),
|
||||
map((change) => issueRealtimeEvents.commentChanged(change)),
|
||||
),
|
||||
commands$: merge(
|
||||
events
|
||||
.on(issuePageEvents.statusChangeRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
issues.changeStatus(payload),
|
||||
'Estado actualizado',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.assignRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
issues.assign(payload.issueId, payload.assigneeId),
|
||||
payload.assigneeId ? 'Asignado' : 'Sin asignar',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.updateRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
issues.update(payload),
|
||||
'Ticket actualizado',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.linkRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
issues.link(payload),
|
||||
'Relación añadida',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events.on(issuePageEvents.linkByKeyRequested).pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
issues.issue(payload.targetKey.trim().toUpperCase()).pipe(
|
||||
switchMap((target) =>
|
||||
issues.link({
|
||||
issueId: payload.issueId,
|
||||
type: payload.type,
|
||||
targetIssueId: target.id,
|
||||
}),
|
||||
),
|
||||
),
|
||||
`Relacionado con ${payload.targetKey.toUpperCase()}`,
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.unlinkRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
issues.unlink(payload),
|
||||
'Relación eliminada',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.releaseChangeRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
issues.setRelease(payload.issueId, payload.releaseId),
|
||||
'Release actualizada',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.commentAddRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
comments.add(payload.issueId, payload.body),
|
||||
'Comentario añadido',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.commentEditRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
comments.edit(payload.commentId, payload.body),
|
||||
'Comentario editado',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.commentDeleteRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
comments.remove(payload.commentId),
|
||||
'Comentario eliminado',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
feedback$: merge(
|
||||
events
|
||||
.on(issueApiEvents.commandSucceeded)
|
||||
.pipe(tap(({ payload }) => notifications.success(payload))),
|
||||
events
|
||||
.on(issueApiEvents.commandFailed, issueApiEvents.loadFailed)
|
||||
.pipe(tap(({ payload }) => notifications.error(payload))),
|
||||
),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
304
libs/web/tracker/application/src/lib/stores/project.store.ts
Normal file
304
libs/web/tracker/application/src/lib/stores/project.store.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { computed, inject } from '@angular/core';
|
||||
import { mapResponse } from '@ngrx/operators';
|
||||
import { signalStore, withComputed, withState } from '@ngrx/signals';
|
||||
import {
|
||||
Events,
|
||||
on,
|
||||
withEventHandlers,
|
||||
withReducer,
|
||||
} from '@ngrx/signals/events';
|
||||
import { AuthFacade } from '@task-stream/web/identity/application';
|
||||
import {
|
||||
Activity,
|
||||
ActivityGateway,
|
||||
Project,
|
||||
ProjectRole,
|
||||
ProjectsGateway,
|
||||
ProjectStats,
|
||||
Release,
|
||||
ReleasesGateway,
|
||||
TrackerRealtimeGateway,
|
||||
UserDirectoryEntry,
|
||||
UsersGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import {
|
||||
debounceTime,
|
||||
map,
|
||||
merge,
|
||||
mergeMap,
|
||||
retry,
|
||||
switchMap,
|
||||
tap,
|
||||
throwError,
|
||||
timer,
|
||||
} from 'rxjs';
|
||||
import {
|
||||
projectApiEvents,
|
||||
projectPageEvents,
|
||||
projectRealtimeEvents,
|
||||
} from '../events/project.events';
|
||||
import { describeError, isNotFound, Notifications } from '../notifications';
|
||||
import { runCommand } from './command';
|
||||
|
||||
interface ProjectState {
|
||||
project: Project | null;
|
||||
stats: ProjectStats | null;
|
||||
releases: Release[];
|
||||
activity: Activity[];
|
||||
userResults: UserDirectoryEntry[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** The open project: details, members, releases, stats and live activity. Scoped to the project route. */
|
||||
export const ProjectStore = signalStore(
|
||||
withState<ProjectState>({
|
||||
project: null,
|
||||
stats: null,
|
||||
releases: [],
|
||||
activity: [],
|
||||
userResults: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
withComputed(({ project }, auth = inject(AuthFacade)) => {
|
||||
const myRole = computed(
|
||||
() =>
|
||||
project()?.members.find((m) => m.user.id === auth.user()?.id)?.role ??
|
||||
null,
|
||||
);
|
||||
return {
|
||||
myRole,
|
||||
canManage: computed(
|
||||
() => myRole() === ProjectRole.OWNER || myRole() === ProjectRole.ADMIN,
|
||||
),
|
||||
canContribute: computed(
|
||||
() => !!myRole() && myRole() !== ProjectRole.VIEWER,
|
||||
),
|
||||
members: computed(() => project()?.members ?? []),
|
||||
};
|
||||
}),
|
||||
withReducer(
|
||||
on(projectPageEvents.opened, () => ({
|
||||
loading: true,
|
||||
error: null,
|
||||
project: null,
|
||||
stats: null,
|
||||
releases: [],
|
||||
activity: [],
|
||||
})),
|
||||
on(
|
||||
projectApiEvents.loaded,
|
||||
projectRealtimeEvents.projectChanged,
|
||||
({ payload }) => ({ project: payload, loading: false }),
|
||||
),
|
||||
on(projectApiEvents.loadFailed, ({ payload }) => ({
|
||||
loading: false,
|
||||
error: payload,
|
||||
})),
|
||||
on(projectApiEvents.statsLoaded, ({ payload }) => ({ stats: payload })),
|
||||
on(projectApiEvents.releasesLoaded, ({ payload }) => ({
|
||||
releases: payload,
|
||||
})),
|
||||
on(projectApiEvents.activityLoaded, ({ payload }) => ({
|
||||
activity: payload,
|
||||
})),
|
||||
on(projectApiEvents.usersFound, ({ payload }) => ({
|
||||
userResults: payload,
|
||||
})),
|
||||
on(projectRealtimeEvents.activityAdded, ({ payload }, state) => ({
|
||||
activity: [
|
||||
payload,
|
||||
...state.activity.filter((a) => a.id !== payload.id),
|
||||
].slice(0, 100),
|
||||
})),
|
||||
),
|
||||
withEventHandlers(
|
||||
(
|
||||
store,
|
||||
events = inject(Events),
|
||||
projects = inject(ProjectsGateway),
|
||||
releases = inject(ReleasesGateway),
|
||||
activity = inject(ActivityGateway),
|
||||
realtime = inject(TrackerRealtimeGateway),
|
||||
notifications = inject(Notifications),
|
||||
users = inject(UsersGateway),
|
||||
) => {
|
||||
const loaded$ = events.on(projectApiEvents.loaded);
|
||||
const reloadReleases = (projectId: string) =>
|
||||
releases.list(projectId).pipe(
|
||||
mapResponse({
|
||||
next: (list) => projectApiEvents.releasesLoaded(list),
|
||||
error: (error) =>
|
||||
projectApiEvents.commandFailed(describeError(error)),
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
load$: events.on(projectPageEvents.opened).pipe(
|
||||
switchMap(({ payload }) =>
|
||||
projects.project(payload.idOrKey).pipe(
|
||||
// Just created: the read model may be a few ms behind.
|
||||
retry({
|
||||
count: 5,
|
||||
delay: (error) =>
|
||||
isNotFound(error) ? timer(300) : throwError(() => error),
|
||||
}),
|
||||
mapResponse({
|
||||
next: (project) => projectApiEvents.loaded(project),
|
||||
error: (error) =>
|
||||
projectApiEvents.loadFailed(describeError(error)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
details$: loaded$.pipe(
|
||||
switchMap(({ payload }) =>
|
||||
merge(
|
||||
projects
|
||||
.stats(payload.id)
|
||||
.pipe(map((stats) => projectApiEvents.statsLoaded(stats))),
|
||||
reloadReleases(payload.id),
|
||||
activity
|
||||
.list(payload.id)
|
||||
.pipe(map((list) => projectApiEvents.activityLoaded(list))),
|
||||
),
|
||||
),
|
||||
),
|
||||
live$: loaded$.pipe(
|
||||
switchMap(({ payload }) =>
|
||||
merge(
|
||||
realtime
|
||||
.projectChanges(payload.id)
|
||||
.pipe(map((p) => projectRealtimeEvents.projectChanged(p))),
|
||||
realtime
|
||||
.activity(payload.id)
|
||||
.pipe(map((a) => projectRealtimeEvents.activityAdded(a))),
|
||||
realtime
|
||||
.releaseChanges(payload.id)
|
||||
.pipe(map((r) => projectRealtimeEvents.releaseChanged(r))),
|
||||
),
|
||||
),
|
||||
),
|
||||
/** Activity means tickets moved: refresh the dashboard (debounced). */
|
||||
refreshStats$: events.on(projectRealtimeEvents.activityAdded).pipe(
|
||||
debounceTime(800),
|
||||
switchMap(() => {
|
||||
const id = store.project()?.id;
|
||||
return id
|
||||
? projects
|
||||
.stats(id)
|
||||
.pipe(map((stats) => projectApiEvents.statsLoaded(stats)))
|
||||
: [];
|
||||
}),
|
||||
),
|
||||
/** Progress is computed by the API: refetch the list when a release or its tickets change. */
|
||||
refreshReleases$: merge(
|
||||
events.on(projectRealtimeEvents.releaseChanged),
|
||||
events
|
||||
.on(projectRealtimeEvents.activityAdded)
|
||||
.pipe(debounceTime(800)),
|
||||
).pipe(
|
||||
switchMap(() => {
|
||||
const id = store.project()?.id;
|
||||
return id ? reloadReleases(id) : [];
|
||||
}),
|
||||
),
|
||||
/** Pireon directory search (to add members). */
|
||||
searchUsers$: events.on(projectPageEvents.userSearchRequested).pipe(
|
||||
debounceTime(250),
|
||||
switchMap(({ payload }) =>
|
||||
users.search(payload).pipe(
|
||||
mapResponse({
|
||||
next: (list) => projectApiEvents.usersFound(list),
|
||||
error: () => projectApiEvents.usersFound([]),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
commands$: merge(
|
||||
events
|
||||
.on(projectPageEvents.memberAddRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
projects.addMember(payload),
|
||||
'Miembro añadido',
|
||||
projectApiEvents.commandSucceeded,
|
||||
projectApiEvents.commandFailed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(projectPageEvents.memberRoleChangeRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
projects.changeMemberRole(payload),
|
||||
'Rol actualizado',
|
||||
projectApiEvents.commandSucceeded,
|
||||
projectApiEvents.commandFailed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(projectPageEvents.memberRemoveRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
projects.removeMember(payload.projectId, payload.userId),
|
||||
'Miembro eliminado',
|
||||
projectApiEvents.commandSucceeded,
|
||||
projectApiEvents.commandFailed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(projectPageEvents.releaseCreateRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
releases.create(payload),
|
||||
`Release ${payload.name} creada`,
|
||||
projectApiEvents.commandSucceeded,
|
||||
projectApiEvents.commandFailed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(projectPageEvents.releasePublishRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
releases.publish(payload.releaseId, payload.force),
|
||||
'Release publicada 🚀',
|
||||
projectApiEvents.commandSucceeded,
|
||||
projectApiEvents.commandFailed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(projectPageEvents.releaseArchiveRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
releases.archive(payload.releaseId),
|
||||
'Release archivada',
|
||||
projectApiEvents.commandSucceeded,
|
||||
projectApiEvents.commandFailed,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
feedback$: merge(
|
||||
events
|
||||
.on(projectApiEvents.commandSucceeded)
|
||||
.pipe(tap(({ payload }) => notifications.success(payload))),
|
||||
events
|
||||
.on(projectApiEvents.commandFailed)
|
||||
.pipe(tap(({ payload }) => notifications.error(payload))),
|
||||
),
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
122
libs/web/tracker/application/src/lib/stores/projects.store.ts
Normal file
122
libs/web/tracker/application/src/lib/stores/projects.store.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { computed, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { mapResponse } from '@ngrx/operators';
|
||||
import { signalStore, withComputed, withState } from '@ngrx/signals';
|
||||
import {
|
||||
Events,
|
||||
on,
|
||||
withEventHandlers,
|
||||
withReducer,
|
||||
} from '@ngrx/signals/events';
|
||||
import { AuthFacade } from '@task-stream/web/identity/application';
|
||||
import {
|
||||
Project,
|
||||
ProjectsGateway,
|
||||
TrackerRealtimeGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { exhaustMap, map, switchMap, tap } from 'rxjs';
|
||||
import {
|
||||
projectsApiEvents,
|
||||
projectsPageEvents,
|
||||
projectsRealtimeEvents,
|
||||
} from '../events/projects.events';
|
||||
import { describeError, Notifications } from '../notifications';
|
||||
|
||||
interface ProjectsState {
|
||||
projects: Project[];
|
||||
loading: boolean;
|
||||
creating: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** "My projects": the list plus live membership changes. */
|
||||
export const ProjectsStore = signalStore(
|
||||
{ providedIn: 'root' },
|
||||
withState<ProjectsState>({
|
||||
projects: [],
|
||||
loading: false,
|
||||
creating: false,
|
||||
error: null,
|
||||
}),
|
||||
withComputed(({ projects }) => ({
|
||||
sorted: computed(() =>
|
||||
[...projects()].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
),
|
||||
})),
|
||||
withReducer(
|
||||
on(projectsPageEvents.opened, () => ({ loading: true, error: null })),
|
||||
on(projectsApiEvents.loaded, ({ payload }) => ({
|
||||
projects: payload,
|
||||
loading: false,
|
||||
})),
|
||||
on(projectsApiEvents.loadFailed, ({ payload }) => ({
|
||||
loading: false,
|
||||
error: payload,
|
||||
})),
|
||||
on(projectsPageEvents.createRequested, () => ({ creating: true })),
|
||||
on(projectsApiEvents.created, projectsApiEvents.createFailed, () => ({
|
||||
creating: false,
|
||||
})),
|
||||
on(projectsRealtimeEvents.projectChanged, ({ payload }, state) => {
|
||||
const others = state.projects.filter((p) => p.id !== payload.project.id);
|
||||
return {
|
||||
projects: payload.stillMember ? [...others, payload.project] : others,
|
||||
};
|
||||
}),
|
||||
),
|
||||
withEventHandlers(
|
||||
(
|
||||
_store,
|
||||
events = inject(Events),
|
||||
projects = inject(ProjectsGateway),
|
||||
realtime = inject(TrackerRealtimeGateway),
|
||||
notifications = inject(Notifications),
|
||||
router = inject(Router),
|
||||
auth = inject(AuthFacade),
|
||||
) => ({
|
||||
load$: events.on(projectsPageEvents.opened).pipe(
|
||||
switchMap(() =>
|
||||
projects.myProjects().pipe(
|
||||
mapResponse({
|
||||
next: (list) => projectsApiEvents.loaded(list),
|
||||
error: (error) =>
|
||||
projectsApiEvents.loadFailed(describeError(error)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
/** One live subscription for the whole session. */
|
||||
live$: events.on(projectsPageEvents.opened).pipe(
|
||||
exhaustMap(() => realtime.myProjectsChanges()),
|
||||
map((project) =>
|
||||
projectsRealtimeEvents.projectChanged({
|
||||
project,
|
||||
stillMember: project.members.some(
|
||||
(m) => m.user.id === auth.user()?.id,
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
create$: events.on(projectsPageEvents.createRequested).pipe(
|
||||
exhaustMap(({ payload }) =>
|
||||
projects.create(payload).pipe(
|
||||
mapResponse({
|
||||
next: (result) => projectsApiEvents.created(result),
|
||||
error: (error) =>
|
||||
projectsApiEvents.createFailed(describeError(error)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
createdFeedback$: events.on(projectsApiEvents.created).pipe(
|
||||
tap(({ payload }) => {
|
||||
notifications.success(`Proyecto ${payload.key} creado`);
|
||||
void router.navigate(['/p', payload.key]);
|
||||
}),
|
||||
),
|
||||
failureFeedback$: events
|
||||
.on(projectsApiEvents.createFailed, projectsApiEvents.loadFailed)
|
||||
.pipe(tap(({ payload }) => notifications.error(payload))),
|
||||
}),
|
||||
),
|
||||
);
|
||||
5
libs/web/tracker/application/src/test-setup.ts
Normal file
5
libs/web/tracker/application/src/test-setup.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import '@angular/compiler';
|
||||
import '@analogjs/vitest-angular/setup-snapshots';
|
||||
import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed';
|
||||
|
||||
setupTestBed();
|
||||
30
libs/web/tracker/application/tsconfig.json
Normal file
30
libs/web/tracker/application/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"target": "es2022",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
26
libs/web/tracker/application/tsconfig.lib.json
Normal file
26
libs/web/tracker/application/tsconfig.lib.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts",
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx",
|
||||
"src/test-setup.ts"
|
||||
]
|
||||
}
|
||||
29
libs/web/tracker/application/tsconfig.spec.json
Normal file
29
libs/web/tracker/application/tsconfig.spec.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"vitest/importMeta",
|
||||
"vite/client",
|
||||
"node",
|
||||
"vitest"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx",
|
||||
"src/**/*.d.ts"
|
||||
],
|
||||
"files": ["src/test-setup.ts"]
|
||||
}
|
||||
29
libs/web/tracker/application/vite.config.mts
Normal file
29
libs/web/tracker/application/vite.config.mts
Normal file
@@ -0,0 +1,29 @@
|
||||
/// <reference types='vitest' />
|
||||
import { defineConfig } from 'vite';
|
||||
import angular from '@analogjs/vite-plugin-angular';
|
||||
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
|
||||
|
||||
export default defineConfig(() => ({
|
||||
root: import.meta.dirname,
|
||||
cacheDir: '../../../../node_modules/.vite/libs/web/tracker/application',
|
||||
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||
// Uncomment this if you are using workers.
|
||||
// worker: {
|
||||
// plugins: () => [ nxViteTsPaths() ],
|
||||
// },
|
||||
test: {
|
||||
name: 'web-tracker-application',
|
||||
watch: false,
|
||||
passWithNoTests: true,
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
setupFiles: ['src/test-setup.ts'],
|
||||
reporters: ['default'],
|
||||
coverage: {
|
||||
reportsDirectory: '../../../../coverage/libs/web/tracker/application',
|
||||
provider: 'v8' as const,
|
||||
},
|
||||
},
|
||||
}));
|
||||
21
libs/web/tracker/domain/README.md
Normal file
21
libs/web/tracker/domain/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# web-tracker-domain
|
||||
|
||||
Modelo del tracker en el cliente.
|
||||
|
||||
- **Import**: `@task-stream/web/tracker/domain`
|
||||
- **Capa**: `domain` · **Tags**: `scope:web-tracker`, `type:domain`
|
||||
|
||||
## Contenido
|
||||
|
||||
- Modelos (`Project`, `IssueSummary`, `Issue`, `Release`…), enums e inputs.
|
||||
- Workflow (`REASON_REQUIRED`, `BOARD_COLUMNS`).
|
||||
- Puertos: `ProjectsGateway`, `IssuesGateway`, `CommentsGateway`, `ReleasesGateway`, `ActivityGateway`, `UsersGateway`, `TrackerRealtimeGateway`.
|
||||
|
||||
## Comandos
|
||||
|
||||
```sh
|
||||
npx nx lint web-tracker-domain
|
||||
npx nx test web-tracker-domain # Jest
|
||||
```
|
||||
|
||||
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).
|
||||
3
libs/web/tracker/domain/eslint.config.mjs
Normal file
3
libs/web/tracker/domain/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from '../../../../eslint.config.mjs';
|
||||
|
||||
export default [...baseConfig];
|
||||
11
libs/web/tracker/domain/jest.config.cts
Normal file
11
libs/web/tracker/domain/jest.config.cts
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
displayName: 'web-tracker-domain',
|
||||
preset: '../../../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
passWithNoTests: true,
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../../../coverage/libs/web/tracker/domain',
|
||||
};
|
||||
9
libs/web/tracker/domain/project.json
Normal file
9
libs/web/tracker/domain/project.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "web-tracker-domain",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/web/tracker/domain/src",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:web-tracker", "type:domain"],
|
||||
"// targets": "to see all targets run: nx show project web-tracker-domain --web",
|
||||
"targets": {}
|
||||
}
|
||||
5
libs/web/tracker/domain/src/index.ts
Normal file
5
libs/web/tracker/domain/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './lib/models/enums';
|
||||
export * from './lib/models/models';
|
||||
export * from './lib/models/inputs';
|
||||
export * from './lib/models/workflow';
|
||||
export * from './lib/ports/gateways';
|
||||
57
libs/web/tracker/domain/src/lib/models/enums.ts
Normal file
57
libs/web/tracker/domain/src/lib/models/enums.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export enum IssueType {
|
||||
BUG = 'BUG',
|
||||
FEATURE = 'FEATURE',
|
||||
UI_CHANGE = 'UI_CHANGE',
|
||||
TASK = 'TASK',
|
||||
QA = 'QA',
|
||||
}
|
||||
|
||||
export enum IssueArea {
|
||||
FRONTEND = 'FRONTEND',
|
||||
BACKEND = 'BACKEND',
|
||||
MOBILE = 'MOBILE',
|
||||
QA = 'QA',
|
||||
DESIGN = 'DESIGN',
|
||||
DEVOPS = 'DEVOPS',
|
||||
}
|
||||
|
||||
export enum IssuePriority {
|
||||
LOWEST = 'LOWEST',
|
||||
LOW = 'LOW',
|
||||
MEDIUM = 'MEDIUM',
|
||||
HIGH = 'HIGH',
|
||||
CRITICAL = 'CRITICAL',
|
||||
}
|
||||
|
||||
export enum IssueStatus {
|
||||
BACKLOG = 'BACKLOG',
|
||||
TODO = 'TODO',
|
||||
IN_PROGRESS = 'IN_PROGRESS',
|
||||
IN_REVIEW = 'IN_REVIEW',
|
||||
READY_FOR_QA = 'READY_FOR_QA',
|
||||
IN_QA = 'IN_QA',
|
||||
QA_FAILED = 'QA_FAILED',
|
||||
DONE = 'DONE',
|
||||
CANCELLED = 'CANCELLED',
|
||||
}
|
||||
|
||||
export enum IssueLinkType {
|
||||
BLOCKS = 'BLOCKS',
|
||||
DEPENDS_ON = 'DEPENDS_ON',
|
||||
TESTS = 'TESTS',
|
||||
DUPLICATES = 'DUPLICATES',
|
||||
RELATES_TO = 'RELATES_TO',
|
||||
}
|
||||
|
||||
export enum ProjectRole {
|
||||
OWNER = 'OWNER',
|
||||
ADMIN = 'ADMIN',
|
||||
MEMBER = 'MEMBER',
|
||||
VIEWER = 'VIEWER',
|
||||
}
|
||||
|
||||
export enum ReleaseStatus {
|
||||
PLANNED = 'PLANNED',
|
||||
RELEASED = 'RELEASED',
|
||||
ARCHIVED = 'ARCHIVED',
|
||||
}
|
||||
65
libs/web/tracker/domain/src/lib/models/inputs.ts
Normal file
65
libs/web/tracker/domain/src/lib/models/inputs.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
IssueArea,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
IssueType,
|
||||
ProjectRole,
|
||||
} from './enums';
|
||||
|
||||
export interface CreateProjectInput {
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateIssueInput {
|
||||
projectId: string;
|
||||
type: IssueType;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
area?: IssueArea | null;
|
||||
priority?: IssuePriority;
|
||||
labels?: string[];
|
||||
figmaUrl?: string | null;
|
||||
assigneeId?: string | null;
|
||||
releaseId?: string | null;
|
||||
}
|
||||
|
||||
export type UpdateIssueInput = Partial<
|
||||
Omit<CreateIssueInput, 'projectId' | 'assigneeId' | 'releaseId'>
|
||||
> & {
|
||||
issueId: string;
|
||||
};
|
||||
|
||||
export interface ChangeStatusInput {
|
||||
issueId: string;
|
||||
status: IssueStatus;
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
export interface LinkIssuesInput {
|
||||
issueId: string;
|
||||
type: IssueLinkType;
|
||||
targetIssueId: string;
|
||||
}
|
||||
|
||||
export interface MemberInput {
|
||||
projectId: string;
|
||||
userId: string;
|
||||
role: ProjectRole;
|
||||
}
|
||||
|
||||
export interface CreateReleaseInput {
|
||||
projectId: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
targetDate?: string | null;
|
||||
}
|
||||
|
||||
export interface BoardFilter {
|
||||
projectId: string;
|
||||
assigneeId?: string;
|
||||
releaseId?: string;
|
||||
area?: IssueArea;
|
||||
}
|
||||
131
libs/web/tracker/domain/src/lib/models/models.ts
Normal file
131
libs/web/tracker/domain/src/lib/models/models.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
IssueArea,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
IssueType,
|
||||
ProjectRole,
|
||||
ReleaseStatus,
|
||||
} from './enums';
|
||||
|
||||
export interface UserRef {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ProjectMember {
|
||||
user: UserRef;
|
||||
role: ProjectRole;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
owner: UserRef;
|
||||
members: ProjectMember[];
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface IssueLink {
|
||||
type: IssueLinkType;
|
||||
/** OUTWARD: this issue → other. INWARD: other → this issue. */
|
||||
direction: 'OUTWARD' | 'INWARD';
|
||||
issueId: string;
|
||||
issueKey: string;
|
||||
}
|
||||
|
||||
/** What boards and lists show. */
|
||||
export interface IssueSummary {
|
||||
id: string;
|
||||
projectId: string;
|
||||
key: string;
|
||||
type: IssueType;
|
||||
area: IssueArea | null;
|
||||
title: string;
|
||||
status: IssueStatus;
|
||||
priority: IssuePriority;
|
||||
assignee: UserRef | null;
|
||||
release: { id: string; name: string } | null;
|
||||
labels: string[];
|
||||
commentCount: number;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface Issue extends IssueSummary {
|
||||
description: string | null;
|
||||
reporter: UserRef;
|
||||
figmaUrl: string | null;
|
||||
storyPoints: number | null;
|
||||
dueDate: string | null;
|
||||
links: IssueLink[];
|
||||
allowedTransitions: IssueStatus[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
issueId: string;
|
||||
author: UserRef;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
}
|
||||
|
||||
export interface Release {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
targetDate: string | null;
|
||||
status: ReleaseStatus;
|
||||
releasedAt: string | null;
|
||||
progress: { total: number; finished: number; percent: number };
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
type: string;
|
||||
issueId: string | null;
|
||||
issueKey: string | null;
|
||||
actor: UserRef;
|
||||
summary: string;
|
||||
occurredAt: string;
|
||||
}
|
||||
|
||||
export interface CountBy<T = string> {
|
||||
key: T;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ProjectStats {
|
||||
total: number;
|
||||
open: number;
|
||||
byStatus: CountBy<IssueStatus>[];
|
||||
byType: CountBy[];
|
||||
byPriority: CountBy[];
|
||||
byArea: CountBy[];
|
||||
byAssignee: { user: UserRef | null; count: number }[];
|
||||
}
|
||||
|
||||
export interface BoardColumn {
|
||||
status: IssueStatus;
|
||||
issues: IssueSummary[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface UserDirectoryEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
/** Ack of a command: the read model catches up asynchronously. */
|
||||
export interface CommandResult {
|
||||
id: string;
|
||||
key?: string | null;
|
||||
version: number;
|
||||
}
|
||||
50
libs/web/tracker/domain/src/lib/models/workflow.ts
Normal file
50
libs/web/tracker/domain/src/lib/models/workflow.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { IssuePriority, IssueStatus, IssueType } from './enums';
|
||||
|
||||
/** Statuses that require an explanation (the API rejects them without one). */
|
||||
export const REASON_REQUIRED: readonly IssueStatus[] = [
|
||||
IssueStatus.QA_FAILED,
|
||||
IssueStatus.CANCELLED,
|
||||
];
|
||||
|
||||
export const BOARD_COLUMNS: readonly IssueStatus[] = [
|
||||
IssueStatus.BACKLOG,
|
||||
IssueStatus.TODO,
|
||||
IssueStatus.IN_PROGRESS,
|
||||
IssueStatus.IN_REVIEW,
|
||||
IssueStatus.READY_FOR_QA,
|
||||
IssueStatus.IN_QA,
|
||||
IssueStatus.QA_FAILED,
|
||||
IssueStatus.DONE,
|
||||
];
|
||||
|
||||
export const STATUS_LABEL: Record<IssueStatus, string> = {
|
||||
[IssueStatus.BACKLOG]: 'Backlog',
|
||||
[IssueStatus.TODO]: 'To do',
|
||||
[IssueStatus.IN_PROGRESS]: 'In progress',
|
||||
[IssueStatus.IN_REVIEW]: 'In review',
|
||||
[IssueStatus.READY_FOR_QA]: 'Ready for QA',
|
||||
[IssueStatus.IN_QA]: 'In QA',
|
||||
[IssueStatus.QA_FAILED]: 'QA failed',
|
||||
[IssueStatus.DONE]: 'Done',
|
||||
[IssueStatus.CANCELLED]: 'Cancelled',
|
||||
};
|
||||
|
||||
export const TYPE_LABEL: Record<IssueType, string> = {
|
||||
[IssueType.BUG]: 'Bug',
|
||||
[IssueType.FEATURE]: 'Feature',
|
||||
[IssueType.UI_CHANGE]: 'UI change',
|
||||
[IssueType.TASK]: 'Task',
|
||||
[IssueType.QA]: 'QA',
|
||||
};
|
||||
|
||||
export const PRIORITY_ORDER: readonly IssuePriority[] = [
|
||||
IssuePriority.CRITICAL,
|
||||
IssuePriority.HIGH,
|
||||
IssuePriority.MEDIUM,
|
||||
IssuePriority.LOW,
|
||||
IssuePriority.LOWEST,
|
||||
];
|
||||
|
||||
export function isFinished(status: IssueStatus): boolean {
|
||||
return status === IssueStatus.DONE || status === IssueStatus.CANCELLED;
|
||||
}
|
||||
108
libs/web/tracker/domain/src/lib/ports/gateways.ts
Normal file
108
libs/web/tracker/domain/src/lib/ports/gateways.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
Activity,
|
||||
BoardColumn,
|
||||
Comment,
|
||||
CommandResult,
|
||||
Issue,
|
||||
IssueSummary,
|
||||
Project,
|
||||
ProjectStats,
|
||||
Release,
|
||||
UserDirectoryEntry,
|
||||
} from '../models/models';
|
||||
import {
|
||||
BoardFilter,
|
||||
ChangeStatusInput,
|
||||
CreateIssueInput,
|
||||
CreateProjectInput,
|
||||
CreateReleaseInput,
|
||||
LinkIssuesInput,
|
||||
MemberInput,
|
||||
UpdateIssueInput,
|
||||
} from '../models/inputs';
|
||||
|
||||
/*
|
||||
* Outbound ports of the tracker. The application layer depends on these
|
||||
* abstract classes; the infrastructure layer implements them over GraphQL.
|
||||
*/
|
||||
|
||||
export abstract class ProjectsGateway {
|
||||
abstract myProjects(): Observable<Project[]>;
|
||||
abstract project(idOrKey: string): Observable<Project>;
|
||||
abstract stats(projectId: string): Observable<ProjectStats>;
|
||||
abstract create(input: CreateProjectInput): Observable<CommandResult>;
|
||||
abstract addMember(input: MemberInput): Observable<CommandResult>;
|
||||
abstract changeMemberRole(input: MemberInput): Observable<CommandResult>;
|
||||
abstract removeMember(
|
||||
projectId: string,
|
||||
userId: string,
|
||||
): Observable<CommandResult>;
|
||||
}
|
||||
|
||||
export abstract class IssuesGateway {
|
||||
abstract board(filter: BoardFilter): Observable<BoardColumn[]>;
|
||||
abstract issue(idOrKey: string): Observable<Issue>;
|
||||
abstract search(projectId: string, text: string): Observable<IssueSummary[]>;
|
||||
abstract create(input: CreateIssueInput): Observable<CommandResult>;
|
||||
abstract update(input: UpdateIssueInput): Observable<CommandResult>;
|
||||
abstract changeStatus(input: ChangeStatusInput): Observable<CommandResult>;
|
||||
abstract assign(
|
||||
issueId: string,
|
||||
assigneeId: string | null,
|
||||
): Observable<CommandResult>;
|
||||
abstract link(input: LinkIssuesInput): Observable<CommandResult>;
|
||||
abstract unlink(input: LinkIssuesInput): Observable<CommandResult>;
|
||||
abstract setRelease(
|
||||
issueId: string,
|
||||
releaseId: string | null,
|
||||
): Observable<CommandResult>;
|
||||
}
|
||||
|
||||
export abstract class CommentsGateway {
|
||||
abstract list(issueId: string): Observable<Comment[]>;
|
||||
abstract add(issueId: string, body: string): Observable<CommandResult>;
|
||||
abstract edit(commentId: string, body: string): Observable<CommandResult>;
|
||||
abstract remove(commentId: string): Observable<CommandResult>;
|
||||
}
|
||||
|
||||
export abstract class ReleasesGateway {
|
||||
abstract list(projectId: string): Observable<Release[]>;
|
||||
abstract create(input: CreateReleaseInput): Observable<CommandResult>;
|
||||
abstract publish(
|
||||
releaseId: string,
|
||||
force: boolean,
|
||||
): Observable<CommandResult>;
|
||||
abstract archive(releaseId: string): Observable<CommandResult>;
|
||||
}
|
||||
|
||||
export abstract class ActivityGateway {
|
||||
abstract list(projectId: string, issueId?: string): Observable<Activity[]>;
|
||||
}
|
||||
|
||||
export abstract class UsersGateway {
|
||||
abstract search(term: string): Observable<UserDirectoryEntry[]>;
|
||||
}
|
||||
|
||||
/** Realtime (GraphQL subscriptions over WebSocket). */
|
||||
export interface IssueChanged {
|
||||
action: string;
|
||||
version: number;
|
||||
actorId: string;
|
||||
issue: IssueSummary;
|
||||
}
|
||||
|
||||
export interface CommentChanged {
|
||||
action: string;
|
||||
commentId: string;
|
||||
comment: Comment | null;
|
||||
}
|
||||
|
||||
export abstract class TrackerRealtimeGateway {
|
||||
abstract issueChanges(projectId: string): Observable<IssueChanged>;
|
||||
abstract commentChanges(issueId: string): Observable<CommentChanged>;
|
||||
abstract activity(projectId: string): Observable<Activity>;
|
||||
abstract releaseChanges(projectId: string): Observable<Release>;
|
||||
abstract projectChanges(projectId: string): Observable<Project>;
|
||||
abstract myProjectsChanges(): Observable<Project>;
|
||||
}
|
||||
23
libs/web/tracker/domain/tsconfig.json
Normal file
23
libs/web/tracker/domain/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"importHelpers": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noPropertyAccessFromIndexSignature": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
15
libs/web/tracker/domain/tsconfig.lib.json
Normal file
15
libs/web/tracker/domain/tsconfig.lib.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
16
libs/web/tracker/domain/tsconfig.spec.json
Normal file
16
libs/web/tracker/domain/tsconfig.spec.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
20
libs/web/tracker/infrastructure/README.md
Normal file
20
libs/web/tracker/infrastructure/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# web-tracker-infrastructure
|
||||
|
||||
Adaptadores GraphQL del tracker.
|
||||
|
||||
- **Import**: `@task-stream/web/tracker/infrastructure`
|
||||
- **Capa**: `infrastructure` · **Tags**: `scope:web-tracker`, `type:infrastructure`
|
||||
|
||||
## Contenido
|
||||
|
||||
- `graphql/*.graphql`: operaciones; `generated.ts` lo genera `npx nx run web:codegen`.
|
||||
- `adapters/`: implementaciones de los gateways y `mappers.ts` (GraphQL → dominio).
|
||||
- `provideTrackerInfrastructure()`.
|
||||
|
||||
## Comandos
|
||||
|
||||
```sh
|
||||
npx nx lint web-tracker-infrastructure
|
||||
```
|
||||
|
||||
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).
|
||||
34
libs/web/tracker/infrastructure/eslint.config.mjs
Normal file
34
libs/web/tracker/infrastructure/eslint.config.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import nx from '@nx/eslint-plugin';
|
||||
import baseConfig from '../../../../eslint.config.mjs';
|
||||
|
||||
export default [
|
||||
...nx.configs['flat/angular'],
|
||||
...nx.configs['flat/angular-template'],
|
||||
...baseConfig,
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
rules: {
|
||||
'@angular-eslint/directive-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'attribute',
|
||||
prefix: 'ts',
|
||||
style: 'camelCase',
|
||||
},
|
||||
],
|
||||
'@angular-eslint/component-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'element',
|
||||
prefix: 'ts',
|
||||
style: 'kebab-case',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.html'],
|
||||
// Override or add rules here
|
||||
rules: {},
|
||||
},
|
||||
];
|
||||
13
libs/web/tracker/infrastructure/project.json
Normal file
13
libs/web/tracker/infrastructure/project.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "web-tracker-infrastructure",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/web/tracker/infrastructure/src",
|
||||
"prefix": "ts",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:web-tracker", "type:infrastructure"],
|
||||
"targets": {
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
libs/web/tracker/infrastructure/src/index.ts
Normal file
1
libs/web/tracker/infrastructure/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './lib/tracker-infrastructure.providers';
|
||||
@@ -0,0 +1,108 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import {
|
||||
Activity,
|
||||
ActivityGateway,
|
||||
CommandResult,
|
||||
Comment,
|
||||
CommentsGateway,
|
||||
CreateReleaseInput,
|
||||
Release,
|
||||
ReleasesGateway,
|
||||
UserDirectoryEntry,
|
||||
UsersGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import {
|
||||
ActivityDocument,
|
||||
AddCommentDocument,
|
||||
ArchiveReleaseDocument,
|
||||
CreateReleaseDocument,
|
||||
DeleteCommentDocument,
|
||||
EditCommentDocument,
|
||||
IssueCommentsDocument,
|
||||
PublishReleaseDocument,
|
||||
ReleasesDocument,
|
||||
SearchUsersDocument,
|
||||
} from '../graphql/generated';
|
||||
import { GraphqlOperations } from './graphql-operations';
|
||||
import { toActivity, toCommandResult, toComment, toRelease } from './mappers';
|
||||
|
||||
@Injectable()
|
||||
export class GraphqlCommentsGateway extends CommentsGateway {
|
||||
private readonly gql = inject(GraphqlOperations);
|
||||
|
||||
list(issueId: string): Observable<Comment[]> {
|
||||
return this.gql
|
||||
.query(IssueCommentsDocument, { idOrKey: issueId })
|
||||
.pipe(map((d) => d.issue.comments.items.map(toComment)));
|
||||
}
|
||||
|
||||
add(issueId: string, body: string): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(AddCommentDocument, { issueId, body })
|
||||
.pipe(map((d) => toCommandResult(d.addComment)));
|
||||
}
|
||||
|
||||
edit(commentId: string, body: string): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(EditCommentDocument, { commentId, body })
|
||||
.pipe(map((d) => toCommandResult(d.editComment)));
|
||||
}
|
||||
|
||||
remove(commentId: string): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(DeleteCommentDocument, { commentId })
|
||||
.pipe(map((d) => toCommandResult(d.deleteComment)));
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GraphqlReleasesGateway extends ReleasesGateway {
|
||||
private readonly gql = inject(GraphqlOperations);
|
||||
|
||||
list(projectId: string): Observable<Release[]> {
|
||||
return this.gql
|
||||
.query(ReleasesDocument, { idOrKey: projectId })
|
||||
.pipe(map((d) => d.project.releases.map(toRelease)));
|
||||
}
|
||||
|
||||
create(input: CreateReleaseInput): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(CreateReleaseDocument, { input })
|
||||
.pipe(map((d) => toCommandResult(d.createRelease)));
|
||||
}
|
||||
|
||||
publish(releaseId: string, force: boolean): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(PublishReleaseDocument, { releaseId, force })
|
||||
.pipe(map((d) => toCommandResult(d.publishRelease)));
|
||||
}
|
||||
|
||||
archive(releaseId: string): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(ArchiveReleaseDocument, { releaseId })
|
||||
.pipe(map((d) => toCommandResult(d.archiveRelease)));
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GraphqlActivityGateway extends ActivityGateway {
|
||||
private readonly gql = inject(GraphqlOperations);
|
||||
|
||||
list(projectId: string, issueId?: string): Observable<Activity[]> {
|
||||
return this.gql
|
||||
.query(ActivityDocument, { projectId, issueId: issueId ?? null })
|
||||
.pipe(map((d) => d.activity.map(toActivity)));
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GraphqlUsersGateway extends UsersGateway {
|
||||
private readonly gql = inject(GraphqlOperations);
|
||||
|
||||
search(term: string): Observable<UserDirectoryEntry[]> {
|
||||
return this.gql
|
||||
.query(SearchUsersDocument, { search: term })
|
||||
.pipe(map((d) => d.users));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import {
|
||||
BoardColumn,
|
||||
BoardFilter,
|
||||
ChangeStatusInput,
|
||||
CommandResult,
|
||||
CreateIssueInput,
|
||||
Issue,
|
||||
IssuesGateway,
|
||||
IssueSummary,
|
||||
LinkIssuesInput,
|
||||
UpdateIssueInput,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import {
|
||||
AssignIssueDocument,
|
||||
BoardDocument,
|
||||
ChangeIssueStatusDocument,
|
||||
CreateIssueDocument,
|
||||
IssueDocument,
|
||||
LinkIssuesDocument,
|
||||
SearchIssuesDocument,
|
||||
SetIssueReleaseDocument,
|
||||
UnlinkIssuesDocument,
|
||||
UpdateIssueDocument,
|
||||
} from '../graphql/generated';
|
||||
import { GraphqlOperations } from './graphql-operations';
|
||||
import { toCommandResult, toIssue, toIssueSummary } from './mappers';
|
||||
|
||||
@Injectable()
|
||||
export class GraphqlIssuesGateway extends IssuesGateway {
|
||||
private readonly gql = inject(GraphqlOperations);
|
||||
|
||||
board(filter: BoardFilter): Observable<BoardColumn[]> {
|
||||
return this.gql
|
||||
.query(BoardDocument, {
|
||||
idOrKey: filter.projectId,
|
||||
releaseId: filter.releaseId ?? null,
|
||||
assigneeId: filter.assigneeId ?? null,
|
||||
area: filter.area ?? null,
|
||||
})
|
||||
.pipe(
|
||||
map((d) =>
|
||||
d.project.board.map((column) => ({
|
||||
status: column.status as BoardColumn['status'],
|
||||
total: column.total,
|
||||
issues: column.issues.map(toIssueSummary),
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
issue(idOrKey: string): Observable<Issue> {
|
||||
return this.gql
|
||||
.query(IssueDocument, { idOrKey })
|
||||
.pipe(map((d) => toIssue(d.issue)));
|
||||
}
|
||||
|
||||
search(projectId: string, text: string): Observable<IssueSummary[]> {
|
||||
return this.gql
|
||||
.query(SearchIssuesDocument, { filter: { projectId, search: text } })
|
||||
.pipe(map((d) => d.issues.items.map(toIssueSummary)));
|
||||
}
|
||||
|
||||
create(input: CreateIssueInput): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(CreateIssueDocument, { input: input as never })
|
||||
.pipe(map((d) => toCommandResult(d.createIssue)));
|
||||
}
|
||||
|
||||
update(input: UpdateIssueInput): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(UpdateIssueDocument, { input: input as never })
|
||||
.pipe(map((d) => toCommandResult(d.updateIssue)));
|
||||
}
|
||||
|
||||
changeStatus(input: ChangeStatusInput): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(ChangeIssueStatusDocument, { input: input as never })
|
||||
.pipe(map((d) => toCommandResult(d.changeIssueStatus)));
|
||||
}
|
||||
|
||||
assign(
|
||||
issueId: string,
|
||||
assigneeId: string | null,
|
||||
): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(AssignIssueDocument, { issueId, assigneeId })
|
||||
.pipe(map((d) => toCommandResult(d.assignIssue)));
|
||||
}
|
||||
|
||||
link(input: LinkIssuesInput): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(LinkIssuesDocument, { input: input as never })
|
||||
.pipe(map((d) => toCommandResult(d.linkIssues)));
|
||||
}
|
||||
|
||||
unlink(input: LinkIssuesInput): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(UnlinkIssuesDocument, { input: input as never })
|
||||
.pipe(map((d) => toCommandResult(d.unlinkIssues)));
|
||||
}
|
||||
|
||||
setRelease(
|
||||
issueId: string,
|
||||
releaseId: string | null,
|
||||
): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(SetIssueReleaseDocument, { issueId, releaseId })
|
||||
.pipe(map((d) => toCommandResult(d.setIssueRelease)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
|
||||
import { Apollo } from 'apollo-angular';
|
||||
import { filter, map, Observable } from 'rxjs';
|
||||
|
||||
type Vars = Record<string, unknown>;
|
||||
|
||||
/** Thin typed wrapper: one-shot queries/mutations and subscriptions as plain data streams. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class GraphqlOperations {
|
||||
private readonly apollo = inject(Apollo);
|
||||
|
||||
query<TData, TVars extends Vars>(
|
||||
document: TypedDocumentNode<TData, TVars>,
|
||||
variables?: TVars,
|
||||
): Observable<TData> {
|
||||
return this.apollo
|
||||
.query<TData, TVars>({ query: document, variables: variables as TVars })
|
||||
.pipe(map((result) => result.data as TData));
|
||||
}
|
||||
|
||||
mutate<TData, TVars extends Vars>(
|
||||
document: TypedDocumentNode<TData, TVars>,
|
||||
variables: TVars,
|
||||
): Observable<TData> {
|
||||
return this.apollo
|
||||
.mutate<TData, TVars>({ mutation: document, variables })
|
||||
.pipe(map((result) => result.data as TData));
|
||||
}
|
||||
|
||||
subscribe<TData, TVars extends Vars>(
|
||||
document: TypedDocumentNode<TData, TVars>,
|
||||
variables?: TVars,
|
||||
): Observable<TData> {
|
||||
return this.apollo
|
||||
.subscribe<
|
||||
TData,
|
||||
TVars
|
||||
>({ query: document, variables: variables as TVars })
|
||||
.pipe(
|
||||
map((result) => result.data),
|
||||
filter((data): data is NonNullable<typeof data> => !!data),
|
||||
map((data) => data as TData),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import {
|
||||
CommandResult,
|
||||
CreateProjectInput,
|
||||
MemberInput,
|
||||
Project,
|
||||
ProjectsGateway,
|
||||
ProjectStats,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import {
|
||||
AddProjectMemberDocument,
|
||||
ChangeProjectMemberRoleDocument,
|
||||
CreateProjectDocument,
|
||||
MyProjectsDocument,
|
||||
ProjectDocument,
|
||||
ProjectStatsDocument,
|
||||
RemoveProjectMemberDocument,
|
||||
} from '../graphql/generated';
|
||||
import { GraphqlOperations } from './graphql-operations';
|
||||
import { toCommandResult, toProject, toStats } from './mappers';
|
||||
|
||||
@Injectable()
|
||||
export class GraphqlProjectsGateway extends ProjectsGateway {
|
||||
private readonly gql = inject(GraphqlOperations);
|
||||
|
||||
myProjects(): Observable<Project[]> {
|
||||
return this.gql
|
||||
.query(MyProjectsDocument)
|
||||
.pipe(map((d) => d.myProjects.map(toProject)));
|
||||
}
|
||||
|
||||
project(idOrKey: string): Observable<Project> {
|
||||
return this.gql
|
||||
.query(ProjectDocument, { idOrKey })
|
||||
.pipe(map((d) => toProject(d.project)));
|
||||
}
|
||||
|
||||
stats(projectId: string): Observable<ProjectStats> {
|
||||
return this.gql
|
||||
.query(ProjectStatsDocument, { idOrKey: projectId })
|
||||
.pipe(map((d) => toStats(d.project.stats)));
|
||||
}
|
||||
|
||||
create(input: CreateProjectInput): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(CreateProjectDocument, { input })
|
||||
.pipe(map((d) => toCommandResult(d.createProject)));
|
||||
}
|
||||
|
||||
addMember(input: MemberInput): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(AddProjectMemberDocument, { input: input as never })
|
||||
.pipe(map((d) => toCommandResult(d.addProjectMember)));
|
||||
}
|
||||
|
||||
changeMemberRole(input: MemberInput): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(ChangeProjectMemberRoleDocument, { input: input as never })
|
||||
.pipe(map((d) => toCommandResult(d.changeProjectMemberRole)));
|
||||
}
|
||||
|
||||
removeMember(projectId: string, userId: string): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(RemoveProjectMemberDocument, { projectId, userId })
|
||||
.pipe(map((d) => toCommandResult(d.removeProjectMember)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import {
|
||||
Activity,
|
||||
CommentChanged,
|
||||
IssueChanged,
|
||||
Project,
|
||||
Release,
|
||||
TrackerRealtimeGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import {
|
||||
ActivityAddedDocument,
|
||||
CommentChangedDocument,
|
||||
IssueChangedDocument,
|
||||
MyProjectsChangedDocument,
|
||||
ProjectChangedDocument,
|
||||
ReleaseChangedDocument,
|
||||
} from '../graphql/generated';
|
||||
import { GraphqlOperations } from './graphql-operations';
|
||||
import {
|
||||
toActivity,
|
||||
toComment,
|
||||
toIssueSummary,
|
||||
toProject,
|
||||
toRelease,
|
||||
} from './mappers';
|
||||
|
||||
/** GraphQL subscriptions (graphql-ws). Each stream lives while it is subscribed. */
|
||||
@Injectable()
|
||||
export class GraphqlRealtimeGateway extends TrackerRealtimeGateway {
|
||||
private readonly gql = inject(GraphqlOperations);
|
||||
|
||||
issueChanges(projectId: string): Observable<IssueChanged> {
|
||||
return this.gql.subscribe(IssueChangedDocument, { projectId }).pipe(
|
||||
map(({ issueChanged }) => ({
|
||||
...issueChanged,
|
||||
issue: toIssueSummary(issueChanged.issue),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
commentChanges(issueId: string): Observable<CommentChanged> {
|
||||
return this.gql.subscribe(CommentChangedDocument, { issueId }).pipe(
|
||||
map(({ commentChanged }) => ({
|
||||
action: commentChanged.action,
|
||||
commentId: commentChanged.commentId,
|
||||
comment: commentChanged.comment
|
||||
? toComment(commentChanged.comment)
|
||||
: null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
activity(projectId: string): Observable<Activity> {
|
||||
return this.gql
|
||||
.subscribe(ActivityAddedDocument, { projectId })
|
||||
.pipe(map((d) => toActivity(d.activityAdded)));
|
||||
}
|
||||
|
||||
releaseChanges(projectId: string): Observable<Release> {
|
||||
return this.gql
|
||||
.subscribe(ReleaseChangedDocument, { projectId })
|
||||
.pipe(map((d) => toRelease(d.releaseChanged.release)));
|
||||
}
|
||||
|
||||
projectChanges(projectId: string): Observable<Project> {
|
||||
return this.gql
|
||||
.subscribe(ProjectChangedDocument, { projectId })
|
||||
.pipe(map((d) => toProject(d.projectChanged.project)));
|
||||
}
|
||||
|
||||
myProjectsChanges(): Observable<Project> {
|
||||
return this.gql
|
||||
.subscribe(MyProjectsChangedDocument)
|
||||
.pipe(map((d) => toProject(d.myProjectsChanged.project)));
|
||||
}
|
||||
}
|
||||
55
libs/web/tracker/infrastructure/src/lib/adapters/mappers.ts
Normal file
55
libs/web/tracker/infrastructure/src/lib/adapters/mappers.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type {
|
||||
Activity,
|
||||
Comment,
|
||||
CommandResult,
|
||||
Issue,
|
||||
IssueSummary,
|
||||
Project,
|
||||
ProjectStats,
|
||||
Release,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import type {
|
||||
ActivityFieldsFragment,
|
||||
CommandResultFieldsFragment,
|
||||
CommentFieldsFragment,
|
||||
IssueFieldsFragment,
|
||||
IssueSummaryFieldsFragment,
|
||||
ProjectFieldsFragment,
|
||||
ProjectStatsQuery,
|
||||
ReleaseFieldsFragment,
|
||||
} from '../graphql/generated';
|
||||
|
||||
/*
|
||||
* GraphQL → domain. The generated types already have the domain's shape;
|
||||
* enums arrive as string unions with the same values as the domain enums,
|
||||
* so mapping is a typed re-labelling kept in one place.
|
||||
*/
|
||||
|
||||
const as = <T>(value: unknown): T => value as T;
|
||||
|
||||
export const toProject = (p: ProjectFieldsFragment): Project => as<Project>(p);
|
||||
export const toIssueSummary = (i: IssueSummaryFieldsFragment): IssueSummary =>
|
||||
as<IssueSummary>(i);
|
||||
export const toIssue = (i: IssueFieldsFragment): Issue => as<Issue>(i);
|
||||
export const toComment = (c: CommentFieldsFragment): Comment => c;
|
||||
export const toActivity = (a: ActivityFieldsFragment): Activity => a;
|
||||
export const toCommandResult = (
|
||||
r: CommandResultFieldsFragment,
|
||||
): CommandResult => r;
|
||||
|
||||
export function toRelease(
|
||||
r: ReleaseFieldsFragment & {
|
||||
progress?: { total: number; finished: number; percent: number };
|
||||
},
|
||||
): Release {
|
||||
return as<Release>({
|
||||
...r,
|
||||
progress: r.progress ?? { total: 0, finished: 0, percent: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
export function toStats(
|
||||
stats: NonNullable<ProjectStatsQuery['project']>['stats'],
|
||||
): ProjectStats {
|
||||
return as<ProjectStats>(stats);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
fragment UserRefFields on UserRef {
|
||||
id
|
||||
name
|
||||
}
|
||||
|
||||
fragment ProjectFields on Project {
|
||||
id
|
||||
key
|
||||
name
|
||||
description
|
||||
updatedAt
|
||||
version
|
||||
owner {
|
||||
...UserRefFields
|
||||
}
|
||||
members {
|
||||
role
|
||||
user {
|
||||
...UserRefFields
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment IssueSummaryFields on Issue {
|
||||
id
|
||||
projectId
|
||||
key
|
||||
type
|
||||
area
|
||||
title
|
||||
status
|
||||
priority
|
||||
labels
|
||||
commentCount
|
||||
updatedAt
|
||||
version
|
||||
assignee {
|
||||
...UserRefFields
|
||||
}
|
||||
release {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
fragment IssueFields on Issue {
|
||||
...IssueSummaryFields
|
||||
description
|
||||
figmaUrl
|
||||
storyPoints
|
||||
dueDate
|
||||
createdAt
|
||||
allowedTransitions
|
||||
reporter {
|
||||
...UserRefFields
|
||||
}
|
||||
links {
|
||||
type
|
||||
direction
|
||||
issueId
|
||||
issueKey
|
||||
}
|
||||
}
|
||||
|
||||
fragment CommentFields on Comment {
|
||||
id
|
||||
issueId
|
||||
body
|
||||
createdAt
|
||||
editedAt
|
||||
author {
|
||||
...UserRefFields
|
||||
}
|
||||
}
|
||||
|
||||
fragment ReleaseFields on Release {
|
||||
id
|
||||
projectId
|
||||
name
|
||||
description
|
||||
targetDate
|
||||
status
|
||||
releasedAt
|
||||
}
|
||||
|
||||
fragment ActivityFields on Activity {
|
||||
id
|
||||
type
|
||||
issueId
|
||||
issueKey
|
||||
summary
|
||||
occurredAt
|
||||
actor {
|
||||
...UserRefFields
|
||||
}
|
||||
}
|
||||
|
||||
fragment CommandResultFields on CommandResult {
|
||||
id
|
||||
key
|
||||
version
|
||||
}
|
||||
4412
libs/web/tracker/infrastructure/src/lib/graphql/generated.ts
Normal file
4412
libs/web/tracker/infrastructure/src/lib/graphql/generated.ts
Normal file
File diff suppressed because it is too large
Load Diff
107
libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql
Normal file
107
libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql
Normal file
@@ -0,0 +1,107 @@
|
||||
query Board(
|
||||
$idOrKey: String!
|
||||
$releaseId: ID
|
||||
$assigneeId: ID
|
||||
$area: IssueArea
|
||||
) {
|
||||
project(idOrKey: $idOrKey) {
|
||||
id
|
||||
board(
|
||||
releaseId: $releaseId
|
||||
assigneeId: $assigneeId
|
||||
area: $area
|
||||
perColumn: 100
|
||||
) {
|
||||
status
|
||||
total
|
||||
issues {
|
||||
...IssueSummaryFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query Issue($idOrKey: String!) {
|
||||
issue(idOrKey: $idOrKey) {
|
||||
...IssueFields
|
||||
}
|
||||
}
|
||||
|
||||
query SearchIssues($filter: IssueFilterInput!) {
|
||||
issues(filter: $filter, limit: 20) {
|
||||
items {
|
||||
...IssueSummaryFields
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutation CreateIssue($input: CreateIssueInput!) {
|
||||
createIssue(input: $input) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation UpdateIssue($input: UpdateIssueInput!) {
|
||||
updateIssue(input: $input) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation ChangeIssueStatus($input: ChangeIssueStatusInput!) {
|
||||
changeIssueStatus(input: $input) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation AssignIssue($issueId: ID!, $assigneeId: ID) {
|
||||
assignIssue(issueId: $issueId, assigneeId: $assigneeId) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation LinkIssues($input: LinkIssuesInput!) {
|
||||
linkIssues(input: $input) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation UnlinkIssues($input: LinkIssuesInput!) {
|
||||
unlinkIssues(input: $input) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation SetIssueRelease($issueId: ID!, $releaseId: ID) {
|
||||
setIssueRelease(issueId: $issueId, releaseId: $releaseId) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
query IssueComments($idOrKey: String!) {
|
||||
issue(idOrKey: $idOrKey) {
|
||||
id
|
||||
comments(limit: 200) {
|
||||
items {
|
||||
...CommentFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutation AddComment($issueId: ID!, $body: String!) {
|
||||
addComment(issueId: $issueId, body: $body) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation EditComment($commentId: ID!, $body: String!) {
|
||||
editComment(commentId: $commentId, body: $body) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation DeleteComment($commentId: ID!) {
|
||||
deleteComment(commentId: $commentId) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
query MyProjects {
|
||||
myProjects {
|
||||
...ProjectFields
|
||||
}
|
||||
}
|
||||
|
||||
query Project($idOrKey: String!) {
|
||||
project(idOrKey: $idOrKey) {
|
||||
...ProjectFields
|
||||
}
|
||||
}
|
||||
|
||||
query ProjectStats($idOrKey: String!) {
|
||||
project(idOrKey: $idOrKey) {
|
||||
id
|
||||
stats {
|
||||
total
|
||||
open
|
||||
byStatus {
|
||||
key
|
||||
count
|
||||
}
|
||||
byType {
|
||||
key
|
||||
count
|
||||
}
|
||||
byPriority {
|
||||
key
|
||||
count
|
||||
}
|
||||
byArea {
|
||||
key
|
||||
count
|
||||
}
|
||||
byAssignee {
|
||||
count
|
||||
user {
|
||||
...UserRefFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutation CreateProject($input: CreateProjectInput!) {
|
||||
createProject(input: $input) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation AddProjectMember($input: ProjectMemberInput!) {
|
||||
addProjectMember(input: $input) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation ChangeProjectMemberRole($input: ProjectMemberInput!) {
|
||||
changeProjectMemberRole(input: $input) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation RemoveProjectMember($projectId: ID!, $userId: ID!) {
|
||||
removeProjectMember(projectId: $projectId, userId: $userId) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
query SearchUsers($search: String!) {
|
||||
users(search: $search, limit: 20) {
|
||||
id
|
||||
name
|
||||
email
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
query Releases($idOrKey: String!) {
|
||||
project(idOrKey: $idOrKey) {
|
||||
id
|
||||
releases {
|
||||
...ReleaseFields
|
||||
progress {
|
||||
total
|
||||
finished
|
||||
percent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutation CreateRelease($input: CreateReleaseInput!) {
|
||||
createRelease(input: $input) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation PublishRelease($releaseId: ID!, $force: Boolean!) {
|
||||
publishRelease(releaseId: $releaseId, force: $force) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation ArchiveRelease($releaseId: ID!) {
|
||||
archiveRelease(releaseId: $releaseId) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
query Activity($projectId: ID!, $issueId: ID) {
|
||||
activity(projectId: $projectId, issueId: $issueId, limit: 100) {
|
||||
...ActivityFields
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
subscription IssueChanged($projectId: ID!) {
|
||||
issueChanged(projectId: $projectId) {
|
||||
action
|
||||
version
|
||||
actorId
|
||||
issue {
|
||||
...IssueSummaryFields
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subscription CommentChanged($issueId: ID!) {
|
||||
commentChanged(issueId: $issueId) {
|
||||
action
|
||||
commentId
|
||||
comment {
|
||||
...CommentFields
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subscription ActivityAdded($projectId: ID!) {
|
||||
activityAdded(projectId: $projectId) {
|
||||
...ActivityFields
|
||||
}
|
||||
}
|
||||
|
||||
subscription ReleaseChanged($projectId: ID!) {
|
||||
releaseChanged(projectId: $projectId) {
|
||||
release {
|
||||
...ReleaseFields
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subscription ProjectChanged($projectId: ID!) {
|
||||
projectChanged(projectId: $projectId) {
|
||||
project {
|
||||
...ProjectFields
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subscription MyProjectsChanged {
|
||||
myProjectsChanged {
|
||||
project {
|
||||
...ProjectFields
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
makeEnvironmentProviders,
|
||||
type EnvironmentProviders,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
ActivityGateway,
|
||||
CommentsGateway,
|
||||
IssuesGateway,
|
||||
ProjectsGateway,
|
||||
ReleasesGateway,
|
||||
TrackerRealtimeGateway,
|
||||
UsersGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import {
|
||||
GraphqlActivityGateway,
|
||||
GraphqlCommentsGateway,
|
||||
GraphqlReleasesGateway,
|
||||
GraphqlUsersGateway,
|
||||
} from './adapters/graphql-collaboration.gateways';
|
||||
import { GraphqlIssuesGateway } from './adapters/graphql-issues.gateway';
|
||||
import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway';
|
||||
import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway';
|
||||
|
||||
/** Ports → GraphQL adapters. */
|
||||
export function provideTrackerInfrastructure(): EnvironmentProviders {
|
||||
return makeEnvironmentProviders([
|
||||
{ provide: ProjectsGateway, useClass: GraphqlProjectsGateway },
|
||||
{ provide: IssuesGateway, useClass: GraphqlIssuesGateway },
|
||||
{ provide: CommentsGateway, useClass: GraphqlCommentsGateway },
|
||||
{ provide: ReleasesGateway, useClass: GraphqlReleasesGateway },
|
||||
{ provide: ActivityGateway, useClass: GraphqlActivityGateway },
|
||||
{ provide: UsersGateway, useClass: GraphqlUsersGateway },
|
||||
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
|
||||
]);
|
||||
}
|
||||
27
libs/web/tracker/infrastructure/tsconfig.json
Normal file
27
libs/web/tracker/infrastructure/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"target": "es2022",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
12
libs/web/tracker/infrastructure/tsconfig.lib.json
Normal file
12
libs/web/tracker/infrastructure/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
20
libs/web/tracker/presenters/README.md
Normal file
20
libs/web/tracker/presenters/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# web-tracker-presenters
|
||||
|
||||
Pantallas del tracker con `@pi/ui`.
|
||||
|
||||
- **Import**: `@task-stream/web/tracker/presenters`
|
||||
- **Capa**: `presenters` · **Tags**: `scope:web-tracker`, `type:presenters`
|
||||
|
||||
## Contenido
|
||||
|
||||
- Rutas `trackerRoutes`: proyectos, tablero kanban (drag & drop), ticket, releases, seguimiento, actividad y miembros.
|
||||
- `ui/`: `IssueCardComponent`, `AvatarsComponent` y `copy.ts` (textos, iconos y tonos de color por estado, tipo,
|
||||
prioridad y proyecto).
|
||||
|
||||
## Comandos
|
||||
|
||||
```sh
|
||||
npx nx lint web-tracker-presenters
|
||||
```
|
||||
|
||||
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).
|
||||
34
libs/web/tracker/presenters/eslint.config.mjs
Normal file
34
libs/web/tracker/presenters/eslint.config.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import nx from '@nx/eslint-plugin';
|
||||
import baseConfig from '../../../../eslint.config.mjs';
|
||||
|
||||
export default [
|
||||
...nx.configs['flat/angular'],
|
||||
...nx.configs['flat/angular-template'],
|
||||
...baseConfig,
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
rules: {
|
||||
'@angular-eslint/directive-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'attribute',
|
||||
prefix: 'ts',
|
||||
style: 'camelCase',
|
||||
},
|
||||
],
|
||||
'@angular-eslint/component-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'element',
|
||||
prefix: 'ts',
|
||||
style: 'kebab-case',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.html'],
|
||||
// Override or add rules here
|
||||
rules: {},
|
||||
},
|
||||
];
|
||||
13
libs/web/tracker/presenters/project.json
Normal file
13
libs/web/tracker/presenters/project.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "web-tracker-presenters",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/web/tracker/presenters/src",
|
||||
"prefix": "ts",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:web-tracker", "type:presenters"],
|
||||
"targets": {
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
libs/web/tracker/presenters/src/index.ts
Normal file
1
libs/web/tracker/presenters/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './lib/tracker.routes';
|
||||
161
libs/web/tracker/presenters/src/lib/pages/activity.page.ts
Normal file
161
libs/web/tracker/presenters/src/lib/pages/activity.page.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { PiEmptyStateComponent } from '@pi/ui';
|
||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||
import { clock, initials, timeAgo } from '../ui/copy';
|
||||
|
||||
/** Live feed of everything that happens in the project, as a log. */
|
||||
@Component({
|
||||
selector: 'ts-activity-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink, PiEmptyStateComponent],
|
||||
template: `
|
||||
<div class="ts-log-head">
|
||||
<p class="ts-eyebrow"><span class="ts-live"></span> En directo</p>
|
||||
<span class="ts-muted ts-mono"
|
||||
>{{ facade.activity().length }} eventos</span
|
||||
>
|
||||
</div>
|
||||
@if (facade.activity().length) {
|
||||
<ol class="ts-log ts-panel" aria-live="polite">
|
||||
@for (entry of facade.activity(); track entry.id) {
|
||||
<li>
|
||||
<time class="ts-log__time" [attr.datetime]="entry.occurredAt">{{
|
||||
clock(entry.occurredAt)
|
||||
}}</time>
|
||||
<span class="ts-log__who" [attr.title]="entry.actor.name">{{
|
||||
initialsOf(entry.actor.name)
|
||||
}}</span>
|
||||
<span class="ts-log__what">{{ entry.summary }}</span>
|
||||
@if (entry.issueKey) {
|
||||
<a class="ts-key" [routerLink]="['../issues', entry.issueKey]">{{
|
||||
entry.issueKey
|
||||
}}</a>
|
||||
} @else {
|
||||
<span></span>
|
||||
}
|
||||
<span class="ts-log__ago">{{ ago(entry.occurredAt) }}</span>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
} @else {
|
||||
<pi-empty-state icon="activity" title="Sin actividad todavía" />
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.ts-log-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
.ts-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.ts-muted {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
.ts-live {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--ts-s-done);
|
||||
animation: ts-pulse 2s ease-out infinite;
|
||||
}
|
||||
@keyframes ts-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0
|
||||
color-mix(in srgb, var(--ts-s-done) 60%, transparent);
|
||||
}
|
||||
70%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0.5rem transparent;
|
||||
}
|
||||
}
|
||||
.ts-log {
|
||||
margin: 0;
|
||||
padding: 0.25rem 0;
|
||||
list-style: none;
|
||||
li {
|
||||
display: grid;
|
||||
grid-template-columns: 3.5rem 1.75rem minmax(0, 1fr) auto 8.5rem;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
margin: 0;
|
||||
padding: 0.625rem 1.125rem;
|
||||
border-bottom: 1px dashed var(--ts-line);
|
||||
animation: ts-in 0.35s ease-out;
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
&:first-child {
|
||||
animation-name: ts-in, ts-flash;
|
||||
animation-duration: 0.35s, 1.6s;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ts-log__time,
|
||||
.ts-log__ago {
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
.ts-log__ago {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ts-log__who {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--ts-brand) 16%, var(--ts-surface));
|
||||
color: color-mix(in srgb, var(--ts-brand) 70%, var(--ts-ink));
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ts-log__what {
|
||||
font-size: 0.875rem;
|
||||
color: var(--ts-ink-soft);
|
||||
}
|
||||
a.ts-key {
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
color: var(--ts-brand);
|
||||
border-color: var(--ts-brand);
|
||||
}
|
||||
}
|
||||
@keyframes ts-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
@keyframes ts-flash {
|
||||
from {
|
||||
background: color-mix(in srgb, var(--ts-brand) 10%, transparent);
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.ts-log li {
|
||||
grid-template-columns: 1.75rem minmax(0, 1fr) auto;
|
||||
gap: 0.625rem;
|
||||
padding-inline: 0.875rem;
|
||||
}
|
||||
.ts-log__time,
|
||||
.ts-log__ago {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ActivityPage {
|
||||
protected readonly facade = inject(ProjectFacade);
|
||||
protected readonly ago = timeAgo;
|
||||
protected readonly initialsOf = initials;
|
||||
protected readonly clock = clock;
|
||||
}
|
||||
125
libs/web/tracker/presenters/src/lib/pages/board.page.scss
Normal file
125
libs/web/tracker/presenters/src/lib/pages/board.page.scss
Normal file
@@ -0,0 +1,125 @@
|
||||
.ts-board-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.ts-filter {
|
||||
width: 13rem;
|
||||
|
||||
@media (max-width: 640px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-board-total {
|
||||
font-size: 0.875rem;
|
||||
color: var(--ts-muted);
|
||||
|
||||
strong {
|
||||
color: var(--ts-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// One lane per status, tinted with the status colour.
|
||||
.ts-board {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(17rem, 1fr);
|
||||
gap: 0.875rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.75rem;
|
||||
scroll-snap-type: x proximity;
|
||||
}
|
||||
|
||||
.ts-column {
|
||||
--tone: var(--ts-muted);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 62vh;
|
||||
border-radius: 1.125rem;
|
||||
background: color-mix(in srgb, var(--tone) 5%, var(--ts-sunken));
|
||||
border: 1px solid color-mix(in srgb, var(--tone) 18%, var(--ts-line));
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
.ts-column__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.875rem 0.875rem 0.625rem;
|
||||
}
|
||||
|
||||
.ts-column__name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--tone) 75%, var(--ts-ink));
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--tone);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--tone) 18%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.ts-column__count {
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
color: var(--ts-ink-soft);
|
||||
}
|
||||
|
||||
.ts-column__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
padding: 0.125rem 0.625rem 0.75rem;
|
||||
}
|
||||
|
||||
.ts-column__empty {
|
||||
margin: 1.25rem 0;
|
||||
text-align: center;
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: color-mix(in srgb, var(--ts-muted) 70%, transparent);
|
||||
}
|
||||
|
||||
// CDK drag & drop
|
||||
.cdk-drag-preview {
|
||||
box-shadow: 0 18px 36px -14px rgb(0 0 0 / 45%);
|
||||
border-radius: 0.625rem;
|
||||
transform: rotate(1.5deg);
|
||||
}
|
||||
|
||||
.cdk-drag-placeholder {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.cdk-drag-animating,
|
||||
.ts-column__list.cdk-drop-list-dragging
|
||||
ts-issue-card:not(.cdk-drag-placeholder) {
|
||||
transition: transform 200ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.ts-column__list.cdk-drop-list-receiving {
|
||||
outline: 2px dashed color-mix(in srgb, var(--tone) 55%, transparent);
|
||||
outline-offset: -4px;
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
374
libs/web/tracker/presenters/src/lib/pages/board.page.ts
Normal file
374
libs/web/tracker/presenters/src/lib/pages/board.page.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import {
|
||||
CdkDrag,
|
||||
CdkDragDrop,
|
||||
CdkDropList,
|
||||
CdkDropListGroup,
|
||||
} from '@angular/cdk/drag-drop';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
TemplateRef,
|
||||
untracked,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { form, FormField, maxLength, required } from '@angular/forms/signals';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiLabelDirective,
|
||||
PiSegmentedControlComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiSkeletonComponent,
|
||||
PiTextareaComponent,
|
||||
PiTextFieldComponent,
|
||||
type PiSelectFieldOption,
|
||||
type SegmentedControlOption,
|
||||
} from '@pi/ui';
|
||||
import { DrawerService } from '@task-stream/web/shared/ui';
|
||||
import {
|
||||
BoardFacade,
|
||||
ProjectFacade,
|
||||
provideBoardFacade,
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
BoardColumn,
|
||||
IssueArea,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
IssueSummary,
|
||||
IssueType,
|
||||
REASON_REQUIRED,
|
||||
ReleaseStatus,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { IssueCardComponent } from '../ui/issue-card';
|
||||
import { AREA, options, PRIORITY, STATUS, TYPE } from '../ui/copy';
|
||||
|
||||
const ALL = '';
|
||||
|
||||
@Component({
|
||||
selector: 'ts-board-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CdkDropListGroup,
|
||||
CdkDropList,
|
||||
CdkDrag,
|
||||
FormField,
|
||||
IssueCardComponent,
|
||||
PiActionButtonComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiSegmentedControlComponent,
|
||||
PiSkeletonComponent,
|
||||
PiTextFieldComponent,
|
||||
PiTextareaComponent,
|
||||
PiLabelDirective,
|
||||
],
|
||||
providers: [provideBoardFacade()],
|
||||
styleUrl: './board.page.scss',
|
||||
template: `
|
||||
<div class="ts-board-toolbar">
|
||||
<pi-select-field
|
||||
class="ts-filter"
|
||||
placeholder="Persona"
|
||||
[options]="assigneeOptions()"
|
||||
[(value)]="assignee"
|
||||
/>
|
||||
<pi-select-field
|
||||
class="ts-filter"
|
||||
placeholder="Release"
|
||||
[options]="releaseOptions()"
|
||||
[(value)]="releaseFilter"
|
||||
/>
|
||||
<pi-select-field
|
||||
class="ts-filter"
|
||||
placeholder="Área"
|
||||
[options]="areaOptions"
|
||||
[(value)]="area"
|
||||
/>
|
||||
<span class="ts-board-total"
|
||||
><strong class="ts-mono">{{ board.total() }}</strong> tickets</span
|
||||
>
|
||||
<span class="ts-spacer"></span>
|
||||
@if (project.canContribute()) {
|
||||
<pi-action-button
|
||||
color="primary"
|
||||
text="Nuevo ticket"
|
||||
(execute)="openCreate()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (board.loading() && !board.columns().length) {
|
||||
<div class="ts-board">
|
||||
@for (i of [1, 2, 3, 4]; track i) {
|
||||
<pi-skeleton shape="block" height="20rem" />
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="ts-board" cdkDropListGroup>
|
||||
@for (column of board.columns(); track column.status) {
|
||||
<section
|
||||
class="ts-column"
|
||||
[attr.data-status]="column.status"
|
||||
[style.--tone]="statusTone(column.status)"
|
||||
>
|
||||
<header class="ts-column__header">
|
||||
<span class="ts-column__name">{{
|
||||
statusLabel(column.status)
|
||||
}}</span>
|
||||
<span class="ts-column__count">{{ column.total }}</span>
|
||||
</header>
|
||||
<div
|
||||
class="ts-column__list"
|
||||
cdkDropList
|
||||
[id]="column.status"
|
||||
[cdkDropListData]="column"
|
||||
[cdkDropListDisabled]="!project.canContribute()"
|
||||
(cdkDropListDropped)="drop($event)"
|
||||
>
|
||||
@for (issue of column.issues; track issue.id) {
|
||||
<ts-issue-card cdkDrag [cdkDragData]="issue" [issue]="issue" />
|
||||
} @empty {
|
||||
<p class="ts-column__empty">Nada por aquí</p>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<ng-template #createForm>
|
||||
<form
|
||||
class="ts-form"
|
||||
(submit)="$event.preventDefault(); create()"
|
||||
novalidate
|
||||
>
|
||||
<pi-segmented-control
|
||||
ariaLabel="Tipo"
|
||||
[options]="typeOptions"
|
||||
[(value)]="newType"
|
||||
/>
|
||||
<pi-text-field [formField]="f.title" [placeholder]="titlePlaceholder()">
|
||||
<pi-label [isRequired]="true">Título</pi-label>
|
||||
</pi-text-field>
|
||||
<pi-textarea
|
||||
[formField]="f.description"
|
||||
placeholder="Pasos para reproducir, criterios de aceptación…"
|
||||
>
|
||||
<pi-label>Descripción</pi-label>
|
||||
</pi-textarea>
|
||||
@if (newType() === 'UI_CHANGE') {
|
||||
<pi-text-field
|
||||
[formField]="f.figmaUrl"
|
||||
placeholder="https://www.figma.com/design/…"
|
||||
>
|
||||
<pi-label>Frame de Figma</pi-label>
|
||||
</pi-text-field>
|
||||
}
|
||||
<div class="ts-form__row">
|
||||
<pi-select-field [formField]="f.priority" [options]="priorityOptions">
|
||||
<pi-label>Prioridad</pi-label>
|
||||
</pi-select-field>
|
||||
<pi-select-field [formField]="f.area" [options]="areaOptions">
|
||||
<pi-label>Área</pi-label>
|
||||
</pi-select-field>
|
||||
</div>
|
||||
<div class="ts-form__row">
|
||||
<pi-select-field
|
||||
[formField]="f.assigneeId"
|
||||
[options]="assigneeOptions()"
|
||||
>
|
||||
<pi-label>Asignar a</pi-label>
|
||||
</pi-select-field>
|
||||
<pi-select-field
|
||||
[formField]="f.releaseId"
|
||||
[options]="releaseOptions()"
|
||||
>
|
||||
<pi-label>Release</pi-label>
|
||||
</pi-select-field>
|
||||
</div>
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
[text]="board.creating() ? 'Creando…' : 'Crear ticket'"
|
||||
[disabled]="f().invalid() || board.creating()"
|
||||
(execute)="create()"
|
||||
/>
|
||||
</form>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #reasonForm>
|
||||
<form
|
||||
class="ts-form"
|
||||
(submit)="$event.preventDefault(); confirmMove()"
|
||||
novalidate
|
||||
>
|
||||
<p class="ts-muted">
|
||||
Explica el motivo: quedará en el historial del ticket.
|
||||
</p>
|
||||
<pi-textarea
|
||||
[formField]="reason.text"
|
||||
placeholder="Sigue fallando en Safari 17…"
|
||||
>
|
||||
<pi-label [isRequired]="true">Motivo</pi-label>
|
||||
</pi-textarea>
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
text="Mover"
|
||||
[disabled]="reason().invalid()"
|
||||
(execute)="confirmMove()"
|
||||
/>
|
||||
</form>
|
||||
</ng-template>
|
||||
`,
|
||||
})
|
||||
export class BoardPage {
|
||||
/** `?release=` (from the releases page), bound by the router. */
|
||||
readonly release = input<string | undefined>();
|
||||
|
||||
protected readonly board = inject(BoardFacade);
|
||||
protected readonly project = inject(ProjectFacade);
|
||||
private readonly drawer = inject(DrawerService);
|
||||
private readonly createForm =
|
||||
viewChild.required<TemplateRef<unknown>>('createForm');
|
||||
private readonly reasonForm =
|
||||
viewChild.required<TemplateRef<unknown>>('reasonForm');
|
||||
|
||||
/* Filters */
|
||||
protected readonly assignee = signal<string | null>(ALL);
|
||||
protected readonly releaseFilter = signal<string | null>(ALL);
|
||||
protected readonly area = signal<string | null>(ALL);
|
||||
|
||||
protected readonly assigneeOptions = computed<PiSelectFieldOption<string>[]>(
|
||||
() => [
|
||||
{ value: ALL, label: 'Todas las personas' },
|
||||
...this.project
|
||||
.members()
|
||||
.map((m) => ({ value: m.user.id, label: m.user.name })),
|
||||
],
|
||||
);
|
||||
protected readonly releaseOptions = computed<PiSelectFieldOption<string>[]>(
|
||||
() => [
|
||||
{ value: ALL, label: 'Todas las releases' },
|
||||
...this.project
|
||||
.releases()
|
||||
.filter((r) => r.status === ReleaseStatus.PLANNED)
|
||||
.map((r) => ({ value: r.id, label: r.name })),
|
||||
],
|
||||
);
|
||||
protected readonly areaOptions: PiSelectFieldOption<string>[] = [
|
||||
{ value: ALL, label: 'Todas las áreas' },
|
||||
...options(AREA),
|
||||
];
|
||||
protected readonly priorityOptions = options(PRIORITY);
|
||||
protected readonly typeOptions: SegmentedControlOption<IssueType>[] =
|
||||
options(TYPE);
|
||||
|
||||
/* New ticket */
|
||||
protected readonly newType = signal<IssueType>(IssueType.BUG);
|
||||
private readonly model = signal({
|
||||
title: '',
|
||||
description: '',
|
||||
figmaUrl: '',
|
||||
priority: IssuePriority.MEDIUM as string,
|
||||
area: ALL as string,
|
||||
assigneeId: ALL as string,
|
||||
releaseId: ALL as string,
|
||||
});
|
||||
protected readonly f = form(this.model, (p) => {
|
||||
required(p.title);
|
||||
maxLength(p.title, 255);
|
||||
});
|
||||
protected readonly titlePlaceholder = computed(() =>
|
||||
this.newType() === IssueType.BUG
|
||||
? 'El botón de pagar no responde en Safari'
|
||||
: 'Qué hay que hacer',
|
||||
);
|
||||
|
||||
/* Move needing a reason */
|
||||
private pendingMove: { issueId: string; to: IssueStatus } | null = null;
|
||||
private readonly reasonModel = signal({ text: '' });
|
||||
protected readonly reason = form(this.reasonModel, (p) => required(p.text));
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const release = this.release();
|
||||
if (release) untracked(() => this.releaseFilter.set(release));
|
||||
});
|
||||
// (Re)load whenever the project or a filter changes.
|
||||
effect(() => {
|
||||
const projectId = this.project.project()?.id;
|
||||
const filter = {
|
||||
assigneeId: this.assignee() || undefined,
|
||||
releaseId: this.releaseFilter() || undefined,
|
||||
area: (this.area() || undefined) as IssueArea | undefined,
|
||||
};
|
||||
if (!projectId) return;
|
||||
untracked(() => this.board.show({ projectId, ...filter }));
|
||||
});
|
||||
}
|
||||
|
||||
protected statusLabel = (status: IssueStatus) => STATUS[status].label;
|
||||
protected statusTone = (status: IssueStatus) => STATUS[status].tone;
|
||||
|
||||
protected drop(
|
||||
event: CdkDragDrop<BoardColumn, BoardColumn, IssueSummary>,
|
||||
): void {
|
||||
if (event.previousContainer === event.container) return;
|
||||
const to = event.container.data.status;
|
||||
const issueId = event.item.data.id;
|
||||
if (REASON_REQUIRED.includes(to)) {
|
||||
this.pendingMove = { issueId, to };
|
||||
this.reasonModel.set({ text: '' });
|
||||
this.drawer.open(`Mover a «${STATUS[to].label}»`, this.reasonForm());
|
||||
return;
|
||||
}
|
||||
this.board.moveIssue(issueId, to);
|
||||
}
|
||||
|
||||
protected confirmMove(): void {
|
||||
if (!this.pendingMove || this.reason().invalid()) return;
|
||||
const { issueId, to } = this.pendingMove;
|
||||
this.board.moveIssue(issueId, to, this.reasonModel().text);
|
||||
this.pendingMove = null;
|
||||
this.drawer.closeAll();
|
||||
}
|
||||
|
||||
protected openCreate(): void {
|
||||
this.model.set({
|
||||
title: '',
|
||||
description: '',
|
||||
figmaUrl: '',
|
||||
priority: IssuePriority.MEDIUM,
|
||||
area: ALL,
|
||||
assigneeId: ALL,
|
||||
releaseId: this.releaseFilter() || ALL,
|
||||
});
|
||||
this.drawer.open('Nuevo ticket', this.createForm(), '36rem');
|
||||
}
|
||||
|
||||
protected create(): void {
|
||||
const projectId = this.project.project()?.id;
|
||||
if (!projectId || this.f().invalid()) return;
|
||||
const v = this.model();
|
||||
this.board.createIssue({
|
||||
projectId,
|
||||
type: this.newType(),
|
||||
title: v.title,
|
||||
description: v.description || null,
|
||||
figmaUrl:
|
||||
this.newType() === IssueType.UI_CHANGE && v.figmaUrl
|
||||
? v.figmaUrl
|
||||
: null,
|
||||
priority: v.priority as IssuePriority,
|
||||
area: (v.area || null) as IssueArea | null,
|
||||
assigneeId: v.assigneeId || null,
|
||||
releaseId: v.releaseId || null,
|
||||
});
|
||||
this.drawer.closeAll();
|
||||
}
|
||||
}
|
||||
335
libs/web/tracker/presenters/src/lib/pages/dashboard.page.ts
Normal file
335
libs/web/tracker/presenters/src/lib/pages/dashboard.page.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { PiSkeletonComponent, PiStatCardComponent } from '@pi/ui';
|
||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
BOARD_COLUMNS,
|
||||
IssueStatus,
|
||||
IssueType,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { initials, STATUS, TYPE } from '../ui/copy';
|
||||
|
||||
/** "Seguimiento": the state of the whole project at a glance (live). */
|
||||
@Component({
|
||||
selector: 'ts-dashboard-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PiSkeletonComponent, PiStatCardComponent],
|
||||
template: `
|
||||
@if (facade.stats(); as stats) {
|
||||
<section class="ts-kpis" aria-label="Resumen">
|
||||
@for (kpi of kpis(); track kpi.label) {
|
||||
<pi-stat-card
|
||||
[icon]="kpi.icon"
|
||||
[label]="kpi.label"
|
||||
[value]="'' + kpi.value"
|
||||
[color]="kpi.color"
|
||||
/>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="ts-panel ts-flow" aria-labelledby="flow-title">
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="flow-title">Flujo</h2>
|
||||
<span class="ts-muted">Dónde está cada ticket del proyecto</span>
|
||||
</header>
|
||||
@if (stats.total) {
|
||||
<div class="ts-flow__bar" role="img" [attr.aria-label]="flowLabel()">
|
||||
@for (segment of flow(); track segment.status) {
|
||||
@if (segment.count) {
|
||||
<span
|
||||
class="ts-flow__segment"
|
||||
[style.--tone]="segment.tone"
|
||||
[style.flex-grow]="segment.count"
|
||||
[attr.title]="segment.label + ': ' + segment.count"
|
||||
></span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<ul class="ts-flow__legend">
|
||||
@for (segment of flow(); track segment.status) {
|
||||
<li
|
||||
[style.--tone]="segment.tone"
|
||||
[class.is-empty]="!segment.count"
|
||||
>
|
||||
<span class="ts-flow__count">{{ segment.count }}</span>
|
||||
<span>{{ segment.label }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="ts-muted">Todavía no hay tickets.</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<div class="ts-dashboard">
|
||||
<section class="ts-panel" aria-labelledby="load-title">
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="load-title">¿Quién está con qué?</h2>
|
||||
<span class="ts-muted">Tickets abiertos</span>
|
||||
</header>
|
||||
<ul class="ts-rows">
|
||||
@for (row of stats.byAssignee; track row.user?.id ?? 'none') {
|
||||
<li>
|
||||
@if (row.user; as user) {
|
||||
<span class="ts-who">{{ initialsOf(user.name) }}</span>
|
||||
<span class="ts-rows__name">{{ user.name }}</span>
|
||||
} @else {
|
||||
<span class="ts-who ts-who--none">—</span>
|
||||
<span class="ts-rows__name ts-muted">Sin asignar</span>
|
||||
}
|
||||
<span class="ts-bar"
|
||||
><span [style.width.%]="(row.count / maxLoad()) * 100"></span
|
||||
></span>
|
||||
<span class="ts-rows__count">{{ row.count }}</span>
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="ts-muted">Nada abierto.</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="ts-panel" aria-labelledby="type-title">
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="type-title">Por tipo</h2>
|
||||
<span class="ts-muted">Todos los tickets</span>
|
||||
</header>
|
||||
<ul class="ts-rows ts-rows--types">
|
||||
@for (row of stats.byType; track row.key) {
|
||||
<li [style.--tone]="typeOf(row.key).tone">
|
||||
<span class="ts-rows__name">{{ typeOf(row.key).label }}</span>
|
||||
<span class="ts-bar ts-bar--tone"
|
||||
><span
|
||||
[style.width.%]="(row.count / stats.total) * 100"
|
||||
></span
|
||||
></span>
|
||||
<span class="ts-rows__count">{{ row.count }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
} @else {
|
||||
<pi-skeleton shape="block" height="12rem" />
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
/* Same stat row as pireon.dev's hero. */
|
||||
.ts-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||
gap: 1.25rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.ts-panel {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.ts-panel__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1.125rem;
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ts-muted {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
.ts-flow {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.ts-flow__bar {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
height: 2.25rem;
|
||||
}
|
||||
.ts-flow__segment {
|
||||
flex-basis: 0;
|
||||
min-width: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
background: var(--tone);
|
||||
}
|
||||
.ts-flow__legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.5rem;
|
||||
margin: 1rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4375rem;
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ts-ink-soft);
|
||||
&::before {
|
||||
content: '';
|
||||
align-self: center;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 2px;
|
||||
background: var(--tone);
|
||||
}
|
||||
}
|
||||
li.is-empty {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
.ts-flow__count {
|
||||
font-family: var(--ts-font-mono);
|
||||
font-weight: 600;
|
||||
color: var(--ts-ink);
|
||||
}
|
||||
.ts-dashboard {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.ts-rows {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
li {
|
||||
display: grid;
|
||||
grid-template-columns: 1.75rem minmax(6rem, 9rem) 1fr 2rem;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
.ts-rows--types li {
|
||||
grid-template-columns: minmax(6rem, 9rem) 1fr 2rem;
|
||||
}
|
||||
.ts-rows__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ts-rows__count {
|
||||
font-family: var(--ts-font-mono);
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
.ts-who {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--ts-brand) 16%, var(--ts-surface));
|
||||
color: color-mix(in srgb, var(--ts-brand) 70%, var(--ts-ink));
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ts-who--none {
|
||||
background: transparent;
|
||||
border: 1px dashed var(--ts-line-strong);
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
.ts-bar {
|
||||
height: 0.625rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--ts-sunken);
|
||||
overflow: hidden;
|
||||
span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--ts-brand);
|
||||
}
|
||||
}
|
||||
.ts-bar--tone span {
|
||||
background: var(--tone);
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class DashboardPage {
|
||||
protected readonly facade = inject(ProjectFacade);
|
||||
protected readonly initialsOf = initials;
|
||||
|
||||
private count(status: IssueStatus): number {
|
||||
return (
|
||||
this.facade.stats()?.byStatus.find((s) => s.key === status)?.count ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
protected readonly kpis = computed<
|
||||
{
|
||||
label: string;
|
||||
value: number;
|
||||
icon: string;
|
||||
color: 'primary' | 'accent' | 'warn' | 'highlighted' | 'success';
|
||||
}[]
|
||||
>(() => {
|
||||
const stats = this.facade.stats();
|
||||
if (!stats) return [];
|
||||
const bugs = stats.byType.find((t) => t.key === IssueType.BUG)?.count ?? 0;
|
||||
return [
|
||||
{ label: 'Tickets', value: stats.total, icon: 'list', color: 'primary' },
|
||||
{
|
||||
label: 'Abiertos',
|
||||
value: stats.open,
|
||||
icon: 'time-detail',
|
||||
color: 'accent',
|
||||
},
|
||||
{ label: 'Bugs (total)', value: bugs, icon: 'bug', color: 'warn' },
|
||||
{
|
||||
label: 'Pendientes de QA',
|
||||
value:
|
||||
this.count(IssueStatus.READY_FOR_QA) + this.count(IssueStatus.IN_QA),
|
||||
icon: 'qa',
|
||||
color: 'highlighted',
|
||||
},
|
||||
{
|
||||
label: 'QA fallidos',
|
||||
value: this.count(IssueStatus.QA_FAILED),
|
||||
icon: 'exclamation',
|
||||
color: 'warn',
|
||||
},
|
||||
{
|
||||
label: 'Hechos',
|
||||
value: this.count(IssueStatus.DONE),
|
||||
icon: 'check-rounded',
|
||||
color: 'success',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
/** Statuses in workflow order (cancelled at the end). */
|
||||
protected readonly flow = computed(() =>
|
||||
[...BOARD_COLUMNS, IssueStatus.CANCELLED].map((status) => ({
|
||||
status,
|
||||
label: STATUS[status].label,
|
||||
tone: STATUS[status].tone,
|
||||
count: this.count(status),
|
||||
})),
|
||||
);
|
||||
protected readonly flowLabel = computed(() =>
|
||||
this.flow()
|
||||
.filter((s) => s.count)
|
||||
.map((s) => `${s.label}: ${s.count}`)
|
||||
.join(', '),
|
||||
);
|
||||
|
||||
protected readonly maxLoad = computed(() =>
|
||||
Math.max(
|
||||
1,
|
||||
...(this.facade.stats()?.byAssignee.map((r) => r.count) ?? [1]),
|
||||
),
|
||||
);
|
||||
protected readonly typeOf = (key: string) =>
|
||||
TYPE[key as IssueType] ?? { label: key, tone: 'var(--ts-muted)' };
|
||||
}
|
||||
340
libs/web/tracker/presenters/src/lib/pages/issue.page.scss
Normal file
340
libs/web/tracker/presenters/src/lib/pages/issue.page.scss
Normal file
@@ -0,0 +1,340 @@
|
||||
.ts-back {
|
||||
display: inline-block;
|
||||
margin-bottom: 1.25rem;
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ts-muted);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--ts-ink);
|
||||
}
|
||||
}
|
||||
|
||||
.ts-issue {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 20rem;
|
||||
gap: 2.5rem;
|
||||
align-items: start;
|
||||
|
||||
@media (max-width: 960px) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.ts-issue__main {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// Header: key · type · status, then the title.
|
||||
.ts-issue__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ts-type {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3125rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--tone);
|
||||
}
|
||||
|
||||
.ts-issue__title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
|
||||
h1 {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: clamp(1.625rem, 1.2rem + 1.4vw, 2.375rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
|
||||
// Workflow: one button per allowed destination, in that status' colour.
|
||||
.ts-transitions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
border: 1px dashed var(--ts-line-strong);
|
||||
border-radius: 0.75rem;
|
||||
|
||||
.ts-eyebrow {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-move {
|
||||
--tone: var(--ts-muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4375rem 0.75rem 0.4375rem 0.625rem;
|
||||
border: 1px solid color-mix(in srgb, var(--tone) 35%, var(--ts-line));
|
||||
border-radius: 999px;
|
||||
background: var(--ts-surface);
|
||||
color: var(--ts-ink);
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--tone);
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--tone);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--tone) 10%, var(--ts-surface));
|
||||
border-color: var(--tone);
|
||||
|
||||
span {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--tone);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// Sections: small uppercase label over a rule (pireon.dev's eyebrow, in grey).
|
||||
.ts-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 0.875rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--ts-line);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ts-muted);
|
||||
|
||||
.ts-mono {
|
||||
color: var(--ts-ink-soft);
|
||||
}
|
||||
}
|
||||
|
||||
.ts-block > p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.ts-figma {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--ts-t-ui) 10%, var(--ts-surface));
|
||||
color: color-mix(in srgb, var(--ts-t-ui) 80%, var(--ts-ink));
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ts-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.375rem 0;
|
||||
}
|
||||
|
||||
.ts-link__kind {
|
||||
min-width: 9rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
|
||||
a.ts-key {
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--ts-brand);
|
||||
color: var(--ts-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.ts-inline-form {
|
||||
display: grid;
|
||||
grid-template-columns: 12rem minmax(0, 1fr) auto;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-top: 0.875rem;
|
||||
|
||||
@media (max-width: 640px) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
// Comments: a thread with initials on a rail.
|
||||
.ts-comment {
|
||||
display: grid;
|
||||
grid-template-columns: 2rem minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 0;
|
||||
border-bottom: 1px solid var(--ts-line);
|
||||
}
|
||||
|
||||
.ts-comment__avatar {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--ts-brand) 16%, var(--ts-surface));
|
||||
color: color-mix(in srgb, var(--ts-brand) 70%, var(--ts-ink));
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ts-comment__body {
|
||||
min-width: 0;
|
||||
|
||||
p {
|
||||
margin: 0.25rem 0 0;
|
||||
line-height: 1.55;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-comment__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.ts-comment__when {
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
|
||||
.ts-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// History as a log: time · what · how long ago.
|
||||
.ts-log {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: grid;
|
||||
grid-template-columns: 3.75rem minmax(0, 1fr) auto;
|
||||
gap: 0.875rem;
|
||||
align-items: baseline;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px dashed var(--ts-line);
|
||||
font-size: 0.875rem;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ts-log__when,
|
||||
.ts-log__ago {
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
|
||||
.ts-log__what {
|
||||
color: var(--ts-ink-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.ts-log li {
|
||||
grid-template-columns: 3.25rem minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.ts-log__ago {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Side: the ticket's sheet.
|
||||
.ts-issue__side {
|
||||
display: grid;
|
||||
gap: 1.375rem;
|
||||
|
||||
> .ts-eyebrow {
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
position: sticky;
|
||||
top: 5.5rem;
|
||||
padding: 1.5rem;
|
||||
|
||||
@media (max-width: 960px) {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-facts {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.625rem 1rem;
|
||||
margin: 0.25rem 0 0;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px dashed var(--ts-line);
|
||||
font-size: 0.875rem;
|
||||
|
||||
dt {
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ts-muted);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ts-mono {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.ts-label {
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ts-brand);
|
||||
}
|
||||
615
libs/web/tracker/presenters/src/lib/pages/issue.page.ts
Normal file
615
libs/web/tracker/presenters/src/lib/pages/issue.page.ts
Normal file
@@ -0,0 +1,615 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
TemplateRef,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { form, FormField, required } from '@angular/forms/signals';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiIconButtonComponent,
|
||||
PiIconComponent,
|
||||
PiLabelDirective,
|
||||
PiNoteComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiSkeletonComponent,
|
||||
PiTextareaComponent,
|
||||
PiTextFieldComponent,
|
||||
type PiSelectFieldOption,
|
||||
} from '@pi/ui';
|
||||
import { DrawerService } from '@task-stream/web/shared/ui';
|
||||
import {
|
||||
IssueFacade,
|
||||
ProjectFacade,
|
||||
provideIssueFacade,
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
Comment,
|
||||
IssueArea,
|
||||
IssueLink,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
REASON_REQUIRED,
|
||||
ReleaseStatus,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import {
|
||||
AREA,
|
||||
clock,
|
||||
initials,
|
||||
LINK,
|
||||
options,
|
||||
PRIORITY,
|
||||
STATUS,
|
||||
TYPE,
|
||||
timeAgo,
|
||||
} from '../ui/copy';
|
||||
|
||||
const NONE = '';
|
||||
|
||||
@Component({
|
||||
selector: 'ts-issue-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
RouterLink,
|
||||
FormField,
|
||||
PiActionButtonComponent,
|
||||
PiIconComponent,
|
||||
PiIconButtonComponent,
|
||||
PiLabelDirective,
|
||||
PiNoteComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiSkeletonComponent,
|
||||
PiTextFieldComponent,
|
||||
PiTextareaComponent,
|
||||
],
|
||||
providers: [provideIssueFacade()],
|
||||
styleUrl: './issue.page.scss',
|
||||
template: `
|
||||
<a class="ts-back" routerLink="../../board">← Tablero</a>
|
||||
|
||||
@if (facade.issue(); as issue) {
|
||||
<div class="ts-issue">
|
||||
<section class="ts-issue__main">
|
||||
<header class="ts-issue__header">
|
||||
<div class="ts-issue__meta">
|
||||
<span class="ts-key">{{ issue.key }}</span>
|
||||
<span class="ts-type" [style.--tone]="type().tone">
|
||||
<pi-icon [icon]="type().icon" size="x-small" />
|
||||
{{ type().label }}
|
||||
</span>
|
||||
<span class="ts-pill" [style.--tone]="status().tone">{{
|
||||
status().label
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="ts-issue__title-row">
|
||||
<h1>{{ issue.title }}</h1>
|
||||
@if (project.canContribute()) {
|
||||
<pi-icon-button
|
||||
icon="edit"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
aria-label="Editar"
|
||||
(execute)="openEdit()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (project.canContribute() && issue.allowedTransitions.length) {
|
||||
<div class="ts-transitions" role="group" aria-label="Mover a">
|
||||
<span class="ts-eyebrow">Mover a</span>
|
||||
@for (to of issue.allowedTransitions; track to) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-move"
|
||||
[style.--tone]="statusTone(to)"
|
||||
(click)="move(to)"
|
||||
>
|
||||
{{ statusLabel(to) }}
|
||||
<span aria-hidden="true">→</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<article class="ts-block">
|
||||
<h2 class="ts-section">Descripción</h2>
|
||||
@if (issue.description) {
|
||||
<p class="ts-prewrap">{{ issue.description }}</p>
|
||||
} @else {
|
||||
<p class="ts-muted">Sin descripción.</p>
|
||||
}
|
||||
@if (issue.figmaUrl) {
|
||||
<a
|
||||
class="ts-figma"
|
||||
[href]="issue.figmaUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<pi-icon icon="figma" size="small" /> Ver el diseño en Figma
|
||||
</a>
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="ts-block">
|
||||
<h2 class="ts-section">Relaciones</h2>
|
||||
@for (
|
||||
link of facade.outwardLinks();
|
||||
track link.issueId + link.type
|
||||
) {
|
||||
<div class="ts-link">
|
||||
<span class="ts-link__kind">{{ linkLabel(link) }}</span>
|
||||
<a class="ts-key" [routerLink]="['..', link.issueKey]">{{
|
||||
link.issueKey
|
||||
}}</a>
|
||||
@if (project.canContribute()) {
|
||||
<pi-icon-button
|
||||
icon="close"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
aria-label="Quitar"
|
||||
(execute)="unlink(link)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@for (
|
||||
link of facade.inwardLinks();
|
||||
track link.issueId + link.type
|
||||
) {
|
||||
<div class="ts-link">
|
||||
<span class="ts-link__kind">{{ linkLabel(link) }}</span>
|
||||
<a class="ts-key" [routerLink]="['..', link.issueKey]">{{
|
||||
link.issueKey
|
||||
}}</a>
|
||||
</div>
|
||||
}
|
||||
@if (!issue.links.length) {
|
||||
<p class="ts-muted">
|
||||
Sin relaciones. Vincula FE ↔ BE ↔ QA para seguir el trabajo de
|
||||
punta a punta.
|
||||
</p>
|
||||
}
|
||||
@if (project.canContribute()) {
|
||||
<form
|
||||
class="ts-inline-form"
|
||||
(submit)="$event.preventDefault(); link()"
|
||||
novalidate
|
||||
>
|
||||
<pi-select-field
|
||||
[options]="linkTypeOptions"
|
||||
[(value)]="linkType"
|
||||
/>
|
||||
<pi-text-field
|
||||
placeholder="Clave, p. ej. BE-3"
|
||||
[(value)]="linkTarget"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="primary"
|
||||
text="Relacionar"
|
||||
[disabled]="!linkTarget().trim()"
|
||||
(execute)="link()"
|
||||
/>
|
||||
</form>
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="ts-block">
|
||||
<h2 class="ts-section">
|
||||
Comentarios
|
||||
<span class="ts-mono">{{ facade.comments().length }}</span>
|
||||
</h2>
|
||||
@for (comment of facade.comments(); track comment.id) {
|
||||
<div class="ts-comment">
|
||||
<span class="ts-comment__avatar" aria-hidden="true">{{
|
||||
initialsOf(comment.author.name)
|
||||
}}</span>
|
||||
<div class="ts-comment__body">
|
||||
<div class="ts-comment__head">
|
||||
<strong>{{ comment.author.name }}</strong>
|
||||
<span class="ts-comment__when"
|
||||
>{{ ago(comment.createdAt)
|
||||
}}{{ comment.editedAt ? ' · editado' : '' }}</span
|
||||
>
|
||||
<span class="ts-spacer"></span>
|
||||
@if (comment.author.id === facade.me()) {
|
||||
<pi-icon-button
|
||||
icon="edit"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
aria-label="Editar"
|
||||
(execute)="openEditComment(comment)"
|
||||
/>
|
||||
}
|
||||
@if (
|
||||
comment.author.id === facade.me() || project.canManage()
|
||||
) {
|
||||
<pi-icon-button
|
||||
icon="trash"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
aria-label="Eliminar"
|
||||
(execute)="deleteComment(comment)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
<p class="ts-prewrap">{{ comment.body }}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (project.canContribute()) {
|
||||
<form
|
||||
class="ts-form"
|
||||
(submit)="$event.preventDefault(); addComment()"
|
||||
novalidate
|
||||
>
|
||||
<pi-textarea
|
||||
placeholder="Deja una decisión, una explicación, un paso para reproducir…"
|
||||
[(value)]="commentBody"
|
||||
/>
|
||||
<div>
|
||||
<pi-action-button
|
||||
color="primary"
|
||||
text="Comentar"
|
||||
[disabled]="!commentBody().trim()"
|
||||
(execute)="addComment()"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</article>
|
||||
|
||||
<article class="ts-block">
|
||||
<h2 class="ts-section">Historial</h2>
|
||||
<ol class="ts-log">
|
||||
@for (entry of facade.history(); track entry.id) {
|
||||
<li>
|
||||
<time
|
||||
class="ts-log__when"
|
||||
[attr.datetime]="entry.occurredAt"
|
||||
>{{ clock(entry.occurredAt) }}</time
|
||||
>
|
||||
<span class="ts-log__what">{{ entry.summary }}</span>
|
||||
<span class="ts-log__ago">{{ ago(entry.occurredAt) }}</span>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<aside class="ts-issue__side ts-panel">
|
||||
<p class="ts-eyebrow">Ficha</p>
|
||||
<pi-select-field
|
||||
[options]="assigneeOptions()"
|
||||
[disabled]="!project.canContribute()"
|
||||
[value]="issue.assignee?.id ?? ''"
|
||||
(valueChange)="assign($event)"
|
||||
>
|
||||
<pi-label>Asignado</pi-label>
|
||||
</pi-select-field>
|
||||
<pi-select-field
|
||||
[options]="releaseOptions()"
|
||||
[disabled]="!project.canContribute()"
|
||||
[value]="issue.release?.id ?? ''"
|
||||
(valueChange)="setRelease($event)"
|
||||
>
|
||||
<pi-label>Release</pi-label>
|
||||
</pi-select-field>
|
||||
<pi-select-field
|
||||
[options]="priorityOptions"
|
||||
[disabled]="!project.canContribute()"
|
||||
[value]="issue.priority"
|
||||
(valueChange)="update({ priority: $any($event) })"
|
||||
>
|
||||
<pi-label>Prioridad</pi-label>
|
||||
</pi-select-field>
|
||||
<pi-select-field
|
||||
[options]="areaOptions"
|
||||
[disabled]="!project.canContribute()"
|
||||
[value]="issue.area ?? ''"
|
||||
(valueChange)="update({ area: $any($event || null) })"
|
||||
>
|
||||
<pi-label>Área</pi-label>
|
||||
</pi-select-field>
|
||||
|
||||
<dl class="ts-facts">
|
||||
<dt>Reportado por</dt>
|
||||
<dd>{{ issue.reporter.name }}</dd>
|
||||
<dt>Creado</dt>
|
||||
<dd class="ts-mono">{{ ago(issue.createdAt) }}</dd>
|
||||
<dt>Actualizado</dt>
|
||||
<dd class="ts-mono">{{ ago(issue.updatedAt) }}</dd>
|
||||
@if (issue.labels.length) {
|
||||
<dt>Etiquetas</dt>
|
||||
<dd class="ts-labels">
|
||||
@for (label of issue.labels; track label) {
|
||||
<span class="ts-label">#{{ label }}</span>
|
||||
}
|
||||
</dd>
|
||||
}
|
||||
</dl>
|
||||
</aside>
|
||||
</div>
|
||||
} @else if (facade.error(); as error) {
|
||||
<pi-note
|
||||
gap="xs"
|
||||
status="error"
|
||||
appearance="fill"
|
||||
type="status"
|
||||
title="No se pudo abrir el ticket"
|
||||
[text]="error"
|
||||
/>
|
||||
} @else {
|
||||
<pi-skeleton shape="text" [lines]="8" />
|
||||
}
|
||||
|
||||
<ng-template #reasonForm>
|
||||
<form
|
||||
class="ts-form"
|
||||
(submit)="$event.preventDefault(); confirmMove()"
|
||||
novalidate
|
||||
>
|
||||
<p class="ts-muted">
|
||||
Explica el motivo: quedará en el historial del ticket.
|
||||
</p>
|
||||
<pi-textarea
|
||||
[formField]="reason.text"
|
||||
placeholder="Qué ha fallado y cómo reproducirlo"
|
||||
>
|
||||
<pi-label [isRequired]="true">Motivo</pi-label>
|
||||
</pi-textarea>
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
text="Mover"
|
||||
[disabled]="reason().invalid()"
|
||||
(execute)="confirmMove()"
|
||||
/>
|
||||
</form>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #editForm>
|
||||
<form
|
||||
class="ts-form"
|
||||
(submit)="$event.preventDefault(); saveEdit()"
|
||||
novalidate
|
||||
>
|
||||
<pi-text-field [formField]="edit.title">
|
||||
<pi-label [isRequired]="true">Título</pi-label>
|
||||
</pi-text-field>
|
||||
<pi-textarea [formField]="edit.description">
|
||||
<pi-label>Descripción</pi-label>
|
||||
</pi-textarea>
|
||||
<pi-text-field [formField]="edit.labels" placeholder="safari, checkout">
|
||||
<pi-label>Etiquetas (separadas por comas)</pi-label>
|
||||
</pi-text-field>
|
||||
<pi-text-field
|
||||
[formField]="edit.figmaUrl"
|
||||
placeholder="https://www.figma.com/design/…"
|
||||
>
|
||||
<pi-label>Frame de Figma</pi-label>
|
||||
</pi-text-field>
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
text="Guardar"
|
||||
[disabled]="edit().invalid()"
|
||||
(execute)="saveEdit()"
|
||||
/>
|
||||
</form>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #commentEditForm>
|
||||
<form
|
||||
class="ts-form"
|
||||
(submit)="$event.preventDefault(); saveComment()"
|
||||
novalidate
|
||||
>
|
||||
<pi-textarea [formField]="commentEdit.body" />
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
text="Guardar"
|
||||
[disabled]="commentEdit().invalid()"
|
||||
(execute)="saveComment()"
|
||||
/>
|
||||
</form>
|
||||
</ng-template>
|
||||
`,
|
||||
})
|
||||
export class IssuePage {
|
||||
/** Route param (`withComponentInputBinding`). */
|
||||
readonly issueKey = input.required<string>();
|
||||
|
||||
protected readonly facade = inject(IssueFacade);
|
||||
protected readonly project = inject(ProjectFacade);
|
||||
private readonly drawer = inject(DrawerService);
|
||||
private readonly reasonForm =
|
||||
viewChild.required<TemplateRef<unknown>>('reasonForm');
|
||||
private readonly editForm =
|
||||
viewChild.required<TemplateRef<unknown>>('editForm');
|
||||
private readonly commentEditForm =
|
||||
viewChild.required<TemplateRef<unknown>>('commentEditForm');
|
||||
|
||||
protected readonly type = computed(
|
||||
() => TYPE[this.facade.issue()?.type ?? 'TASK'],
|
||||
);
|
||||
protected readonly status = computed(
|
||||
() => STATUS[this.facade.issue()?.status ?? IssueStatus.BACKLOG],
|
||||
);
|
||||
protected readonly ago = timeAgo;
|
||||
|
||||
protected readonly assigneeOptions = computed<PiSelectFieldOption<string>[]>(
|
||||
() => [
|
||||
{ value: NONE, label: 'Sin asignar' },
|
||||
...this.project
|
||||
.members()
|
||||
.map((m) => ({ value: m.user.id, label: m.user.name })),
|
||||
],
|
||||
);
|
||||
protected readonly releaseOptions = computed<PiSelectFieldOption<string>[]>(
|
||||
() => [
|
||||
{ value: NONE, label: 'Sin release' },
|
||||
...this.project
|
||||
.releases()
|
||||
.filter(
|
||||
(r) =>
|
||||
r.status === ReleaseStatus.PLANNED ||
|
||||
r.id === this.facade.issue()?.release?.id,
|
||||
)
|
||||
.map((r) => ({ value: r.id, label: r.name })),
|
||||
],
|
||||
);
|
||||
protected readonly priorityOptions = options(PRIORITY);
|
||||
protected readonly areaOptions: PiSelectFieldOption<string>[] = [
|
||||
{ value: NONE, label: 'Sin área' },
|
||||
...options(AREA),
|
||||
];
|
||||
protected readonly linkTypeOptions = (
|
||||
Object.keys(LINK) as IssueLinkType[]
|
||||
).map((value) => ({
|
||||
value,
|
||||
label: LINK[value].outward,
|
||||
}));
|
||||
|
||||
// Inline forms: plain signals (a required validator would flag them red right after submitting).
|
||||
protected readonly linkType = signal<string | null>(IssueLinkType.RELATES_TO);
|
||||
protected readonly linkTarget = signal('');
|
||||
protected readonly commentBody = signal('');
|
||||
|
||||
private pendingStatus: IssueStatus | null = null;
|
||||
private readonly reasonModel = signal({ text: '' });
|
||||
protected readonly reason = form(this.reasonModel, (p) => required(p.text));
|
||||
|
||||
private readonly editModel = signal({
|
||||
title: '',
|
||||
description: '',
|
||||
labels: '',
|
||||
figmaUrl: '',
|
||||
});
|
||||
protected readonly edit = form(this.editModel, (p) => required(p.title));
|
||||
|
||||
private editingComment: string | null = null;
|
||||
private readonly commentEditModel = signal({ body: '' });
|
||||
protected readonly commentEdit = form(this.commentEditModel, (p) =>
|
||||
required(p.body),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => this.facade.open(this.issueKey()));
|
||||
}
|
||||
|
||||
protected statusLabel = (status: IssueStatus) => STATUS[status].label;
|
||||
protected statusTone = (status: IssueStatus) => STATUS[status].tone;
|
||||
protected readonly initialsOf = initials;
|
||||
protected readonly clock = clock;
|
||||
protected linkLabel = (link: IssueLink) =>
|
||||
link.direction === 'OUTWARD'
|
||||
? LINK[link.type].outward
|
||||
: LINK[link.type].inward;
|
||||
|
||||
protected move(to: IssueStatus): void {
|
||||
if (REASON_REQUIRED.includes(to)) {
|
||||
this.pendingStatus = to;
|
||||
this.reasonModel.set({ text: '' });
|
||||
this.drawer.open(`Mover a «${STATUS[to].label}»`, this.reasonForm());
|
||||
return;
|
||||
}
|
||||
this.facade.changeStatus(to);
|
||||
}
|
||||
|
||||
protected confirmMove(): void {
|
||||
if (!this.pendingStatus || this.reason().invalid()) return;
|
||||
this.facade.changeStatus(this.pendingStatus, this.reasonModel().text);
|
||||
this.pendingStatus = null;
|
||||
this.drawer.closeAll();
|
||||
}
|
||||
|
||||
protected assign(assigneeId: string | null): void {
|
||||
this.facade.assign(assigneeId || null);
|
||||
}
|
||||
|
||||
protected setRelease(releaseId: string | null): void {
|
||||
this.facade.setRelease(releaseId || null);
|
||||
}
|
||||
|
||||
protected update(changes: {
|
||||
priority?: IssuePriority;
|
||||
area?: IssueArea | null;
|
||||
}): void {
|
||||
this.facade.update(changes);
|
||||
}
|
||||
|
||||
protected link(): void {
|
||||
const targetKey = this.linkTarget().trim();
|
||||
if (!targetKey) return;
|
||||
this.facade.link(
|
||||
(this.linkType() ?? IssueLinkType.RELATES_TO) as IssueLinkType,
|
||||
targetKey,
|
||||
);
|
||||
this.linkTarget.set('');
|
||||
}
|
||||
|
||||
protected unlink(link: IssueLink): void {
|
||||
this.facade.unlink(link);
|
||||
}
|
||||
|
||||
protected addComment(): void {
|
||||
const body = this.commentBody().trim();
|
||||
if (!body) return;
|
||||
this.facade.addComment(body);
|
||||
this.commentBody.set('');
|
||||
}
|
||||
|
||||
protected openEditComment(comment: Comment): void {
|
||||
this.editingComment = comment.id;
|
||||
this.commentEditModel.set({ body: comment.body });
|
||||
this.drawer.open('Editar comentario', this.commentEditForm());
|
||||
}
|
||||
|
||||
protected saveComment(): void {
|
||||
if (!this.editingComment || this.commentEdit().invalid()) return;
|
||||
this.facade.editComment(this.editingComment, this.commentEditModel().body);
|
||||
this.drawer.closeAll();
|
||||
}
|
||||
|
||||
protected deleteComment(comment: Comment): void {
|
||||
if (confirm('¿Eliminar este comentario?'))
|
||||
this.facade.deleteComment(comment.id);
|
||||
}
|
||||
|
||||
protected openEdit(): void {
|
||||
const issue = this.facade.issue();
|
||||
if (!issue) return;
|
||||
this.editModel.set({
|
||||
title: issue.title,
|
||||
description: issue.description ?? '',
|
||||
labels: issue.labels.join(', '),
|
||||
figmaUrl: issue.figmaUrl ?? '',
|
||||
});
|
||||
this.drawer.open(`Editar ${issue.key}`, this.editForm(), '36rem');
|
||||
}
|
||||
|
||||
protected saveEdit(): void {
|
||||
if (this.edit().invalid()) return;
|
||||
const v = this.editModel();
|
||||
this.facade.update({
|
||||
title: v.title,
|
||||
description: v.description || null,
|
||||
labels: v.labels
|
||||
.split(',')
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean),
|
||||
figmaUrl: v.figmaUrl || null,
|
||||
});
|
||||
this.drawer.closeAll();
|
||||
}
|
||||
}
|
||||
273
libs/web/tracker/presenters/src/lib/pages/members.page.ts
Normal file
273
libs/web/tracker/presenters/src/lib/pages/members.page.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiIconButtonComponent,
|
||||
PiLabelDirective,
|
||||
PiTextFieldComponent,
|
||||
PiSelectFieldComponent,
|
||||
type PiSelectFieldOption,
|
||||
} from '@pi/ui';
|
||||
import { AuthFacade } from '@task-stream/web/identity/application';
|
||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||
import { ProjectMember, ProjectRole } from '@task-stream/web/tracker/domain';
|
||||
import { initials, ROLE } from '../ui/copy';
|
||||
|
||||
const ROLE_TONE: Record<ProjectRole, string> = {
|
||||
[ProjectRole.OWNER]: 'var(--ts-brand)',
|
||||
[ProjectRole.ADMIN]: 'var(--ts-s-review)',
|
||||
[ProjectRole.MEMBER]: 'var(--ts-s-todo)',
|
||||
[ProjectRole.VIEWER]: 'var(--ts-s-cancelled)',
|
||||
};
|
||||
|
||||
/** Team of the project. Users come from Pireon. */
|
||||
@Component({
|
||||
selector: 'ts-members-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
PiActionButtonComponent,
|
||||
PiIconButtonComponent,
|
||||
PiSelectFieldComponent,
|
||||
PiTextFieldComponent,
|
||||
PiLabelDirective,
|
||||
],
|
||||
template: `
|
||||
<div class="ts-members">
|
||||
<section class="ts-roster ts-panel" aria-labelledby="team-title">
|
||||
<header class="ts-roster__head">
|
||||
<h2 id="team-title">Equipo</h2>
|
||||
<span class="ts-muted ts-mono"
|
||||
>{{ facade.members().length }} personas</span
|
||||
>
|
||||
</header>
|
||||
<ul class="ts-member-list">
|
||||
@for (member of facade.members(); track member.user.id) {
|
||||
<li>
|
||||
<span class="ts-initials" aria-hidden="true">{{
|
||||
initialsOf(member.user.name)
|
||||
}}</span>
|
||||
<span class="ts-member-list__name"
|
||||
>{{ member.user.name }}
|
||||
@if (member.user.id === me()) {
|
||||
<span class="ts-you">tú</span>
|
||||
}
|
||||
</span>
|
||||
@if (facade.canManage() && member.role !== 'OWNER') {
|
||||
<pi-select-field
|
||||
class="ts-role"
|
||||
[options]="roleOptions"
|
||||
[value]="member.role"
|
||||
(valueChange)="changeRole(member, $event)"
|
||||
/>
|
||||
<pi-icon-button
|
||||
icon="trash"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
aria-label="Quitar"
|
||||
(execute)="remove(member)"
|
||||
/>
|
||||
} @else {
|
||||
<span class="ts-pill" [style.--tone]="roleTone(member.role)">{{
|
||||
roleLabel(member.role)
|
||||
}}</span>
|
||||
@if (member.user.id === me() && member.role !== 'OWNER') {
|
||||
<pi-action-button
|
||||
size="small"
|
||||
appearance="outline"
|
||||
color="grey"
|
||||
text="Salir"
|
||||
(execute)="remove(member)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@if (facade.canManage()) {
|
||||
<section class="ts-add ts-panel" aria-labelledby="add-title">
|
||||
<h2 id="add-title">Añadir persona</h2>
|
||||
<p class="ts-muted">Busca en el directorio de Pireon.</p>
|
||||
<pi-text-field
|
||||
placeholder="Nombre o email"
|
||||
[value]="term()"
|
||||
(valueChange)="search($event)"
|
||||
>
|
||||
<pi-label>Buscar</pi-label>
|
||||
</pi-text-field>
|
||||
<pi-select-field [options]="roleOptions" [(value)]="newRole">
|
||||
<pi-label>Rol</pi-label>
|
||||
</pi-select-field>
|
||||
<ul class="ts-member-list">
|
||||
@for (user of candidates(); track user.id) {
|
||||
<li>
|
||||
<span class="ts-initials" aria-hidden="true">{{
|
||||
initialsOf(user.name)
|
||||
}}</span>
|
||||
<span class="ts-member-list__name"
|
||||
>{{ user.name }}
|
||||
<span class="ts-member-list__email">{{
|
||||
user.email
|
||||
}}</span></span
|
||||
>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="primary"
|
||||
text="Añadir"
|
||||
(execute)="add(user.id)"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
.ts-members {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 24rem;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
@media (max-width: 960px) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
.ts-roster,
|
||||
.ts-add {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.ts-roster__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ts-muted {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
.ts-member-list {
|
||||
display: grid;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-height: 3rem;
|
||||
margin: 0;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px dashed var(--ts-line);
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
.ts-initials {
|
||||
flex: none;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--ts-brand) 16%, var(--ts-surface));
|
||||
color: color-mix(in srgb, var(--ts-brand) 70%, var(--ts-ink));
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ts-member-list__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ts-member-list__email {
|
||||
display: block;
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
.ts-you {
|
||||
margin-left: 0.25rem;
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
.ts-role {
|
||||
width: 10rem;
|
||||
@media (max-width: 480px) {
|
||||
width: 8rem;
|
||||
}
|
||||
}
|
||||
.ts-add {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
p {
|
||||
margin: -0.875rem 0 0;
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MembersPage {
|
||||
protected readonly facade = inject(ProjectFacade);
|
||||
private readonly auth = inject(AuthFacade);
|
||||
|
||||
protected readonly me = computed(() => this.auth.user()?.id);
|
||||
protected readonly term = signal('');
|
||||
protected readonly newRole = signal<string | null>(ProjectRole.MEMBER);
|
||||
protected readonly roleOptions: PiSelectFieldOption<string>[] = [
|
||||
ProjectRole.ADMIN,
|
||||
ProjectRole.MEMBER,
|
||||
ProjectRole.VIEWER,
|
||||
].map((value) => ({ value, label: ROLE[value] }));
|
||||
protected readonly candidates = computed(() => {
|
||||
const members = new Set(this.facade.members().map((m) => m.user.id));
|
||||
return this.term()
|
||||
? this.facade.userResults().filter((u) => !members.has(u.id))
|
||||
: [];
|
||||
});
|
||||
protected roleLabel = (role: ProjectRole) => ROLE[role];
|
||||
protected roleTone = (role: ProjectRole) => ROLE_TONE[role];
|
||||
protected readonly initialsOf = initials;
|
||||
|
||||
protected search(term: string): void {
|
||||
this.term.set(term);
|
||||
this.facade.searchUsers(term);
|
||||
}
|
||||
|
||||
protected add(userId: string): void {
|
||||
this.facade.addMember(
|
||||
userId,
|
||||
(this.newRole() ?? ProjectRole.MEMBER) as ProjectRole,
|
||||
);
|
||||
}
|
||||
|
||||
protected changeRole(member: ProjectMember, role: string | null): void {
|
||||
if (!role || role === member.role) return;
|
||||
this.facade.changeMemberRole(member.user.id, role as ProjectRole);
|
||||
}
|
||||
|
||||
protected remove(member: ProjectMember): void {
|
||||
if (!confirm(`¿Quitar a ${member.user.name} del proyecto?`)) return;
|
||||
this.facade.removeMember(member.user.id);
|
||||
}
|
||||
}
|
||||
144
libs/web/tracker/presenters/src/lib/pages/project-shell.page.ts
Normal file
144
libs/web/tracker/presenters/src/lib/pages/project-shell.page.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||
import { PiNoteComponent, PiSpinnerComponent } from '@pi/ui';
|
||||
import {
|
||||
ProjectFacade,
|
||||
provideProjectFacade,
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import { AvatarsComponent } from '../ui/avatars';
|
||||
import { projectTone, ROLE } from '../ui/copy';
|
||||
|
||||
/** /p/:key — loads the project once for all its tabs (its store and facade are scoped here). */
|
||||
@Component({
|
||||
selector: 'ts-project-shell',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
RouterOutlet,
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
AvatarsComponent,
|
||||
PiSpinnerComponent,
|
||||
PiNoteComponent,
|
||||
],
|
||||
providers: [provideProjectFacade()],
|
||||
template: `
|
||||
@if (facade.project(); as project) {
|
||||
<header class="ts-project-header" [style.--tone]="tone(project.key)">
|
||||
<div class="ts-project-header__main">
|
||||
<p class="ts-eyebrow">
|
||||
<a class="ts-plain-link" routerLink="/">Proyectos</a> /
|
||||
{{ project.key }}
|
||||
@if (facade.myRole(); as role) {
|
||||
· {{ roleLabel(role) }}
|
||||
}
|
||||
</p>
|
||||
<h1>
|
||||
<span class="ts-project-header__key">{{ project.key }}</span>
|
||||
{{ project.name }}
|
||||
</h1>
|
||||
@if (project.description) {
|
||||
<p class="ts-project-header__description">
|
||||
{{ project.description }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="ts-project-header__team">
|
||||
<ts-avatars [names]="memberNames()" [max]="6" />
|
||||
<a class="ts-project-header__people ts-mono" routerLink="members"
|
||||
>{{ project.members.length }} en el equipo</a
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
<nav class="ts-tabs" aria-label="Secciones del proyecto">
|
||||
<a routerLink="board" routerLinkActive="is-active">Tablero</a>
|
||||
<a routerLink="releases" routerLinkActive="is-active">Releases</a>
|
||||
<a routerLink="dashboard" routerLinkActive="is-active">Seguimiento</a>
|
||||
<a routerLink="activity" routerLinkActive="is-active">Actividad</a>
|
||||
<a routerLink="members" routerLinkActive="is-active">Miembros</a>
|
||||
</nav>
|
||||
<router-outlet />
|
||||
} @else if (facade.error(); as error) {
|
||||
<pi-note
|
||||
gap="xs"
|
||||
status="error"
|
||||
appearance="fill"
|
||||
type="status"
|
||||
title="No se pudo abrir el proyecto"
|
||||
[text]="error"
|
||||
/>
|
||||
} @else {
|
||||
<div class="ts-center">
|
||||
<pi-spinner [isLoading]="true" [diameter]="40" />
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.ts-project-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
h1 {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 0.875rem;
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: clamp(1.75rem, 1.2rem + 1.8vw, 2.75rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.05;
|
||||
}
|
||||
}
|
||||
.ts-project-header__main {
|
||||
min-width: 0;
|
||||
}
|
||||
.ts-project-header__key {
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.55em;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--tone);
|
||||
}
|
||||
.ts-project-header__description {
|
||||
max-width: 48rem;
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
.ts-project-header__team {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.ts-project-header__people {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ts-muted);
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
color: var(--ts-ink);
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ProjectShellPage {
|
||||
/** Route param (`withComponentInputBinding`). */
|
||||
readonly key = input.required<string>();
|
||||
|
||||
protected readonly facade = inject(ProjectFacade);
|
||||
protected readonly roleLabel = (role: keyof typeof ROLE) => ROLE[role];
|
||||
protected readonly tone = projectTone;
|
||||
protected readonly memberNames = computed(() =>
|
||||
this.facade.members().map((m) => m.user.name),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => this.facade.open(this.key()));
|
||||
}
|
||||
}
|
||||
258
libs/web/tracker/presenters/src/lib/pages/projects.page.ts
Normal file
258
libs/web/tracker/presenters/src/lib/pages/projects.page.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
TemplateRef,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
form,
|
||||
FormField,
|
||||
maxLength,
|
||||
pattern,
|
||||
required,
|
||||
} from '@angular/forms/signals';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiEmptyStateComponent,
|
||||
PiLabelDirective,
|
||||
PiSkeletonComponent,
|
||||
PiTextareaComponent,
|
||||
PiTextFieldComponent,
|
||||
} from '@pi/ui';
|
||||
import { DrawerService } from '@task-stream/web/shared/ui';
|
||||
import { ProjectsFacade } from '@task-stream/web/tracker/application';
|
||||
import { AvatarsComponent } from '../ui/avatars';
|
||||
import { projectTone, timeAgo } from '../ui/copy';
|
||||
|
||||
@Component({
|
||||
selector: 'ts-projects-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
RouterLink,
|
||||
FormField,
|
||||
AvatarsComponent,
|
||||
PiActionButtonComponent,
|
||||
PiEmptyStateComponent,
|
||||
PiSkeletonComponent,
|
||||
PiTextFieldComponent,
|
||||
PiTextareaComponent,
|
||||
PiLabelDirective,
|
||||
],
|
||||
template: `
|
||||
<header class="ts-page-header">
|
||||
<div>
|
||||
<p class="ts-eyebrow">
|
||||
Tus proyectos ·
|
||||
<span class="ts-mono">{{ pad(facade.projects().length) }}</span>
|
||||
</p>
|
||||
<h1>Proyectos</h1>
|
||||
<p>Los proyectos de los que formas parte, al día en tiempo real.</p>
|
||||
</div>
|
||||
<pi-action-button
|
||||
color="primary"
|
||||
text="Nuevo proyecto"
|
||||
(execute)="openCreate()"
|
||||
/>
|
||||
</header>
|
||||
|
||||
@if (facade.loading() && !facade.projects().length) {
|
||||
<div class="ts-projects">
|
||||
@for (i of [1, 2, 3]; track i) {
|
||||
<pi-skeleton shape="block" height="12rem" />
|
||||
}
|
||||
</div>
|
||||
} @else if (!facade.projects().length) {
|
||||
<pi-empty-state
|
||||
icon="kanban"
|
||||
title="Aún no tienes proyectos"
|
||||
description="Crea uno o pide que te añadan a un proyecto existente."
|
||||
>
|
||||
<pi-action-button
|
||||
color="primary"
|
||||
text="Crear el primero"
|
||||
(execute)="openCreate()"
|
||||
/>
|
||||
</pi-empty-state>
|
||||
} @else {
|
||||
<div class="ts-projects">
|
||||
@for (project of facade.sorted(); track project.id) {
|
||||
<a
|
||||
class="ts-project ts-card-link"
|
||||
[routerLink]="['/p', project.key]"
|
||||
[style.--tone]="tone(project.key)"
|
||||
>
|
||||
<div class="ts-project__top">
|
||||
<span class="ts-project__key">{{ project.key }}</span>
|
||||
<span class="ts-project__updated ts-mono">{{
|
||||
ago(project.updatedAt)
|
||||
}}</span>
|
||||
</div>
|
||||
<h2 class="ts-project__name">{{ project.name }}</h2>
|
||||
@if (project.description) {
|
||||
<p class="ts-project__description">{{ project.description }}</p>
|
||||
}
|
||||
<div class="ts-project__bottom">
|
||||
<ts-avatars [names]="memberNames(project.members)" />
|
||||
<span class="ts-project__count ts-mono"
|
||||
>{{ project.members.length }}
|
||||
{{
|
||||
project.members.length === 1 ? 'persona' : 'personas'
|
||||
}}</span
|
||||
>
|
||||
<span class="ts-project__go" aria-hidden="true">→</span>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<ng-template #createForm>
|
||||
<form
|
||||
class="ts-form"
|
||||
(submit)="$event.preventDefault(); create()"
|
||||
novalidate
|
||||
>
|
||||
<pi-text-field [formField]="f.key" placeholder="TS">
|
||||
<pi-label [isRequired]="true"
|
||||
>Clave (prefijo de los tickets)</pi-label
|
||||
>
|
||||
</pi-text-field>
|
||||
<pi-text-field [formField]="f.name" placeholder="Checkout revamp">
|
||||
<pi-label [isRequired]="true">Nombre</pi-label>
|
||||
</pi-text-field>
|
||||
<pi-textarea
|
||||
[formField]="f.description"
|
||||
placeholder="¿De qué va el proyecto?"
|
||||
>
|
||||
<pi-label>Descripción</pi-label>
|
||||
</pi-textarea>
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
[text]="facade.creating() ? 'Creando…' : 'Crear proyecto'"
|
||||
[disabled]="f().invalid() || facade.creating()"
|
||||
(execute)="create()"
|
||||
/>
|
||||
</form>
|
||||
</ng-template>
|
||||
`,
|
||||
styles: `
|
||||
.ts-projects {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.ts-project {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
gap: 0.375rem;
|
||||
min-height: 12rem;
|
||||
padding: 1.5rem 1.5rem 1.25rem;
|
||||
&:hover .ts-project__go {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.ts-project__top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.ts-project__key {
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--tone);
|
||||
}
|
||||
.ts-project__updated {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
.ts-project__name {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
}
|
||||
.ts-project__description {
|
||||
margin: 0;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ts-project__bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.875rem;
|
||||
border-top: 1px dashed var(--ts-line);
|
||||
}
|
||||
.ts-project__count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
.ts-project__go {
|
||||
margin-left: auto;
|
||||
font-size: 1.125rem;
|
||||
color: var(--tone);
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ProjectsPage implements OnInit {
|
||||
protected readonly facade = inject(ProjectsFacade);
|
||||
private readonly drawer = inject(DrawerService);
|
||||
private readonly createForm =
|
||||
viewChild.required<TemplateRef<unknown>>('createForm');
|
||||
|
||||
private readonly model = signal({ key: '', name: '', description: '' });
|
||||
protected readonly f = form(this.model, (p) => {
|
||||
required(p.key);
|
||||
pattern(p.key, /^[A-Za-z][A-Za-z0-9]{1,9}$/, {
|
||||
message: '2-10 letras/números, empezando por letra',
|
||||
});
|
||||
required(p.name);
|
||||
maxLength(p.name, 120);
|
||||
});
|
||||
|
||||
protected readonly ago = timeAgo;
|
||||
protected readonly tone = projectTone;
|
||||
protected readonly pad = (n: number) => String(n).padStart(2, '0');
|
||||
protected readonly memberNames = (members: { user: { name: string } }[]) =>
|
||||
members.map((m) => m.user.name);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.facade.load();
|
||||
}
|
||||
|
||||
protected openCreate(): void {
|
||||
this.model.set({ key: '', name: '', description: '' });
|
||||
this.drawer.open('Nuevo proyecto', this.createForm());
|
||||
}
|
||||
|
||||
protected create(): void {
|
||||
if (this.f().invalid()) return;
|
||||
const { key, name, description } = this.model();
|
||||
this.facade.create({
|
||||
key: key.toUpperCase(),
|
||||
name,
|
||||
description: description || null,
|
||||
});
|
||||
this.drawer.closeAll();
|
||||
}
|
||||
}
|
||||
345
libs/web/tracker/presenters/src/lib/pages/releases.page.ts
Normal file
345
libs/web/tracker/presenters/src/lib/pages/releases.page.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
TemplateRef,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { form, FormField, maxLength, required } from '@angular/forms/signals';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiEmptyStateComponent,
|
||||
PiLabelDirective,
|
||||
PiTextareaComponent,
|
||||
PiTextFieldComponent,
|
||||
} from '@pi/ui';
|
||||
import { DrawerService } 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, timeAgo } from '../ui/copy';
|
||||
|
||||
@Component({
|
||||
selector: 'ts-releases-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
RouterLink,
|
||||
FormField,
|
||||
PiActionButtonComponent,
|
||||
PiEmptyStateComponent,
|
||||
PiLabelDirective,
|
||||
PiTextFieldComponent,
|
||||
PiTextareaComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="ts-section-header">
|
||||
<p class="ts-muted">
|
||||
Controla qué tickets entran en cada versión. Una release no se publica
|
||||
con tickets sin terminar.
|
||||
</p>
|
||||
@if (facade.canManage()) {
|
||||
<pi-action-button
|
||||
color="primary"
|
||||
text="Nueva release"
|
||||
(execute)="openCreate()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
<ol class="ts-releases">
|
||||
@for (release of facade.releases(); track release.id) {
|
||||
<li
|
||||
class="ts-release"
|
||||
[attr.data-status]="release.status"
|
||||
[style.--tone]="toneOf(release)"
|
||||
>
|
||||
<span class="ts-release__node" aria-hidden="true"></span>
|
||||
<div class="ts-release__body ts-panel">
|
||||
<header class="ts-release__head">
|
||||
<h2 class="ts-release__name">{{ release.name }}</h2>
|
||||
<span class="ts-pill" [style.--tone]="toneOf(release)">{{
|
||||
statusOf(release).label
|
||||
}}</span>
|
||||
<span class="ts-release__when ts-mono">{{ when(release) }}</span>
|
||||
</header>
|
||||
@if (release.description) {
|
||||
<p class="ts-release__description">{{ release.description }}</p>
|
||||
}
|
||||
<div class="ts-release__progress">
|
||||
<div
|
||||
class="ts-meter"
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
[attr.aria-valuenow]="release.progress.percent"
|
||||
[attr.aria-label]="'Progreso de ' + release.name"
|
||||
>
|
||||
<span [style.width.%]="release.progress.percent"></span>
|
||||
</div>
|
||||
<span class="ts-mono"
|
||||
><strong>{{ release.progress.finished }}</strong
|
||||
>/{{ release.progress.total }} tickets ·
|
||||
{{ release.progress.percent }}%</span
|
||||
>
|
||||
</div>
|
||||
<div class="ts-release__actions">
|
||||
<a
|
||||
class="ts-release__link"
|
||||
[routerLink]="['../board']"
|
||||
[queryParams]="{ release: release.id }"
|
||||
>Ver en el tablero →</a
|
||||
>
|
||||
<span class="ts-spacer"></span>
|
||||
@if (facade.canManage() && release.status === 'PLANNED') {
|
||||
<pi-action-button
|
||||
size="small"
|
||||
appearance="outline"
|
||||
color="grey"
|
||||
text="Archivar"
|
||||
(execute)="archive(release)"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="primary"
|
||||
text="Publicar"
|
||||
(execute)="publish(release)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
} @empty {
|
||||
<pi-empty-state
|
||||
icon="rocket"
|
||||
title="Sin releases"
|
||||
description="Crea una versión y mete en ella los tickets que deben salir juntos."
|
||||
/>
|
||||
}
|
||||
</ol>
|
||||
|
||||
<ng-template #createForm>
|
||||
<form
|
||||
class="ts-form"
|
||||
(submit)="$event.preventDefault(); create()"
|
||||
novalidate
|
||||
>
|
||||
<pi-text-field [formField]="f.name" placeholder="v2.0.0">
|
||||
<pi-label [isRequired]="true">Nombre</pi-label>
|
||||
</pi-text-field>
|
||||
<pi-textarea [formField]="f.description" placeholder="Qué incluye">
|
||||
<pi-label>Descripción</pi-label>
|
||||
</pi-textarea>
|
||||
<pi-text-field [formField]="f.targetDate" placeholder="2026-12-01">
|
||||
<pi-label>Fecha objetivo (AAAA-MM-DD)</pi-label>
|
||||
</pi-text-field>
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
text="Crear release"
|
||||
[disabled]="f().invalid()"
|
||||
(execute)="create()"
|
||||
/>
|
||||
</form>
|
||||
</ng-template>
|
||||
`,
|
||||
styles: `
|
||||
/* A timeline: one node per version on a vertical rail. */
|
||||
.ts-releases {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
padding: 0 0 0 2rem;
|
||||
list-style: none;
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0.75rem auto 0.75rem 0.5625rem;
|
||||
width: 2px;
|
||||
background: var(--ts-line-strong);
|
||||
}
|
||||
}
|
||||
.ts-release {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
}
|
||||
.ts-release__node {
|
||||
position: absolute;
|
||||
left: -2rem;
|
||||
top: 1.375rem;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 50%;
|
||||
background: var(--ts-bg);
|
||||
border: 3px solid var(--tone);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ts-release[data-status='RELEASED'] .ts-release__node {
|
||||
background: var(--tone);
|
||||
}
|
||||
.ts-release__body {
|
||||
padding: 1.25rem 1.5rem 1.125rem;
|
||||
}
|
||||
.ts-release[data-status='ARCHIVED'] .ts-release__body {
|
||||
opacity: 0.65;
|
||||
}
|
||||
.ts-release__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 0.875rem;
|
||||
}
|
||||
.ts-release__name {
|
||||
margin: 0;
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.ts-release__when {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
.ts-release__description {
|
||||
margin: 0.375rem 0 0;
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
.ts-release__progress {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ts-muted);
|
||||
strong {
|
||||
color: var(--ts-ink);
|
||||
}
|
||||
}
|
||||
.ts-meter {
|
||||
height: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--ts-sunken);
|
||||
overflow: hidden;
|
||||
span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--ts-s-done);
|
||||
}
|
||||
}
|
||||
.ts-release__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.875rem;
|
||||
}
|
||||
.ts-release__link {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.ts-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.ts-release__link {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.ts-releases {
|
||||
padding-left: 1.625rem;
|
||||
}
|
||||
.ts-release__node {
|
||||
left: -1.625rem;
|
||||
}
|
||||
.ts-release__body {
|
||||
padding: 1rem;
|
||||
}
|
||||
.ts-release__when {
|
||||
flex-basis: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
.ts-release__progress {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.ts-release__actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ReleasesPage {
|
||||
protected readonly facade = inject(ProjectFacade);
|
||||
private readonly drawer = inject(DrawerService);
|
||||
private readonly createForm =
|
||||
viewChild.required<TemplateRef<unknown>>('createForm');
|
||||
|
||||
private readonly model = signal({
|
||||
name: '',
|
||||
description: '',
|
||||
targetDate: '',
|
||||
});
|
||||
protected readonly f = form(this.model, (p) => {
|
||||
required(p.name);
|
||||
maxLength(p.name, 60);
|
||||
});
|
||||
protected statusOf = (release: Release) =>
|
||||
RELEASE_STATUS[release.status as ReleaseStatus];
|
||||
protected toneOf = (release: Release) =>
|
||||
release.status === ReleaseStatus.RELEASED
|
||||
? 'var(--ts-s-done)'
|
||||
: release.status === ReleaseStatus.ARCHIVED
|
||||
? 'var(--ts-s-cancelled)'
|
||||
: 'var(--ts-s-progress)';
|
||||
/** "publicada hace 2 días", "objetivo 17 oct · en 14 días". */
|
||||
protected when = (release: Release) => {
|
||||
if (release.releasedAt) return `publicada ${timeAgo(release.releasedAt)}`;
|
||||
if (!release.targetDate) return 'sin fecha';
|
||||
const target = new Date(release.targetDate);
|
||||
const days = Math.ceil((target.getTime() - Date.now()) / 86_400_000);
|
||||
const date = target.toLocaleDateString('es', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
});
|
||||
if (days < 0) return `objetivo ${date} · retrasada ${-days} d`;
|
||||
return days === 0 ? `objetivo hoy` : `objetivo ${date} · en ${days} d`;
|
||||
};
|
||||
|
||||
protected openCreate(): void {
|
||||
this.model.set({ name: '', description: '', targetDate: '' });
|
||||
this.drawer.open('Nueva release', this.createForm());
|
||||
}
|
||||
|
||||
protected create(): void {
|
||||
if (this.f().invalid()) return;
|
||||
const v = this.model();
|
||||
this.facade.createRelease({
|
||||
name: v.name,
|
||||
description: v.description || null,
|
||||
targetDate: v.targetDate || null,
|
||||
});
|
||||
this.drawer.closeAll();
|
||||
}
|
||||
|
||||
protected publish(release: Release): void {
|
||||
const unfinished = release.progress.total - release.progress.finished;
|
||||
if (unfinished > 0) {
|
||||
if (
|
||||
!confirm(
|
||||
`${release.name} tiene ${unfinished} ticket(s) sin terminar. ¿Publicar igualmente?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
this.facade.publishRelease(release.id, true);
|
||||
return;
|
||||
}
|
||||
this.facade.publishRelease(release.id);
|
||||
}
|
||||
|
||||
protected archive(release: Release): void {
|
||||
this.facade.archiveRelease(release.id);
|
||||
}
|
||||
}
|
||||
55
libs/web/tracker/presenters/src/lib/tracker.routes.ts
Normal file
55
libs/web/tracker/presenters/src/lib/tracker.routes.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
/** Everything behind authentication. */
|
||||
export const trackerRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
title: 'Proyectos · TaskStream',
|
||||
loadComponent: () =>
|
||||
import('./pages/projects.page').then((m) => m.ProjectsPage),
|
||||
},
|
||||
{
|
||||
path: 'p/:key',
|
||||
loadComponent: () =>
|
||||
import('./pages/project-shell.page').then((m) => m.ProjectShellPage),
|
||||
children: [
|
||||
{ path: '', pathMatch: 'full', redirectTo: 'board' },
|
||||
{
|
||||
path: 'board',
|
||||
title: 'Tablero · TaskStream',
|
||||
loadComponent: () =>
|
||||
import('./pages/board.page').then((m) => m.BoardPage),
|
||||
},
|
||||
{
|
||||
path: 'issues/:issueKey',
|
||||
title: 'Ticket · TaskStream',
|
||||
loadComponent: () =>
|
||||
import('./pages/issue.page').then((m) => m.IssuePage),
|
||||
},
|
||||
{
|
||||
path: 'releases',
|
||||
title: 'Releases · TaskStream',
|
||||
loadComponent: () =>
|
||||
import('./pages/releases.page').then((m) => m.ReleasesPage),
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
title: 'Seguimiento · TaskStream',
|
||||
loadComponent: () =>
|
||||
import('./pages/dashboard.page').then((m) => m.DashboardPage),
|
||||
},
|
||||
{
|
||||
path: 'activity',
|
||||
title: 'Actividad · TaskStream',
|
||||
loadComponent: () =>
|
||||
import('./pages/activity.page').then((m) => m.ActivityPage),
|
||||
},
|
||||
{
|
||||
path: 'members',
|
||||
title: 'Miembros · TaskStream',
|
||||
loadComponent: () =>
|
||||
import('./pages/members.page').then((m) => m.MembersPage),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
49
libs/web/tracker/presenters/src/lib/ui/avatars.ts
Normal file
49
libs/web/tracker/presenters/src/lib/ui/avatars.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import { initials } from './copy';
|
||||
|
||||
/** Overlapping initials (members of a project, people on a ticket). */
|
||||
@Component({
|
||||
selector: 'ts-avatars',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
@for (name of names().slice(0, max()); track $index) {
|
||||
<span class="ts-avatar" [attr.title]="name">{{ initialsOf(name) }}</span>
|
||||
}
|
||||
@if (names().length > max()) {
|
||||
<span class="ts-avatar ts-avatar--more"
|
||||
>+{{ names().length - max() }}</span
|
||||
>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding-left: 0.375rem;
|
||||
}
|
||||
.ts-avatar {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: var(--ts-avatar-size, 1.75rem);
|
||||
height: var(--ts-avatar-size, 1.75rem);
|
||||
margin-left: -0.375rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--ts-surface);
|
||||
background: color-mix(in srgb, var(--ts-brand) 18%, var(--ts-surface));
|
||||
color: color-mix(in srgb, var(--ts-brand) 70%, var(--ts-ink));
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.ts-avatar--more {
|
||||
background: var(--ts-sunken);
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class AvatarsComponent {
|
||||
readonly names = input.required<readonly string[]>();
|
||||
readonly max = input(5);
|
||||
protected readonly initialsOf = initials;
|
||||
}
|
||||
243
libs/web/tracker/presenters/src/lib/ui/copy.ts
Normal file
243
libs/web/tracker/presenters/src/lib/ui/copy.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { DatasetColor } from '@pi/ui';
|
||||
import {
|
||||
IssueArea,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
IssueStatus,
|
||||
IssueType,
|
||||
ProjectRole,
|
||||
ReleaseStatus,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
|
||||
type ChipColor =
|
||||
| 'accent'
|
||||
| 'grey'
|
||||
| 'highlighted'
|
||||
| 'primary'
|
||||
| 'success'
|
||||
| 'warn';
|
||||
type IconColor =
|
||||
| 'primary'
|
||||
| 'accent'
|
||||
| 'warn'
|
||||
| 'success'
|
||||
| 'grey'
|
||||
| 'highlighted';
|
||||
|
||||
/* UI copy (es) and the visual language of the tracker. */
|
||||
|
||||
/** `tone`: CSS colour token of the status/type (see `--ts-s-*`, `--ts-t-*` in styles.scss). */
|
||||
export const STATUS: Record<
|
||||
IssueStatus,
|
||||
{ label: string; color: ChipColor; chart: DatasetColor; tone: string }
|
||||
> = {
|
||||
[IssueStatus.BACKLOG]: {
|
||||
label: 'Backlog',
|
||||
color: 'grey',
|
||||
chart: DatasetColor.grey,
|
||||
tone: 'var(--ts-s-backlog)',
|
||||
},
|
||||
[IssueStatus.TODO]: {
|
||||
label: 'Por hacer',
|
||||
color: 'grey',
|
||||
chart: DatasetColor.grey300,
|
||||
tone: 'var(--ts-s-todo)',
|
||||
},
|
||||
[IssueStatus.IN_PROGRESS]: {
|
||||
label: 'En curso',
|
||||
color: 'primary',
|
||||
chart: DatasetColor.primary,
|
||||
tone: 'var(--ts-s-progress)',
|
||||
},
|
||||
[IssueStatus.IN_REVIEW]: {
|
||||
label: 'En revisión',
|
||||
color: 'accent',
|
||||
chart: DatasetColor.accent,
|
||||
tone: 'var(--ts-s-review)',
|
||||
},
|
||||
[IssueStatus.READY_FOR_QA]: {
|
||||
label: 'Listo para QA',
|
||||
color: 'highlighted',
|
||||
chart: DatasetColor.highlighted,
|
||||
tone: 'var(--ts-s-ready)',
|
||||
},
|
||||
[IssueStatus.IN_QA]: {
|
||||
label: 'En QA',
|
||||
color: 'highlighted',
|
||||
chart: DatasetColor.highlighted50,
|
||||
tone: 'var(--ts-s-qa)',
|
||||
},
|
||||
[IssueStatus.QA_FAILED]: {
|
||||
label: 'QA fallido',
|
||||
color: 'warn',
|
||||
chart: DatasetColor.warn,
|
||||
tone: 'var(--ts-s-failed)',
|
||||
},
|
||||
[IssueStatus.DONE]: {
|
||||
label: 'Hecho',
|
||||
color: 'success',
|
||||
chart: DatasetColor.success,
|
||||
tone: 'var(--ts-s-done)',
|
||||
},
|
||||
[IssueStatus.CANCELLED]: {
|
||||
label: 'Cancelado',
|
||||
color: 'grey',
|
||||
chart: DatasetColor.grey50,
|
||||
tone: 'var(--ts-s-cancelled)',
|
||||
},
|
||||
};
|
||||
|
||||
export const TYPE: Record<
|
||||
IssueType,
|
||||
{ label: string; icon: string; color: IconColor; tone: string }
|
||||
> = {
|
||||
[IssueType.BUG]: {
|
||||
label: 'Bug',
|
||||
icon: 'bug',
|
||||
color: 'warn',
|
||||
tone: 'var(--ts-t-bug)',
|
||||
},
|
||||
[IssueType.FEATURE]: {
|
||||
label: 'Feature',
|
||||
icon: 'feature',
|
||||
color: 'primary',
|
||||
tone: 'var(--ts-t-feature)',
|
||||
},
|
||||
[IssueType.UI_CHANGE]: {
|
||||
label: 'Cambio de UI',
|
||||
icon: 'palette',
|
||||
color: 'accent',
|
||||
tone: 'var(--ts-t-ui)',
|
||||
},
|
||||
[IssueType.TASK]: {
|
||||
label: 'Tarea',
|
||||
icon: 'task',
|
||||
color: 'grey',
|
||||
tone: 'var(--ts-t-task)',
|
||||
},
|
||||
[IssueType.QA]: {
|
||||
label: 'QA',
|
||||
icon: 'qa',
|
||||
color: 'highlighted',
|
||||
tone: 'var(--ts-t-qa)',
|
||||
},
|
||||
};
|
||||
|
||||
/** Signal strength of a priority, drawn as bars (1–4) like a phone signal. */
|
||||
export const PRIORITY_LEVEL: Record<IssuePriority, number> = {
|
||||
[IssuePriority.CRITICAL]: 4,
|
||||
[IssuePriority.HIGH]: 3,
|
||||
[IssuePriority.MEDIUM]: 2,
|
||||
[IssuePriority.LOW]: 1,
|
||||
[IssuePriority.LOWEST]: 0,
|
||||
};
|
||||
|
||||
export const PRIORITY: Record<
|
||||
IssuePriority,
|
||||
{ label: string; color: ChipColor }
|
||||
> = {
|
||||
[IssuePriority.CRITICAL]: { label: 'Crítica', color: 'warn' },
|
||||
[IssuePriority.HIGH]: { label: 'Alta', color: 'accent' },
|
||||
[IssuePriority.MEDIUM]: { label: 'Media', color: 'primary' },
|
||||
[IssuePriority.LOW]: { label: 'Baja', color: 'grey' },
|
||||
[IssuePriority.LOWEST]: { label: 'Mínima', color: 'grey' },
|
||||
};
|
||||
|
||||
export const AREA: Record<IssueArea, string> = {
|
||||
[IssueArea.FRONTEND]: 'Frontend',
|
||||
[IssueArea.BACKEND]: 'Backend',
|
||||
[IssueArea.MOBILE]: 'Mobile',
|
||||
[IssueArea.QA]: 'QA',
|
||||
[IssueArea.DESIGN]: 'Diseño',
|
||||
[IssueArea.DEVOPS]: 'DevOps',
|
||||
};
|
||||
|
||||
export const LINK: Record<IssueLinkType, { outward: string; inward: string }> =
|
||||
{
|
||||
[IssueLinkType.BLOCKS]: {
|
||||
outward: 'bloquea a',
|
||||
inward: 'está bloqueado por',
|
||||
},
|
||||
[IssueLinkType.DEPENDS_ON]: {
|
||||
outward: 'depende de',
|
||||
inward: 'es dependencia de',
|
||||
},
|
||||
[IssueLinkType.TESTS]: { outward: 'prueba', inward: 'es probado por' },
|
||||
[IssueLinkType.DUPLICATES]: {
|
||||
outward: 'duplica a',
|
||||
inward: 'está duplicado por',
|
||||
},
|
||||
[IssueLinkType.RELATES_TO]: {
|
||||
outward: 'relacionado con',
|
||||
inward: 'relacionado con',
|
||||
},
|
||||
};
|
||||
|
||||
export const ROLE: Record<ProjectRole, string> = {
|
||||
[ProjectRole.OWNER]: 'Propietario',
|
||||
[ProjectRole.ADMIN]: 'Admin',
|
||||
[ProjectRole.MEMBER]: 'Miembro',
|
||||
[ProjectRole.VIEWER]: 'Lector',
|
||||
};
|
||||
|
||||
export const RELEASE_STATUS: Record<
|
||||
ReleaseStatus,
|
||||
{ label: string; color: ChipColor }
|
||||
> = {
|
||||
[ReleaseStatus.PLANNED]: { label: 'Planificada', color: 'primary' },
|
||||
[ReleaseStatus.RELEASED]: { label: 'Publicada', color: 'success' },
|
||||
[ReleaseStatus.ARCHIVED]: { label: 'Archivada', color: 'grey' },
|
||||
};
|
||||
|
||||
export const options = <T extends string>(
|
||||
record: Record<T, string | { label: string }>,
|
||||
) =>
|
||||
(Object.keys(record) as T[]).map((value) => {
|
||||
const entry = record[value];
|
||||
return { value, label: typeof entry === 'string' ? entry : entry.label };
|
||||
});
|
||||
|
||||
/** "hace 5 min", "ayer"… */
|
||||
export function timeAgo(iso: string, now = Date.now()): string {
|
||||
const seconds = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));
|
||||
const rtf = new Intl.RelativeTimeFormat('es', { numeric: 'auto' });
|
||||
if (seconds < 60) return rtf.format(-seconds, 'second');
|
||||
if (seconds < 3600) return rtf.format(-Math.round(seconds / 60), 'minute');
|
||||
if (seconds < 86400) return rtf.format(-Math.round(seconds / 3600), 'hour');
|
||||
return rtf.format(-Math.round(seconds / 86400), 'day');
|
||||
}
|
||||
|
||||
const PROJECT_TONES = [
|
||||
'var(--ts-t-feature)',
|
||||
'var(--ts-s-review)',
|
||||
'var(--ts-t-ui)',
|
||||
'var(--ts-t-qa)',
|
||||
'var(--ts-s-done)',
|
||||
'var(--ts-s-qa)',
|
||||
];
|
||||
|
||||
/** A stable colour per project (from its key), so it is recognisable everywhere. */
|
||||
export function projectTone(key: string): string {
|
||||
let hash = 0;
|
||||
for (const char of key) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
|
||||
return PROJECT_TONES[hash % PROJECT_TONES.length];
|
||||
}
|
||||
|
||||
/** "Ana García" → "AG". */
|
||||
export function initials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/** "14:05" today, "3 oct" otherwise (logs and histories). */
|
||||
export function clock(iso: string, now = new Date()): string {
|
||||
const date = new Date(iso);
|
||||
return date.toDateString() === now.toDateString()
|
||||
? date.toLocaleTimeString('es', { hour: '2-digit', minute: '2-digit' })
|
||||
: date.toLocaleDateString('es', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
216
libs/web/tracker/presenters/src/lib/ui/issue-card.ts
Normal file
216
libs/web/tracker/presenters/src/lib/ui/issue-card.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { PiIconComponent } from '@pi/ui';
|
||||
import { IssueSummary } from '@task-stream/web/tracker/domain';
|
||||
import { AREA, initials, PRIORITY, PRIORITY_LEVEL, TYPE } from './copy';
|
||||
|
||||
/** A ticket on the board / in lists: type stripe, key, title, signals. */
|
||||
@Component({
|
||||
selector: 'ts-issue-card',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink, PiIconComponent],
|
||||
template: `
|
||||
<a
|
||||
class="ts-card"
|
||||
[routerLink]="['..', 'issues', issue().key]"
|
||||
[style.--type]="type().tone"
|
||||
[class.ts-card--critical]="issue().priority === 'CRITICAL'"
|
||||
>
|
||||
<div class="ts-card__top">
|
||||
<span class="ts-card__type" [attr.title]="type().label">
|
||||
<pi-icon [icon]="type().icon" size="small" />
|
||||
</span>
|
||||
<span class="ts-card__key">{{ issue().key }}</span>
|
||||
@if (issue().area; as area) {
|
||||
<span class="ts-card__area">{{ areaLabel(area) }}</span>
|
||||
}
|
||||
</div>
|
||||
<p class="ts-card__title">{{ issue().title }}</p>
|
||||
<div class="ts-card__bottom">
|
||||
<span
|
||||
class="ts-signal"
|
||||
[attr.data-level]="level()"
|
||||
[attr.title]="'Prioridad ' + priority().label.toLowerCase()"
|
||||
[attr.aria-label]="'Prioridad ' + priority().label.toLowerCase()"
|
||||
>
|
||||
<i></i><i></i><i></i><i></i>
|
||||
</span>
|
||||
@if (issue().release; as release) {
|
||||
<span class="ts-card__meta" title="Release"
|
||||
>◆ {{ release.name }}</span
|
||||
>
|
||||
}
|
||||
@if (issue().commentCount) {
|
||||
<span class="ts-card__meta" title="Comentarios">
|
||||
<pi-icon icon="chat" size="x-small" />
|
||||
{{ issue().commentCount }}
|
||||
</span>
|
||||
}
|
||||
<span class="ts-card__spacer"></span>
|
||||
@if (issue().assignee; as assignee) {
|
||||
<span class="ts-card__who" [attr.title]="assignee.name">{{
|
||||
initialsOf(assignee.name)
|
||||
}}</span>
|
||||
} @else {
|
||||
<span class="ts-card__who ts-card__who--none" title="Sin asignar"
|
||||
>—</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.ts-card {
|
||||
--type: var(--ts-muted);
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 0.75rem 0.6875rem 0.9375rem;
|
||||
overflow: hidden;
|
||||
border-radius: 0.625rem;
|
||||
background: var(--ts-surface);
|
||||
border: 1px solid var(--ts-line);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 1px 0 rgb(0 0 0 / 3%);
|
||||
transition:
|
||||
box-shadow 0.15s ease,
|
||||
transform 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 3px;
|
||||
background: var(--type);
|
||||
}
|
||||
&:hover {
|
||||
border-color: color-mix(in srgb, var(--type) 40%, var(--ts-line));
|
||||
box-shadow: 0 10px 22px -16px
|
||||
color-mix(in srgb, var(--type) 70%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--type);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
.ts-card__top,
|
||||
.ts-card__bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.ts-card__type {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
border-radius: 0.375rem;
|
||||
color: var(--type);
|
||||
background: color-mix(in srgb, var(--type) 13%, transparent);
|
||||
--pi-icon-size: 0.875rem;
|
||||
}
|
||||
.ts-card__key {
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--ts-ink-soft);
|
||||
}
|
||||
.ts-card__area {
|
||||
margin-left: auto;
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.625rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
.ts-card__title {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
color: var(--ts-ink);
|
||||
}
|
||||
.ts-card__meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--ts-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ts-card__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.ts-card__who {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--ts-brand) 16%, var(--ts-surface));
|
||||
color: color-mix(in srgb, var(--ts-brand) 70%, var(--ts-ink));
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ts-card__who--none {
|
||||
background: transparent;
|
||||
border: 1px dashed var(--ts-line-strong);
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
/* Priority as signal bars: as many lit bars as the level. */
|
||||
.ts-signal {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 0.75rem;
|
||||
i {
|
||||
width: 3px;
|
||||
border-radius: 1px;
|
||||
background: var(--ts-line-strong);
|
||||
}
|
||||
i:nth-child(1) {
|
||||
height: 35%;
|
||||
}
|
||||
i:nth-child(2) {
|
||||
height: 55%;
|
||||
}
|
||||
i:nth-child(3) {
|
||||
height: 78%;
|
||||
}
|
||||
i:nth-child(4) {
|
||||
height: 100%;
|
||||
}
|
||||
&[data-level='1'] i:nth-child(-n + 1),
|
||||
&[data-level='2'] i:nth-child(-n + 2),
|
||||
&[data-level='3'] i:nth-child(-n + 3) {
|
||||
background: var(--ts-ink-soft);
|
||||
}
|
||||
&[data-level='4'] i {
|
||||
background: var(--ts-s-failed);
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class IssueCardComponent {
|
||||
readonly issue = input.required<IssueSummary>();
|
||||
protected readonly type = computed(() => TYPE[this.issue().type]);
|
||||
protected readonly priority = computed(() => PRIORITY[this.issue().priority]);
|
||||
protected readonly level = computed(
|
||||
() => PRIORITY_LEVEL[this.issue().priority],
|
||||
);
|
||||
protected readonly areaLabel = (area: keyof typeof AREA) => AREA[area];
|
||||
protected readonly initialsOf = initials;
|
||||
}
|
||||
27
libs/web/tracker/presenters/tsconfig.json
Normal file
27
libs/web/tracker/presenters/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": true,
|
||||
"target": "es2022",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
12
libs/web/tracker/presenters/tsconfig.lib.json
Normal file
12
libs/web/tracker/presenters/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user