feat: saved views of my work, across my projects
A person saves named views of «my work»: projects, assigned to me, that I follow, status categories, priorities, bugs only, overdue and text. They are private (Mongo my_views, no history), listed and deleted by their owner (GraphQL myViews / saveMyView / deleteMyView, REST /api/me/views), and run as the cross-project search, which gains project, priority, bugsOnly and overdue filters (also in MCP my_issues). The home page shows them as a bar above «my work», with a drawer to save a new one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { type } from '@ngrx/signals';
|
||||
import { eventGroup } from '@ngrx/signals/events';
|
||||
import {
|
||||
IssuePage,
|
||||
MyView,
|
||||
MyViewCriteria,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
|
||||
export const myViewsPageEvents = eventGroup({
|
||||
source: 'My Work Views',
|
||||
events: {
|
||||
opened: type<void>(),
|
||||
/** null: back to «Mis tickets». */
|
||||
selected: type<string | null>(),
|
||||
saveRequested: type<{ name: string; criteria: MyViewCriteria }>(),
|
||||
deleteRequested: type<string>(),
|
||||
},
|
||||
});
|
||||
|
||||
export const myViewsApiEvents = eventGroup({
|
||||
source: 'My Views API',
|
||||
events: {
|
||||
listed: type<MyView[]>(),
|
||||
listFailed: type<string>(),
|
||||
resultsLoaded: type<IssuePage>(),
|
||||
resultsFailed: type<string>(),
|
||||
saved: type<string>(),
|
||||
saveFailed: type<string>(),
|
||||
deleted: type<string>(),
|
||||
deleteFailed: type<string>(),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { injectDispatch } from '@ngrx/signals/events';
|
||||
import { MyViewCriteria } from '@task-stream/web/tracker/domain';
|
||||
import { myViewsPageEvents } from '../events/my-views.events';
|
||||
import { MyViewsStore, VIEW_LIMIT } from '../stores/my-views.store';
|
||||
|
||||
/** My saved views of «my work», across my projects. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MyViewsFacade {
|
||||
private readonly store = inject(MyViewsStore);
|
||||
private readonly dispatch = injectDispatch(myViewsPageEvents);
|
||||
|
||||
/** By name. */
|
||||
readonly views = this.store.views;
|
||||
/** The one picked (null: «Mis tickets»). */
|
||||
readonly selected = this.store.selected;
|
||||
/** Its tickets, most recently updated first (up to `limit`). */
|
||||
readonly results = this.store.results;
|
||||
readonly total = this.store.total;
|
||||
readonly limit = VIEW_LIMIT;
|
||||
readonly loading = this.store.loading;
|
||||
readonly saving = this.store.saving;
|
||||
readonly error = this.store.error;
|
||||
|
||||
/** Loads my views (and refreshes the one picked). */
|
||||
open(): void {
|
||||
this.dispatch.opened();
|
||||
}
|
||||
|
||||
select(viewId: string | null): void {
|
||||
this.dispatch.selected(viewId);
|
||||
}
|
||||
|
||||
save(name: string, criteria: MyViewCriteria): void {
|
||||
this.dispatch.saveRequested({ name, criteria });
|
||||
}
|
||||
|
||||
remove(viewId: string): void {
|
||||
this.dispatch.deleteRequested(viewId);
|
||||
}
|
||||
}
|
||||
180
libs/web/tracker/application/src/lib/stores/my-views.store.ts
Normal file
180
libs/web/tracker/application/src/lib/stores/my-views.store.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
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 {
|
||||
IssuesGateway,
|
||||
IssueSummary,
|
||||
MyIssuesFilter,
|
||||
MyView,
|
||||
MyViewCriteria,
|
||||
OPEN_CATEGORIES,
|
||||
SavedFiltersGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { filter, map, merge, mergeMap, switchMap, take, tap } from 'rxjs';
|
||||
import { myViewsApiEvents, myViewsPageEvents } from '../events/my-views.events';
|
||||
import { describeError, Notifications } from '../notifications';
|
||||
|
||||
/** Tickets a view lists at most. */
|
||||
export const VIEW_LIMIT = 100;
|
||||
|
||||
/** A view's criteria as the «my work» search; no statuses: the open ones. */
|
||||
export function viewFilter(criteria: MyViewCriteria): MyIssuesFilter {
|
||||
return {
|
||||
assignedToMe: !!criteria.assignedToMe,
|
||||
watching: !!criteria.watching,
|
||||
categories: criteria.categories?.length
|
||||
? criteria.categories
|
||||
: [...OPEN_CATEGORIES],
|
||||
priorities: criteria.priorities ?? undefined,
|
||||
projectIds: criteria.projectIds ?? undefined,
|
||||
bugsOnly: !!criteria.bugsOnly,
|
||||
overdue: !!criteria.overdue,
|
||||
search: criteria.search ?? undefined,
|
||||
limit: VIEW_LIMIT,
|
||||
};
|
||||
}
|
||||
|
||||
interface MyViewsState {
|
||||
views: MyView[];
|
||||
selectedId: string | null;
|
||||
results: IssueSummary[];
|
||||
total: number;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** My saved views of «my work» and the tickets of the one picked. Root-scoped. */
|
||||
export const MyViewsStore = signalStore(
|
||||
{ providedIn: 'root' },
|
||||
withState<MyViewsState>({
|
||||
views: [],
|
||||
selectedId: null,
|
||||
results: [],
|
||||
total: 0,
|
||||
loading: false,
|
||||
saving: false,
|
||||
error: null,
|
||||
}),
|
||||
withComputed((store) => ({
|
||||
selected: computed(
|
||||
() => store.views().find((v) => v.id === store.selectedId()) ?? null,
|
||||
),
|
||||
})),
|
||||
withReducer(
|
||||
on(myViewsApiEvents.listed, ({ payload }, state) => ({
|
||||
views: payload,
|
||||
// A view deleted elsewhere is no longer picked.
|
||||
selectedId: payload.some((v) => v.id === state.selectedId)
|
||||
? state.selectedId
|
||||
: null,
|
||||
})),
|
||||
on(myViewsPageEvents.selected, ({ payload }) => ({
|
||||
selectedId: payload,
|
||||
results: [],
|
||||
total: 0,
|
||||
loading: payload !== null,
|
||||
error: null,
|
||||
})),
|
||||
on(myViewsApiEvents.resultsLoaded, ({ payload }) => ({
|
||||
results: payload.items,
|
||||
total: payload.total,
|
||||
loading: false,
|
||||
})),
|
||||
on(myViewsApiEvents.resultsFailed, ({ payload }) => ({
|
||||
loading: false,
|
||||
error: payload,
|
||||
})),
|
||||
on(myViewsPageEvents.saveRequested, () => ({ saving: true })),
|
||||
on(myViewsApiEvents.saved, () => ({ saving: false })),
|
||||
on(myViewsApiEvents.saveFailed, () => ({ saving: false })),
|
||||
),
|
||||
withEventHandlers(
|
||||
(
|
||||
store,
|
||||
events = inject(Events),
|
||||
filters = inject(SavedFiltersGateway),
|
||||
issues = inject(IssuesGateway),
|
||||
notifications = inject(Notifications),
|
||||
) => ({
|
||||
list$: merge(
|
||||
events.on(myViewsPageEvents.opened),
|
||||
events.on(myViewsApiEvents.saved, myViewsApiEvents.deleted),
|
||||
).pipe(
|
||||
switchMap(() =>
|
||||
filters.myViews().pipe(
|
||||
mapResponse({
|
||||
next: (views) => myViewsApiEvents.listed(views),
|
||||
error: (error) =>
|
||||
myViewsApiEvents.listFailed(describeError(error)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
// The picked view's tickets: on picking it, and again on opening.
|
||||
results$: merge(
|
||||
events
|
||||
.on(myViewsPageEvents.selected)
|
||||
.pipe(map(({ payload }) => payload)),
|
||||
events.on(myViewsPageEvents.opened).pipe(map(() => store.selectedId())),
|
||||
).pipe(
|
||||
filter((id): id is string => !!id),
|
||||
switchMap((id) => {
|
||||
const view = store.views().find((v) => v.id === id);
|
||||
if (!view) return [];
|
||||
return issues.mine(viewFilter(view.criteria)).pipe(
|
||||
mapResponse({
|
||||
next: (page) => myViewsApiEvents.resultsLoaded(page),
|
||||
error: (error) =>
|
||||
myViewsApiEvents.resultsFailed(describeError(error)),
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
save$: events.on(myViewsPageEvents.saveRequested).pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
filters.saveMyView(payload).pipe(
|
||||
mapResponse({
|
||||
next: (id) => myViewsApiEvents.saved(id),
|
||||
error: (error) =>
|
||||
myViewsApiEvents.saveFailed(describeError(error)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Once listed again, the new view is the one shown.
|
||||
selectSaved$: events.on(myViewsApiEvents.saved).pipe(
|
||||
switchMap(({ payload }) =>
|
||||
events.on(myViewsApiEvents.listed).pipe(
|
||||
take(1),
|
||||
map(() => myViewsPageEvents.selected(payload)),
|
||||
),
|
||||
),
|
||||
),
|
||||
delete$: events.on(myViewsPageEvents.deleteRequested).pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
filters.removeMyView(payload).pipe(
|
||||
mapResponse({
|
||||
next: () => myViewsApiEvents.deleted(payload),
|
||||
error: (error) =>
|
||||
myViewsApiEvents.deleteFailed(describeError(error)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
feedback$: events
|
||||
.on(
|
||||
myViewsApiEvents.saveFailed,
|
||||
myViewsApiEvents.deleteFailed,
|
||||
myViewsApiEvents.listFailed,
|
||||
)
|
||||
.pipe(tap(({ payload }) => notifications.error(payload))),
|
||||
}),
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user