feat: comments marked as decisions, on top of the ticket and searchable
Whoever can change the ticket flags a comment as a decision (new event
CommentDecisionMarkedEvent on the comment). The ticket page lists its
decisions above the description, with a link to the comment in the
conversation (`?comment=<id>` scrolls to it and points at it), and the
search palette finds them by their text across my projects.
API: markDecision / PUT-DELETE /api/comments/{id}/decision; Issue.decisions
and GET /api/issues/{issue}/decisions; decisions(projectId?, search) and
GET /api/projects/{project}/decisions, GET /api/me/decisions. MCP:
mark_decision, search_decisions, and get_issue includes them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -71,6 +71,7 @@ export const issuePageEvents = eventGroup({
|
||||
}>(),
|
||||
commentEditRequested: type<{ commentId: string; body: string }>(),
|
||||
reactRequested: type<{ commentId: string; emoji: string; on: boolean }>(),
|
||||
decisionRequested: type<{ commentId: string; on: 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 }>(),
|
||||
|
||||
@@ -231,6 +231,11 @@ export class IssueFacade {
|
||||
this.dispatch.reactRequested({ commentId, emoji, on });
|
||||
}
|
||||
|
||||
/** Marks a comment as a decision of the ticket (`on`) or unmarks it. */
|
||||
markDecision(commentId: string, on: boolean): void {
|
||||
this.dispatch.decisionRequested({ commentId, on });
|
||||
}
|
||||
|
||||
/** A copy of what the open ticket says; answers its key. */
|
||||
duplicate(): Observable<string | null> {
|
||||
const issueId = this.store.issue()?.id ?? '';
|
||||
|
||||
@@ -423,6 +423,20 @@ export const IssueStore = signalStore(
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.decisionRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
comments.markDecision(payload.commentId, payload.on),
|
||||
payload.on
|
||||
? 'Marcado como decisión: sale arriba del ticket'
|
||||
: 'Ya no es una decisión',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events.on(issuePageEvents.reactRequested).pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
comments.react(payload.commentId, payload.emoji, payload.on).pipe(
|
||||
|
||||
@@ -192,6 +192,8 @@ export interface Issue extends IssueSummary {
|
||||
watchers: UserRef[];
|
||||
/** Whether I keep it pinned (home page and search). */
|
||||
pinned: boolean;
|
||||
/** Its comments marked as decisions, oldest first. */
|
||||
decisions: Comment[];
|
||||
checklist: ChecklistItem[];
|
||||
/** Commits, branches and pull requests that mention it. */
|
||||
devLinks: DevLink[];
|
||||
@@ -326,10 +328,20 @@ export interface Comment {
|
||||
deleted: boolean;
|
||||
/** 👍… and who chose each (only the ones somebody chose). */
|
||||
reactions: { emoji: string; users: UserRef[] }[];
|
||||
/** Marked as a decision of the ticket: shown above it and searchable. */
|
||||
decision: boolean;
|
||||
/** Who marked it as a decision (null when it is not one). */
|
||||
decidedBy: UserRef | null;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
}
|
||||
|
||||
/** A decision found by its text, with its ticket. */
|
||||
export interface Decision {
|
||||
comment: Comment;
|
||||
issue: { id: string; key: string; title: string };
|
||||
}
|
||||
|
||||
export interface SprintProgress {
|
||||
total: number;
|
||||
/** Done or cancelled. */
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
PushDeviceSubscription,
|
||||
CommandResult,
|
||||
Comment,
|
||||
Decision,
|
||||
Epic,
|
||||
GitIntegration,
|
||||
GitRepository,
|
||||
@@ -144,6 +145,14 @@ export abstract class IssuesGateway {
|
||||
abstract pins(): Observable<IssueSummary[]>;
|
||||
/** Pins the ticket for me (or unpins it): private, at most 20. */
|
||||
abstract pin(issueId: string, pinned: boolean): Observable<void>;
|
||||
/**
|
||||
* Decisions (comments marked as such) of all my projects by their text,
|
||||
* most recent first: here and not in CommentsGateway, for the palette.
|
||||
*/
|
||||
abstract searchDecisions(
|
||||
search: string,
|
||||
limit: number,
|
||||
): Observable<Decision[]>;
|
||||
/** Tickets hanging from `issueId`. */
|
||||
abstract children(
|
||||
projectId: string,
|
||||
@@ -289,6 +298,11 @@ export abstract class CommentsGateway {
|
||||
on: boolean,
|
||||
): Observable<CommandResult>;
|
||||
abstract remove(commentId: string): Observable<CommandResult>;
|
||||
/** Marks it as a decision of its ticket (`on`) or unmarks it. */
|
||||
abstract markDecision(
|
||||
commentId: string,
|
||||
on: boolean,
|
||||
): Observable<CommandResult>;
|
||||
}
|
||||
|
||||
/** A ticket's files. Uploads and downloads go through the REST API. */
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
EditCommentDocument,
|
||||
IssueCommentsDocument,
|
||||
ReactToCommentDocument,
|
||||
MarkDecisionDocument,
|
||||
PublishReleaseDocument,
|
||||
ReleaseBurnupDocument,
|
||||
ReleaseNotesDraftDocument,
|
||||
@@ -75,6 +76,12 @@ export class GraphqlCommentsGateway extends CommentsGateway {
|
||||
.mutate(DeleteCommentDocument, { commentId })
|
||||
.pipe(map((d) => toCommandResult(d.deleteComment)));
|
||||
}
|
||||
|
||||
markDecision(commentId: string, on: boolean): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(MarkDecisionDocument, { commentId, on })
|
||||
.pipe(map((d) => toCommandResult(d.markDecision)));
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ChangeStatusInput,
|
||||
CommandResult,
|
||||
CreateIssueInput,
|
||||
Decision,
|
||||
Epic,
|
||||
Issue,
|
||||
IssuePage,
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
VoteIssueDocument,
|
||||
WatchIssueDocument,
|
||||
MyPinsDocument,
|
||||
SearchDecisionsDocument,
|
||||
PinIssueDocument,
|
||||
UpdateChecklistItemDocument,
|
||||
UpdateIssueDocument,
|
||||
@@ -57,6 +59,7 @@ import {
|
||||
toCommandResult,
|
||||
toIssue,
|
||||
toIssueLink,
|
||||
toComment,
|
||||
toIssueSummary,
|
||||
} from './mappers';
|
||||
|
||||
@@ -201,6 +204,18 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
||||
.pipe(map((d) => d.myPins.map(toIssueSummary)));
|
||||
}
|
||||
|
||||
searchDecisions(search: string, limit: number): Observable<Decision[]> {
|
||||
return this.gql
|
||||
.query(SearchDecisionsDocument, { search: search.trim() || null, limit })
|
||||
.pipe(
|
||||
map((d) =>
|
||||
d.decisions.map((found) => ({
|
||||
comment: toComment(found.comment),
|
||||
issue: found.issue,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
pin(issueId: string, pinned: boolean): Observable<void> {
|
||||
const done$: Observable<unknown> = pinned
|
||||
? this.gql.mutate(PinIssueDocument, { issueId })
|
||||
|
||||
@@ -121,6 +121,9 @@ fragment IssueSummaryFields on Issue {
|
||||
fragment IssueFields on Issue {
|
||||
...IssueSummaryFields
|
||||
pinned
|
||||
decisions {
|
||||
...CommentFields
|
||||
}
|
||||
description
|
||||
figmaUrl
|
||||
createdAt
|
||||
@@ -178,6 +181,10 @@ fragment CommentFields on Comment {
|
||||
...UserRefFields
|
||||
}
|
||||
}
|
||||
decision
|
||||
decidedBy {
|
||||
...UserRefFields
|
||||
}
|
||||
createdAt
|
||||
editedAt
|
||||
author {
|
||||
|
||||
@@ -650,6 +650,22 @@ export type IssueFieldsFragment = {
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
trashedAt: string | null;
|
||||
decisions: Array<{
|
||||
id: string;
|
||||
issueId: string;
|
||||
body: string;
|
||||
replyTo: string | null;
|
||||
deleted: boolean;
|
||||
decision: boolean;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
reactions: Array<{
|
||||
emoji: string;
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
}>;
|
||||
reporter: { id: string; name: string };
|
||||
links: Array<{
|
||||
type: IssueLinkType;
|
||||
@@ -693,12 +709,14 @@ export type CommentFieldsFragment = {
|
||||
body: string;
|
||||
replyTo: string | null;
|
||||
deleted: boolean;
|
||||
decision: boolean;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
reactions: Array<{
|
||||
emoji: string;
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
};
|
||||
|
||||
@@ -873,6 +891,22 @@ export type IssueQuery = {
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
trashedAt: string | null;
|
||||
decisions: Array<{
|
||||
id: string;
|
||||
issueId: string;
|
||||
body: string;
|
||||
replyTo: string | null;
|
||||
deleted: boolean;
|
||||
decision: boolean;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
reactions: Array<{
|
||||
emoji: string;
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
}>;
|
||||
reporter: { id: string; name: string };
|
||||
links: Array<{
|
||||
type: IssueLinkType;
|
||||
@@ -1083,12 +1117,14 @@ export type IssueCommentsQuery = {
|
||||
body: string;
|
||||
replyTo: string | null;
|
||||
deleted: boolean;
|
||||
decision: boolean;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
reactions: Array<{
|
||||
emoji: string;
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
}>;
|
||||
};
|
||||
@@ -1490,6 +1526,42 @@ export type ReactToCommentMutation = {
|
||||
reactToComment: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type MarkDecisionMutationVariables = Exact<{
|
||||
commentId: string;
|
||||
on: boolean;
|
||||
}>;
|
||||
|
||||
export type MarkDecisionMutation = {
|
||||
markDecision: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type SearchDecisionsQueryVariables = Exact<{
|
||||
search?: string | null | undefined;
|
||||
limit: number;
|
||||
}>;
|
||||
|
||||
export type SearchDecisionsQuery = {
|
||||
decisions: Array<{
|
||||
comment: {
|
||||
id: string;
|
||||
issueId: string;
|
||||
body: string;
|
||||
replyTo: string | null;
|
||||
deleted: boolean;
|
||||
decision: boolean;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
reactions: Array<{
|
||||
emoji: string;
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
};
|
||||
issue: { id: string; key: string; title: string };
|
||||
}>;
|
||||
};
|
||||
|
||||
export type DuplicateIssueMutationVariables = Exact<{
|
||||
issueId: string;
|
||||
}>;
|
||||
@@ -2780,12 +2852,14 @@ export type CommentChangedSubscription = {
|
||||
body: string;
|
||||
replyTo: string | null;
|
||||
deleted: boolean;
|
||||
decision: boolean;
|
||||
createdAt: string;
|
||||
editedAt: string | null;
|
||||
reactions: Array<{
|
||||
emoji: string;
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
} | null;
|
||||
};
|
||||
@@ -3498,6 +3572,96 @@ export const IssueSummaryFieldsFragmentDoc = {
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<IssueSummaryFieldsFragment, unknown>;
|
||||
export const CommentFieldsFragmentDoc = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'CommentFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Comment' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'body' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'replyTo' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'deleted' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'reactions' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'emoji' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'users' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'decision' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'decidedBy' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'author' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'UserRef' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<CommentFieldsFragment, unknown>;
|
||||
export const IssueFieldsFragmentDoc = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -3516,6 +3680,19 @@ export const IssueFieldsFragmentDoc = {
|
||||
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'pinned' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'decisions' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'CommentFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
@@ -3742,11 +3919,6 @@ export const IssueFieldsFragmentDoc = {
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<IssueFieldsFragment, unknown>;
|
||||
export const CommentFieldsFragmentDoc = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'CommentFields' },
|
||||
@@ -3785,6 +3957,20 @@ export const CommentFieldsFragmentDoc = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'decision' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'decidedBy' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
@@ -3803,23 +3989,8 @@ export const CommentFieldsFragmentDoc = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'UserRef' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<CommentFieldsFragment, unknown>;
|
||||
} as unknown as DocumentNode<IssueFieldsFragment, unknown>;
|
||||
export const ReleaseFieldsFragmentDoc = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -5716,6 +5887,76 @@ export const IssueDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'CommentFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Comment' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'body' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'replyTo' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'deleted' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'reactions' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'emoji' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'users' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'decision' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'decidedBy' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'author' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'IssueFields' },
|
||||
@@ -5731,6 +5972,19 @@ export const IssueDocument = {
|
||||
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'pinned' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'decisions' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'CommentFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
@@ -7051,6 +7305,20 @@ export const IssueCommentsDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'decision' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'decidedBy' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
@@ -9539,6 +9807,271 @@ export const ReactToCommentDocument = {
|
||||
ReactToCommentMutation,
|
||||
ReactToCommentMutationVariables
|
||||
>;
|
||||
export const MarkDecisionDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'MarkDecision' },
|
||||
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: 'on' } },
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'markDecision' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'commentId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'commentId' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'on' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'on' },
|
||||
},
|
||||
},
|
||||
],
|
||||
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<
|
||||
MarkDecisionMutation,
|
||||
MarkDecisionMutationVariables
|
||||
>;
|
||||
export const SearchDecisionsDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
name: { kind: 'Name', value: 'SearchDecisions' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'search' },
|
||||
},
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'limit' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'decisions' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'search' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'search' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'limit' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'limit' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'comment' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'CommentFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'issue' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'UserRef' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'FragmentDefinition',
|
||||
name: { kind: 'Name', value: 'CommentFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Comment' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'issueId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'body' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'replyTo' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'deleted' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'reactions' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'emoji' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'users' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'decision' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'decidedBy' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'author' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
SearchDecisionsQuery,
|
||||
SearchDecisionsQueryVariables
|
||||
>;
|
||||
export const DuplicateIssueDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -16294,6 +16827,20 @@ export const CommentChangedDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'decision' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'decidedBy' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'createdAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'editedAt' } },
|
||||
{
|
||||
|
||||
@@ -293,6 +293,25 @@ mutation ReactToComment($commentId: ID!, $emoji: String!, $on: Boolean!) {
|
||||
}
|
||||
}
|
||||
|
||||
mutation MarkDecision($commentId: ID!, $on: Boolean!) {
|
||||
markDecision(commentId: $commentId, on: $on) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
query SearchDecisions($search: String, $limit: Int!) {
|
||||
decisions(search: $search, limit: $limit) {
|
||||
comment {
|
||||
...CommentFields
|
||||
}
|
||||
issue {
|
||||
id
|
||||
key
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutation DuplicateIssue($issueId: ID!) {
|
||||
duplicateIssue(issueId: $issueId) {
|
||||
...CommandResultFields
|
||||
|
||||
@@ -331,6 +331,109 @@ a.ts-key {
|
||||
}
|
||||
}
|
||||
|
||||
/* A decision: marked on its comment, and listed above the ticket. */
|
||||
.ts-comment.is-decision {
|
||||
margin-inline: -0.75rem;
|
||||
padding-inline: 0.75rem;
|
||||
border-left: 3px solid var(--ts-c-green);
|
||||
background: color-mix(in srgb, var(--ts-c-green) 6%, transparent);
|
||||
}
|
||||
|
||||
.ts-comment.is-pointed {
|
||||
animation: ts-pointed 2.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes ts-pointed {
|
||||
0%,
|
||||
40% {
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ts-brand) 45%, transparent);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 3px transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-comment__decision {
|
||||
padding: 0.0625rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--ts-c-green) 16%, transparent);
|
||||
color: var(--ts-c-green);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* A plain icon button, not @pi/ui's sized one. */
|
||||
.ts-comment__flag {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 2rem !important;
|
||||
min-width: 0 !important;
|
||||
height: 2rem !important;
|
||||
padding: 0 !important;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
color: var(--ts-muted);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: var(--ts-sunken);
|
||||
color: var(--ts-ink);
|
||||
}
|
||||
|
||||
&.is-on {
|
||||
color: var(--ts-c-green);
|
||||
}
|
||||
}
|
||||
|
||||
.ts-decisions {
|
||||
ol {
|
||||
display: grid;
|
||||
gap: 0.875rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li {
|
||||
padding: 0.75rem 1rem;
|
||||
border-left: 3px solid var(--ts-c-green);
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
background: color-mix(in srgb, var(--ts-c-green) 7%, var(--ts-surface));
|
||||
}
|
||||
|
||||
.ts-section pi-icon {
|
||||
color: var(--ts-c-green);
|
||||
}
|
||||
}
|
||||
|
||||
.ts-decisions__meta {
|
||||
margin: 0.375rem 0 0;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* Looks like a link: not @pi/ui's sized button. */
|
||||
.ts-decisions__go {
|
||||
display: inline !important;
|
||||
min-width: 0 !important;
|
||||
height: auto !important;
|
||||
padding: 0 !important;
|
||||
border: 0;
|
||||
background: none !important;
|
||||
color: var(--ts-brand);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-comment__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -259,6 +259,47 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (issue.decisions.length) {
|
||||
<article
|
||||
class="ts-block ts-decisions"
|
||||
aria-labelledby="decisions-title"
|
||||
>
|
||||
<h2 class="ts-section" id="decisions-title">
|
||||
<pi-icon icon="flag" size="small" />
|
||||
Decisiones
|
||||
<span class="ts-mono">{{ issue.decisions.length }}</span>
|
||||
</h2>
|
||||
<ol>
|
||||
@for (decision of issue.decisions; track decision.id) {
|
||||
<li>
|
||||
<ts-markdown
|
||||
[text]="decision.body"
|
||||
[me]="facade.me()"
|
||||
[loader]="loadImage"
|
||||
/>
|
||||
<p class="ts-decisions__meta">
|
||||
{{ decision.author.name }} ·
|
||||
{{ ago(decision.createdAt) }}
|
||||
@if (decision.decidedBy; as by) {
|
||||
@if (by.id !== decision.author.id) {
|
||||
· marcada por {{ by.name }}
|
||||
}
|
||||
}
|
||||
·
|
||||
<button
|
||||
type="button"
|
||||
class="ts-decisions__go"
|
||||
(click)="goToComment(decision.id)"
|
||||
>
|
||||
Ver en la conversación
|
||||
</button>
|
||||
</p>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</article>
|
||||
}
|
||||
|
||||
<article class="ts-block">
|
||||
<h2 class="ts-section">Descripción</h2>
|
||||
@if (issue.description) {
|
||||
@@ -615,7 +656,11 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
<span class="ts-mono">{{ commentCount() }}</span>
|
||||
</h2>
|
||||
<ng-template #commentView let-comment>
|
||||
<div class="ts-comment">
|
||||
<div
|
||||
class="ts-comment"
|
||||
[id]="'comment-' + comment.id"
|
||||
[class.is-decision]="comment.decision && !comment.deleted"
|
||||
>
|
||||
<span class="ts-comment__avatar" aria-hidden="true">{{
|
||||
initialsOf(comment.author.name)
|
||||
}}</span>
|
||||
@@ -628,7 +673,34 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
>{{ ago(comment.createdAt)
|
||||
}}{{ comment.editedAt ? ' · editado' : '' }}</span
|
||||
>
|
||||
@if (comment.decision && !comment.deleted) {
|
||||
<span class="ts-comment__decision">Decisión</span>
|
||||
}
|
||||
<span class="ts-spacer"></span>
|
||||
@if (!comment.deleted && project.canContribute()) {
|
||||
<!-- Native: pi-icon-button leaves its button unnamed. -->
|
||||
<button
|
||||
type="button"
|
||||
class="ts-comment__flag"
|
||||
[class.is-on]="comment.decision"
|
||||
[attr.aria-pressed]="comment.decision"
|
||||
[attr.aria-label]="
|
||||
comment.decision
|
||||
? 'Quitar de las decisiones'
|
||||
: 'Marcar como decisión'
|
||||
"
|
||||
[attr.title]="
|
||||
comment.decision
|
||||
? 'Quitar de las decisiones'
|
||||
: 'Marcar como decisión: sale arriba del ticket'
|
||||
"
|
||||
(click)="
|
||||
facade.markDecision(comment.id, !comment.decision)
|
||||
"
|
||||
>
|
||||
<pi-icon icon="flag" size="small" />
|
||||
</button>
|
||||
}
|
||||
@if (
|
||||
!comment.deleted && comment.author.id === facade.me()
|
||||
) {
|
||||
@@ -1028,6 +1100,8 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
export class IssuePage {
|
||||
/** Route param (`withComponentInputBinding`). */
|
||||
readonly issueKey = input.required<string>();
|
||||
/** `?comment=` (query param): the comment to bring into view. */
|
||||
readonly comment = input<string>();
|
||||
|
||||
protected readonly facade = inject(IssueFacade);
|
||||
protected readonly project = inject(ProjectFacade);
|
||||
@@ -1334,12 +1408,29 @@ export class IssuePage {
|
||||
});
|
||||
});
|
||||
});
|
||||
effect(() => {
|
||||
const id = this.comment();
|
||||
// Once its comment has arrived (they load after the ticket).
|
||||
if (id && this.facade.comments().some((c) => c.id === id))
|
||||
untracked(() => setTimeout(() => this.goToComment(id)));
|
||||
});
|
||||
effect(() => {
|
||||
const editing = this.editingInDrawer() || !!this.commentBody().trim();
|
||||
untracked(() => this.presence.setEditing(editing));
|
||||
});
|
||||
}
|
||||
|
||||
/** Scrolls to a comment of the conversation and points at it. */
|
||||
protected goToComment(id: string): void {
|
||||
const element = document.getElementById(`comment-${id}`);
|
||||
if (!element) return;
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
element.classList.remove('is-pointed');
|
||||
// Restart the animation when it is pointed at again.
|
||||
void element.offsetWidth;
|
||||
element.classList.add('is-pointed');
|
||||
}
|
||||
|
||||
/** While a form is open in the drawer, I am editing. */
|
||||
private trackEditing(ref: {
|
||||
afterClosed(): { subscribe(f: () => void): unknown };
|
||||
|
||||
@@ -14,6 +14,7 @@ import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
||||
import { Router } from '@angular/router';
|
||||
import { ProjectsFacade } from '@task-stream/web/tracker/application';
|
||||
import {
|
||||
Decision,
|
||||
GO_TO,
|
||||
isTyping,
|
||||
IssuesGateway,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import {
|
||||
catchError,
|
||||
combineLatest,
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
@@ -39,9 +41,28 @@ const issueResult = (i: IssueSummary): Result => ({
|
||||
url: `/p/${i.key.slice(0, i.key.lastIndexOf('-'))}/issues/${i.key}`,
|
||||
});
|
||||
|
||||
/** A decision as a result: opens its ticket at the comment. */
|
||||
const decisionResult = ({ comment, issue }: Decision): Result => ({
|
||||
id: `d:${comment.id}`,
|
||||
kind: 'decision',
|
||||
label: `${issue.key} · ${excerpt(comment.body)}`,
|
||||
detail: comment.author.name,
|
||||
url: `/p/${issue.key.slice(0, issue.key.lastIndexOf('-'))}/issues/${issue.key}?comment=${comment.id}`,
|
||||
});
|
||||
|
||||
/** The first words of a comment, without Markdown marks. */
|
||||
function excerpt(body: string): string {
|
||||
const text = body
|
||||
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/[*_`~#>]+/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
return text.length > 90 ? `${text.slice(0, 89)}…` : text;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
id: string;
|
||||
kind: 'project' | 'issue';
|
||||
kind: 'project' | 'issue' | 'decision';
|
||||
label: string;
|
||||
detail: string;
|
||||
url: string;
|
||||
@@ -83,7 +104,7 @@ interface Result {
|
||||
#search
|
||||
class="ts-palette__input"
|
||||
type="search"
|
||||
placeholder="Busca un ticket (WEB-12, título…) o un proyecto"
|
||||
placeholder="Busca un ticket (WEB-12, título…), una decisión o un proyecto"
|
||||
role="combobox"
|
||||
aria-controls="ts-palette-results"
|
||||
[attr.aria-expanded]="results().length > 0"
|
||||
@@ -113,9 +134,11 @@ interface Result {
|
||||
<span class="ts-palette__kind">{{
|
||||
result.kind === 'project'
|
||||
? 'Proyecto'
|
||||
: text().trim().length < 2
|
||||
? 'Fijado'
|
||||
: 'Ticket'
|
||||
: result.kind === 'decision'
|
||||
? 'Decisión'
|
||||
: text().trim().length < 2
|
||||
? 'Fijado'
|
||||
: 'Ticket'
|
||||
}}</span>
|
||||
<span class="ts-palette__label">{{ result.label }}</span>
|
||||
<span class="ts-palette__detail">{{ result.detail }}</span>
|
||||
@@ -268,6 +291,7 @@ export class CommandPalette {
|
||||
private goTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private readonly foundIssues = signal<IssueSummary[]>([]);
|
||||
private readonly foundDecisions = signal<Decision[]>([]);
|
||||
/** Mine, shown before typing; read again each time it opens. */
|
||||
protected readonly pins = signal<IssueSummary[]>([]);
|
||||
protected readonly results = computed<Result[]>(() => {
|
||||
@@ -288,7 +312,11 @@ export class CommandPalette {
|
||||
detail: p.key,
|
||||
url: `/p/${p.key}/board`,
|
||||
}));
|
||||
return [...projects, ...this.foundIssues().map(issueResult)];
|
||||
return [
|
||||
...projects,
|
||||
...this.foundIssues().map(issueResult),
|
||||
...this.foundDecisions().map(decisionResult),
|
||||
];
|
||||
});
|
||||
|
||||
constructor() {
|
||||
@@ -299,15 +327,25 @@ export class CommandPalette {
|
||||
distinctUntilChanged(),
|
||||
switchMap((search) =>
|
||||
search.length < 2
|
||||
? of([])
|
||||
: this.issues.mine({ assignedToMe: false, search, limit: 8 }).pipe(
|
||||
map((page) => page.items),
|
||||
catchError(() => of([])),
|
||||
),
|
||||
? of<[IssueSummary[], Decision[]]>([[], []])
|
||||
: combineLatest([
|
||||
this.issues
|
||||
.mine({ assignedToMe: false, search, limit: 8 })
|
||||
.pipe(
|
||||
map((page) => page.items),
|
||||
catchError(() => of([])),
|
||||
),
|
||||
this.issues
|
||||
.searchDecisions(search, 4)
|
||||
.pipe(catchError(() => of([]))),
|
||||
]),
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe((found) => this.foundIssues.set(found));
|
||||
.subscribe((found) => {
|
||||
this.foundIssues.set(found[0]);
|
||||
this.foundDecisions.set(found[1]);
|
||||
});
|
||||
effect(() => {
|
||||
if (this.mode() === 'search') this.searchInput()?.nativeElement.focus();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user