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:
@@ -10,6 +10,7 @@ export * from './lib/facades/explore.facade';
|
||||
export * from './lib/facades/inbox.facade';
|
||||
export * from './lib/facades/epics.facade';
|
||||
export * from './lib/facades/insights.facade';
|
||||
export * from './lib/facades/my-views.facade';
|
||||
export * from './lib/facades/backlog.facade';
|
||||
export * from './lib/facades/list.facade';
|
||||
export * from './lib/facades/propose.facade';
|
||||
|
||||
@@ -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))),
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -122,6 +122,12 @@ export interface MyIssuesFilter {
|
||||
categories?: StatusCategory[];
|
||||
/** Matches key, title or description. */
|
||||
search?: string;
|
||||
projectIds?: string[];
|
||||
priorities?: IssuePriority[];
|
||||
/** Only types drawn with the bug icon. */
|
||||
bugsOnly?: boolean;
|
||||
/** Only open tickets past their due date. */
|
||||
overdue?: boolean;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,29 @@ export interface SavedFilterCriteria {
|
||||
search?: string | null;
|
||||
}
|
||||
|
||||
/** What a view of «my work» narrows to, across my projects. */
|
||||
export interface MyViewCriteria {
|
||||
/** Only these projects (empty: all of mine). */
|
||||
projectIds?: string[] | null;
|
||||
assignedToMe?: boolean | null;
|
||||
watching?: boolean | null;
|
||||
categories?: StatusCategory[] | null;
|
||||
priorities?: IssuePriority[] | null;
|
||||
/** Only types drawn with the bug icon. */
|
||||
bugsOnly?: boolean | null;
|
||||
/** Only open tickets past their due date. */
|
||||
overdue?: boolean | null;
|
||||
search?: string | null;
|
||||
}
|
||||
|
||||
/** A named view of «my work», mine only. */
|
||||
export interface MyView {
|
||||
id: string;
|
||||
name: string;
|
||||
criteria: MyViewCriteria;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** A named view of the board: private, or shared with the project. */
|
||||
export interface SavedFilter {
|
||||
id: string;
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
Release,
|
||||
ReleaseBurnup,
|
||||
SavedFilter,
|
||||
MyView,
|
||||
MyViewCriteria,
|
||||
SavedFilterCriteria,
|
||||
Sprint,
|
||||
SprintBurndown,
|
||||
@@ -243,6 +245,13 @@ export abstract class SavedFiltersGateway {
|
||||
criteria: SavedFilterCriteria;
|
||||
}): Observable<string>;
|
||||
abstract remove(id: string): Observable<void>;
|
||||
/** My views of «my work», by name. */
|
||||
abstract myViews(): Observable<MyView[]>;
|
||||
abstract saveMyView(input: {
|
||||
name: string;
|
||||
criteria: MyViewCriteria;
|
||||
}): Observable<string>;
|
||||
abstract removeMyView(id: string): Observable<void>;
|
||||
}
|
||||
|
||||
export abstract class CommentsGateway {
|
||||
|
||||
@@ -102,6 +102,12 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
||||
categories: filter.categories as never,
|
||||
limit: filter.limit,
|
||||
search: filter.search?.trim() || null,
|
||||
projectIds: filter.projectIds?.length ? filter.projectIds : null,
|
||||
priorities: filter.priorities?.length
|
||||
? (filter.priorities as never)
|
||||
: null,
|
||||
bugsOnly: filter.bugsOnly ?? null,
|
||||
overdue: filter.overdue ?? null,
|
||||
})
|
||||
.pipe(
|
||||
map((d) => ({
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import {
|
||||
MyView,
|
||||
MyViewCriteria,
|
||||
SavedFilter,
|
||||
SavedFilterCriteria,
|
||||
SavedFiltersGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import {
|
||||
DeleteMyViewDocument,
|
||||
MyViewsDocument,
|
||||
SaveMyViewDocument,
|
||||
DeleteSavedFilterDocument,
|
||||
SavedFiltersDocument,
|
||||
SaveFilterDocument,
|
||||
@@ -38,4 +43,25 @@ export class GraphqlSavedFiltersGateway extends SavedFiltersGateway {
|
||||
.mutate(DeleteSavedFilterDocument, { id })
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
|
||||
myViews(): Observable<MyView[]> {
|
||||
return this.gql
|
||||
.query(MyViewsDocument, {})
|
||||
.pipe(map((d) => d.myViews as MyView[]));
|
||||
}
|
||||
|
||||
saveMyView(input: {
|
||||
name: string;
|
||||
criteria: MyViewCriteria;
|
||||
}): Observable<string> {
|
||||
return this.gql
|
||||
.mutate(SaveMyViewDocument, { input: input as never })
|
||||
.pipe(map((d) => d.saveMyView));
|
||||
}
|
||||
|
||||
removeMyView(id: string): Observable<void> {
|
||||
return this.gql
|
||||
.mutate(DeleteMyViewDocument, { id })
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,6 +236,18 @@ export type LinkIssuesInput = {
|
||||
type: IssueLinkType;
|
||||
};
|
||||
|
||||
export type MyViewCriteriaInput = {
|
||||
assignedToMe?: boolean | null | undefined;
|
||||
bugsOnly?: boolean | null | undefined;
|
||||
categories?: Array<StatusCategory> | null | undefined;
|
||||
overdue?: boolean | null | undefined;
|
||||
priorities?: Array<IssuePriority> | null | undefined;
|
||||
/** Empty: all my projects */
|
||||
projectIds?: Array<string> | null | undefined;
|
||||
search?: string | null | undefined;
|
||||
watching?: boolean | null | undefined;
|
||||
};
|
||||
|
||||
/** Why someone is told */
|
||||
export type NotificationKind =
|
||||
| 'ADDED_TO_PROJECT'
|
||||
@@ -293,6 +305,11 @@ export type SaveFilterInput = {
|
||||
shared?: boolean;
|
||||
};
|
||||
|
||||
export type SaveMyViewInput = {
|
||||
criteria: MyViewCriteriaInput;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type SavedFilterCriteriaInput = {
|
||||
areas?: Array<string> | null | undefined;
|
||||
assigneeId?: string | null | undefined;
|
||||
@@ -1023,6 +1040,10 @@ export type MyIssuesQueryVariables = Exact<{
|
||||
categories?: Array<StatusCategory> | StatusCategory | null | undefined;
|
||||
limit: number;
|
||||
search?: string | null | undefined;
|
||||
projectIds?: Array<string> | string | null | undefined;
|
||||
priorities?: Array<IssuePriority> | IssuePriority | null | undefined;
|
||||
bugsOnly?: boolean | null | undefined;
|
||||
overdue?: boolean | null | undefined;
|
||||
}>;
|
||||
|
||||
export type MyIssuesQuery = {
|
||||
@@ -2117,6 +2138,38 @@ export type DeleteSavedFilterMutationVariables = Exact<{
|
||||
|
||||
export type DeleteSavedFilterMutation = { deleteSavedFilter: boolean };
|
||||
|
||||
export type MyViewsQueryVariables = Exact<{ [key: string]: never }>;
|
||||
|
||||
export type MyViewsQuery = {
|
||||
myViews: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
criteria: {
|
||||
projectIds: Array<string> | null;
|
||||
assignedToMe: boolean | null;
|
||||
watching: boolean | null;
|
||||
categories: Array<StatusCategory> | null;
|
||||
priorities: Array<IssuePriority> | null;
|
||||
bugsOnly: boolean | null;
|
||||
overdue: boolean | null;
|
||||
search: string | null;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
export type SaveMyViewMutationVariables = Exact<{
|
||||
input: SaveMyViewInput;
|
||||
}>;
|
||||
|
||||
export type SaveMyViewMutation = { saveMyView: string };
|
||||
|
||||
export type DeleteMyViewMutationVariables = Exact<{
|
||||
id: string;
|
||||
}>;
|
||||
|
||||
export type DeleteMyViewMutation = { deleteMyView: boolean };
|
||||
|
||||
export type SprintFieldsFragment = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
@@ -6604,6 +6657,53 @@ export const MyIssuesDocument = {
|
||||
},
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'projectIds' },
|
||||
},
|
||||
type: {
|
||||
kind: 'ListType',
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'priorities' },
|
||||
},
|
||||
type: {
|
||||
kind: 'ListType',
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'IssuePriority' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'bugsOnly' },
|
||||
},
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } },
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'overdue' },
|
||||
},
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } },
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
@@ -6652,6 +6752,38 @@ export const MyIssuesDocument = {
|
||||
name: { kind: 'Name', value: 'search' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'projectIds' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'projectIds' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'priorities' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'priorities' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'bugsOnly' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'bugsOnly' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'overdue' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'overdue' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
@@ -12637,6 +12769,161 @@ export const DeleteSavedFilterDocument = {
|
||||
DeleteSavedFilterMutation,
|
||||
DeleteSavedFilterMutationVariables
|
||||
>;
|
||||
export const MyViewsDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
name: { kind: 'Name', value: 'MyViews' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'myViews' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'criteria' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'projectIds' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'assignedToMe' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'watching' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'categories' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'priorities' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'bugsOnly' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'overdue' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'search' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<MyViewsQuery, MyViewsQueryVariables>;
|
||||
export const SaveMyViewDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'SaveMyView' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'input' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'SaveMyViewInput' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'saveMyView' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'input' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'input' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<SaveMyViewMutation, SaveMyViewMutationVariables>;
|
||||
export const DeleteMyViewDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'DeleteMyView' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } },
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'deleteMyView' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'id' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'id' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
DeleteMyViewMutation,
|
||||
DeleteMyViewMutationVariables
|
||||
>;
|
||||
export const SprintsDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
|
||||
@@ -136,6 +136,10 @@ query MyIssues(
|
||||
$categories: [StatusCategory!]
|
||||
$limit: Int!
|
||||
$search: String
|
||||
$projectIds: [ID!]
|
||||
$priorities: [IssuePriority!]
|
||||
$bugsOnly: Boolean
|
||||
$overdue: Boolean
|
||||
) {
|
||||
myIssues(
|
||||
assignedToMe: $assignedToMe
|
||||
@@ -143,6 +147,10 @@ query MyIssues(
|
||||
categories: $categories
|
||||
limit: $limit
|
||||
search: $search
|
||||
projectIds: $projectIds
|
||||
priorities: $priorities
|
||||
bugsOnly: $bugsOnly
|
||||
overdue: $overdue
|
||||
) {
|
||||
total
|
||||
items {
|
||||
|
||||
@@ -32,3 +32,29 @@ mutation SaveFilter($input: SaveFilterInput!) {
|
||||
mutation DeleteSavedFilter($id: ID!) {
|
||||
deleteSavedFilter(id: $id)
|
||||
}
|
||||
|
||||
query MyViews {
|
||||
myViews {
|
||||
id
|
||||
name
|
||||
createdAt
|
||||
criteria {
|
||||
projectIds
|
||||
assignedToMe
|
||||
watching
|
||||
categories
|
||||
priorities
|
||||
bugsOnly
|
||||
overdue
|
||||
search
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutation SaveMyView($input: SaveMyViewInput!) {
|
||||
saveMyView(input: $input)
|
||||
}
|
||||
|
||||
mutation DeleteMyView($id: ID!) {
|
||||
deleteMyView(id: $id)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
typeLook,
|
||||
} from '../ui/copy';
|
||||
import { CalendarFeedComponent } from '../ui/calendar-feed';
|
||||
import { MyViewsComponent } from '../ui/my-views';
|
||||
|
||||
const VISIBLE = 12;
|
||||
|
||||
@@ -49,6 +50,7 @@ const VISIBLE = 12;
|
||||
PiIconComponent,
|
||||
PiSkeletonComponent,
|
||||
CalendarFeedComponent,
|
||||
MyViewsComponent,
|
||||
],
|
||||
template: `
|
||||
@if (!home.loaded()) {
|
||||
@@ -60,6 +62,7 @@ const VISIBLE = 12;
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<ts-my-views />
|
||||
<div class="ts-work">
|
||||
<section class="ts-panel ts-work__panel" aria-labelledby="mine-title">
|
||||
<header class="ts-work__head">
|
||||
|
||||
481
libs/web/tracker/presenters/src/lib/ui/my-views.ts
Normal file
481
libs/web/tracker/presenters/src/lib/ui/my-views.ts
Normal file
@@ -0,0 +1,481 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
OnInit,
|
||||
signal,
|
||||
TemplateRef,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import {
|
||||
PiActionButtonComponent,
|
||||
PiCheckboxComponent,
|
||||
PiLabelDirective,
|
||||
PiTextFieldComponent,
|
||||
} from '@pi/ui';
|
||||
import { DrawerService } from '@task-stream/web/shared/ui';
|
||||
import {
|
||||
MyViewsFacade,
|
||||
ProjectsFacade,
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
IssuePriority,
|
||||
IssueSummary,
|
||||
MyViewCriteria,
|
||||
PRIORITY_ORDER,
|
||||
StatusCategory,
|
||||
Workflow,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import {
|
||||
CATEGORY,
|
||||
PRIORITY,
|
||||
projectKeyOf,
|
||||
projectTone,
|
||||
statusLook,
|
||||
typeLook,
|
||||
} from './copy';
|
||||
|
||||
const CATEGORIES = Object.values(StatusCategory);
|
||||
|
||||
/** Toggles a value in a list kept as a signal. */
|
||||
function toggle<T>(
|
||||
list: ReturnType<typeof signal<T[]>>,
|
||||
value: T,
|
||||
on: boolean,
|
||||
) {
|
||||
list.update((values) =>
|
||||
on ? [...new Set([...values, value])] : values.filter((v) => v !== value),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* My saved views of «my work», across my projects: a bar to pick one, its
|
||||
* tickets, and the drawer to save a new one.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ts-my-views',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
RouterLink,
|
||||
PiActionButtonComponent,
|
||||
PiCheckboxComponent,
|
||||
PiLabelDirective,
|
||||
PiTextFieldComponent,
|
||||
],
|
||||
template: `
|
||||
<nav class="ts-views" aria-label="Mis vistas">
|
||||
<span class="ts-views__label">Vistas</span>
|
||||
@for (view of views.views(); track view.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-views__chip"
|
||||
[class.is-on]="views.selected()?.id === view.id"
|
||||
[attr.aria-pressed]="views.selected()?.id === view.id"
|
||||
(click)="
|
||||
views.select(views.selected()?.id === view.id ? null : view.id)
|
||||
"
|
||||
>
|
||||
{{ view.name }}
|
||||
</button>
|
||||
}
|
||||
<button type="button" class="ts-views__new" (click)="openForm()">
|
||||
+ Nueva vista
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@if (views.selected(); as view) {
|
||||
<section class="ts-panel ts-views__panel" aria-labelledby="view-title">
|
||||
<header class="ts-views__head">
|
||||
<h2 id="view-title">{{ view.name }}</h2>
|
||||
<span class="ts-muted">{{ summary() }}</span>
|
||||
<span class="ts-muted ts-mono">{{ views.total() }}</span>
|
||||
<span class="ts-spacer"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-plain-link"
|
||||
(click)="views.remove(view.id)"
|
||||
>
|
||||
Borrar vista
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-plain-link"
|
||||
(click)="views.select(null)"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
</header>
|
||||
@if (views.loading()) {
|
||||
<p class="ts-muted">Cargando…</p>
|
||||
} @else if (views.error(); as error) {
|
||||
<p class="ts-muted">{{ error }}</p>
|
||||
} @else {
|
||||
<ul class="ts-views__list">
|
||||
@for (issue of views.results(); track issue.id) {
|
||||
<li>
|
||||
<a
|
||||
class="ts-views__row"
|
||||
[routerLink]="['/p', keyOf(issue.key), 'issues', issue.key]"
|
||||
[style.--type]="typeTone(issue)"
|
||||
>
|
||||
<span class="ts-key">{{ issue.key }}</span>
|
||||
<span class="ts-views__title"
|
||||
><span
|
||||
class="ts-views__status"
|
||||
[style.--tone]="statusTone(issue)"
|
||||
>{{ statusLabel(issue) }}</span
|
||||
>
|
||||
{{ issue.title }}</span
|
||||
>
|
||||
<span class="ts-views__who">{{
|
||||
issue.assignee?.name ?? 'Sin asignar'
|
||||
}}</span>
|
||||
<span
|
||||
class="ts-views__project"
|
||||
[style.--tone]="projectToneOf(issue.key)"
|
||||
>{{ keyOf(issue.key) }}</span
|
||||
>
|
||||
</a>
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="ts-muted">Ningún ticket encaja ahora mismo.</li>
|
||||
}
|
||||
</ul>
|
||||
@if (views.total() > views.results().length) {
|
||||
<p class="ts-muted ts-views__more">
|
||||
Se muestran {{ views.results().length }} de {{ views.total() }}.
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<ng-template #form>
|
||||
<form
|
||||
class="ts-form ts-views__form"
|
||||
novalidate
|
||||
(submit)="$event.preventDefault(); save()"
|
||||
>
|
||||
<pi-text-field [(value)]="name" placeholder="Bugs críticos">
|
||||
<pi-label [isRequired]="true">Nombre</pi-label>
|
||||
</pi-text-field>
|
||||
|
||||
<fieldset>
|
||||
<legend>Proyectos <span>(ninguno: todos los míos)</span></legend>
|
||||
@for (project of projects.sorted(); track project.id) {
|
||||
<pi-checkbox
|
||||
[label]="project.key + ' · ' + project.name"
|
||||
[checked]="projectIds().includes(project.id)"
|
||||
(checkedChange)="pick(projectIds, project.id, $event)"
|
||||
/>
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Quién</legend>
|
||||
<pi-checkbox
|
||||
label="Asignados a mí"
|
||||
[checked]="assignedToMe()"
|
||||
(checkedChange)="assignedToMe.set($event)"
|
||||
/>
|
||||
<pi-checkbox
|
||||
label="Que sigo"
|
||||
[checked]="watching()"
|
||||
(checkedChange)="watching.set($event)"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Estado <span>(ninguno: los abiertos)</span></legend>
|
||||
@for (category of categories; track category) {
|
||||
<pi-checkbox
|
||||
[label]="categoryLabel(category)"
|
||||
[checked]="categoriesPicked().includes(category)"
|
||||
(checkedChange)="pick(categoriesPicked, category, $event)"
|
||||
/>
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Prioridad <span>(ninguna: todas)</span></legend>
|
||||
@for (priority of priorities; track priority) {
|
||||
<pi-checkbox
|
||||
[label]="priorityLabel(priority)"
|
||||
[checked]="prioritiesPicked().includes(priority)"
|
||||
(checkedChange)="pick(prioritiesPicked, priority, $event)"
|
||||
/>
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Además</legend>
|
||||
<pi-checkbox
|
||||
label="Solo bugs"
|
||||
[checked]="bugsOnly()"
|
||||
(checkedChange)="bugsOnly.set($event)"
|
||||
/>
|
||||
<pi-checkbox
|
||||
label="Solo vencidos"
|
||||
[checked]="overdue()"
|
||||
(checkedChange)="overdue.set($event)"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<pi-text-field [(value)]="search" placeholder="pago, Safari…">
|
||||
<pi-label>Texto (clave, título o descripción)</pi-label>
|
||||
</pi-text-field>
|
||||
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
[text]="views.saving() ? 'Guardando…' : 'Guardar vista'"
|
||||
[disabled]="!name().trim() || views.saving()"
|
||||
(execute)="save()"
|
||||
/>
|
||||
</form>
|
||||
</ng-template>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.ts-views {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.ts-views__label {
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.ts-views__chip,
|
||||
.ts-views__new {
|
||||
padding: 0.3125rem 0.75rem;
|
||||
border: 1px solid var(--ts-line);
|
||||
border-radius: 999px;
|
||||
background: var(--ts-surface);
|
||||
color: var(--ts-ink);
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
border-color: var(--ts-brand);
|
||||
}
|
||||
}
|
||||
.ts-views__chip.is-on {
|
||||
border-color: var(--ts-brand);
|
||||
background: color-mix(in srgb, var(--ts-brand) 14%, var(--ts-surface));
|
||||
font-weight: 600;
|
||||
}
|
||||
.ts-views__new {
|
||||
border-style: dashed;
|
||||
color: var(--ts-brand);
|
||||
}
|
||||
.ts-views__panel {
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
.ts-views__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
.ts-muted {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
.ts-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.ts-views__list {
|
||||
display: grid;
|
||||
gap: 0.125rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.ts-views__row {
|
||||
display: grid;
|
||||
grid-template-columns: 5.5rem minmax(0, 1fr) minmax(0, 9rem) 3.5rem;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-left: 3px solid var(--type, var(--ts-line));
|
||||
border-radius: 0.375rem;
|
||||
color: inherit;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
background: var(--pi-color-primary-25);
|
||||
}
|
||||
}
|
||||
.ts-views__title,
|
||||
.ts-views__who {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ts-views__who {
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.ts-views__status {
|
||||
margin-right: 0.375rem;
|
||||
color: var(--tone);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ts-views__project {
|
||||
justify-self: end;
|
||||
color: var(--tone);
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ts-views__more {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ts-views__form fieldset {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
.ts-views__form fieldset + pi-text-field {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.ts-views__form legend {
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
span {
|
||||
color: var(--ts-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.ts-views__row {
|
||||
grid-template-columns: 5rem minmax(0, 1fr) 3rem;
|
||||
}
|
||||
.ts-views__who {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MyViewsComponent implements OnInit {
|
||||
protected readonly views = inject(MyViewsFacade);
|
||||
protected readonly projects = inject(ProjectsFacade);
|
||||
private readonly drawer = inject(DrawerService);
|
||||
private readonly form = viewChild.required<TemplateRef<unknown>>('form');
|
||||
|
||||
protected readonly categories = CATEGORIES;
|
||||
protected readonly priorities = PRIORITY_ORDER;
|
||||
|
||||
/* The form */
|
||||
protected readonly name = signal('');
|
||||
protected readonly projectIds = signal<string[]>([]);
|
||||
protected readonly assignedToMe = signal(false);
|
||||
protected readonly watching = signal(false);
|
||||
protected readonly categoriesPicked = signal<StatusCategory[]>([]);
|
||||
protected readonly prioritiesPicked = signal<IssuePriority[]>([]);
|
||||
protected readonly bugsOnly = signal(false);
|
||||
protected readonly overdue = signal(false);
|
||||
protected readonly search = signal('');
|
||||
|
||||
ngOnInit(): void {
|
||||
this.views.open();
|
||||
}
|
||||
|
||||
protected readonly pick = toggle;
|
||||
|
||||
protected openForm(): void {
|
||||
this.name.set('');
|
||||
this.projectIds.set([]);
|
||||
this.assignedToMe.set(false);
|
||||
this.watching.set(false);
|
||||
this.categoriesPicked.set([]);
|
||||
this.prioritiesPicked.set([]);
|
||||
this.bugsOnly.set(false);
|
||||
this.overdue.set(false);
|
||||
this.search.set('');
|
||||
this.drawer.open('Nueva vista de mi trabajo', this.form());
|
||||
}
|
||||
|
||||
protected save(): void {
|
||||
const name = this.name().trim();
|
||||
if (!name) return;
|
||||
const criteria: MyViewCriteria = {
|
||||
projectIds: this.projectIds(),
|
||||
assignedToMe: this.assignedToMe(),
|
||||
watching: this.watching(),
|
||||
categories: this.categoriesPicked(),
|
||||
priorities: this.prioritiesPicked(),
|
||||
bugsOnly: this.bugsOnly(),
|
||||
overdue: this.overdue(),
|
||||
search: this.search().trim() || null,
|
||||
};
|
||||
this.views.save(name, criteria);
|
||||
this.drawer.closeAll();
|
||||
}
|
||||
|
||||
/** What the picked view narrows to, in words. */
|
||||
protected readonly summary = computed(() => {
|
||||
const c = this.views.selected()?.criteria;
|
||||
if (!c) return '';
|
||||
const keys = (c.projectIds ?? [])
|
||||
.map((id) => this.projects.projects().find((p) => p.id === id)?.key)
|
||||
.filter(Boolean);
|
||||
return [
|
||||
keys.length ? keys.join(', ') : 'todos mis proyectos',
|
||||
c.assignedToMe ? 'asignados a mí' : null,
|
||||
c.watching ? 'que sigo' : null,
|
||||
c.categories?.length
|
||||
? c.categories.map((x) => CATEGORY[x].label.toLowerCase()).join('/')
|
||||
: 'abiertos',
|
||||
c.priorities?.length
|
||||
? c.priorities.map((p) => PRIORITY[p].label.toLowerCase()).join('/')
|
||||
: null,
|
||||
c.bugsOnly ? 'solo bugs' : null,
|
||||
c.overdue ? 'vencidos' : null,
|
||||
c.search ? `«${c.search}»` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
});
|
||||
|
||||
protected readonly categoryLabel = (c: StatusCategory) => CATEGORY[c].label;
|
||||
protected readonly priorityLabel = (p: IssuePriority) => PRIORITY[p].label;
|
||||
protected readonly keyOf = projectKeyOf;
|
||||
protected readonly projectToneOf = (issueKey: string) =>
|
||||
projectTone(projectKeyOf(issueKey));
|
||||
|
||||
/** Each ticket with its own project's names and colours. */
|
||||
private readonly workflows = computed(
|
||||
() =>
|
||||
new Map(
|
||||
this.projects
|
||||
.projects()
|
||||
.map((p) => [p.id, new Workflow(p.settings)] as const),
|
||||
),
|
||||
);
|
||||
private workflowOf(issue: IssueSummary): Workflow {
|
||||
return this.workflows().get(issue.projectId) ?? new Workflow();
|
||||
}
|
||||
protected readonly statusLabel = (issue: IssueSummary) =>
|
||||
statusLook(this.workflowOf(issue), issue.status).label;
|
||||
protected readonly statusTone = (issue: IssueSummary) =>
|
||||
statusLook(this.workflowOf(issue), issue.status).tone;
|
||||
protected readonly typeTone = (issue: IssueSummary) =>
|
||||
typeLook(this.workflowOf(issue), issue.type).tone;
|
||||
}
|
||||
Reference in New Issue
Block a user