first commit

This commit is contained in:
2026-10-03 13:40:26 +02:00
parent 732a5b5a16
commit 566f415fa7
555 changed files with 62704 additions and 0 deletions

View 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';

View 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>(),
},
});

View 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>(),
},
});

View File

@@ -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>(),
},
});

View File

@@ -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 }>(),
},
});

View 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];
}

View 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',
});
});
});

View 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];
}

View File

@@ -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];
}

View File

@@ -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);
}
}

View 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));
}

View File

@@ -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']);
});
});

View File

@@ -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,
);
}

View 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))),
),
};
},
),
);

View 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)),
}),
);
}

View 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))),
),
};
},
),
);

View 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))),
),
};
},
),
);

View 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))),
}),
),
);

View File

@@ -0,0 +1,5 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-snapshots';
import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed';
setupTestBed();