feat: what is new since my last visit, and turning a comment into a ticket
Opening a ticket notes the visit and answers the one before (issue_visits), so the page marks others' comments and changes since. A comment can become a ticket of its own, as a subtask or linked; it remembers where it went. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -110,6 +110,12 @@ export const issuePageEvents = eventGroup({
|
||||
resolved: boolean;
|
||||
conclusion: string | null;
|
||||
}>(),
|
||||
commentToIssueRequested: type<{
|
||||
commentId: string;
|
||||
title: string;
|
||||
type: string;
|
||||
asSubtask: boolean;
|
||||
}>(),
|
||||
voteRequested: type<{ issueId: string; on: boolean }>(),
|
||||
/** An image pasted into a text was uploaded (it joins the ticket's files). */
|
||||
imagePasted: type<{ issueId: string }>(),
|
||||
|
||||
@@ -316,6 +316,21 @@ export class IssueFacade {
|
||||
});
|
||||
}
|
||||
|
||||
/** Makes a ticket of a comment: a subtask of this one, or linked to it. */
|
||||
turnCommentIntoIssue(
|
||||
commentId: string,
|
||||
title: string,
|
||||
type: string,
|
||||
asSubtask: boolean,
|
||||
): void {
|
||||
this.dispatch.commentToIssueRequested({
|
||||
commentId,
|
||||
title: title.trim(),
|
||||
type,
|
||||
asSubtask,
|
||||
});
|
||||
}
|
||||
|
||||
/** Marks a comment as a decision of the ticket (`on`) or unmarks it. */
|
||||
markDecision(commentId: string, on: boolean): void {
|
||||
this.dispatch.decisionRequested({ commentId, on });
|
||||
|
||||
@@ -608,6 +608,28 @@ export const IssueStore = signalStore(
|
||||
),
|
||||
),
|
||||
),
|
||||
events.on(issuePageEvents.commentToIssueRequested).pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
comments
|
||||
.turnIntoIssue(
|
||||
payload.commentId,
|
||||
payload.title,
|
||||
payload.type,
|
||||
payload.asSubtask,
|
||||
)
|
||||
.pipe(
|
||||
mapResponse({
|
||||
next: (result) =>
|
||||
done(
|
||||
result.key
|
||||
? `Creado ${result.key}`
|
||||
: 'Ticket creado',
|
||||
),
|
||||
error: (error) => failed(describeError(error)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
events.on(issuePageEvents.reactRequested).pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
comments.react(payload.commentId, payload.emoji, payload.on).pipe(
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from './lib/models/bulk';
|
||||
export * from './lib/models/reminders';
|
||||
export * from './lib/models/my-day';
|
||||
export * from './lib/models/health';
|
||||
export * from './lib/models/news';
|
||||
export * from './lib/models/inputs';
|
||||
export * from './lib/models/workflow';
|
||||
export * from './lib/ports/gateways';
|
||||
|
||||
@@ -385,6 +385,8 @@ export interface Comment {
|
||||
resolved: boolean;
|
||||
/** Who settled it (null while open). */
|
||||
resolvedBy: UserRef | null;
|
||||
/** Tickets made from it («Convertir en ticket»), oldest first. */
|
||||
spawned: { id: string; key: string }[];
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
}
|
||||
|
||||
86
libs/web/tracker/domain/src/lib/models/news.spec.ts
Normal file
86
libs/web/tracker/domain/src/lib/models/news.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Activity, Comment } from './models';
|
||||
import { newsSince, titleFrom } from './news';
|
||||
|
||||
const user = (id: string, name: string) => ({ id, name });
|
||||
const comment = (
|
||||
id: string,
|
||||
by: string,
|
||||
at: string,
|
||||
deleted = false,
|
||||
): Comment =>
|
||||
({
|
||||
id,
|
||||
author: user(by, by === 'me' ? 'Yo' : by === 'ana' ? 'Ana' : 'Bob'),
|
||||
createdAt: at,
|
||||
deleted,
|
||||
}) as Comment;
|
||||
const change = (id: string, by: string, at: string, type: string): Activity =>
|
||||
({
|
||||
id,
|
||||
type,
|
||||
actor: user(by, by === 'ana' ? 'Ana' : 'Bob'),
|
||||
occurredAt: at,
|
||||
}) as Activity;
|
||||
|
||||
describe('news since my last visit', () => {
|
||||
const since = '2026-10-20T10:00:00.000Z';
|
||||
|
||||
it('counts what others did since, not mine nor what came before', () => {
|
||||
const news = newsSince(
|
||||
[
|
||||
comment('old', 'ana', '2026-10-19T09:00:00.000Z'),
|
||||
comment('mine', 'me', '2026-10-21T09:00:00.000Z'),
|
||||
comment('c1', 'bob', '2026-10-21T09:30:00.000Z'),
|
||||
comment('gone', 'ana', '2026-10-21T09:40:00.000Z', true),
|
||||
comment('c2', 'ana', '2026-10-21T10:00:00.000Z'),
|
||||
],
|
||||
[
|
||||
change(
|
||||
'h1',
|
||||
'ana',
|
||||
'2026-10-21T08:00:00.000Z',
|
||||
'IssueStatusChangedEvent',
|
||||
),
|
||||
// The comment itself is already counted as a comment.
|
||||
change('h2', 'bob', '2026-10-21T09:30:00.000Z', 'CommentAddedEvent'),
|
||||
change('h3', 'bob', '2026-10-19T08:00:00.000Z', 'IssueAssignedEvent'),
|
||||
],
|
||||
since,
|
||||
'me',
|
||||
);
|
||||
expect(news).toEqual({
|
||||
since,
|
||||
what: '2 comentarios y 1 cambio',
|
||||
who: 'Bob y Ana',
|
||||
firstComment: 'c1',
|
||||
});
|
||||
});
|
||||
|
||||
it('says nothing the first time, or when nothing happened', () => {
|
||||
const comments = [comment('c1', 'ana', '2026-10-21T09:00:00.000Z')];
|
||||
expect(newsSince(comments, [], null, 'me')).toBeNull();
|
||||
expect(
|
||||
newsSince(comments, [], '2026-10-22T00:00:00.000Z', 'me'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('titleFrom', () => {
|
||||
it('takes the first line without Markdown', () => {
|
||||
expect(
|
||||
titleFrom(
|
||||
'\n## También falla en **Android** ([log](https://x.dev))\nMás',
|
||||
),
|
||||
).toBe('También falla en Android (log)');
|
||||
expect(titleFrom('- `npm ci` rompe en CI')).toBe('npm ci rompe en CI');
|
||||
expect(titleFrom('También falla en Android.\nAdjunto el log.')).toBe(
|
||||
'También falla en Android',
|
||||
);
|
||||
});
|
||||
|
||||
it('cuts long lines at a word', () => {
|
||||
const title = titleFrom('palabra '.repeat(30));
|
||||
expect(title.length).toBeLessThanOrEqual(121);
|
||||
expect(title.endsWith('palabra…')).toBe(true);
|
||||
});
|
||||
});
|
||||
102
libs/web/tracker/domain/src/lib/models/news.ts
Normal file
102
libs/web/tracker/domain/src/lib/models/news.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Activity, Comment } from './models';
|
||||
|
||||
/*
|
||||
* «Lo nuevo desde tu última visita»: what others did on a ticket since I
|
||||
* last opened it. My own comments and changes are not news to me.
|
||||
*/
|
||||
|
||||
/** A comment someone else wrote since (deleted ones say nothing). */
|
||||
export function isNewComment(
|
||||
comment: Comment,
|
||||
since: string | null,
|
||||
me: string | null,
|
||||
): boolean {
|
||||
return (
|
||||
!!since &&
|
||||
!comment.deleted &&
|
||||
comment.createdAt > since &&
|
||||
comment.author.id !== me
|
||||
);
|
||||
}
|
||||
|
||||
/** A change someone else made since; comments count on their own. */
|
||||
export function isNewChange(
|
||||
entry: Activity,
|
||||
since: string | null,
|
||||
me: string | null,
|
||||
): boolean {
|
||||
return (
|
||||
!!since &&
|
||||
entry.occurredAt > since &&
|
||||
entry.actor.id !== me &&
|
||||
!entry.type.startsWith('Comment')
|
||||
);
|
||||
}
|
||||
|
||||
export interface IssueNews {
|
||||
since: string;
|
||||
/** «2 comentarios y 1 cambio». */
|
||||
what: string;
|
||||
/** «Ana y Bob». */
|
||||
who: string;
|
||||
/** The first new comment, to go to it. */
|
||||
firstComment: string | null;
|
||||
}
|
||||
|
||||
/** What is new since, or null when nothing is (or it is my first time). */
|
||||
export function newsSince(
|
||||
comments: Comment[],
|
||||
history: Activity[],
|
||||
since: string | null,
|
||||
me: string | null,
|
||||
): IssueNews | null {
|
||||
if (!since) return null;
|
||||
const fresh = comments.filter((c) => isNewComment(c, since, me));
|
||||
const changes = history.filter((e) => isNewChange(e, since, me));
|
||||
if (!fresh.length && !changes.length) return null;
|
||||
const what = [
|
||||
count(fresh.length, 'comentario', 'comentarios'),
|
||||
count(changes.length, 'cambio', 'cambios'),
|
||||
].filter(Boolean);
|
||||
const who = [
|
||||
...new Set([
|
||||
...fresh.map((c) => c.author.name),
|
||||
...changes.map((e) => e.actor.name),
|
||||
]),
|
||||
];
|
||||
return {
|
||||
since,
|
||||
what: what.join(' y '),
|
||||
who:
|
||||
who.length > 1
|
||||
? `${who.slice(0, -1).join(', ')} y ${who[who.length - 1]}`
|
||||
: who[0],
|
||||
firstComment: fresh[0]?.id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function count(n: number, one: string, many: string): string {
|
||||
return n === 1 ? `1 ${one}` : n ? `${n} ${many}` : '';
|
||||
}
|
||||
|
||||
/** A ticket title out of a comment: its first line, without Markdown. */
|
||||
export function titleFrom(body: string): string {
|
||||
const line =
|
||||
body
|
||||
.split('\n')
|
||||
.map((l) =>
|
||||
l
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/^\s*(#+|>|[-*+]|\d+\.)\s*/, '')
|
||||
.replace(/[*_`~]/g, '')
|
||||
.trim()
|
||||
// A title ends without the sentence's stop.
|
||||
.replace(/[.:;,]+$/, ''),
|
||||
)
|
||||
.find(Boolean) ?? '';
|
||||
if (line.length <= 120) return line;
|
||||
const cut = line.slice(0, 120);
|
||||
const space = cut.lastIndexOf(' ');
|
||||
return `${space > 60 ? cut.slice(0, space) : cut}…`;
|
||||
}
|
||||
@@ -170,6 +170,8 @@ export abstract class IssuesGateway {
|
||||
abstract pin(issueId: string, pinned: boolean): Observable<void>;
|
||||
/** Follows the ticket with everything under it (or stops). */
|
||||
abstract followTree(issueId: string, on: boolean): Observable<void>;
|
||||
/** I opened it now: answers my visit before (null: never). */
|
||||
abstract visit(issueId: string): Observable<string | null>;
|
||||
/** Reminds me of the ticket then (replaces the waiting one). */
|
||||
abstract setReminder(
|
||||
issueId: string,
|
||||
@@ -368,6 +370,13 @@ export abstract class CommentsGateway {
|
||||
resolved: boolean,
|
||||
conclusion: string | null,
|
||||
): Observable<CommandResult>;
|
||||
/** Makes a ticket of it: a subtask of its ticket, or linked. Answers the new one. */
|
||||
abstract turnIntoIssue(
|
||||
commentId: string,
|
||||
title: string,
|
||||
type: string,
|
||||
asSubtask: boolean,
|
||||
): Observable<CommandResult>;
|
||||
/** Marks it as a decision of its ticket (`on`) or unmarks it. */
|
||||
abstract markDecision(
|
||||
commentId: string,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
ReactToCommentDocument,
|
||||
MarkDecisionDocument,
|
||||
ResolveThreadDocument,
|
||||
TurnCommentIntoIssueDocument,
|
||||
VotePollDocument,
|
||||
ClosePollDocument,
|
||||
PublishReleaseDocument,
|
||||
@@ -111,6 +112,22 @@ export class GraphqlCommentsGateway extends CommentsGateway {
|
||||
.pipe(map((d) => toCommandResult(d.resolveThread)));
|
||||
}
|
||||
|
||||
turnIntoIssue(
|
||||
commentId: string,
|
||||
title: string,
|
||||
type: string,
|
||||
asSubtask: boolean,
|
||||
): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(TurnCommentIntoIssueDocument, {
|
||||
commentId,
|
||||
title,
|
||||
type,
|
||||
asSubtask,
|
||||
})
|
||||
.pipe(map((d) => toCommandResult(d.turnCommentIntoIssue)));
|
||||
}
|
||||
|
||||
markDecision(commentId: string, on: boolean): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(MarkDecisionDocument, { commentId, on })
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
CancelReviewDocument,
|
||||
MyPinsDocument,
|
||||
FollowTreeDocument,
|
||||
VisitIssueDocument,
|
||||
SetReminderDocument,
|
||||
MyDayDocument,
|
||||
AddToMyDayDocument,
|
||||
@@ -272,6 +273,12 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
||||
.pipe(map(() => undefined));
|
||||
}
|
||||
|
||||
visit(issueId: string): Observable<string | null> {
|
||||
return this.gql
|
||||
.mutate(VisitIssueDocument, { issueId })
|
||||
.pipe(map((d) => d.visitIssue ?? null));
|
||||
}
|
||||
|
||||
followTree(issueId: string, on: boolean): Observable<void> {
|
||||
const done$: Observable<unknown> = on
|
||||
? this.gql.mutate(FollowTreeDocument, { issueId })
|
||||
|
||||
@@ -218,6 +218,10 @@ fragment CommentFields on Comment {
|
||||
resolvedBy {
|
||||
...UserRefFields
|
||||
}
|
||||
spawned {
|
||||
id
|
||||
key
|
||||
}
|
||||
createdAt
|
||||
editedAt
|
||||
author {
|
||||
|
||||
@@ -754,6 +754,7 @@ export type IssueFieldsFragment = {
|
||||
}>;
|
||||
} | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
spawned: Array<{ id: string; key: string }>;
|
||||
author: { id: string; name: string };
|
||||
}>;
|
||||
reporter: { id: string; name: string };
|
||||
@@ -817,6 +818,7 @@ export type CommentFieldsFragment = {
|
||||
}>;
|
||||
} | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
spawned: Array<{ id: string; key: string }>;
|
||||
author: { id: string; name: string };
|
||||
};
|
||||
|
||||
@@ -1027,6 +1029,7 @@ export type IssueQuery = {
|
||||
}>;
|
||||
} | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
spawned: Array<{ id: string; key: string }>;
|
||||
author: { id: string; name: string };
|
||||
}>;
|
||||
reporter: { id: string; name: string };
|
||||
@@ -1398,6 +1401,7 @@ export type IssueCommentsQuery = {
|
||||
}>;
|
||||
} | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
spawned: Array<{ id: string; key: string }>;
|
||||
author: { id: string; name: string };
|
||||
}>;
|
||||
};
|
||||
@@ -1830,6 +1834,23 @@ export type ResolveThreadMutation = {
|
||||
resolveThread: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type TurnCommentIntoIssueMutationVariables = Exact<{
|
||||
commentId: string;
|
||||
title: string;
|
||||
type: string;
|
||||
asSubtask: boolean;
|
||||
}>;
|
||||
|
||||
export type TurnCommentIntoIssueMutation = {
|
||||
turnCommentIntoIssue: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type VisitIssueMutationVariables = Exact<{
|
||||
issueId: string;
|
||||
}>;
|
||||
|
||||
export type VisitIssueMutation = { visitIssue: string | null };
|
||||
|
||||
export type MarkDecisionMutationVariables = Exact<{
|
||||
commentId: string;
|
||||
on: boolean;
|
||||
@@ -1870,6 +1891,7 @@ export type SearchDecisionsQuery = {
|
||||
}>;
|
||||
} | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
spawned: Array<{ id: string; key: string }>;
|
||||
author: { id: string; name: string };
|
||||
};
|
||||
issue: { id: string; key: string; title: string };
|
||||
@@ -3254,6 +3276,7 @@ export type CommentChangedSubscription = {
|
||||
}>;
|
||||
} | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
spawned: Array<{ id: string; key: string }>;
|
||||
author: { id: string; name: string };
|
||||
} | null;
|
||||
};
|
||||
@@ -4091,6 +4114,17 @@ export const CommentFieldsFragmentDoc = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'spawned' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
@@ -4536,6 +4570,17 @@ export const IssueFieldsFragmentDoc = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'spawned' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
@@ -6853,6 +6898,17 @@ export const IssueDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'spawned' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
@@ -9204,6 +9260,17 @@ export const IssueCommentsDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'spawned' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
@@ -12014,6 +12081,181 @@ export const ResolveThreadDocument = {
|
||||
ResolveThreadMutation,
|
||||
ResolveThreadMutationVariables
|
||||
>;
|
||||
export const TurnCommentIntoIssueDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'TurnCommentIntoIssue' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'commentId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'title' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'String' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: { kind: 'Variable', name: { kind: 'Name', value: 'type' } },
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'String' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'asSubtask' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'turnCommentIntoIssue' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'commentId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'commentId' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'title' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'title' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'type' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'type' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'asSubtask' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'asSubtask' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'CommandResult' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
TurnCommentIntoIssueMutation,
|
||||
TurnCommentIntoIssueMutationVariables
|
||||
>;
|
||||
export const VisitIssueDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'VisitIssue' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'visitIssue' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<VisitIssueMutation, VisitIssueMutationVariables>;
|
||||
export const MarkDecisionDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -12304,6 +12546,17 @@ export const SearchDecisionsDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'spawned' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
@@ -19548,6 +19801,17 @@ export const CommentChangedDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'spawned' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
|
||||
@@ -396,6 +396,26 @@ mutation ResolveThread(
|
||||
}
|
||||
}
|
||||
|
||||
mutation TurnCommentIntoIssue(
|
||||
$commentId: ID!
|
||||
$title: String!
|
||||
$type: String!
|
||||
$asSubtask: Boolean!
|
||||
) {
|
||||
turnCommentIntoIssue(
|
||||
commentId: $commentId
|
||||
title: $title
|
||||
type: $type
|
||||
asSubtask: $asSubtask
|
||||
) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation VisitIssue($issueId: ID!) {
|
||||
visitIssue(issueId: $issueId)
|
||||
}
|
||||
|
||||
mutation MarkDecision($commentId: ID!, $on: Boolean!) {
|
||||
markDecision(commentId: $commentId, on: $on) {
|
||||
...CommandResultFields
|
||||
|
||||
@@ -339,6 +339,60 @@ a.ts-key {
|
||||
background: color-mix(in srgb, var(--ts-c-green) 6%, transparent);
|
||||
}
|
||||
|
||||
/* Written by someone else since my last visit. */
|
||||
.ts-comment.is-new:not(.is-decision) {
|
||||
margin-inline: -0.75rem;
|
||||
padding-inline: 0.75rem;
|
||||
border-left: 3px solid var(--ts-brand);
|
||||
background: color-mix(in srgb, var(--ts-brand) 5%, transparent);
|
||||
}
|
||||
|
||||
.ts-comment__new {
|
||||
padding: 0.0625rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--ts-brand) 14%, transparent);
|
||||
color: var(--ts-brand);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* «→ Convertido en WEB-45». */
|
||||
.ts-comment__spawned {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 0.5rem;
|
||||
margin: 0.375rem 0 0;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
|
||||
a {
|
||||
font-family: var(--ts-font-mono);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
/* What others did since my last visit, above the ticket. */
|
||||
.ts-news {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.25rem 0.625rem;
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: 0.625rem;
|
||||
background: color-mix(in srgb, var(--ts-brand) 9%, transparent);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.ts-news__dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--ts-brand);
|
||||
}
|
||||
|
||||
.ts-comment.is-pointed {
|
||||
animation: ts-pointed 2.4s ease-out;
|
||||
}
|
||||
@@ -469,6 +523,23 @@ a.ts-key {
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
/* Changed by someone else since my last visit. */
|
||||
&.is-new .ts-log__what {
|
||||
color: var(--ts-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.is-new .ts-log__when::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
margin-right: 0.375rem;
|
||||
border-radius: 50%;
|
||||
background: var(--ts-brand);
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
PiIconComponent,
|
||||
PiLabelDirective,
|
||||
PiNoteComponent,
|
||||
PiSegmentedControlComponent,
|
||||
PiProgressBarComponent,
|
||||
PiSearchableSelectFieldComponent,
|
||||
PiSelectFieldComponent,
|
||||
@@ -44,7 +45,12 @@ import {
|
||||
} from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
Attachment,
|
||||
Activity,
|
||||
Comment,
|
||||
isNewChange,
|
||||
isNewComment,
|
||||
newsSince,
|
||||
titleFrom,
|
||||
CommentPoll,
|
||||
DevLink,
|
||||
Issue,
|
||||
@@ -113,6 +119,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
PiIconButtonComponent,
|
||||
PiLabelDirective,
|
||||
PiNoteComponent,
|
||||
PiSegmentedControlComponent,
|
||||
PiProgressBarComponent,
|
||||
PiSearchableSelectFieldComponent,
|
||||
PiSelectFieldComponent,
|
||||
@@ -337,6 +344,27 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (news(); as news) {
|
||||
<p class="ts-news" role="status">
|
||||
<span class="ts-news__dot" aria-hidden="true"></span>
|
||||
<span
|
||||
>Desde tu última visita, {{ ago(news.since) }}:
|
||||
<strong>{{ news.what }}</strong> de {{ news.who }}.</span
|
||||
>
|
||||
@if (news.firstComment; as first) {
|
||||
<span class="ts-reminder__actions">
|
||||
<pi-action-button
|
||||
size="x-small"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
text="Ver lo nuevo"
|
||||
(execute)="goToNews(first)"
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (project.canContribute() && issue.allowedTransitions.length) {
|
||||
<div class="ts-transitions" role="group" aria-label="Mover a">
|
||||
<span class="ts-eyebrow">Mover a</span>
|
||||
@@ -852,6 +880,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
class="ts-comment"
|
||||
[id]="'comment-' + comment.id"
|
||||
[class.is-decision]="comment.decision && !comment.deleted"
|
||||
[class.is-new]="isNew(comment)"
|
||||
>
|
||||
<span class="ts-comment__avatar" aria-hidden="true">{{
|
||||
initialsOf(comment.author.name)
|
||||
@@ -868,6 +897,9 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
@if (comment.decision && !comment.deleted) {
|
||||
<span class="ts-comment__decision">Decisión</span>
|
||||
}
|
||||
@if (isNew(comment)) {
|
||||
<span class="ts-comment__new">Nuevo</span>
|
||||
}
|
||||
<span class="ts-spacer"></span>
|
||||
@if (!comment.deleted && project.canContribute()) {
|
||||
<!-- Native: pi-icon-button leaves its button unnamed. -->
|
||||
@@ -893,6 +925,18 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
<pi-icon icon="flag" size="small" />
|
||||
</button>
|
||||
}
|
||||
@if (!comment.deleted && project.canContribute()) {
|
||||
<!-- Native, like the flag: a named button. -->
|
||||
<button
|
||||
type="button"
|
||||
class="ts-comment__flag"
|
||||
aria-label="Convertir en ticket"
|
||||
title="Convertir en ticket: su texto será la descripción"
|
||||
(click)="openTurnIntoIssue(comment)"
|
||||
>
|
||||
<pi-icon icon="task" size="small" />
|
||||
</button>
|
||||
}
|
||||
@if (
|
||||
!comment.deleted && comment.author.id === facade.me()
|
||||
) {
|
||||
@@ -925,6 +969,16 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
[me]="facade.me()"
|
||||
[loader]="loadImage"
|
||||
/>
|
||||
@if (comment.spawned.length) {
|
||||
<p class="ts-comment__spawned">
|
||||
<span aria-hidden="true">→</span> Convertido en
|
||||
@for (spawn of comment.spawned; track spawn.id) {
|
||||
<a [routerLink]="['..', spawn.key]">{{
|
||||
spawn.key
|
||||
}}</a>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
}
|
||||
@if (!comment.deleted && comment.poll; as poll) {
|
||||
<div
|
||||
@@ -1044,6 +1098,12 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
: ''
|
||||
}}</span
|
||||
>
|
||||
@if (newIn(thread); as count) {
|
||||
<span class="ts-comment__new"
|
||||
>{{ count }}
|
||||
{{ count === 1 ? 'nuevo' : 'nuevos' }}</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1304,7 +1364,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
<h2 class="ts-section">Historial</h2>
|
||||
<ol class="ts-log">
|
||||
@for (entry of facade.history(); track entry.id) {
|
||||
<li>
|
||||
<li [class.is-new]="isNewChange(entry)">
|
||||
<time
|
||||
class="ts-log__when"
|
||||
[attr.datetime]="entry.occurredAt"
|
||||
@@ -1451,6 +1511,41 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
<pi-skeleton shape="text" [lines]="8" />
|
||||
}
|
||||
|
||||
<ng-template #turnIntoIssueForm>
|
||||
<form
|
||||
class="ts-form"
|
||||
(submit)="$event.preventDefault(); sendTurnIntoIssue()"
|
||||
novalidate
|
||||
>
|
||||
<p class="ts-muted">
|
||||
El texto del comentario será la descripción, y el comentario enlazará
|
||||
al ticket nuevo.
|
||||
</p>
|
||||
<pi-text-field [(value)]="spawnTitle">
|
||||
<pi-label [isRequired]="true">Título</pi-label>
|
||||
</pi-text-field>
|
||||
<pi-select-field
|
||||
[options]="subtaskTypeOptions()"
|
||||
[value]="spawnType()"
|
||||
(valueChange)="spawnType.set($event ?? '')"
|
||||
>
|
||||
<pi-label>Tipo</pi-label>
|
||||
</pi-select-field>
|
||||
<pi-segmented-control
|
||||
ariaLabel="Cómo queda"
|
||||
[options]="spawnModes()"
|
||||
[(value)]="spawnMode"
|
||||
/>
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
text="Crear ticket"
|
||||
[disabled]="!spawnTitle().trim() || !spawnType()"
|
||||
(execute)="sendTurnIntoIssue()"
|
||||
/>
|
||||
</form>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #reviewRequestForm>
|
||||
<form
|
||||
class="ts-form"
|
||||
@@ -1673,6 +1768,20 @@ export class IssuePage {
|
||||
viewChild.required<TemplateRef<unknown>>('reasonForm');
|
||||
private readonly reminderForm =
|
||||
viewChild.required<TemplateRef<unknown>>('reminderForm');
|
||||
private readonly turnIntoIssueForm =
|
||||
viewChild.required<TemplateRef<unknown>>('turnIntoIssueForm');
|
||||
/** «Convertir en ticket»: the comment, and the ticket being made of it. */
|
||||
private readonly spawnFrom = signal<Comment | null>(null);
|
||||
protected readonly spawnTitle = signal('');
|
||||
protected readonly spawnType = signal('');
|
||||
protected readonly spawnMode = signal<'link' | 'subtask'>('link');
|
||||
protected readonly spawnModes = computed(() => {
|
||||
const key = this.facade.issue()?.key ?? 'este';
|
||||
return [
|
||||
{ value: 'link' as const, label: `Enlazado a ${key}` },
|
||||
{ value: 'subtask' as const, label: `Subtarea de ${key}` },
|
||||
];
|
||||
});
|
||||
private readonly reviewRequestForm =
|
||||
viewChild.required<TemplateRef<unknown>>('reviewRequestForm');
|
||||
private readonly reviewAnswerForm =
|
||||
@@ -2024,6 +2133,17 @@ export class IssuePage {
|
||||
if (id && this.facade.comments().some((c) => c.id === id))
|
||||
untracked(() => setTimeout(() => this.goToComment(id)));
|
||||
});
|
||||
// My visit before this one, once per ticket (only with a session).
|
||||
effect(() => {
|
||||
const issueId = this.openIssueId();
|
||||
if (!issueId || !this.facade.me()) return;
|
||||
untracked(() =>
|
||||
this.issuesGateway.visit(issueId).subscribe({
|
||||
next: (since) => this.visit.set({ issueId, since }),
|
||||
error: () => this.visit.set(null),
|
||||
}),
|
||||
);
|
||||
});
|
||||
// Whether it is in today's «Mi día», once per ticket.
|
||||
effect(() => {
|
||||
const issueId = this.openIssueId();
|
||||
@@ -2106,6 +2226,65 @@ export class IssuePage {
|
||||
protected readonly inMyDay = signal<boolean | null>(null);
|
||||
/** Changes with the ticket, not with each reload of it. */
|
||||
private readonly openIssueId = computed(() => this.facade.issue()?.id);
|
||||
|
||||
/* «Lo nuevo desde tu última visita»: what others did since I last came. */
|
||||
private readonly visit = signal<{
|
||||
issueId: string;
|
||||
since: string | null;
|
||||
} | null>(null);
|
||||
private readonly since = computed(() => {
|
||||
const visit = this.visit();
|
||||
return visit && visit.issueId === this.openIssueId() ? visit.since : null;
|
||||
});
|
||||
protected isNew(comment: Comment): boolean {
|
||||
return isNewComment(comment, this.since(), this.facade.me());
|
||||
}
|
||||
protected isNewChange(entry: Activity): boolean {
|
||||
return isNewChange(entry, this.since(), this.facade.me());
|
||||
}
|
||||
protected newIn(thread: { comment: Comment; replies: Comment[] }): number {
|
||||
return [thread.comment, ...thread.replies].filter((c) => this.isNew(c))
|
||||
.length;
|
||||
}
|
||||
protected readonly news = computed(() =>
|
||||
newsSince(
|
||||
this.facade.comments(),
|
||||
this.facade.history(),
|
||||
this.since(),
|
||||
this.facade.me(),
|
||||
),
|
||||
);
|
||||
|
||||
/** To the first new comment, unfolding its conversation if settled. */
|
||||
protected goToNews(commentId: string): void {
|
||||
const thread = this.threads().find(
|
||||
(t) =>
|
||||
t.comment.id === commentId || t.replies.some((r) => r.id === commentId),
|
||||
);
|
||||
if (thread?.comment.resolved && !this.expanded().has(thread.comment.id))
|
||||
this.toggleThread(thread.comment.id);
|
||||
setTimeout(() => this.goToComment(commentId));
|
||||
}
|
||||
|
||||
protected openTurnIntoIssue(comment: Comment): void {
|
||||
this.spawnFrom.set(comment);
|
||||
this.spawnTitle.set(titleFrom(comment.body));
|
||||
this.spawnType.set(this.defaultSubtaskType());
|
||||
this.spawnMode.set('link');
|
||||
this.drawer.open('Convertir en ticket', this.turnIntoIssueForm());
|
||||
}
|
||||
|
||||
protected sendTurnIntoIssue(): void {
|
||||
const comment = this.spawnFrom();
|
||||
if (!comment || !this.spawnTitle().trim() || !this.spawnType()) return;
|
||||
this.facade.turnCommentIntoIssue(
|
||||
comment.id,
|
||||
this.spawnTitle(),
|
||||
this.spawnType(),
|
||||
this.spawnMode() === 'subtask',
|
||||
);
|
||||
this.drawer.closeAll();
|
||||
}
|
||||
private readonly issuesGateway = inject(IssuesGateway);
|
||||
|
||||
protected toggleMyDay(issue: Issue): void {
|
||||
|
||||
Reference in New Issue
Block a user