first commit
This commit is contained in:
@@ -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