first commit
This commit is contained in:
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))),
|
||||
}),
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user