feat: a dashboard for clients, behind a secret link
Owners and admins turn it on in Configuración → General, pick what it
shows (tickets per status, the sprint under way, release forecasts,
open epics and activity per day) and can renew or drop the link. Whoever
has it sees /share/{token} without an account: only those blocks, no
people nor single tickets but the epics, no links into the app, read
again every minute. A renewed or dropped link says it no longer works.
REST (GET/PUT/DELETE /api/projects/{project}/share and the public
GET /api/shared/dashboards/{token}) and GraphQL (dashboardShare,
shareDashboard, unshareDashboard, sharedDashboard). The token is kept
in dashboard_shares and goes with the project.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,8 @@ export * from './lib/transfer.service';
|
||||
export * from './lib/automations.service';
|
||||
export * from './lib/calendar.service';
|
||||
export * from './lib/calendar/calendar';
|
||||
export * from './lib/sharing.service';
|
||||
export * from './lib/sharing/dashboard-shares';
|
||||
export * from './lib/automations/automations';
|
||||
export * from './lib/automations/automation-rules';
|
||||
export * from './lib/transfer/import-export';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { GitIntegrationsRepository } from '../ports/git-integrations.repository';
|
||||
import { DashboardSharesRepository } from '../ports/dashboard-shares.repository';
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { ProjectDeletedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { ChangeFeed } from '../ports/change-feed';
|
||||
@@ -8,7 +9,7 @@ import { versionOf } from './read-model-notifier';
|
||||
|
||||
/**
|
||||
* Removes the project (and its activity and timeline) from the read model,
|
||||
* forgets its Git repositories' secrets, and tells its members, whose project
|
||||
* forgets its Git repositories' secrets and its link for clients, and tells its members, whose project
|
||||
* lists drop it. Tickets, comments and
|
||||
* releases go as the cascade saga deletes them.
|
||||
*/
|
||||
@@ -21,6 +22,7 @@ export class ProjectDeletedEventHandler
|
||||
private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository,
|
||||
private readonly changeFeed: ChangeFeed,
|
||||
private readonly gitIntegrations: GitIntegrationsRepository,
|
||||
private readonly shares: DashboardSharesRepository,
|
||||
) {}
|
||||
|
||||
async handle(event: ProjectDeletedEvent) {
|
||||
@@ -28,6 +30,8 @@ export class ProjectDeletedEventHandler
|
||||
await this.upsertMaterializedProjectRepository.remove(event.projectId);
|
||||
// No webhook of it should be accepted any more.
|
||||
await this.gitIntegrations.saveAll(event.projectId, []);
|
||||
// Nor its link for clients.
|
||||
await this.shares.remove(event.projectId);
|
||||
if (!project) return; // Already removed (event handled twice).
|
||||
await this.changeFeed.publishProject(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { DashboardShareReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
/**
|
||||
* One link for clients per project. The token is kept as it is (not hashed)
|
||||
* so the link can be copied again; it only reads the blocks chosen.
|
||||
*/
|
||||
export abstract class DashboardSharesRepository {
|
||||
abstract findByProject(
|
||||
projectId: string,
|
||||
): Promise<DashboardShareReadModel | null>;
|
||||
abstract findByToken(token: string): Promise<DashboardShareReadModel | null>;
|
||||
/** Replaces the project's link, if any. */
|
||||
abstract save(share: DashboardShareReadModel): Promise<void>;
|
||||
abstract remove(projectId: string): Promise<void>;
|
||||
}
|
||||
@@ -30,3 +30,4 @@ export * from './rate-limit.store';
|
||||
export * from './calendar-feeds.repository';
|
||||
export * from './push-sender';
|
||||
export * from './push-subscriptions.repository';
|
||||
export * from './dashboard-shares.repository';
|
||||
|
||||
55
libs/api/tracker/application/src/lib/sharing.service.ts
Normal file
55
libs/api/tracker/application/src/lib/sharing.service.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { CommandRunner } from '@task-stream/api/shared/application';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
DashboardShareReadModel,
|
||||
SharedDashboardReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
GetDashboardShareQuery,
|
||||
GetSharedDashboardQuery,
|
||||
ShareDashboardCommand,
|
||||
UnshareDashboardCommand,
|
||||
} from './sharing/dashboard-shares';
|
||||
|
||||
/** The project's dashboard for clients, behind a secret link. */
|
||||
@Injectable()
|
||||
export class SharingService {
|
||||
constructor(
|
||||
private readonly commands: CommandRunner,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
/** The project's link, if it has one (owners and admins). */
|
||||
share(
|
||||
viewerId: string,
|
||||
projectId: string,
|
||||
): Promise<DashboardShareReadModel | null> {
|
||||
return this.queryBus.execute(
|
||||
new GetDashboardShareQuery(projectId, viewerId),
|
||||
);
|
||||
}
|
||||
|
||||
/** Turns the link on or changes its blocks; `renew` makes a new secret. */
|
||||
save(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
blocks: string[],
|
||||
renew = false,
|
||||
): Promise<DashboardShareReadModel> {
|
||||
return this.commands.execute(
|
||||
new ShareDashboardCommand(projectId, actorId, blocks, renew),
|
||||
);
|
||||
}
|
||||
|
||||
remove(actorId: string, projectId: string): Promise<void> {
|
||||
return this.commands.execute(
|
||||
new UnshareDashboardCommand(projectId, actorId),
|
||||
);
|
||||
}
|
||||
|
||||
/** What a link shows now, or null if no project has it. */
|
||||
shared(token: string): Promise<SharedDashboardReadModel | null> {
|
||||
return this.queryBus.execute(new GetSharedDashboardQuery(token));
|
||||
}
|
||||
}
|
||||
255
libs/api/tracker/application/src/lib/sharing/dashboard-shares.ts
Normal file
255
libs/api/tracker/application/src/lib/sharing/dashboard-shares.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import {
|
||||
Command,
|
||||
CommandHandler,
|
||||
ICommandHandler,
|
||||
IQueryHandler,
|
||||
Query,
|
||||
QueryHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import { assertThat } from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
DashboardShareReadModel,
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
SHARED_BLOCKS,
|
||||
SharedBlock,
|
||||
SharedDashboardReadModel,
|
||||
SprintStatus,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { computeInsights, heatmapStart } from '../analytics/insights';
|
||||
import { WriteAccess } from '../commands/write-access';
|
||||
import { DashboardSharesRepository } from '../ports/dashboard-shares.repository';
|
||||
import { FindActivityRepository } from '../ports/find-activity.repository';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { FindReleasesRepository } from '../ports/find-releases.repository';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
import { FindTimelineRepository } from '../ports/find-timeline.repository';
|
||||
|
||||
const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED];
|
||||
/** Epics a client sees, nearest due date first. */
|
||||
const EPICS = 8;
|
||||
|
||||
/** Only known blocks, each once, in the order they are shown. */
|
||||
function cleanBlocks(blocks: string[]): SharedBlock[] {
|
||||
const chosen = new Set(blocks);
|
||||
return SHARED_BLOCKS.filter((b) => chosen.has(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the project's link for clients on (or changes its blocks). With
|
||||
* `renew`, or the first time, a new secret: the old link stops working.
|
||||
*/
|
||||
export class ShareDashboardCommand extends Command<DashboardShareReadModel> {
|
||||
constructor(
|
||||
public readonly projectId: string,
|
||||
public readonly actorId: string,
|
||||
public readonly blocks: string[],
|
||||
public readonly renew: boolean,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** The project's link for clients stops working. */
|
||||
export class UnshareDashboardCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly projectId: string,
|
||||
public readonly actorId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** The project's link for clients, for who manages it. */
|
||||
export class GetDashboardShareQuery extends Query<DashboardShareReadModel | null> {
|
||||
constructor(
|
||||
public readonly projectId: string,
|
||||
public readonly viewerId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** What a link shows, read now; null for an unknown token. */
|
||||
export class GetSharedDashboardQuery extends Query<SharedDashboardReadModel | null> {
|
||||
constructor(public readonly token: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(ShareDashboardCommand)
|
||||
export class ShareDashboardCommandHandler
|
||||
implements ICommandHandler<ShareDashboardCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly shares: DashboardSharesRepository,
|
||||
private readonly access: WriteAccess,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
command: ShareDashboardCommand,
|
||||
): Promise<DashboardShareReadModel> {
|
||||
(await this.access.project(command.projectId)).assertCanManage(
|
||||
command.actorId,
|
||||
);
|
||||
const blocks = cleanBlocks(command.blocks);
|
||||
assertThat(blocks.length > 0, 'Pick at least one block');
|
||||
const current = await this.shares.findByProject(command.projectId);
|
||||
const fresh = !current || command.renew;
|
||||
const share: DashboardShareReadModel = {
|
||||
projectId: command.projectId,
|
||||
// 32 random bytes: nobody guesses it.
|
||||
token: fresh ? randomBytes(32).toString('base64url') : current.token,
|
||||
blocks,
|
||||
createdAt: fresh ? new Date().toISOString() : current.createdAt,
|
||||
};
|
||||
await this.shares.save(share);
|
||||
return share;
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(UnshareDashboardCommand)
|
||||
export class UnshareDashboardCommandHandler
|
||||
implements ICommandHandler<UnshareDashboardCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly shares: DashboardSharesRepository,
|
||||
private readonly access: WriteAccess,
|
||||
) {}
|
||||
|
||||
async execute(command: UnshareDashboardCommand): Promise<void> {
|
||||
(await this.access.project(command.projectId)).assertManages(
|
||||
command.actorId,
|
||||
);
|
||||
await this.shares.remove(command.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(GetDashboardShareQuery)
|
||||
export class GetDashboardShareQueryHandler
|
||||
implements
|
||||
IQueryHandler<GetDashboardShareQuery, DashboardShareReadModel | null>
|
||||
{
|
||||
constructor(
|
||||
private readonly shares: DashboardSharesRepository,
|
||||
private readonly access: WriteAccess,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
query: GetDashboardShareQuery,
|
||||
): Promise<DashboardShareReadModel | null> {
|
||||
(await this.access.project(query.projectId)).assertManages(query.viewerId);
|
||||
return this.shares.findByProject(query.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The chosen blocks of a project, for anyone with the link: computed like
|
||||
* the dashboard, then cut down to what outsiders may see.
|
||||
*/
|
||||
@QueryHandler(GetSharedDashboardQuery)
|
||||
export class GetSharedDashboardQueryHandler
|
||||
implements
|
||||
IQueryHandler<GetSharedDashboardQuery, SharedDashboardReadModel | null>
|
||||
{
|
||||
constructor(
|
||||
private readonly shares: DashboardSharesRepository,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly findTimeline: FindTimelineRepository,
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly findReleases: FindReleasesRepository,
|
||||
private readonly findActivity: FindActivityRepository,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
query: GetSharedDashboardQuery,
|
||||
): Promise<SharedDashboardReadModel | null> {
|
||||
const share = await this.shares.findByToken(query.token);
|
||||
if (!share) return null;
|
||||
const project = await this.findProjects.findById(share.projectId);
|
||||
if (!project) return null;
|
||||
const has = (block: SharedBlock) => share.blocks.includes(block);
|
||||
const now = new Date();
|
||||
const [issues, timeline, sprints, releases, epics, activity] =
|
||||
await Promise.all([
|
||||
this.findIssues.findByProject(project.id),
|
||||
this.findTimeline.findByProject(project.id),
|
||||
this.findSprints.findByProject(project.id),
|
||||
this.findReleases.findByProject(project.id),
|
||||
has('epics') ? this.findIssues.epics(project.id) : [],
|
||||
has('activity')
|
||||
? this.findActivity.dailyCounts(
|
||||
[project.id],
|
||||
heatmapStart(now.getTime()),
|
||||
)
|
||||
: [],
|
||||
]);
|
||||
const settings = project.settings ?? DEFAULT_PROJECT_SETTINGS;
|
||||
const reading = computeInsights({
|
||||
issues,
|
||||
timeline,
|
||||
settings,
|
||||
activeSprint:
|
||||
sprints.find((s) => s.status === SprintStatus.ACTIVE) ?? null,
|
||||
sprints,
|
||||
releases,
|
||||
members: project.members,
|
||||
since: null,
|
||||
mentions: 0,
|
||||
activity,
|
||||
now,
|
||||
});
|
||||
const live = issues.filter((i) => !i.trashedAt);
|
||||
return {
|
||||
projectKey: project.key,
|
||||
projectName: project.name,
|
||||
generatedAt: now.toISOString(),
|
||||
blocks: share.blocks,
|
||||
flow: has('flow')
|
||||
? settings.statuses
|
||||
.filter((s) => !s.archived)
|
||||
.map((s) => ({
|
||||
status: s.id,
|
||||
name: s.name,
|
||||
color: s.color,
|
||||
count: live.filter((i) => i.status === s.id).length,
|
||||
}))
|
||||
: null,
|
||||
sprint: has('sprint') ? reading.sprint : null,
|
||||
releases: has('releases') ? reading.releases : null,
|
||||
epics: has('epics')
|
||||
? epics
|
||||
.filter(
|
||||
(e) =>
|
||||
!FINISHED.includes(e.issue.statusCategory) &&
|
||||
!e.issue.trashedAt,
|
||||
)
|
||||
.sort((a, b) =>
|
||||
(a.issue.dueDate ?? '9999').localeCompare(
|
||||
b.issue.dueDate ?? '9999',
|
||||
),
|
||||
)
|
||||
.slice(0, EPICS)
|
||||
.map((e) => ({
|
||||
key: e.issue.key,
|
||||
title: e.issue.title,
|
||||
dueDate: e.issue.dueDate,
|
||||
done: e.progress.finished,
|
||||
total: e.progress.total,
|
||||
percent: e.progress.percent,
|
||||
}))
|
||||
: null,
|
||||
activity: has('activity') ? reading.heatmap : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const SHARING_HANDLERS = [
|
||||
ShareDashboardCommandHandler,
|
||||
UnshareDashboardCommandHandler,
|
||||
GetDashboardShareQueryHandler,
|
||||
GetSharedDashboardQueryHandler,
|
||||
];
|
||||
@@ -49,6 +49,8 @@ import { PresenceService } from './presence.service';
|
||||
import { AUTOMATION_HANDLERS } from './automations/automations';
|
||||
import { CALENDAR_HANDLERS } from './calendar/calendar';
|
||||
import { CalendarService } from './calendar.service';
|
||||
import { SharingService } from './sharing.service';
|
||||
import { SHARING_HANDLERS } from './sharing/dashboard-shares';
|
||||
import { AutomationsEventHandler } from './automations/automations.event-handler';
|
||||
|
||||
/** Notifications: derived from events, kept and dismissed per person. */
|
||||
@@ -66,6 +68,7 @@ const NOTIFICATIONS = [
|
||||
|
||||
const SERVICES = [
|
||||
CalendarService,
|
||||
SharingService,
|
||||
ProjectsService,
|
||||
IssuesService,
|
||||
CommentsService,
|
||||
@@ -110,6 +113,7 @@ const SERVICES = [
|
||||
...TRANSFER_HANDLERS,
|
||||
...AUTOMATION_HANDLERS,
|
||||
...CALENDAR_HANDLERS,
|
||||
...SHARING_HANDLERS,
|
||||
AutomationsEventHandler,
|
||||
],
|
||||
exports: SERVICES,
|
||||
|
||||
@@ -11,3 +11,4 @@ export * from './saved-filter.read-model';
|
||||
export * from './sprint.read-model';
|
||||
export * from './insights.read-model';
|
||||
export * from './home.read-model';
|
||||
export * from './shared-dashboard.read-model';
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
ActivityDayReadModel,
|
||||
ReleaseForecastReadModel,
|
||||
SprintPulseReadModel,
|
||||
} from './insights.read-model';
|
||||
|
||||
/** The blocks a project can show to outsiders, in the order they are shown. */
|
||||
export const SHARED_BLOCKS = [
|
||||
'flow',
|
||||
'sprint',
|
||||
'releases',
|
||||
'epics',
|
||||
'activity',
|
||||
] as const;
|
||||
export type SharedBlock = (typeof SHARED_BLOCKS)[number];
|
||||
|
||||
/** A project's link for clients: the secret, and what it shows. */
|
||||
export class DashboardShareReadModel {
|
||||
projectId!: string;
|
||||
/** Random and long; whoever has it sees the chosen blocks. */
|
||||
token!: string;
|
||||
blocks!: SharedBlock[];
|
||||
createdAt!: string;
|
||||
}
|
||||
|
||||
/** Tickets in one status (the board's columns, archived ones left out). */
|
||||
export class SharedFlowReadModel {
|
||||
status!: string;
|
||||
name!: string;
|
||||
/** A colour name of the project's settings. */
|
||||
color!: string;
|
||||
count!: number;
|
||||
}
|
||||
|
||||
/** An open epic and how far it got: no more than its key and title. */
|
||||
export class SharedEpicReadModel {
|
||||
key!: string;
|
||||
title!: string;
|
||||
dueDate!: string | null;
|
||||
done!: number;
|
||||
total!: number;
|
||||
percent!: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a client sees through the link: only the chosen blocks (the others
|
||||
* are null), nothing about people or single tickets but the epics.
|
||||
*/
|
||||
export class SharedDashboardReadModel {
|
||||
projectKey!: string;
|
||||
projectName!: string;
|
||||
generatedAt!: string;
|
||||
blocks!: SharedBlock[];
|
||||
flow!: SharedFlowReadModel[] | null;
|
||||
sprint!: SprintPulseReadModel | null;
|
||||
releases!: ReleaseForecastReadModel[] | null;
|
||||
epics!: SharedEpicReadModel[] | null;
|
||||
activity!: ActivityDayReadModel[] | null;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { DashboardSharesRepository } from '@task-stream/api/tracker/application';
|
||||
import {
|
||||
DashboardShareReadModel,
|
||||
SharedBlock,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { Model } from 'mongoose';
|
||||
import { DashboardShareDoc, ReadModels } from './read-model.schemas';
|
||||
|
||||
@Injectable()
|
||||
export class MongoDashboardSharesRepository extends DashboardSharesRepository {
|
||||
constructor(
|
||||
@InjectModel(ReadModels.DashboardShare)
|
||||
private readonly shares: Model<DashboardShareDoc>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async findByProject(
|
||||
projectId: string,
|
||||
): Promise<DashboardShareReadModel | null> {
|
||||
const doc = await this.shares
|
||||
.findOne({ id: projectId })
|
||||
.lean<DashboardShareDoc>();
|
||||
return doc ? toShare(doc) : null;
|
||||
}
|
||||
|
||||
async findByToken(token: string): Promise<DashboardShareReadModel | null> {
|
||||
const doc = await this.shares.findOne({ token }).lean<DashboardShareDoc>();
|
||||
return doc ? toShare(doc) : null;
|
||||
}
|
||||
|
||||
async save(share: DashboardShareReadModel): Promise<void> {
|
||||
await this.shares.updateOne(
|
||||
{ id: share.projectId },
|
||||
{
|
||||
$set: {
|
||||
token: share.token,
|
||||
blocks: share.blocks,
|
||||
createdAt: share.createdAt,
|
||||
},
|
||||
},
|
||||
{ upsert: true },
|
||||
);
|
||||
}
|
||||
|
||||
async remove(projectId: string): Promise<void> {
|
||||
await this.shares.deleteOne({ id: projectId });
|
||||
}
|
||||
}
|
||||
|
||||
function toShare(doc: DashboardShareDoc): DashboardShareReadModel {
|
||||
return {
|
||||
projectId: doc.id,
|
||||
token: doc.token,
|
||||
blocks: (doc.blocks ?? []) as SharedBlock[],
|
||||
createdAt: doc.createdAt,
|
||||
};
|
||||
}
|
||||
@@ -74,6 +74,13 @@ export interface PushSubscriptionDoc {
|
||||
keys: { p256dh: string; auth: string };
|
||||
createdAt: string;
|
||||
}
|
||||
export interface DashboardShareDoc {
|
||||
/** The project's id. */
|
||||
id: string;
|
||||
token: string;
|
||||
blocks: string[];
|
||||
createdAt: string;
|
||||
}
|
||||
export interface CalendarFeedDoc {
|
||||
/** The user's id. */
|
||||
id: string;
|
||||
@@ -103,6 +110,7 @@ export const ReadModels = {
|
||||
Automation: 'Automation',
|
||||
AutomationRun: 'AutomationRun',
|
||||
CalendarFeed: 'CalendarFeed',
|
||||
DashboardShare: 'DashboardShare',
|
||||
PushSubscription: 'PushSubscription',
|
||||
} as const;
|
||||
|
||||
@@ -370,6 +378,19 @@ class CalendarFeedDocument {
|
||||
export const CalendarFeedSchema =
|
||||
SchemaFactory.createForClass(CalendarFeedDocument);
|
||||
|
||||
/** One per project: the secret of its link for clients, and what it shows. */
|
||||
@Schema({ ...LOOSE, collection: 'dashboard_shares' })
|
||||
class DashboardShareDocument {
|
||||
/** The project's id. */
|
||||
@Prop({ type: String, required: true, unique: true }) id!: string;
|
||||
@Prop({ type: String, required: true, unique: true }) token!: string;
|
||||
@Prop({ type: [String] }) blocks!: string[];
|
||||
@Prop({ type: String }) createdAt!: string;
|
||||
}
|
||||
export const DashboardShareSchema = SchemaFactory.createForClass(
|
||||
DashboardShareDocument,
|
||||
);
|
||||
|
||||
/** The devices where each person turned push on. */
|
||||
@Schema({ ...LOOSE, collection: 'push_subscriptions' })
|
||||
class PushSubscriptionDocument {
|
||||
@@ -404,6 +425,7 @@ export const READ_MODEL_SCHEMAS = [
|
||||
{ name: ReadModels.SavedFilter, schema: SavedFilterSchema },
|
||||
{ name: ReadModels.MyView, schema: MyViewSchema },
|
||||
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
|
||||
{ name: ReadModels.DashboardShare, schema: DashboardShareSchema },
|
||||
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },
|
||||
];
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
NotificationMailer,
|
||||
NotificationsRepository,
|
||||
CalendarFeedsRepository,
|
||||
DashboardSharesRepository,
|
||||
PushSubscriptionsRepository,
|
||||
PushSender,
|
||||
SavedFiltersRepository,
|
||||
@@ -72,6 +73,7 @@ import { RedisRateLimitStore } from './redis/redis-rate-limit.store';
|
||||
import { RedisPresenceStore } from './redis/redis-presence.store';
|
||||
import { MongoNotificationsRepository } from './mongo/notifications.repository';
|
||||
import { MongoCalendarFeedsRepository } from './mongo/calendar-feeds.repository';
|
||||
import { MongoDashboardSharesRepository } from './mongo/dashboard-shares.repository';
|
||||
import { MongoPushSubscriptionsRepository } from './mongo/push-subscriptions.repository';
|
||||
import { WebPushSender } from './push/web-push.sender';
|
||||
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
|
||||
@@ -141,6 +143,10 @@ const PORTS = [
|
||||
// Notifications → MongoDB (per person), copies by e-mail → SMTP (optional)
|
||||
{ provide: NotificationsRepository, useClass: MongoNotificationsRepository },
|
||||
{ provide: CalendarFeedsRepository, useClass: MongoCalendarFeedsRepository },
|
||||
{
|
||||
provide: DashboardSharesRepository,
|
||||
useClass: MongoDashboardSharesRepository,
|
||||
},
|
||||
{
|
||||
provide: PushSubscriptionsRepository,
|
||||
useClass: MongoPushSubscriptionsRepository,
|
||||
|
||||
@@ -861,3 +861,22 @@ export class SaveMyViewInput {
|
||||
@Type(() => MyViewCriteriaInput)
|
||||
criteria!: MyViewCriteriaInput;
|
||||
}
|
||||
|
||||
@InputType({ description: "The project's link for clients" })
|
||||
export class ShareDashboardInput {
|
||||
@Field(() => ID) @IsUUID() projectId!: string;
|
||||
@Field(() => [String], {
|
||||
description: 'Blocks to show: flow, sprint, releases, epics, activity',
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMaxSize(10)
|
||||
@IsString({ each: true })
|
||||
blocks!: string[];
|
||||
@Field({
|
||||
nullable: true,
|
||||
description: 'A new secret: the old link stops working',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
renew?: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Args, ID, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import {
|
||||
CurrentUser,
|
||||
OptionalAuth,
|
||||
} from '@task-stream/api/identity/presenters';
|
||||
import { SharingService } from '@task-stream/api/tracker/application';
|
||||
import { ShareDashboardInput } from './inputs';
|
||||
import { DashboardShareGql, SharedDashboardGql } from './types';
|
||||
|
||||
/** The project's dashboard for clients, behind a secret link. */
|
||||
@Resolver(() => DashboardShareGql)
|
||||
export class SharingResolver {
|
||||
constructor(private readonly sharing: SharingService) {}
|
||||
|
||||
@Query(() => DashboardShareGql, {
|
||||
nullable: true,
|
||||
description: "The project's link for clients, if any (owners and admins)",
|
||||
})
|
||||
dashboardShare(
|
||||
@CurrentUser() me: User,
|
||||
@Args('projectId', { type: () => ID }) projectId: string,
|
||||
) {
|
||||
return this.sharing.share(me.id, projectId);
|
||||
}
|
||||
|
||||
@Mutation(() => DashboardShareGql, {
|
||||
description:
|
||||
'Turn the link on or change its blocks; renew for a new secret',
|
||||
})
|
||||
shareDashboard(
|
||||
@CurrentUser() me: User,
|
||||
@Args('input') input: ShareDashboardInput,
|
||||
) {
|
||||
return this.sharing.save(
|
||||
me.id,
|
||||
input.projectId,
|
||||
input.blocks,
|
||||
input.renew ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, { description: 'The link stops working' })
|
||||
async unshareDashboard(
|
||||
@CurrentUser() me: User,
|
||||
@Args('projectId', { type: () => ID }) projectId: string,
|
||||
) {
|
||||
await this.sharing.remove(me.id, projectId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@OptionalAuth()
|
||||
@Query(() => SharedDashboardGql, {
|
||||
nullable: true,
|
||||
description: 'What a link for clients shows, for anyone with it',
|
||||
})
|
||||
sharedDashboard(@Args('token') token: string) {
|
||||
return this.sharing.shared(token);
|
||||
}
|
||||
}
|
||||
@@ -1260,3 +1260,53 @@ export class HomeGql {
|
||||
@Field(() => FirstStepsGql) firstSteps!: FirstStepsGql;
|
||||
@Field(() => [OnlineGql]) online!: OnlineGql[];
|
||||
}
|
||||
|
||||
@ObjectType('DashboardShare', {
|
||||
description: "The project's link for clients (owners and admins)",
|
||||
})
|
||||
export class DashboardShareGql {
|
||||
@Field(() => ID) projectId!: string;
|
||||
@Field({ description: 'Secret of the link /share/{token}' }) token!: string;
|
||||
@Field(() => [String]) blocks!: string[];
|
||||
@Field() createdAt!: string;
|
||||
}
|
||||
|
||||
@ObjectType('SharedFlow', { description: 'Tickets in one status' })
|
||||
export class SharedFlowGql {
|
||||
@Field() status!: string;
|
||||
@Field() name!: string;
|
||||
@Field({ description: 'A colour name of the project settings' })
|
||||
color!: string;
|
||||
@Field(() => Int) count!: number;
|
||||
}
|
||||
|
||||
@ObjectType('SharedEpic', { description: 'An open epic and how far it got' })
|
||||
export class SharedEpicGql {
|
||||
@Field() key!: string;
|
||||
@Field() title!: string;
|
||||
@Field(() => String, { nullable: true }) dueDate!: string | null;
|
||||
@Field(() => Int) done!: number;
|
||||
@Field(() => Int) total!: number;
|
||||
@Field(() => Int) percent!: number;
|
||||
}
|
||||
|
||||
@ObjectType('SharedDashboard', {
|
||||
description:
|
||||
'What a link for clients shows: only the chosen blocks (the others are null)',
|
||||
})
|
||||
export class SharedDashboardGql {
|
||||
@Field() projectKey!: string;
|
||||
@Field() projectName!: string;
|
||||
@Field() generatedAt!: string;
|
||||
@Field(() => [String]) blocks!: string[];
|
||||
@Field(() => [SharedFlowGql], { nullable: true })
|
||||
flow!: SharedFlowGql[] | null;
|
||||
@Field(() => SprintPulseGql, { nullable: true })
|
||||
sprint!: SprintPulseGql | null;
|
||||
@Field(() => [ReleaseForecastGql], { nullable: true })
|
||||
releases!: ReleaseForecastGql[] | null;
|
||||
@Field(() => [SharedEpicGql], { nullable: true })
|
||||
epics!: SharedEpicGql[] | null;
|
||||
@Field(() => [ActivityDayGql], { nullable: true })
|
||||
activity!: ActivityDayGql[] | null;
|
||||
}
|
||||
|
||||
@@ -1526,3 +1526,22 @@ export class AddGitRepositoryDto {
|
||||
@MaxLength(40)
|
||||
name!: string;
|
||||
}
|
||||
|
||||
export class ShareDashboardDto {
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
example: ['flow', 'sprint', 'releases'],
|
||||
description: 'Blocks to show: flow, sprint, releases, epics, activity',
|
||||
})
|
||||
@IsArray()
|
||||
@ArrayMaxSize(10)
|
||||
@IsString({ each: true })
|
||||
blocks!: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'A new secret: the old link stops working',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
renew?: boolean;
|
||||
}
|
||||
|
||||
@@ -1305,3 +1305,46 @@ export class HomeDto {
|
||||
@ApiProperty({ type: FirstStepsDto }) firstSteps!: FirstStepsDto;
|
||||
@ApiProperty({ type: [OnlineDto] }) online!: OnlineDto[];
|
||||
}
|
||||
|
||||
export class DashboardShareDto {
|
||||
@ApiProperty() projectId!: string;
|
||||
@ApiProperty({ description: 'Secret of the link /share/{token}' })
|
||||
token!: string;
|
||||
@ApiProperty({ type: [String], example: ['flow', 'sprint', 'releases'] })
|
||||
blocks!: string[];
|
||||
@ApiProperty() createdAt!: string;
|
||||
}
|
||||
|
||||
export class SharedFlowDto {
|
||||
@ApiProperty() status!: string;
|
||||
@ApiProperty() name!: string;
|
||||
@ApiProperty({ description: 'A colour name of the project settings' })
|
||||
color!: string;
|
||||
@ApiProperty() count!: number;
|
||||
}
|
||||
|
||||
export class SharedEpicDto {
|
||||
@ApiProperty() key!: string;
|
||||
@ApiProperty() title!: string;
|
||||
@ApiProperty({ type: String, nullable: true }) dueDate!: string | null;
|
||||
@ApiProperty() done!: number;
|
||||
@ApiProperty() total!: number;
|
||||
@ApiProperty() percent!: number;
|
||||
}
|
||||
|
||||
export class SharedDashboardDto {
|
||||
@ApiProperty() projectKey!: string;
|
||||
@ApiProperty() projectName!: string;
|
||||
@ApiProperty() generatedAt!: string;
|
||||
@ApiProperty({ type: [String] }) blocks!: string[];
|
||||
@ApiProperty({ type: [SharedFlowDto], nullable: true })
|
||||
flow!: SharedFlowDto[] | null;
|
||||
@ApiProperty({ type: SprintPulseDto, nullable: true })
|
||||
sprint!: SprintPulseDto | null;
|
||||
@ApiProperty({ type: [ReleaseForecastDto], nullable: true })
|
||||
releases!: ReleaseForecastDto[] | null;
|
||||
@ApiProperty({ type: [SharedEpicDto], nullable: true })
|
||||
epics!: SharedEpicDto[] | null;
|
||||
@ApiProperty({ type: [ActivityDayDto], nullable: true })
|
||||
activity!: ActivityDayDto[] | null;
|
||||
}
|
||||
|
||||
120
libs/api/tracker/presenters/src/lib/http/sharing.controller.ts
Normal file
120
libs/api/tracker/presenters/src/lib/http/sharing.controller.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Put,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import {
|
||||
ApiPireonAuth,
|
||||
CurrentUser,
|
||||
Public,
|
||||
} from '@task-stream/api/identity/presenters';
|
||||
import {
|
||||
ApiCommandErrors,
|
||||
ApiReadErrors,
|
||||
} from '@task-stream/api/shared/presenters';
|
||||
import { SharingService } from '@task-stream/api/tracker/application';
|
||||
import type { Response } from 'express';
|
||||
import { ShareDashboardDto } from './dto/requests.dto';
|
||||
import { DashboardShareDto, SharedDashboardDto } from './dto/responses.dto';
|
||||
import { Refs } from './refs';
|
||||
|
||||
const PROJECT = ApiParam({
|
||||
name: 'project',
|
||||
description: 'Project id or key',
|
||||
example: 'WEB',
|
||||
});
|
||||
|
||||
/**
|
||||
* The project's dashboard for clients: a secret link (/share/{token}) that
|
||||
* shows the blocks its owners and admins chose, without an account.
|
||||
*/
|
||||
@ApiTags('Projects')
|
||||
@Controller()
|
||||
export class SharingController {
|
||||
constructor(
|
||||
private readonly sharing: SharingService,
|
||||
private readonly refs: Refs,
|
||||
) {}
|
||||
|
||||
@Get('projects/:project/share')
|
||||
@ApiPireonAuth()
|
||||
@PROJECT
|
||||
@ApiOperation({ summary: "The project's link for clients, if any" })
|
||||
@ApiOkResponse({ type: DashboardShareDto })
|
||||
@ApiNoContentResponse({ description: 'It has none' })
|
||||
@ApiReadErrors()
|
||||
async share(
|
||||
@CurrentUser() me: User,
|
||||
@Param('project') project: string,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const share = await this.sharing.share(
|
||||
me.id,
|
||||
await this.refs.projectId(me.id, project),
|
||||
);
|
||||
if (!share) response.status(204);
|
||||
return share ?? undefined;
|
||||
}
|
||||
|
||||
@Put('projects/:project/share')
|
||||
@ApiPireonAuth()
|
||||
@PROJECT
|
||||
@ApiOperation({
|
||||
summary: 'Turn the link on or change its blocks (renew: a new secret)',
|
||||
})
|
||||
@ApiOkResponse({ type: DashboardShareDto })
|
||||
@ApiCommandErrors()
|
||||
async save(
|
||||
@CurrentUser() me: User,
|
||||
@Param('project') project: string,
|
||||
@Body() body: ShareDashboardDto,
|
||||
) {
|
||||
return this.sharing.save(
|
||||
me.id,
|
||||
await this.refs.projectId(me.id, project),
|
||||
body.blocks,
|
||||
body.renew ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('projects/:project/share')
|
||||
@HttpCode(204)
|
||||
@ApiPireonAuth()
|
||||
@PROJECT
|
||||
@ApiOperation({ summary: 'The link stops working' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiCommandErrors()
|
||||
async remove(@CurrentUser() me: User, @Param('project') project: string) {
|
||||
await this.sharing.remove(me.id, await this.refs.projectId(me.id, project));
|
||||
}
|
||||
|
||||
@Get('shared/dashboards/:token')
|
||||
@Public()
|
||||
@ApiOperation({
|
||||
summary: 'What a link for clients shows (no session)',
|
||||
description:
|
||||
'Only the chosen blocks: tickets per status, the sprint under way, release forecasts, open epics and activity per day. The others are null.',
|
||||
})
|
||||
@ApiOkResponse({ type: SharedDashboardDto })
|
||||
async shared(@Param('token') token: string) {
|
||||
const dashboard = /^[\w-]{20,100}$/.test(token)
|
||||
? await this.sharing.shared(token)
|
||||
: null;
|
||||
if (!dashboard) throw new NotFoundException('Unknown link');
|
||||
return dashboard;
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,9 @@ import { SprintsController } from './http/sprints.controller';
|
||||
import { GitController } from './http/git.controller';
|
||||
import { GitResolver } from './graphql/git.resolver';
|
||||
import { WebhookResolver } from './graphql/webhook.resolver';
|
||||
import { SharingResolver } from './graphql/sharing.resolver';
|
||||
import { WebhooksController } from './http/webhooks.controller';
|
||||
import { SharingController } from './http/sharing.controller';
|
||||
import { TransferController } from './http/transfer.controller';
|
||||
import { AutomationResolver } from './graphql/automation.resolver';
|
||||
|
||||
@@ -65,6 +67,7 @@ import { AutomationResolver } from './graphql/automation.resolver';
|
||||
SprintsController,
|
||||
GitController,
|
||||
WebhooksController,
|
||||
SharingController,
|
||||
TransferController,
|
||||
],
|
||||
providers: [
|
||||
@@ -84,6 +87,7 @@ import { AutomationResolver } from './graphql/automation.resolver';
|
||||
SprintResolver,
|
||||
GitResolver,
|
||||
WebhookResolver,
|
||||
SharingResolver,
|
||||
AutomationResolver,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './lib/notifications';
|
||||
export * from './lib/facades/projects.facade';
|
||||
export * from './lib/facades/calendar.facade';
|
||||
export * from './lib/facades/sharing.facade';
|
||||
export * from './lib/facades/push.facade';
|
||||
export * from './lib/facades/project.facade';
|
||||
export * from './lib/facades/board.facade';
|
||||
|
||||
128
libs/web/tracker/application/src/lib/facades/sharing.facade.ts
Normal file
128
libs/web/tracker/application/src/lib/facades/sharing.facade.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
DestroyRef,
|
||||
inject,
|
||||
Injectable,
|
||||
Provider,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
DashboardShare,
|
||||
SharedBlock,
|
||||
SharedDashboard,
|
||||
SharingGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { Observable } from 'rxjs';
|
||||
import { describeError } from '../notifications';
|
||||
|
||||
/**
|
||||
* The project's link for clients, in Configuración: turn it on, pick its
|
||||
* blocks, renew or drop it. Seldom touched, so it lives in this facade
|
||||
* alone, without a store.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SharingFacade {
|
||||
private readonly gateway = inject(SharingGateway);
|
||||
private projectId: string | null = null;
|
||||
|
||||
readonly share = signal<DashboardShare | null>(null);
|
||||
readonly loaded = signal(false);
|
||||
readonly busy = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
load(projectId: string): void {
|
||||
this.projectId = projectId;
|
||||
this.run(this.gateway.share(projectId));
|
||||
}
|
||||
|
||||
/** On (or new blocks); `renew` makes a new secret, the old link dies. */
|
||||
save(blocks: SharedBlock[], renew = false): void {
|
||||
if (this.projectId)
|
||||
this.run(this.gateway.save(this.projectId, blocks, renew));
|
||||
}
|
||||
|
||||
remove(): void {
|
||||
if (!this.projectId) return;
|
||||
this.busy.set(true);
|
||||
this.gateway.remove(this.projectId).subscribe({
|
||||
next: () => this.done(null),
|
||||
error: (error) => this.failed(error),
|
||||
});
|
||||
}
|
||||
|
||||
private run(call: Observable<DashboardShare | null>): void {
|
||||
this.busy.set(true);
|
||||
this.error.set(null);
|
||||
call.subscribe({
|
||||
next: (share) => this.done(share),
|
||||
error: (error) => this.failed(error),
|
||||
});
|
||||
}
|
||||
|
||||
private done(share: DashboardShare | null): void {
|
||||
this.share.set(share);
|
||||
this.loaded.set(true);
|
||||
this.busy.set(false);
|
||||
}
|
||||
|
||||
private failed(error: unknown): void {
|
||||
this.loaded.set(true);
|
||||
this.busy.set(false);
|
||||
this.error.set(describeError(error));
|
||||
}
|
||||
}
|
||||
|
||||
/** The link for clients, in Configuración: provided by the page. */
|
||||
export function provideSharingFacade(): Provider[] {
|
||||
return [SharingFacade];
|
||||
}
|
||||
|
||||
/** Every minute: there is no live connection without a session. */
|
||||
const REFRESH_MS = 60_000;
|
||||
|
||||
/** What a client sees through the link, read again every minute. */
|
||||
@Injectable()
|
||||
export class SharedDashboardFacade {
|
||||
private readonly gateway = inject(SharingGateway);
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
readonly dashboard = signal<SharedDashboard | null>(null);
|
||||
readonly loaded = signal(false);
|
||||
/** No project has this link (never had, renewed or dropped). */
|
||||
readonly missing = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
constructor() {
|
||||
inject(DestroyRef).onDestroy(() => this.stop());
|
||||
}
|
||||
|
||||
open(token: string): void {
|
||||
this.stop();
|
||||
this.read(token);
|
||||
this.timer = setInterval(() => this.read(token), REFRESH_MS);
|
||||
}
|
||||
|
||||
private read(token: string): void {
|
||||
this.gateway.shared(token).subscribe({
|
||||
next: (dashboard) => {
|
||||
this.dashboard.set(dashboard);
|
||||
this.missing.set(!dashboard);
|
||||
this.error.set(null);
|
||||
this.loaded.set(true);
|
||||
},
|
||||
error: (error) => {
|
||||
this.error.set(describeError(error));
|
||||
this.loaded.set(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private stop(): void {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The page for clients: provided by its route. */
|
||||
export function provideSharedDashboardFacade(): Provider[] {
|
||||
return [SharedDashboardFacade];
|
||||
}
|
||||
@@ -14,3 +14,4 @@ export * from './lib/models/due';
|
||||
export * from './lib/models/calendar';
|
||||
export * from './lib/models/insights';
|
||||
export * from './lib/models/home';
|
||||
export * from './lib/models/sharing';
|
||||
|
||||
41
libs/web/tracker/domain/src/lib/models/sharing.ts
Normal file
41
libs/web/tracker/domain/src/lib/models/sharing.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ReleaseForecast, SprintPulse } from './insights';
|
||||
|
||||
/** The blocks a project can show to its clients, in the order they are shown. */
|
||||
export const SHARED_BLOCKS = [
|
||||
'flow',
|
||||
'sprint',
|
||||
'releases',
|
||||
'epics',
|
||||
'activity',
|
||||
] as const;
|
||||
export type SharedBlock = (typeof SHARED_BLOCKS)[number];
|
||||
|
||||
/** The project's link for clients: its secret and what it shows. */
|
||||
export interface DashboardShare {
|
||||
projectId: string;
|
||||
token: string;
|
||||
blocks: SharedBlock[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** What a client sees through the link (blocks not chosen come as null). */
|
||||
export interface SharedDashboard {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
generatedAt: string;
|
||||
blocks: SharedBlock[];
|
||||
flow: { status: string; name: string; color: string; count: number }[] | null;
|
||||
sprint: SprintPulse | null;
|
||||
releases: ReleaseForecast[] | null;
|
||||
epics:
|
||||
| {
|
||||
key: string;
|
||||
title: string;
|
||||
dueDate: string | null;
|
||||
done: number;
|
||||
total: number;
|
||||
percent: number;
|
||||
}[]
|
||||
| null;
|
||||
activity: { day: string; count: number }[] | null;
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Home } from '../models/home';
|
||||
import { PortfolioProject, ProjectInsights } from '../models/insights';
|
||||
import {
|
||||
DashboardShare,
|
||||
SharedBlock,
|
||||
SharedDashboard,
|
||||
} from '../models/sharing';
|
||||
import { Observable } from 'rxjs';
|
||||
import {
|
||||
Activity,
|
||||
@@ -368,6 +373,21 @@ export abstract class CalendarGateway {
|
||||
abstract revoke(): Observable<void>;
|
||||
}
|
||||
|
||||
/** The project's dashboard for clients, behind a secret link. */
|
||||
export abstract class SharingGateway {
|
||||
/** The project's link, if it has one (owners and admins). */
|
||||
abstract share(projectId: string): Observable<DashboardShare | null>;
|
||||
/** Turns it on or changes its blocks; `renew` makes a new secret. */
|
||||
abstract save(
|
||||
projectId: string,
|
||||
blocks: SharedBlock[],
|
||||
renew: boolean,
|
||||
): Observable<DashboardShare>;
|
||||
abstract remove(projectId: string): Observable<void>;
|
||||
/** What a link shows, for anyone (null: no such link). */
|
||||
abstract shared(token: string): Observable<SharedDashboard | null>;
|
||||
}
|
||||
|
||||
export abstract class NotificationsGateway {
|
||||
abstract mine(
|
||||
unreadOnly: boolean,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import {
|
||||
DashboardShare,
|
||||
SharedBlock,
|
||||
SharedDashboard,
|
||||
SharingGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import {
|
||||
DashboardShareDocument,
|
||||
ShareDashboardDocument,
|
||||
SharedDashboardDocument,
|
||||
UnshareDashboardDocument,
|
||||
} from '../graphql/generated';
|
||||
import { GraphqlOperations } from './graphql-operations';
|
||||
|
||||
@Injectable()
|
||||
export class GraphqlSharingGateway extends SharingGateway {
|
||||
private readonly gql = inject(GraphqlOperations);
|
||||
|
||||
share(projectId: string): Observable<DashboardShare | null> {
|
||||
return this.gql
|
||||
.query(DashboardShareDocument, { projectId })
|
||||
.pipe(map((d) => (d.dashboardShare as DashboardShare | null) ?? null));
|
||||
}
|
||||
|
||||
save(
|
||||
projectId: string,
|
||||
blocks: SharedBlock[],
|
||||
renew: boolean,
|
||||
): Observable<DashboardShare> {
|
||||
return this.gql
|
||||
.mutate(ShareDashboardDocument, { input: { projectId, blocks, renew } })
|
||||
.pipe(map((d) => d.shareDashboard as DashboardShare));
|
||||
}
|
||||
|
||||
remove(projectId: string): Observable<void> {
|
||||
return this.gql
|
||||
.mutate(UnshareDashboardDocument, { projectId })
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
|
||||
shared(token: string): Observable<SharedDashboard | null> {
|
||||
return this.gql
|
||||
.query(SharedDashboardDocument, { token })
|
||||
.pipe(map((d) => (d.sharedDashboard as SharedDashboard | null) ?? null));
|
||||
}
|
||||
}
|
||||
@@ -329,6 +329,15 @@ export type SavedFilterCriteriaInput = {
|
||||
unassigned?: boolean | null | undefined;
|
||||
};
|
||||
|
||||
/** The project's link for clients */
|
||||
export type ShareDashboardInput = {
|
||||
/** Blocks to show: flow, sprint, releases, epics, activity */
|
||||
blocks: Array<string>;
|
||||
projectId: string;
|
||||
/** A new secret: the old link stops working */
|
||||
renew?: boolean | null | undefined;
|
||||
};
|
||||
|
||||
/** PLANNED, ACTIVE (one per project at a time) or CLOSED */
|
||||
export type SprintStatus = 'ACTIVE' | 'CLOSED' | 'PLANNED';
|
||||
|
||||
@@ -2437,6 +2446,98 @@ export type DeleteMyViewMutationVariables = Exact<{
|
||||
|
||||
export type DeleteMyViewMutation = { deleteMyView: boolean };
|
||||
|
||||
export type DashboardShareFieldsFragment = {
|
||||
projectId: string;
|
||||
token: string;
|
||||
blocks: Array<string>;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type DashboardShareQueryVariables = Exact<{
|
||||
projectId: string;
|
||||
}>;
|
||||
|
||||
export type DashboardShareQuery = {
|
||||
dashboardShare: {
|
||||
projectId: string;
|
||||
token: string;
|
||||
blocks: Array<string>;
|
||||
createdAt: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type ShareDashboardMutationVariables = Exact<{
|
||||
input: ShareDashboardInput;
|
||||
}>;
|
||||
|
||||
export type ShareDashboardMutation = {
|
||||
shareDashboard: {
|
||||
projectId: string;
|
||||
token: string;
|
||||
blocks: Array<string>;
|
||||
createdAt: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type UnshareDashboardMutationVariables = Exact<{
|
||||
projectId: string;
|
||||
}>;
|
||||
|
||||
export type UnshareDashboardMutation = { unshareDashboard: boolean };
|
||||
|
||||
export type SharedDashboardQueryVariables = Exact<{
|
||||
token: string;
|
||||
}>;
|
||||
|
||||
export type SharedDashboardQuery = {
|
||||
sharedDashboard: {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
generatedAt: string;
|
||||
blocks: Array<string>;
|
||||
flow: Array<{
|
||||
status: string;
|
||||
name: string;
|
||||
color: string;
|
||||
count: number;
|
||||
}> | null;
|
||||
sprint: {
|
||||
sprintId: string;
|
||||
name: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
totalDays: number;
|
||||
daysLeft: number;
|
||||
tickets: number;
|
||||
doneTickets: number;
|
||||
points: number;
|
||||
donePoints: number;
|
||||
pacePerDay: number | null;
|
||||
neededPerDay: number | null;
|
||||
behind: boolean;
|
||||
} | null;
|
||||
releases: Array<{
|
||||
releaseId: string;
|
||||
name: string;
|
||||
targetDate: string | null;
|
||||
total: number;
|
||||
finished: number;
|
||||
likelyDate: string | null;
|
||||
safeDate: string | null;
|
||||
verdict: string;
|
||||
}> | null;
|
||||
epics: Array<{
|
||||
key: string;
|
||||
title: string;
|
||||
dueDate: string | null;
|
||||
done: number;
|
||||
total: number;
|
||||
percent: number;
|
||||
}> | null;
|
||||
activity: Array<{ day: string; count: number }> | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type SprintFieldsFragment = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
@@ -3948,6 +4049,28 @@ export const SavedFilterFieldsFragmentDoc = {
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<SavedFilterFieldsFragment, unknown>;
|
||||
export const DashboardShareFieldsFragmentDoc = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'DashboardShareFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'DashboardShare' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'token' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'blocks' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<DashboardShareFieldsFragment, unknown>;
|
||||
export const SprintFieldsFragmentDoc = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -14377,6 +14500,391 @@ export const DeleteMyViewDocument = {
|
||||
DeleteMyViewMutation,
|
||||
DeleteMyViewMutationVariables
|
||||
>;
|
||||
export const DashboardShareDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
name: { kind: 'Name', value: 'DashboardShare' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'dashboardShare' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'DashboardShareFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'DashboardShareFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'DashboardShare' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'token' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'blocks' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<DashboardShareQuery, DashboardShareQueryVariables>;
|
||||
export const ShareDashboardDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'ShareDashboard' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'input' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'ShareDashboardInput' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'shareDashboard' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'input' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'input' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'DashboardShareFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'DashboardShareFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'DashboardShare' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'token' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'blocks' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
ShareDashboardMutation,
|
||||
ShareDashboardMutationVariables
|
||||
>;
|
||||
export const UnshareDashboardDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'UnshareDashboard' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'unshareDashboard' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
UnshareDashboardMutation,
|
||||
UnshareDashboardMutationVariables
|
||||
>;
|
||||
export const SharedDashboardDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
name: { kind: 'Name', value: 'SharedDashboard' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'token' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'String' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'sharedDashboard' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'token' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'token' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'projectKey' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'projectName' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'generatedAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'blocks' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'flow' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'status' },
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'color' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'count' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'sprint' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'sprintId' },
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'startDate' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'endDate' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'totalDays' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'daysLeft' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'tickets' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'doneTickets' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'points' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'donePoints' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'pacePerDay' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'neededPerDay' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'behind' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'releases' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'releaseId' },
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'targetDate' },
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'total' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'finished' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'likelyDate' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'safeDate' },
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'verdict' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'epics' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'dueDate' },
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'done' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'total' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'percent' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'activity' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'day' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'count' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
SharedDashboardQuery,
|
||||
SharedDashboardQueryVariables
|
||||
>;
|
||||
export const SprintsDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
fragment DashboardShareFields on DashboardShare {
|
||||
projectId
|
||||
token
|
||||
blocks
|
||||
createdAt
|
||||
}
|
||||
|
||||
query DashboardShare($projectId: ID!) {
|
||||
dashboardShare(projectId: $projectId) {
|
||||
...DashboardShareFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation ShareDashboard($input: ShareDashboardInput!) {
|
||||
shareDashboard(input: $input) {
|
||||
...DashboardShareFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation UnshareDashboard($projectId: ID!) {
|
||||
unshareDashboard(projectId: $projectId)
|
||||
}
|
||||
|
||||
query SharedDashboard($token: String!) {
|
||||
sharedDashboard(token: $token) {
|
||||
projectKey
|
||||
projectName
|
||||
generatedAt
|
||||
blocks
|
||||
flow {
|
||||
status
|
||||
name
|
||||
color
|
||||
count
|
||||
}
|
||||
sprint {
|
||||
sprintId
|
||||
name
|
||||
startDate
|
||||
endDate
|
||||
totalDays
|
||||
daysLeft
|
||||
tickets
|
||||
doneTickets
|
||||
points
|
||||
donePoints
|
||||
pacePerDay
|
||||
neededPerDay
|
||||
behind
|
||||
}
|
||||
releases {
|
||||
releaseId
|
||||
name
|
||||
targetDate
|
||||
total
|
||||
finished
|
||||
likelyDate
|
||||
safeDate
|
||||
verdict
|
||||
}
|
||||
epics {
|
||||
key
|
||||
title
|
||||
dueDate
|
||||
done
|
||||
total
|
||||
percent
|
||||
}
|
||||
activity {
|
||||
day
|
||||
count
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
WebhooksGateway,
|
||||
TransferGateway,
|
||||
AutomationsGateway,
|
||||
SharingGateway,
|
||||
TrackerRealtimeGateway,
|
||||
UsersGateway,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
@@ -44,6 +45,7 @@ import { GraphqlGitGateway } from './adapters/graphql-git.gateway';
|
||||
import { GraphqlWebhooksGateway } from './adapters/graphql-webhooks.gateway';
|
||||
import { HttpTransferGateway } from './adapters/http-transfer.gateway';
|
||||
import { GraphqlAutomationsGateway } from './adapters/graphql-automations.gateway';
|
||||
import { GraphqlSharingGateway } from './adapters/graphql-sharing.gateway';
|
||||
|
||||
/**
|
||||
* The adapters the app shell needs from the start (the command palette, the
|
||||
@@ -79,5 +81,6 @@ export function provideTrackerPagesInfrastructure(): EnvironmentProviders {
|
||||
{ provide: WebhooksGateway, useClass: GraphqlWebhooksGateway },
|
||||
{ provide: TransferGateway, useClass: HttpTransferGateway },
|
||||
{ provide: AutomationsGateway, useClass: GraphqlAutomationsGateway },
|
||||
{ provide: SharingGateway, useClass: GraphqlSharingGateway },
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { GitIntegrationComponent } from '../ui/git-integration';
|
||||
import { WebhooksComponent } from '../ui/webhooks';
|
||||
import { TransferComponent } from '../ui/transfer';
|
||||
import { AutomationsComponent } from '../ui/automations';
|
||||
import { ClientShareComponent } from '../ui/client-share';
|
||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
CustomFieldType,
|
||||
@@ -58,7 +59,7 @@ const SECTIONS: SettingsSection[] = [
|
||||
{
|
||||
id: 'general',
|
||||
label: 'General',
|
||||
hint: 'Quién ve el proyecto y si el público puede proponer cosas.',
|
||||
hint: 'Quién ve el proyecto, si el público puede proponer cosas y el panel para clientes.',
|
||||
},
|
||||
{
|
||||
id: 'workflow',
|
||||
@@ -143,6 +144,7 @@ function idFrom(name: string): string {
|
||||
WebhooksComponent,
|
||||
TransferComponent,
|
||||
AutomationsComponent,
|
||||
ClientShareComponent,
|
||||
CdkDropList,
|
||||
CdkDrag,
|
||||
CdkDragHandle,
|
||||
@@ -257,6 +259,10 @@ function idFrom(name: string): string {
|
||||
}}</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
@if (facade.canManage()) {
|
||||
<ts-client-share [projectId]="project.id" />
|
||||
}
|
||||
}
|
||||
|
||||
@if (draft(); as d) {
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { PiEmptyStateComponent, PiSkeletonComponent } from '@pi/ui';
|
||||
import {
|
||||
provideSharedDashboardFacade,
|
||||
SharedDashboardFacade,
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import { tone } from '../ui/copy';
|
||||
import { ActivityHeatmapComponent } from '../ui/insights/activity-heatmap';
|
||||
import { ReleaseForecastComponent } from '../ui/insights/release-forecast';
|
||||
import { SprintPulseComponent } from '../ui/insights/sprint-pulse';
|
||||
|
||||
/**
|
||||
* /share/:token — a project's dashboard for its clients, without an
|
||||
* account: only the blocks its owners chose, read again every minute.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ts-shared-dashboard-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
PiEmptyStateComponent,
|
||||
PiSkeletonComponent,
|
||||
SprintPulseComponent,
|
||||
ReleaseForecastComponent,
|
||||
ActivityHeatmapComponent,
|
||||
],
|
||||
template: `
|
||||
@if (shared.missing()) {
|
||||
<pi-empty-state
|
||||
icon="lock"
|
||||
title="Este enlace ya no funciona"
|
||||
description="Puede que lo hayan renovado o retirado. Pide uno nuevo a quien te lo dio."
|
||||
/>
|
||||
} @else if (shared.dashboard(); as d) {
|
||||
<header class="ts-page-header">
|
||||
<div>
|
||||
<p class="ts-eyebrow">{{ d.projectKey }}</p>
|
||||
<h1>{{ d.projectName }}</h1>
|
||||
<p>
|
||||
Estado del proyecto · actualizado a las {{ time(d.generatedAt) }} ·
|
||||
se actualiza solo
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="ts-shared">
|
||||
@if (d.flow; as flow) {
|
||||
<section
|
||||
class="ts-panel ts-shared__wide"
|
||||
aria-labelledby="flow-title"
|
||||
>
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="flow-title">Flujo</h2>
|
||||
<span class="ts-muted">{{ total() }} tickets</span>
|
||||
</header>
|
||||
@if (total()) {
|
||||
<div
|
||||
class="ts-flow__bar"
|
||||
role="img"
|
||||
[attr.aria-label]="flowLabel()"
|
||||
>
|
||||
@for (s of flow; track s.status) {
|
||||
@if (s.count) {
|
||||
<span
|
||||
[style.--tone]="toneOf(s.color)"
|
||||
[style.flex-grow]="s.count"
|
||||
[attr.title]="s.name + ': ' + s.count"
|
||||
></span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<ul class="ts-flow__legend">
|
||||
@for (s of flow; track s.status) {
|
||||
<li
|
||||
[style.--tone]="toneOf(s.color)"
|
||||
[class.is-empty]="!s.count"
|
||||
>
|
||||
<span class="ts-flow__count">{{ s.count }}</span>
|
||||
{{ s.name }}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="ts-muted">Todavía no hay tickets.</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@if (d.blocks.includes('sprint')) {
|
||||
@if (d.sprint; as sprint) {
|
||||
<ts-sprint-pulse [sprint]="sprint" [linked]="false" />
|
||||
} @else {
|
||||
<section class="ts-panel">
|
||||
<h2 class="ts-shared__title">Sprint</h2>
|
||||
<p class="ts-muted">No hay un sprint en curso.</p>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@if (d.releases; as releases) {
|
||||
@if (releases.length) {
|
||||
<ts-release-forecast [releases]="releases" [linked]="false" />
|
||||
} @else {
|
||||
<section class="ts-panel">
|
||||
<h2 class="ts-shared__title">Próximas releases</h2>
|
||||
<p class="ts-muted">Ninguna planificada.</p>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
@if (d.epics; as epics) {
|
||||
<section class="ts-panel" aria-labelledby="epics-title">
|
||||
<header class="ts-panel__head">
|
||||
<h2 id="epics-title">Épicas</h2>
|
||||
<span class="ts-muted">Abiertas, por fecha</span>
|
||||
</header>
|
||||
@if (epics.length) {
|
||||
<ul class="ts-epics">
|
||||
@for (e of epics; track e.key) {
|
||||
<li>
|
||||
<span class="ts-epics__title">{{ e.title }}</span>
|
||||
<span class="ts-epics__meta"
|
||||
>{{ e.done }}/{{ e.total }}
|
||||
@if (e.dueDate) {
|
||||
· {{ date(e.dueDate) }}
|
||||
}
|
||||
</span>
|
||||
<span
|
||||
class="ts-epics__bar"
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
[attr.aria-valuenow]="e.percent"
|
||||
[attr.aria-label]="'Progreso de ' + e.title"
|
||||
><span [style.width.%]="e.percent"></span
|
||||
></span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="ts-muted">Ninguna épica abierta.</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@if (d.activity; as activity) {
|
||||
<ts-activity-heatmap [days]="activity" />
|
||||
}
|
||||
</div>
|
||||
<p class="ts-shared__footer">Hecho con TaskStream</p>
|
||||
} @else if (shared.error(); as error) {
|
||||
<pi-empty-state
|
||||
icon="exclamation"
|
||||
title="No se pudo cargar"
|
||||
[description]="error"
|
||||
/>
|
||||
} @else {
|
||||
<pi-skeleton shape="block" height="12rem" />
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.ts-shared {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 24rem), 1fr));
|
||||
gap: 1.25rem;
|
||||
align-items: start;
|
||||
}
|
||||
.ts-shared__wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.ts-panel {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.ts-panel__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1rem;
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ts-muted {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
.ts-shared__title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ts-flow__bar {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
height: 2rem;
|
||||
span {
|
||||
flex-basis: 0;
|
||||
border-radius: 0.375rem;
|
||||
background: var(--tone);
|
||||
}
|
||||
}
|
||||
.ts-flow__legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.25rem;
|
||||
margin: 0.875rem 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 0.875rem;
|
||||
li {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
&::before {
|
||||
content: '';
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
border-radius: 0.1875rem;
|
||||
background: var(--tone);
|
||||
}
|
||||
&.is-empty {
|
||||
color: var(--ts-muted);
|
||||
}
|
||||
}
|
||||
}
|
||||
.ts-flow__count {
|
||||
font-family: var(--ts-font-mono);
|
||||
font-weight: 600;
|
||||
}
|
||||
.ts-epics {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
li {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.25rem 0.75rem;
|
||||
}
|
||||
}
|
||||
.ts-epics__title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.ts-epics__meta {
|
||||
color: var(--ts-muted);
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ts-epics__bar {
|
||||
grid-column: 1 / -1;
|
||||
height: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--ts-sunken);
|
||||
overflow: hidden;
|
||||
span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--ts-brand);
|
||||
}
|
||||
}
|
||||
.ts-shared__footer {
|
||||
margin: 0;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
`,
|
||||
providers: [provideSharedDashboardFacade()],
|
||||
})
|
||||
export class SharedDashboardPage {
|
||||
/** Route param (`withComponentInputBinding`). */
|
||||
readonly token = input.required<string>();
|
||||
protected readonly shared = inject(SharedDashboardFacade);
|
||||
|
||||
protected readonly total = computed(() =>
|
||||
(this.shared.dashboard()?.flow ?? []).reduce((n, s) => n + s.count, 0),
|
||||
);
|
||||
protected readonly flowLabel = computed(() =>
|
||||
(this.shared.dashboard()?.flow ?? [])
|
||||
.map((s) => `${s.name}: ${s.count}`)
|
||||
.join(', '),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const token = this.token();
|
||||
untracked(() => this.shared.open(token));
|
||||
});
|
||||
}
|
||||
|
||||
protected readonly toneOf = tone;
|
||||
|
||||
protected time(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString('es', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
protected date(day: string): string {
|
||||
return new Date(`${day.slice(0, 10)}T00:00:00Z`).toLocaleDateString('es', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,15 @@ export const trackerRoutes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./pages/explore.page').then((m) => m.ExplorePage),
|
||||
},
|
||||
{
|
||||
// A project's dashboard for its clients: the token is the access.
|
||||
path: 'share/:token',
|
||||
title: 'Estado del proyecto · TaskStream',
|
||||
loadComponent: () =>
|
||||
import('./pages/shared-dashboard.page').then(
|
||||
(m) => m.SharedDashboardPage,
|
||||
),
|
||||
},
|
||||
// The Spanish paths, from links shared before.
|
||||
{ path: 'cartera', redirectTo: 'portfolio' },
|
||||
{ path: 'explorar', redirectTo: 'explore' },
|
||||
|
||||
283
libs/web/tracker/presenters/src/lib/ui/client-share.ts
Normal file
283
libs/web/tracker/presenters/src/lib/ui/client-share.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { PiCheckboxComponent } from '@pi/ui';
|
||||
import { ToastService } from '@task-stream/web/shared/ui';
|
||||
import {
|
||||
provideSharingFacade,
|
||||
SharingFacade,
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import { SHARED_BLOCKS, SharedBlock } from '@task-stream/web/tracker/domain';
|
||||
|
||||
/** What each block shows, for whoever picks them. */
|
||||
const BLOCK_LABEL: Record<SharedBlock, { label: string; hint: string }> = {
|
||||
flow: { label: 'Flujo', hint: 'Cuántos tickets hay en cada estado' },
|
||||
sprint: { label: 'Sprint en curso', hint: 'Días que quedan y lo terminado' },
|
||||
releases: {
|
||||
label: 'Próximas releases',
|
||||
hint: 'Con su fecha probable al ritmo real',
|
||||
},
|
||||
epics: { label: 'Épicas', hint: 'Las abiertas, con su progreso' },
|
||||
activity: { label: 'Actividad', hint: 'Movimientos por día, 12 semanas' },
|
||||
};
|
||||
|
||||
/** What a new link shows until someone picks otherwise. */
|
||||
const DEFAULT_BLOCKS: SharedBlock[] = ['flow', 'sprint', 'releases'];
|
||||
|
||||
/**
|
||||
* The project's dashboard for clients: a read-only link, without an
|
||||
* account, showing only the blocks picked here.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ts-client-share',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PiCheckboxComponent],
|
||||
providers: [provideSharingFacade()],
|
||||
host: { class: 'ts-panel', role: 'region', 'aria-labelledby': 'share-title' },
|
||||
template: `
|
||||
<div class="ts-share__head">
|
||||
<div>
|
||||
<h3 id="share-title">Panel para clientes</h3>
|
||||
<p class="ts-muted">
|
||||
Un enlace de solo lectura, sin cuenta, con los bloques que elijas. No
|
||||
enseña personas ni tickets sueltos (salvo las épicas).
|
||||
</p>
|
||||
</div>
|
||||
<label class="ts-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
[checked]="!!sharing.share()"
|
||||
[disabled]="!sharing.loaded() || sharing.busy()"
|
||||
(change)="toggle($any($event.target).checked)"
|
||||
/>
|
||||
<span class="ts-switch__track" aria-hidden="true"></span>
|
||||
<span>{{ sharing.share() ? 'Compartido' : 'Sin compartir' }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (sharing.share(); as share) {
|
||||
<fieldset class="ts-share__blocks" [disabled]="sharing.busy()">
|
||||
<legend>Qué se ve</legend>
|
||||
@for (block of blocks; track block) {
|
||||
<div class="ts-share__block">
|
||||
<pi-checkbox
|
||||
[label]="label(block).label"
|
||||
[checked]="share.blocks.includes(block)"
|
||||
[disabled]="
|
||||
share.blocks.length === 1 && share.blocks.includes(block)
|
||||
"
|
||||
(checkedChange)="setBlock(block, $event)"
|
||||
/>
|
||||
<span class="ts-muted">{{ label(block).hint }}</span>
|
||||
</div>
|
||||
}
|
||||
</fieldset>
|
||||
<div class="ts-share__link">
|
||||
<input
|
||||
class="ts-share__url"
|
||||
readonly
|
||||
aria-label="Enlace para clientes"
|
||||
[value]="url()"
|
||||
(focus)="$any($event.target).select()"
|
||||
/>
|
||||
<button type="button" class="ts-plain-link" (click)="copy()">
|
||||
Copiar
|
||||
</button>
|
||||
<a class="ts-plain-link" [href]="url()" target="_blank" rel="noopener"
|
||||
>Abrir</a
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-plain-link"
|
||||
[disabled]="sharing.busy()"
|
||||
(click)="renew()"
|
||||
>
|
||||
Renovar enlace
|
||||
</button>
|
||||
</div>
|
||||
<p class="ts-muted ts-share__note">
|
||||
Se actualiza solo cada minuto. Renovar hace un enlace nuevo y el
|
||||
anterior deja de funcionar.
|
||||
</p>
|
||||
}
|
||||
@if (sharing.error(); as error) {
|
||||
<p class="ts-share__error" role="alert">{{ error }}</p>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: grid;
|
||||
gap: 0.875rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ts-share__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
> div {
|
||||
flex: 1 1 26rem;
|
||||
}
|
||||
p {
|
||||
max-width: 46rem;
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
.ts-share__blocks {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr));
|
||||
gap: 0.5rem 1.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
legend {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
.ts-share__block {
|
||||
display: grid;
|
||||
.ts-muted {
|
||||
margin-left: 2.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
.ts-share__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ts-share__url {
|
||||
flex: 1 1 20rem;
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--ts-line-strong);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--ts-sunken);
|
||||
color: var(--ts-ink);
|
||||
font-family: var(--ts-font-mono);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.ts-share__note {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ts-share__error {
|
||||
margin: 0;
|
||||
color: var(--ts-c-red);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.ts-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
input:focus-visible + .ts-switch__track {
|
||||
outline: 2px solid var(--ts-brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
input:disabled ~ * {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
.ts-switch__track {
|
||||
position: relative;
|
||||
width: 2.5rem;
|
||||
height: 1.375rem;
|
||||
border-radius: 999px;
|
||||
background: var(--ts-line-strong);
|
||||
transition: background-color 0.15s ease;
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
background: var(--ts-surface);
|
||||
box-shadow: var(--ts-shadow);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
}
|
||||
.ts-switch input:checked + .ts-switch__track {
|
||||
background: var(--ts-brand);
|
||||
&::after {
|
||||
transform: translateX(1.125rem);
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ClientShareComponent {
|
||||
readonly projectId = input.required<string>();
|
||||
protected readonly sharing = inject(SharingFacade);
|
||||
private readonly toast = inject(ToastService);
|
||||
protected readonly blocks = SHARED_BLOCKS;
|
||||
|
||||
protected readonly url = computed(() => {
|
||||
const share = this.sharing.share();
|
||||
return share ? `${location.origin}/share/${share.token}` : '';
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const projectId = this.projectId();
|
||||
untracked(() => this.sharing.load(projectId));
|
||||
});
|
||||
}
|
||||
|
||||
protected label(block: SharedBlock) {
|
||||
return BLOCK_LABEL[block];
|
||||
}
|
||||
|
||||
protected toggle(on: boolean): void {
|
||||
if (on) this.sharing.save(DEFAULT_BLOCKS);
|
||||
else this.sharing.remove();
|
||||
}
|
||||
|
||||
protected setBlock(block: SharedBlock, on: boolean): void {
|
||||
const current = this.sharing.share()?.blocks ?? [];
|
||||
const next = on ? [...current, block] : current.filter((b) => b !== block);
|
||||
if (next.length) this.sharing.save(next);
|
||||
}
|
||||
|
||||
protected renew(): void {
|
||||
const blocks = this.sharing.share()?.blocks;
|
||||
if (blocks) this.sharing.save(blocks, true);
|
||||
}
|
||||
|
||||
protected copy(): void {
|
||||
navigator.clipboard
|
||||
.writeText(this.url())
|
||||
.then(() => this.toast.success('Enlace copiado'))
|
||||
.catch(() => this.toast.error('No se pudo copiar'));
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,11 @@ export function verdictOf(release: ReleaseForecast): {
|
||||
<ul class="ts-issues ts-forecast">
|
||||
@for (row of rows(); track row.releaseId) {
|
||||
<li>
|
||||
<a class="ts-title" routerLink="../releases">{{ row.name }}</a>
|
||||
@if (linked()) {
|
||||
<a class="ts-title" routerLink="../releases">{{ row.name }}</a>
|
||||
} @else {
|
||||
<span class="ts-title">{{ row.name }}</span>
|
||||
}
|
||||
<span class="ts-verdict" [style.--tone]="row.tone">{{
|
||||
row.label
|
||||
}}</span>
|
||||
@@ -110,6 +114,8 @@ export function verdictOf(release: ReleaseForecast): {
|
||||
})
|
||||
export class ReleaseForecastComponent {
|
||||
readonly releases = input.required<ReleaseForecast[]>();
|
||||
/** False outside the app (the page for clients): no links into it. */
|
||||
readonly linked = input(true);
|
||||
|
||||
protected readonly rows = computed(() =>
|
||||
this.releases().map((r) => ({
|
||||
|
||||
@@ -52,7 +52,9 @@ const decimal = (n: number) =>
|
||||
@if (paceText(); as pace) {
|
||||
{{ pace }}
|
||||
}
|
||||
<a routerLink="../sprints">Ver el sprint →</a>
|
||||
@if (linked()) {
|
||||
<a routerLink="../sprints">Ver el sprint →</a>
|
||||
}
|
||||
</p>
|
||||
`,
|
||||
styles: `
|
||||
@@ -105,6 +107,8 @@ const decimal = (n: number) =>
|
||||
})
|
||||
export class SprintPulseComponent {
|
||||
readonly sprint = input.required<SprintPulse>();
|
||||
/** False outside the app (the page for clients): no links into it. */
|
||||
readonly linked = input(true);
|
||||
|
||||
protected readonly when = computed(
|
||||
() => `${short(this.sprint().startDate)} – ${short(this.sprint().endDate)}`,
|
||||
|
||||
Reference in New Issue
Block a user