feat: saved board views and richer board filters
The board now filters by text, type, priority and unassigned tickets as well. Any combination can be saved as a named view, private or shared with the project, and opened again with one click. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
IssueTimelineEntry,
|
||||
ProjectReadModel,
|
||||
ReleaseReadModel,
|
||||
SavedFilterReadModel,
|
||||
implicitRank,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { Schema } from 'mongoose';
|
||||
@@ -26,6 +27,7 @@ export type ReleaseDoc = Doc<ReleaseReadModel>;
|
||||
export type ActivityDoc = Doc<ActivityReadModel>;
|
||||
export type TimelineDoc = Doc<IssueTimelineEntry>;
|
||||
export type NotificationDoc = Doc<NotificationReadModel>;
|
||||
export type SavedFilterDoc = Doc<SavedFilterReadModel>;
|
||||
export interface NotificationSettingsDoc {
|
||||
_id: string;
|
||||
email: boolean;
|
||||
@@ -40,6 +42,7 @@ export const ReadModels = {
|
||||
Timeline: 'IssueTimelineReadModel',
|
||||
Notification: 'NotificationReadModel',
|
||||
NotificationSettings: 'NotificationSettings',
|
||||
SavedFilter: 'SavedFilter',
|
||||
} as const;
|
||||
|
||||
const userRef = { _id: false, id: String, name: String };
|
||||
@@ -160,6 +163,20 @@ export const NotificationSettingsSchema = new Schema<NotificationSettingsDoc>(
|
||||
{ ...loose, collection: 'notification_settings' },
|
||||
);
|
||||
|
||||
export const SavedFilterSchema = new Schema<SavedFilterDoc>(
|
||||
{
|
||||
_id: String,
|
||||
projectId: String,
|
||||
owner: userRef,
|
||||
name: String,
|
||||
shared: Boolean,
|
||||
createdAt: String,
|
||||
},
|
||||
{ ...loose, collection: 'saved_filters' },
|
||||
);
|
||||
SavedFilterSchema.index({ projectId: 1, 'owner.id': 1 });
|
||||
SavedFilterSchema.index({ projectId: 1, shared: 1 });
|
||||
|
||||
export const READ_MODEL_SCHEMAS = [
|
||||
{ name: ReadModels.Project, schema: ProjectSchema },
|
||||
{ name: ReadModels.Issue, schema: IssueSchema },
|
||||
@@ -169,6 +186,7 @@ export const READ_MODEL_SCHEMAS = [
|
||||
{ name: ReadModels.Timeline, schema: TimelineSchema },
|
||||
{ name: ReadModels.Notification, schema: NotificationSchema },
|
||||
{ name: ReadModels.NotificationSettings, schema: NotificationSettingsSchema },
|
||||
{ name: ReadModels.SavedFilter, schema: SavedFilterSchema },
|
||||
];
|
||||
|
||||
export function fromDoc<T extends { _id: string }>(
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { SavedFiltersRepository } from '@task-stream/api/tracker/application';
|
||||
import { SavedFilterReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { Model } from 'mongoose';
|
||||
import { ReadModels, SavedFilterDoc, fromDoc } from './read-model.schemas';
|
||||
|
||||
@Injectable()
|
||||
export class MongoSavedFiltersRepository extends SavedFiltersRepository {
|
||||
constructor(
|
||||
@InjectModel(ReadModels.SavedFilter)
|
||||
private readonly filters: Model<SavedFilterDoc>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async findVisible(
|
||||
projectId: string,
|
||||
userId: string,
|
||||
): Promise<SavedFilterReadModel[]> {
|
||||
const docs = await this.filters
|
||||
.find({ projectId, $or: [{ 'owner.id': userId }, { shared: true }] })
|
||||
.sort({ shared: 1, name: 1 })
|
||||
.lean<SavedFilterDoc[]>();
|
||||
return docs.map((doc) => toFilter(doc));
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<SavedFilterReadModel | null> {
|
||||
const doc = await this.filters.findById(id).lean<SavedFilterDoc>();
|
||||
return doc ? toFilter(doc) : null;
|
||||
}
|
||||
|
||||
countOwned(projectId: string, userId: string): Promise<number> {
|
||||
return this.filters.countDocuments({ projectId, 'owner.id': userId });
|
||||
}
|
||||
|
||||
async insert({ id, ...rest }: SavedFilterReadModel): Promise<void> {
|
||||
await this.filters.create({ _id: id, ...rest });
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.filters.deleteOne({ _id: id });
|
||||
}
|
||||
}
|
||||
|
||||
function toFilter(doc: SavedFilterDoc): SavedFilterReadModel {
|
||||
const filter = fromDoc(doc) as SavedFilterReadModel;
|
||||
return { ...filter, criteria: filter.criteria ?? {} };
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
ProjectDoc,
|
||||
ReadModels,
|
||||
ReleaseDoc,
|
||||
SavedFilterDoc,
|
||||
TimelineDoc,
|
||||
} from './read-model.schemas';
|
||||
import { upsertIfNewer } from './upsert-if-newer';
|
||||
@@ -44,6 +45,8 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
|
||||
private readonly activity: Model<ActivityDoc>,
|
||||
@InjectModel(ReadModels.Timeline)
|
||||
private readonly timeline: Model<TimelineDoc>,
|
||||
@InjectModel(ReadModels.SavedFilter)
|
||||
private readonly savedFilters: Model<SavedFilterDoc>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -53,6 +56,8 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
|
||||
this.projects.deleteOne({ _id: projectId }),
|
||||
this.activity.deleteMany({ projectId }),
|
||||
this.timeline.deleteMany({ projectId }),
|
||||
// Views of the board of a project that is gone.
|
||||
this.savedFilters.deleteMany({ projectId }),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ChangeFeed,
|
||||
NotificationMailer,
|
||||
NotificationsRepository,
|
||||
SavedFiltersRepository,
|
||||
FindActivityRepository,
|
||||
FindCommentsRepository,
|
||||
FindIssuesRepository,
|
||||
@@ -56,6 +57,7 @@ import { PostgresReleaseIssuesRepository } from './postgres/release-issues.repos
|
||||
import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.repository';
|
||||
import { RedisChangeFeed } from './realtime/redis-change-feed';
|
||||
import { MongoNotificationsRepository } from './mongo/notifications.repository';
|
||||
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
|
||||
import { SmtpNotificationMailer } from './mail/smtp-notification.mailer';
|
||||
import { FileSystemAttachmentStorage } from './storage/file-system-attachment.storage';
|
||||
|
||||
@@ -107,6 +109,7 @@ const PORTS = [
|
||||
{ provide: AttachmentStorage, useClass: FileSystemAttachmentStorage },
|
||||
// Notifications → MongoDB (per person), copies by e-mail → SMTP (optional)
|
||||
{ provide: NotificationsRepository, useClass: MongoNotificationsRepository },
|
||||
{ provide: SavedFiltersRepository, useClass: MongoSavedFiltersRepository },
|
||||
{ provide: NotificationMailer, useClass: SmtpNotificationMailer },
|
||||
// Realtime → Redis pub/sub
|
||||
{ provide: ChangeFeed, useClass: RedisChangeFeed },
|
||||
|
||||
Reference in New Issue
Block a user