feat: quick polls in comments that end in a decision
«📊 Añadir una encuesta» adds 2 to 6 options to a new comment. Members, viewers too, vote by clicking an option (one vote each, which they can change or take back) and see each option's votes as a bar. Whoever started it, or an admin, closes it with «Cerrar y decidir»: the most voted wins (none on a tie) and a «Decidido: …» reply marked as a decision shows above the ticket. Events of the comment (voted, closed). API: addComment(…, poll), votePoll, closePoll, Comment.poll; POST /api/comments/{id}/poll/vote and …/poll/close. MCP: add_comment poll, vote_poll, close_poll. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -47,7 +47,11 @@ export const issuePageEvents = eventGroup({
|
||||
files?: File[];
|
||||
/** Comment it answers (one level of replies). */
|
||||
replyTo?: string | null;
|
||||
/** A quick poll's options. */
|
||||
poll?: string[] | null;
|
||||
}>(),
|
||||
pollVoteRequested: type<{ commentId: string; option: number | null }>(),
|
||||
pollCloseRequested: type<{ commentId: string; decide: boolean }>(),
|
||||
watchRequested: type<{ issueId: string; watching: boolean }>(),
|
||||
pinRequested: type<{ issueId: string; pinned: boolean }>(),
|
||||
followTreeRequested: type<{ issueId: string; on: boolean }>(),
|
||||
|
||||
@@ -101,12 +101,29 @@ export class IssueFacade {
|
||||
body: string,
|
||||
files: File[] = [],
|
||||
replyTo: string | null = null,
|
||||
poll: string[] | null = null,
|
||||
): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.commentAddRequested({ issueId, body, files, replyTo }),
|
||||
this.dispatch.commentAddRequested({
|
||||
issueId,
|
||||
body,
|
||||
files,
|
||||
replyTo,
|
||||
poll,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Votes in a comment's poll (null takes the vote back). */
|
||||
votePoll(commentId: string, option: number | null): void {
|
||||
this.dispatch.pollVoteRequested({ commentId, option });
|
||||
}
|
||||
|
||||
/** Closes a poll; with `decide`, its winner stays as a decision. */
|
||||
closePoll(commentId: string, decide = true): void {
|
||||
this.dispatch.pollCloseRequested({ commentId, decide });
|
||||
}
|
||||
|
||||
/** To the trash (true) or back from it (false). */
|
||||
setTrashed(trashed: boolean): void {
|
||||
this.withIssue((issueId) =>
|
||||
|
||||
@@ -381,6 +381,32 @@ export const IssueStore = signalStore(
|
||||
),
|
||||
),
|
||||
),
|
||||
events.on(issuePageEvents.pollVoteRequested).pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
comments.votePoll(payload.commentId, payload.option).pipe(
|
||||
// A vote is not worth a toast, like a reaction.
|
||||
mapResponse({
|
||||
next: () => issueApiEvents.checklistSaved(),
|
||||
error: (error) =>
|
||||
issueApiEvents.commandFailed(describeError(error)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.pollCloseRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
comments.closePoll(payload.commentId, payload.decide),
|
||||
payload.decide
|
||||
? 'Encuesta cerrada: el resultado queda como decisión'
|
||||
: 'Encuesta cerrada',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.reviewRequested)
|
||||
.pipe(
|
||||
@@ -698,7 +724,12 @@ export const IssueStore = signalStore(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
comments
|
||||
.add(payload.issueId, payload.body, payload.replyTo ?? null)
|
||||
.add(
|
||||
payload.issueId,
|
||||
payload.body,
|
||||
payload.replyTo ?? null,
|
||||
payload.poll ?? null,
|
||||
)
|
||||
.pipe(
|
||||
concatMap((comment) =>
|
||||
payload.files?.length
|
||||
|
||||
@@ -379,6 +379,8 @@ export interface Comment {
|
||||
decision: boolean;
|
||||
/** Who marked it as a decision (null when it is not one). */
|
||||
decidedBy: UserRef | null;
|
||||
/** A quick poll in it, if any. */
|
||||
poll: CommentPoll | null;
|
||||
/** A conversation (top-level comment and its replies) that was settled. */
|
||||
resolved: boolean;
|
||||
/** Who settled it (null while open). */
|
||||
@@ -387,6 +389,14 @@ export interface Comment {
|
||||
editedAt: string | null;
|
||||
}
|
||||
|
||||
/** A quick poll in a comment: each option with who voted it. */
|
||||
export interface CommentPoll {
|
||||
options: { text: string; voters: UserRef[] }[];
|
||||
closed: boolean;
|
||||
/** Index of the winning option once closed (null on a tie). */
|
||||
winner: number | null;
|
||||
}
|
||||
|
||||
/** A decision found by its text, with its ticket. */
|
||||
export interface Decision {
|
||||
comment: Comment;
|
||||
|
||||
@@ -336,6 +336,18 @@ export abstract class CommentsGateway {
|
||||
issueId: string,
|
||||
body: string,
|
||||
replyTo?: string | null,
|
||||
/** A quick poll's options (2–6). */
|
||||
poll?: string[] | null,
|
||||
): Observable<CommandResult>;
|
||||
/** Votes in a comment's poll (null takes the vote back). */
|
||||
abstract votePoll(
|
||||
commentId: string,
|
||||
option: number | null,
|
||||
): Observable<CommandResult>;
|
||||
/** Closes a poll; with `decide`, its winner stays as a decision. */
|
||||
abstract closePoll(
|
||||
commentId: string,
|
||||
decide: boolean,
|
||||
): Observable<CommandResult>;
|
||||
abstract edit(commentId: string, body: string): Observable<CommandResult>;
|
||||
/** Adds (`on`) or takes back one's reaction. */
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
ReactToCommentDocument,
|
||||
MarkDecisionDocument,
|
||||
ResolveThreadDocument,
|
||||
VotePollDocument,
|
||||
ClosePollDocument,
|
||||
PublishReleaseDocument,
|
||||
ReleaseBurnupDocument,
|
||||
ReleaseNotesDraftDocument,
|
||||
@@ -50,12 +52,33 @@ export class GraphqlCommentsGateway extends CommentsGateway {
|
||||
issueId: string,
|
||||
body: string,
|
||||
replyTo: string | null = null,
|
||||
poll: string[] | null = null,
|
||||
): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(AddCommentDocument, { issueId, body, replyTo })
|
||||
.mutate(AddCommentDocument, {
|
||||
issueId,
|
||||
body,
|
||||
replyTo,
|
||||
poll: poll?.length ? poll : null,
|
||||
})
|
||||
.pipe(map((d) => toCommandResult(d.addComment)));
|
||||
}
|
||||
|
||||
votePoll(
|
||||
commentId: string,
|
||||
option: number | null,
|
||||
): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(VotePollDocument, { commentId, option })
|
||||
.pipe(map((d) => toCommandResult(d.votePoll)));
|
||||
}
|
||||
|
||||
closePoll(commentId: string, decide: boolean): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(ClosePollDocument, { commentId, decide })
|
||||
.pipe(map((d) => toCommandResult(d.closePoll)));
|
||||
}
|
||||
|
||||
react(
|
||||
commentId: string,
|
||||
emoji: string,
|
||||
|
||||
@@ -204,6 +204,16 @@ fragment CommentFields on Comment {
|
||||
decidedBy {
|
||||
...UserRefFields
|
||||
}
|
||||
poll {
|
||||
options {
|
||||
text
|
||||
voters {
|
||||
...UserRefFields
|
||||
}
|
||||
}
|
||||
closed
|
||||
winner
|
||||
}
|
||||
resolved
|
||||
resolvedBy {
|
||||
...UserRefFields
|
||||
|
||||
@@ -745,6 +745,14 @@ export type IssueFieldsFragment = {
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
poll: {
|
||||
closed: boolean;
|
||||
winner: number | null;
|
||||
options: Array<{
|
||||
text: string;
|
||||
voters: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
} | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
}>;
|
||||
@@ -800,6 +808,14 @@ export type CommentFieldsFragment = {
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
poll: {
|
||||
closed: boolean;
|
||||
winner: number | null;
|
||||
options: Array<{
|
||||
text: string;
|
||||
voters: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
} | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
};
|
||||
@@ -1002,6 +1018,14 @@ export type IssueQuery = {
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
poll: {
|
||||
closed: boolean;
|
||||
winner: number | null;
|
||||
options: Array<{
|
||||
text: string;
|
||||
voters: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
} | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
}>;
|
||||
@@ -1365,6 +1389,14 @@ export type IssueCommentsQuery = {
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
poll: {
|
||||
closed: boolean;
|
||||
winner: number | null;
|
||||
options: Array<{
|
||||
text: string;
|
||||
voters: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
} | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
}>;
|
||||
@@ -1376,12 +1408,31 @@ export type AddCommentMutationVariables = Exact<{
|
||||
issueId: string;
|
||||
body: string;
|
||||
replyTo?: string | null | undefined;
|
||||
poll?: Array<string> | string | null | undefined;
|
||||
}>;
|
||||
|
||||
export type AddCommentMutation = {
|
||||
addComment: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type VotePollMutationVariables = Exact<{
|
||||
commentId: string;
|
||||
option?: number | null | undefined;
|
||||
}>;
|
||||
|
||||
export type VotePollMutation = {
|
||||
votePoll: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type ClosePollMutationVariables = Exact<{
|
||||
commentId: string;
|
||||
decide: boolean;
|
||||
}>;
|
||||
|
||||
export type ClosePollMutation = {
|
||||
closePoll: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type EditCommentMutationVariables = Exact<{
|
||||
commentId: string;
|
||||
body: string;
|
||||
@@ -1810,6 +1861,14 @@ export type SearchDecisionsQuery = {
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
poll: {
|
||||
closed: boolean;
|
||||
winner: number | null;
|
||||
options: Array<{
|
||||
text: string;
|
||||
voters: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
} | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
};
|
||||
@@ -3135,6 +3194,14 @@ export type CommentChangedSubscription = {
|
||||
users: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
decidedBy: { id: string; name: string } | null;
|
||||
poll: {
|
||||
closed: boolean;
|
||||
winner: number | null;
|
||||
options: Array<{
|
||||
text: string;
|
||||
voters: Array<{ id: string; name: string }>;
|
||||
}>;
|
||||
} | null;
|
||||
resolvedBy: { id: string; name: string } | null;
|
||||
author: { id: string; name: string };
|
||||
} | null;
|
||||
@@ -3925,6 +3992,40 @@ export const CommentFieldsFragmentDoc = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'poll' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'options' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'text' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'voters' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'closed' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'winner' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
@@ -4336,6 +4437,40 @@ export const IssueFieldsFragmentDoc = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'poll' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'options' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'text' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'voters' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'closed' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'winner' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
@@ -6598,6 +6733,40 @@ export const IssueDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'poll' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'options' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'text' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'voters' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'closed' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'winner' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
@@ -8915,6 +9084,40 @@ export const IssueCommentsDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'poll' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'options' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'text' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'voters' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'closed' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'winner' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
@@ -8987,6 +9190,20 @@ export const AddCommentDocument = {
|
||||
},
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: { kind: 'Variable', name: { kind: 'Name', value: 'poll' } },
|
||||
type: {
|
||||
kind: 'ListType',
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'String' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
@@ -9019,6 +9236,14 @@ export const AddCommentDocument = {
|
||||
name: { kind: 'Name', value: 'replyTo' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'poll' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'poll' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
@@ -9051,6 +9276,178 @@ export const AddCommentDocument = {
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<AddCommentMutation, AddCommentMutationVariables>;
|
||||
export const VotePollDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'VotePoll' },
|
||||
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: 'option' },
|
||||
},
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } },
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'votePoll' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'commentId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'commentId' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'option' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'option' },
|
||||
},
|
||||
},
|
||||
],
|
||||
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<VotePollMutation, VotePollMutationVariables>;
|
||||
export const ClosePollDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'ClosePoll' },
|
||||
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: 'decide' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'closePoll' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'commentId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'commentId' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'decide' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'decide' },
|
||||
},
|
||||
},
|
||||
],
|
||||
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<ClosePollMutation, ClosePollMutationVariables>;
|
||||
export const EditCommentDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -11787,6 +12184,40 @@ export const SearchDecisionsDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'poll' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'options' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'text' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'voters' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'closed' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'winner' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
@@ -18728,6 +19159,40 @@ export const CommentChangedDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'poll' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'options' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'text' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'voters' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'closed' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'winner' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'resolved' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
|
||||
@@ -195,8 +195,25 @@ query IssueComments($idOrKey: String!) {
|
||||
}
|
||||
}
|
||||
|
||||
mutation AddComment($issueId: ID!, $body: String!, $replyTo: ID) {
|
||||
addComment(issueId: $issueId, body: $body, replyTo: $replyTo) {
|
||||
mutation AddComment(
|
||||
$issueId: ID!
|
||||
$body: String!
|
||||
$replyTo: ID
|
||||
$poll: [String!]
|
||||
) {
|
||||
addComment(issueId: $issueId, body: $body, replyTo: $replyTo, poll: $poll) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation VotePoll($commentId: ID!, $option: Int) {
|
||||
votePoll(commentId: $commentId, option: $option) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation ClosePoll($commentId: ID!, $decide: Boolean!) {
|
||||
closePoll(commentId: $commentId, decide: $decide) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
@@ -926,3 +926,115 @@ a.ts-key {
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* A quick poll in a comment: each option a bar of its share. */
|
||||
.ts-poll {
|
||||
display: grid;
|
||||
gap: 0.375rem;
|
||||
max-width: 28rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.ts-poll__option {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
height: auto !important;
|
||||
min-width: 0 !important;
|
||||
padding: 0.5rem 0.75rem !important;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ts-line-strong) !important;
|
||||
border-radius: 0.5rem !important;
|
||||
background: var(--ts-surface) !important;
|
||||
color: var(--ts-ink);
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
isolation: isolate;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
z-index: -1;
|
||||
width: var(--share, 0%);
|
||||
background: color-mix(in srgb, var(--ts-brand) 14%, transparent);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
&.is-mine {
|
||||
border-color: var(--ts-brand) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.is-winner::before {
|
||||
background: color-mix(in srgb, var(--ts-c-green) 22%, transparent);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible {
|
||||
border-color: var(--ts-brand) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-poll__count {
|
||||
font-family: var(--ts-font-mono);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ts-poll__meta {
|
||||
margin: 0;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Small text buttons, not @pi/ui's sized ones. */
|
||||
.ts-poll__close {
|
||||
height: auto !important;
|
||||
min-width: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: 0;
|
||||
background: none !important;
|
||||
color: var(--ts-brand);
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ts-poll-editor {
|
||||
display: grid;
|
||||
gap: 0.375rem;
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
border: 1px dashed var(--ts-line-strong);
|
||||
border-radius: 0.5rem;
|
||||
|
||||
legend {
|
||||
padding: 0 0.25rem;
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-poll-editor__row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
|
||||
.ts-input {
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-poll-editor__actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
import {
|
||||
Attachment,
|
||||
Comment,
|
||||
CommentPoll,
|
||||
DevLink,
|
||||
Issue,
|
||||
IssueLink,
|
||||
@@ -919,6 +920,68 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
[loader]="loadImage"
|
||||
/>
|
||||
}
|
||||
@if (!comment.deleted && comment.poll; as poll) {
|
||||
<div
|
||||
class="ts-poll"
|
||||
role="group"
|
||||
[attr.aria-label]="
|
||||
poll.closed ? 'Encuesta cerrada' : 'Encuesta'
|
||||
"
|
||||
>
|
||||
@for (option of poll.options; track $index) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-poll__option"
|
||||
[class.is-mine]="votedFor(poll) === $index"
|
||||
[class.is-winner]="
|
||||
poll.closed && poll.winner === $index
|
||||
"
|
||||
[disabled]="poll.closed || !project.myRole()"
|
||||
[attr.aria-pressed]="votedFor(poll) === $index"
|
||||
[style.--share]="share(poll, $index) + '%'"
|
||||
(click)="
|
||||
facade.votePoll(
|
||||
comment.id,
|
||||
votedFor(poll) === $index ? null : $index
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="ts-poll__text">
|
||||
@if (poll.closed && poll.winner === $index) {
|
||||
<span aria-hidden="true">🏆</span>
|
||||
}
|
||||
{{ option.text }}
|
||||
</span>
|
||||
<span
|
||||
class="ts-poll__count"
|
||||
[attr.title]="votersOf(option)"
|
||||
>{{ option.voters.length }}</span
|
||||
>
|
||||
</button>
|
||||
}
|
||||
<p class="ts-poll__meta">
|
||||
{{ totalVotes(poll) }}
|
||||
{{ totalVotes(poll) === 1 ? 'voto' : 'votos' }}
|
||||
@if (poll.closed) {
|
||||
· cerrada{{
|
||||
poll.winner === null ? ', sin ganadora' : ''
|
||||
}}
|
||||
} @else if (
|
||||
comment.author.id === facade.me() ||
|
||||
project.canManage()
|
||||
) {
|
||||
·
|
||||
<button
|
||||
type="button"
|
||||
class="ts-poll__close"
|
||||
(click)="facade.closePoll(comment.id)"
|
||||
>
|
||||
Cerrar y decidir
|
||||
</button>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
@if (
|
||||
!comment.deleted &&
|
||||
(comment.reactions.length || project.myRole())
|
||||
@@ -1144,6 +1207,65 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
[loader]="loadImage"
|
||||
(submitted)="addComment()"
|
||||
/>
|
||||
@if (pollOn()) {
|
||||
<fieldset class="ts-poll-editor">
|
||||
<legend>Encuesta (2 a 6 opciones)</legend>
|
||||
@for (option of pollOptions(); track $index) {
|
||||
<div class="ts-poll-editor__row">
|
||||
<input
|
||||
class="ts-input"
|
||||
maxlength="80"
|
||||
[attr.aria-label]="'Opción ' + ($index + 1)"
|
||||
[placeholder]="'Opción ' + ($index + 1)"
|
||||
[value]="option"
|
||||
(input)="
|
||||
setPollOption($index, $any($event.target).value)
|
||||
"
|
||||
/>
|
||||
@if (pollOptions().length > 2) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-poll__close"
|
||||
[attr.aria-label]="
|
||||
'Quitar la opción ' + ($index + 1)
|
||||
"
|
||||
(click)="removePollOption($index)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="ts-poll-editor__actions">
|
||||
@if (pollOptions().length < 6) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-poll__close"
|
||||
(click)="pollOptions.set([...pollOptions(), ''])"
|
||||
>
|
||||
+ Añadir opción
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="ts-poll__close"
|
||||
(click)="pollOn.set(false)"
|
||||
>
|
||||
Quitar la encuesta
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
} @else {
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-poll__close"
|
||||
(click)="startPoll()"
|
||||
>
|
||||
📊 Añadir una encuesta
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<pi-file-upload
|
||||
label="Adjuntar al comentario"
|
||||
[multiple]="true"
|
||||
@@ -2146,9 +2268,66 @@ export class IssuePage {
|
||||
protected addComment(): void {
|
||||
const body = this.commentBody().trim();
|
||||
if (!body) return;
|
||||
this.facade.addComment(body, this.commentFiles());
|
||||
const options = this.pollOptions()
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean);
|
||||
if (this.pollOn() && options.length < 2) {
|
||||
this.toast.error('Una encuesta necesita al menos dos opciones');
|
||||
return;
|
||||
}
|
||||
this.facade.addComment(
|
||||
body,
|
||||
this.commentFiles(),
|
||||
null,
|
||||
this.pollOn() ? options : null,
|
||||
);
|
||||
this.commentBody.set('');
|
||||
this.commentFiles.set([]);
|
||||
this.pollOn.set(false);
|
||||
}
|
||||
|
||||
/** A quick poll in the new comment: its options while it is written. */
|
||||
protected readonly pollOn = signal(false);
|
||||
protected readonly pollOptions = signal<string[]>(['', '']);
|
||||
|
||||
protected startPoll(): void {
|
||||
this.pollOptions.set(['', '']);
|
||||
this.pollOn.set(true);
|
||||
}
|
||||
|
||||
protected setPollOption(index: number, value: string): void {
|
||||
this.pollOptions.update((options) =>
|
||||
options.map((o, i) => (i === index ? value : o)),
|
||||
);
|
||||
}
|
||||
|
||||
protected removePollOption(index: number): void {
|
||||
this.pollOptions.update((options) => options.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
/** The option I voted, or null. */
|
||||
protected votedFor(poll: CommentPoll): number | null {
|
||||
const me = this.facade.me();
|
||||
const index = poll.options.findIndex((o) =>
|
||||
o.voters.some((v) => v.id === me),
|
||||
);
|
||||
return index < 0 ? null : index;
|
||||
}
|
||||
|
||||
protected totalVotes(poll: CommentPoll): number {
|
||||
return poll.options.reduce((sum, o) => sum + o.voters.length, 0);
|
||||
}
|
||||
|
||||
/** An option's share of the votes, for its bar. */
|
||||
protected share(poll: CommentPoll, index: number): number {
|
||||
const total = this.totalVotes(poll);
|
||||
return total
|
||||
? Math.round((poll.options[index].voters.length / total) * 100)
|
||||
: 0;
|
||||
}
|
||||
|
||||
protected votersOf(option: { voters: { name: string }[] }): string {
|
||||
return option.voters.map((v) => v.name).join(', ') || 'Nadie aún';
|
||||
}
|
||||
|
||||
protected startReply(comment: Comment): void {
|
||||
|
||||
Reference in New Issue
Block a user