feat: ask a teammate to review a ticket
«Pedir revisión» on a ticket asks a member (with an optional note): they
are notified and see it under «Te piden revisión» in my work until they
answer «Todo bien» or «Pedir cambios» (with what to change) from the
ticket's Revisiones box; whoever asked hears the answer and can withdraw
an open request. Events of the ticket (requested, answered, cancelled),
so they show in its history and the activity feed. Viewers can be asked
and answer; not oneself, nor twice the same person with one pending.
API: requestReview / answerReview / cancelReview, Issue.reviews,
myIssues(reviewing); POST /api/issues/{issue}/reviews,
POST …/reviews/{id}/answer, DELETE …/reviews/{id}. MCP: request_review,
answer_review, my_issues reviewing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ export const homeApiEvents = eventGroup({
|
||||
mine: IssuePage;
|
||||
awaitingQa: IssuePage;
|
||||
following: IssuePage;
|
||||
reviewing: IssuePage;
|
||||
releases: Release[];
|
||||
}>(),
|
||||
loadFailed: type<string>(),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IssueSummary,
|
||||
LinkIssuesInput,
|
||||
UpdateIssueInput,
|
||||
ReviewVerdict,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
|
||||
export const issuePageEvents = eventGroup({
|
||||
@@ -50,6 +51,18 @@ export const issuePageEvents = eventGroup({
|
||||
watchRequested: type<{ issueId: string; watching: boolean }>(),
|
||||
pinRequested: type<{ issueId: string; pinned: boolean }>(),
|
||||
followTreeRequested: type<{ issueId: string; on: boolean }>(),
|
||||
reviewRequested: type<{
|
||||
issueId: string;
|
||||
reviewerId: string;
|
||||
note: string | null;
|
||||
}>(),
|
||||
reviewAnswerRequested: type<{
|
||||
issueId: string;
|
||||
reviewId: string;
|
||||
verdict: ReviewVerdict;
|
||||
answer: string | null;
|
||||
}>(),
|
||||
reviewCancelRequested: type<{ issueId: string; reviewId: string }>(),
|
||||
reminderRequested: type<{
|
||||
issueId: string;
|
||||
/** null: drop it. */
|
||||
|
||||
@@ -21,6 +21,8 @@ export class HomeFacade {
|
||||
/** Open tickets I follow, whoever works on them. */
|
||||
readonly following = this.store.following;
|
||||
readonly followingTotal = this.store.followingTotal;
|
||||
/** Tickets I was asked to review and have not answered. */
|
||||
readonly reviewing = this.store.reviewing;
|
||||
/** Planned releases of my projects, soonest first. */
|
||||
readonly releases = this.store.releases;
|
||||
/** False until the first load: show skeletons, not empty states. */
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IssueLinkType,
|
||||
IssueSummary,
|
||||
UpdateIssueInput,
|
||||
ReviewVerdict,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import { issuePageEvents } from '../events/issue.events';
|
||||
@@ -120,6 +121,39 @@ export class IssueFacade {
|
||||
);
|
||||
}
|
||||
|
||||
/** Asks a member to review the open ticket. */
|
||||
requestReview(reviewerId: string, note: string | null = null): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.reviewRequested({
|
||||
issueId,
|
||||
reviewerId,
|
||||
note: note?.trim() || null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Answers a review I was asked for. */
|
||||
answerReview(
|
||||
reviewId: string,
|
||||
verdict: ReviewVerdict,
|
||||
answer: string | null = null,
|
||||
): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.reviewAnswerRequested({
|
||||
issueId,
|
||||
reviewId,
|
||||
verdict,
|
||||
answer: answer?.trim() || null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
cancelReview(reviewId: string): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.reviewCancelRequested({ issueId, reviewId }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Remind me of the ticket then (null: drop it); one per ticket. */
|
||||
setReminder(at: string | null, note: string | null = null): void {
|
||||
this.withIssue((issueId) =>
|
||||
|
||||
@@ -34,6 +34,8 @@ interface HomeState {
|
||||
awaitingQaTotal: number;
|
||||
following: IssueSummary[];
|
||||
followingTotal: number;
|
||||
/** Tickets I was asked to review and have not answered. */
|
||||
reviewing: IssueSummary[];
|
||||
releases: Release[];
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
@@ -54,6 +56,7 @@ export const HomeStore = signalStore(
|
||||
awaitingQaTotal: 0,
|
||||
following: [],
|
||||
followingTotal: 0,
|
||||
reviewing: [],
|
||||
releases: [],
|
||||
loaded: false,
|
||||
loading: false,
|
||||
@@ -88,6 +91,7 @@ export const HomeStore = signalStore(
|
||||
awaitingQaTotal: payload.awaitingQa.total,
|
||||
following: payload.following.items,
|
||||
followingTotal: payload.following.total,
|
||||
reviewing: payload.reviewing.items,
|
||||
releases: payload.releases,
|
||||
loaded: true,
|
||||
loading: false,
|
||||
@@ -123,6 +127,12 @@ export const HomeStore = signalStore(
|
||||
categories: [...OPEN_CATEGORIES],
|
||||
limit: 20,
|
||||
}),
|
||||
reviewing: issues.mine({
|
||||
assignedToMe: false,
|
||||
reviewing: true,
|
||||
categories: [...OPEN_CATEGORIES, StatusCategory.DONE],
|
||||
limit: 20,
|
||||
}),
|
||||
releases: releases.upcoming(5),
|
||||
}).pipe(
|
||||
mapResponse({
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
IssuesGateway,
|
||||
IssueSummary,
|
||||
SprintsGateway,
|
||||
ReviewVerdict,
|
||||
TrackerRealtimeGateway,
|
||||
whenLabel,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
@@ -380,6 +381,53 @@ export const IssueStore = signalStore(
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.reviewRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
issues.requestReview(
|
||||
payload.issueId,
|
||||
payload.reviewerId,
|
||||
payload.note,
|
||||
),
|
||||
'Revisión pedida',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.reviewAnswerRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
issues.answerReview(
|
||||
payload.issueId,
|
||||
payload.reviewId,
|
||||
payload.verdict,
|
||||
payload.answer,
|
||||
),
|
||||
payload.verdict === ReviewVerdict.APPROVED
|
||||
? 'Revisado: todo bien'
|
||||
: 'Revisado: pides cambios',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.reviewCancelRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
issues.cancelReview(payload.issueId, payload.reviewId),
|
||||
'Petición de revisión retirada',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.reminderRequested)
|
||||
.pipe(
|
||||
|
||||
@@ -70,7 +70,15 @@ export enum NotificationKind {
|
||||
WATCHED = 'WATCHED',
|
||||
ADDED_TO_PROJECT = 'ADDED_TO_PROJECT',
|
||||
REMINDER = 'REMINDER',
|
||||
REVIEW_REQUESTED = 'REVIEW_REQUESTED',
|
||||
REVIEW_ANSWERED = 'REVIEW_ANSWERED',
|
||||
}
|
||||
|
||||
/** The reactions a comment takes, in the order they are offered (GitHub's). */
|
||||
export const REACTIONS = ['👍', '👎', '😄', '🎉', '😕', '❤️', '🚀', '👀'];
|
||||
|
||||
/** What a reviewer said. */
|
||||
export enum ReviewVerdict {
|
||||
APPROVED = 'APPROVED',
|
||||
CHANGES = 'CHANGES',
|
||||
}
|
||||
|
||||
@@ -121,6 +121,8 @@ export interface MyIssuesFilter {
|
||||
assignedToMe: boolean;
|
||||
/** Only the tickets I follow. */
|
||||
watching?: boolean;
|
||||
/** Only the tickets I was asked to review (unanswered). */
|
||||
reviewing?: boolean;
|
||||
categories?: StatusCategory[];
|
||||
/** Matches key, title or description. */
|
||||
search?: string;
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
NotificationKind,
|
||||
IssueLinkType,
|
||||
IssuePriority,
|
||||
ReviewVerdict,
|
||||
ProjectRole,
|
||||
ReleaseStatus,
|
||||
SprintStatus,
|
||||
@@ -192,6 +193,8 @@ export interface Issue extends IssueSummary {
|
||||
attachments: Attachment[];
|
||||
/** People following the ticket. */
|
||||
watchers: UserRef[];
|
||||
/** Reviews asked, open and answered, oldest first. */
|
||||
reviews: IssueReview[];
|
||||
/** Whether I keep it pinned (home page and search). */
|
||||
pinned: boolean;
|
||||
/** Whether I follow it with everything under it (an epic). */
|
||||
@@ -208,6 +211,19 @@ export interface Issue extends IssueSummary {
|
||||
}
|
||||
|
||||
/** Someone else on a ticket's page right now. */
|
||||
/** A review someone asked on a ticket. */
|
||||
export interface IssueReview {
|
||||
id: string;
|
||||
requester: UserRef;
|
||||
reviewer: UserRef;
|
||||
note: string | null;
|
||||
requestedAt: string;
|
||||
/** Null while waiting for an answer. */
|
||||
verdict: ReviewVerdict | null;
|
||||
answer: string | null;
|
||||
answeredAt: string | null;
|
||||
}
|
||||
|
||||
/** My own reminder on a ticket. */
|
||||
export interface IssueReminder {
|
||||
/** ISO date-time. */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IssuePriority } from '../models/enums';
|
||||
import { IssuePriority, ReviewVerdict } from '../models/enums';
|
||||
import { MyDay } from '../models/my-day';
|
||||
import { Home } from '../models/home';
|
||||
import { PortfolioProject, ProjectInsights } from '../models/insights';
|
||||
@@ -143,6 +143,23 @@ export abstract class IssuesGateway {
|
||||
issueId: string,
|
||||
releaseId: string | null,
|
||||
): Observable<CommandResult>;
|
||||
/** Asks a member to review the ticket. */
|
||||
abstract requestReview(
|
||||
issueId: string,
|
||||
reviewerId: string,
|
||||
note: string | null,
|
||||
): Observable<CommandResult>;
|
||||
/** Answers a review I was asked for. */
|
||||
abstract answerReview(
|
||||
issueId: string,
|
||||
reviewId: string,
|
||||
verdict: ReviewVerdict,
|
||||
answer: string | null,
|
||||
): Observable<CommandResult>;
|
||||
abstract cancelReview(
|
||||
issueId: string,
|
||||
reviewId: string,
|
||||
): Observable<CommandResult>;
|
||||
/** The signed-in person follows (or stops following) the ticket. */
|
||||
abstract watch(issueId: string, watching: boolean): Observable<CommandResult>;
|
||||
/** My pinned tickets, most recently pinned first. */
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
LinkIssuesInput,
|
||||
MyDay,
|
||||
MyIssuesFilter,
|
||||
ReviewVerdict,
|
||||
mergeBulkResults,
|
||||
UpdateIssueInput,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
@@ -51,6 +52,9 @@ import {
|
||||
UnwatchIssueDocument,
|
||||
VoteIssueDocument,
|
||||
WatchIssueDocument,
|
||||
RequestReviewDocument,
|
||||
AnswerReviewDocument,
|
||||
CancelReviewDocument,
|
||||
MyPinsDocument,
|
||||
FollowTreeDocument,
|
||||
SetReminderDocument,
|
||||
@@ -115,6 +119,7 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
||||
.query(MyIssuesDocument, {
|
||||
assignedToMe: filter.assignedToMe,
|
||||
watching: filter.watching ?? false,
|
||||
reviewing: filter.reviewing ?? false,
|
||||
categories: filter.categories as never,
|
||||
limit: filter.limit,
|
||||
search: filter.search?.trim() || null,
|
||||
@@ -281,6 +286,38 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
||||
return done$.pipe(map(() => undefined));
|
||||
}
|
||||
|
||||
requestReview(
|
||||
issueId: string,
|
||||
reviewerId: string,
|
||||
note: string | null,
|
||||
): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(RequestReviewDocument, { issueId, reviewerId, note })
|
||||
.pipe(map((d) => toCommandResult(d.requestReview)));
|
||||
}
|
||||
|
||||
answerReview(
|
||||
issueId: string,
|
||||
reviewId: string,
|
||||
verdict: ReviewVerdict,
|
||||
answer: string | null,
|
||||
): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(AnswerReviewDocument, {
|
||||
issueId,
|
||||
reviewId,
|
||||
verdict: verdict as never,
|
||||
answer,
|
||||
})
|
||||
.pipe(map((d) => toCommandResult(d.answerReview)));
|
||||
}
|
||||
|
||||
cancelReview(issueId: string, reviewId: string): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(CancelReviewDocument, { issueId, reviewId })
|
||||
.pipe(map((d) => toCommandResult(d.cancelReview)));
|
||||
}
|
||||
|
||||
watch(issueId: string, watching: boolean): Observable<CommandResult> {
|
||||
return watching
|
||||
? this.gql
|
||||
|
||||
@@ -126,6 +126,20 @@ fragment IssueFields on Issue {
|
||||
at
|
||||
note
|
||||
}
|
||||
reviews {
|
||||
id
|
||||
requester {
|
||||
...UserRefFields
|
||||
}
|
||||
reviewer {
|
||||
...UserRefFields
|
||||
}
|
||||
note
|
||||
requestedAt
|
||||
verdict
|
||||
answer
|
||||
answeredAt
|
||||
}
|
||||
decisions {
|
||||
...CommentFields
|
||||
}
|
||||
|
||||
@@ -269,6 +269,8 @@ export type NotificationKind =
|
||||
| 'QA_FAILED'
|
||||
| 'REMINDER'
|
||||
| 'REPLIED'
|
||||
| 'REVIEW_ANSWERED'
|
||||
| 'REVIEW_REQUESTED'
|
||||
| 'STATUS_CHANGED'
|
||||
| 'WATCHED';
|
||||
|
||||
@@ -312,6 +314,9 @@ export type RankIssueInput = {
|
||||
|
||||
export type ReleaseStatus = 'ARCHIVED' | 'PLANNED' | 'RELEASED';
|
||||
|
||||
/** APPROVED: all good. CHANGES: something to change */
|
||||
export type ReviewVerdict = 'APPROVED' | 'CHANGES';
|
||||
|
||||
export type SaveFilterInput = {
|
||||
criteria: SavedFilterCriteriaInput;
|
||||
name: string;
|
||||
@@ -715,6 +720,16 @@ export type IssueFieldsFragment = {
|
||||
dueDate: string | null;
|
||||
trashedAt: string | null;
|
||||
myReminder: { at: string; note: string | null } | null;
|
||||
reviews: Array<{
|
||||
id: string;
|
||||
note: string | null;
|
||||
requestedAt: string;
|
||||
verdict: ReviewVerdict | null;
|
||||
answer: string | null;
|
||||
answeredAt: string | null;
|
||||
requester: { id: string; name: string };
|
||||
reviewer: { id: string; name: string };
|
||||
}>;
|
||||
decisions: Array<{
|
||||
id: string;
|
||||
issueId: string;
|
||||
@@ -962,6 +977,16 @@ export type IssueQuery = {
|
||||
dueDate: string | null;
|
||||
trashedAt: string | null;
|
||||
myReminder: { at: string; note: string | null } | null;
|
||||
reviews: Array<{
|
||||
id: string;
|
||||
note: string | null;
|
||||
requestedAt: string;
|
||||
verdict: ReviewVerdict | null;
|
||||
answer: string | null;
|
||||
answeredAt: string | null;
|
||||
requester: { id: string; name: string };
|
||||
reviewer: { id: string; name: string };
|
||||
}>;
|
||||
decisions: Array<{
|
||||
id: string;
|
||||
issueId: string;
|
||||
@@ -1105,6 +1130,36 @@ export type UnlinkIssuesMutation = {
|
||||
unlinkIssues: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type RequestReviewMutationVariables = Exact<{
|
||||
issueId: string;
|
||||
reviewerId: string;
|
||||
note?: string | null | undefined;
|
||||
}>;
|
||||
|
||||
export type RequestReviewMutation = {
|
||||
requestReview: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type AnswerReviewMutationVariables = Exact<{
|
||||
issueId: string;
|
||||
reviewId: string;
|
||||
verdict: ReviewVerdict;
|
||||
answer?: string | null | undefined;
|
||||
}>;
|
||||
|
||||
export type AnswerReviewMutation = {
|
||||
answerReview: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type CancelReviewMutationVariables = Exact<{
|
||||
issueId: string;
|
||||
reviewId: string;
|
||||
}>;
|
||||
|
||||
export type CancelReviewMutation = {
|
||||
cancelReview: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type WatchIssueMutationVariables = Exact<{
|
||||
issueId: string;
|
||||
}>;
|
||||
@@ -1347,6 +1402,7 @@ export type DeleteCommentMutation = {
|
||||
export type MyIssuesQueryVariables = Exact<{
|
||||
assignedToMe: boolean;
|
||||
watching?: boolean | null | undefined;
|
||||
reviewing?: boolean | null | undefined;
|
||||
categories?: Array<StatusCategory> | StatusCategory | null | undefined;
|
||||
limit: number;
|
||||
search?: string | null | undefined;
|
||||
@@ -3948,6 +4004,47 @@ export const IssueFieldsFragmentDoc = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'reviews' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'requester' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'reviewer' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'note' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'requestedAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'verdict' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'answer' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'answeredAt' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'decisions' },
|
||||
@@ -6560,6 +6657,47 @@ export const IssueDocument = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'reviews' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'requester' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'reviewer' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'note' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'requestedAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'verdict' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'answer' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'answeredAt' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'decisions' },
|
||||
@@ -7308,6 +7446,324 @@ export const UnlinkIssuesDocument = {
|
||||
UnlinkIssuesMutation,
|
||||
UnlinkIssuesMutationVariables
|
||||
>;
|
||||
export const RequestReviewDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'RequestReview' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'reviewerId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: { kind: 'Variable', name: { kind: 'Name', value: 'note' } },
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'requestReview' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'reviewerId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'reviewerId' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'note' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'note' },
|
||||
},
|
||||
},
|
||||
],
|
||||
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<
|
||||
RequestReviewMutation,
|
||||
RequestReviewMutationVariables
|
||||
>;
|
||||
export const AnswerReviewDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'AnswerReview' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'reviewId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'verdict' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'ReviewVerdict' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'answer' },
|
||||
},
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } },
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'answerReview' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'reviewId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'reviewId' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'verdict' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'verdict' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'answer' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'answer' },
|
||||
},
|
||||
},
|
||||
],
|
||||
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<
|
||||
AnswerReviewMutation,
|
||||
AnswerReviewMutationVariables
|
||||
>;
|
||||
export const CancelReviewDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'CancelReview' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'reviewId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'cancelReview' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'reviewId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'reviewId' },
|
||||
},
|
||||
},
|
||||
],
|
||||
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<
|
||||
CancelReviewMutation,
|
||||
CancelReviewMutationVariables
|
||||
>;
|
||||
export const WatchIssueDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -8781,6 +9237,14 @@ export const MyIssuesDocument = {
|
||||
},
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } },
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'reviewing' },
|
||||
},
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } },
|
||||
},
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
@@ -8888,6 +9352,14 @@ export const MyIssuesDocument = {
|
||||
name: { kind: 'Name', value: 'watching' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'reviewing' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'reviewing' },
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'categories' },
|
||||
|
||||
@@ -83,6 +83,34 @@ mutation UnlinkIssues($input: LinkIssuesInput!) {
|
||||
}
|
||||
}
|
||||
|
||||
mutation RequestReview($issueId: ID!, $reviewerId: ID!, $note: String) {
|
||||
requestReview(issueId: $issueId, reviewerId: $reviewerId, note: $note) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation AnswerReview(
|
||||
$issueId: ID!
|
||||
$reviewId: ID!
|
||||
$verdict: ReviewVerdict!
|
||||
$answer: String
|
||||
) {
|
||||
answerReview(
|
||||
issueId: $issueId
|
||||
reviewId: $reviewId
|
||||
verdict: $verdict
|
||||
answer: $answer
|
||||
) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation CancelReview($issueId: ID!, $reviewId: ID!) {
|
||||
cancelReview(issueId: $issueId, reviewId: $reviewId) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation WatchIssue($issueId: ID!) {
|
||||
watchIssue(issueId: $issueId) {
|
||||
...CommandResultFields
|
||||
@@ -188,6 +216,7 @@ mutation DeleteComment($commentId: ID!) {
|
||||
query MyIssues(
|
||||
$assignedToMe: Boolean!
|
||||
$watching: Boolean
|
||||
$reviewing: Boolean
|
||||
$categories: [StatusCategory!]
|
||||
$limit: Int!
|
||||
$search: String
|
||||
@@ -199,6 +228,7 @@ query MyIssues(
|
||||
myIssues(
|
||||
assignedToMe: $assignedToMe
|
||||
watching: $watching
|
||||
reviewing: $reviewing
|
||||
categories: $categories
|
||||
limit: $limit
|
||||
search: $search
|
||||
|
||||
@@ -837,3 +837,92 @@ a.ts-key {
|
||||
color: var(--ts-ink);
|
||||
}
|
||||
}
|
||||
|
||||
/* Reviews asked on the ticket. */
|
||||
.ts-reviews {
|
||||
ul {
|
||||
display: grid;
|
||||
gap: 0.625rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li {
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-left: 3px solid var(--ts-c-amber);
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
background: color-mix(in srgb, var(--ts-c-amber) 8%, var(--ts-surface));
|
||||
|
||||
&.is-approved {
|
||||
border-left-color: var(--ts-c-green);
|
||||
background: color-mix(in srgb, var(--ts-c-green) 7%, var(--ts-surface));
|
||||
}
|
||||
|
||||
&.is-changes {
|
||||
border-left-color: var(--ts-c-red);
|
||||
background: color-mix(in srgb, var(--ts-c-red) 6%, var(--ts-surface));
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ts-reviews__note {
|
||||
margin-top: 0.25rem !important;
|
||||
color: var(--ts-ink-soft);
|
||||
}
|
||||
|
||||
.ts-reviews__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small buttons, not @pi/ui's sized ones. */
|
||||
.ts-reviews__button {
|
||||
height: auto !important;
|
||||
min-width: 0 !important;
|
||||
padding: 0.25rem 0.75rem !important;
|
||||
border: 1px solid var(--ts-line-strong) !important;
|
||||
border-radius: 999px !important;
|
||||
background: var(--ts-surface) !important;
|
||||
color: var(--ts-ink);
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&.is-ok {
|
||||
border-color: transparent !important;
|
||||
background: var(--ts-c-green) !important;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.ts-reviews__link {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -52,11 +52,13 @@ import {
|
||||
IssuePriority,
|
||||
isTyping,
|
||||
POINT_SCALE,
|
||||
ProjectRole,
|
||||
IssuesGateway,
|
||||
localDay,
|
||||
ReleaseStatus,
|
||||
ReminderChoice,
|
||||
reminderChoices,
|
||||
ReviewVerdict,
|
||||
StatusCategory,
|
||||
whenLabel,
|
||||
} from '@task-stream/web/tracker/domain';
|
||||
@@ -211,6 +213,16 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
(execute)="facade.setWatching(!facade.watching())"
|
||||
/>
|
||||
}
|
||||
@if (project.canContribute()) {
|
||||
<pi-icon-button
|
||||
icon="eye"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
text="Pedir revisión"
|
||||
title="Pedir a alguien del equipo que lo revise"
|
||||
(execute)="openReviewRequest()"
|
||||
/>
|
||||
}
|
||||
@if (project.myRole() && inMyDay() !== null) {
|
||||
<pi-icon-button
|
||||
icon="sun"
|
||||
@@ -337,6 +349,91 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (issue.reviews.length) {
|
||||
<article
|
||||
class="ts-block ts-reviews"
|
||||
aria-labelledby="reviews-title"
|
||||
>
|
||||
<h2 class="ts-section" id="reviews-title">Revisiones</h2>
|
||||
<ul>
|
||||
@for (review of issue.reviews; track review.id) {
|
||||
<li
|
||||
[class.is-approved]="review.verdict === approved"
|
||||
[class.is-changes]="review.verdict === changes"
|
||||
>
|
||||
@if (!review.verdict) {
|
||||
<p class="ts-reviews__what">
|
||||
<strong>{{ review.requester.name }}</strong> pidió a
|
||||
<strong>{{
|
||||
review.reviewer.id === facade.me()
|
||||
? 'ti'
|
||||
: review.reviewer.name
|
||||
}}</strong>
|
||||
que lo revise
|
||||
<span class="ts-muted"
|
||||
>· {{ ago(review.requestedAt) }}</span
|
||||
>
|
||||
</p>
|
||||
@if (review.note) {
|
||||
<p class="ts-reviews__note">«{{ review.note }}»</p>
|
||||
}
|
||||
<div class="ts-reviews__actions">
|
||||
@if (review.reviewer.id === facade.me()) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-reviews__button is-ok"
|
||||
(click)="openReviewAnswer(review.id, approved)"
|
||||
>
|
||||
✓ Todo bien
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-reviews__button"
|
||||
(click)="openReviewAnswer(review.id, changes)"
|
||||
>
|
||||
Pedir cambios
|
||||
</button>
|
||||
}
|
||||
@if (
|
||||
review.requester.id === facade.me() ||
|
||||
project.canManage()
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-reviews__link"
|
||||
(click)="facade.cancelReview(review.id)"
|
||||
>
|
||||
Retirar
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="ts-reviews__what">
|
||||
<span aria-hidden="true">{{
|
||||
review.verdict === approved ? '✓' : '✗'
|
||||
}}</span>
|
||||
<strong>{{ review.reviewer.name }}</strong>
|
||||
{{
|
||||
review.verdict === approved
|
||||
? 'lo dio por bueno'
|
||||
: 'pide cambios'
|
||||
}}
|
||||
<span class="ts-muted"
|
||||
>·
|
||||
{{ ago(review.answeredAt ?? review.requestedAt) }} ·
|
||||
lo pidió {{ review.requester.name }}</span
|
||||
>
|
||||
</p>
|
||||
@if (review.answer) {
|
||||
<p class="ts-reviews__note">«{{ review.answer }}»</p>
|
||||
}
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</article>
|
||||
}
|
||||
|
||||
@if (issue.decisions.length) {
|
||||
<article
|
||||
class="ts-block ts-decisions"
|
||||
@@ -1218,6 +1315,85 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
<pi-skeleton shape="text" [lines]="8" />
|
||||
}
|
||||
|
||||
<ng-template #reviewRequestForm>
|
||||
<form
|
||||
class="ts-form"
|
||||
(submit)="$event.preventDefault(); sendReviewRequest()"
|
||||
novalidate
|
||||
>
|
||||
<p class="ts-muted">
|
||||
Le llegará un aviso y lo verá en «Mi trabajo» hasta que responda.
|
||||
</p>
|
||||
<label class="ts-reminder__field">
|
||||
Quién
|
||||
<select
|
||||
class="ts-input"
|
||||
[value]="reviewer()"
|
||||
(change)="reviewer.set($any($event.target).value)"
|
||||
>
|
||||
<option value="">Elige a alguien…</option>
|
||||
@for (member of reviewers(); track member.id) {
|
||||
<option [value]="member.id">{{ member.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="ts-reminder__field">
|
||||
Qué mirar (opcional)
|
||||
<textarea
|
||||
class="ts-input ts-resolve__text"
|
||||
rows="3"
|
||||
maxlength="1000"
|
||||
placeholder="Revisa el flujo en móvil, sobre todo el pago"
|
||||
[value]="reviewNote()"
|
||||
(input)="reviewNote.set($any($event.target).value)"
|
||||
></textarea>
|
||||
</label>
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
text="Pedir revisión"
|
||||
[disabled]="!reviewer()"
|
||||
(execute)="sendReviewRequest()"
|
||||
/>
|
||||
</form>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #reviewAnswerForm>
|
||||
<form
|
||||
class="ts-form"
|
||||
(submit)="$event.preventDefault(); sendReviewAnswer()"
|
||||
novalidate
|
||||
>
|
||||
<label class="ts-reminder__field">
|
||||
{{
|
||||
answering()?.verdict === approved
|
||||
? 'Un comentario (opcional)'
|
||||
: 'Qué hay que cambiar'
|
||||
}}
|
||||
<textarea
|
||||
class="ts-input ts-resolve__text"
|
||||
rows="4"
|
||||
maxlength="1000"
|
||||
[value]="reviewAnswer()"
|
||||
(input)="reviewAnswer.set($any($event.target).value)"
|
||||
></textarea>
|
||||
</label>
|
||||
<pi-action-button
|
||||
size="w100"
|
||||
color="primary"
|
||||
[text]="
|
||||
answering()?.verdict === approved
|
||||
? 'Dar por bueno'
|
||||
: 'Pedir cambios'
|
||||
"
|
||||
[disabled]="
|
||||
answering()?.verdict === changes && !reviewAnswer().trim()
|
||||
"
|
||||
(execute)="sendReviewAnswer()"
|
||||
/>
|
||||
</form>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #reminderForm>
|
||||
<form
|
||||
class="ts-form"
|
||||
@@ -1373,6 +1549,30 @@ export class IssuePage {
|
||||
viewChild.required<TemplateRef<unknown>>('reasonForm');
|
||||
private readonly reminderForm =
|
||||
viewChild.required<TemplateRef<unknown>>('reminderForm');
|
||||
private readonly reviewRequestForm =
|
||||
viewChild.required<TemplateRef<unknown>>('reviewRequestForm');
|
||||
private readonly reviewAnswerForm =
|
||||
viewChild.required<TemplateRef<unknown>>('reviewAnswerForm');
|
||||
protected readonly approved = ReviewVerdict.APPROVED;
|
||||
protected readonly changes = ReviewVerdict.CHANGES;
|
||||
/** The review being asked: to whom, and what to look at. */
|
||||
protected readonly reviewer = signal('');
|
||||
protected readonly reviewNote = signal('');
|
||||
/** The review being answered, and the answer. */
|
||||
protected readonly answering = signal<{
|
||||
reviewId: string;
|
||||
verdict: ReviewVerdict;
|
||||
} | null>(null);
|
||||
protected readonly reviewAnswer = signal('');
|
||||
/** Who can be asked: the members, not me. */
|
||||
protected readonly reviewers = computed(() =>
|
||||
this.project
|
||||
.members()
|
||||
.filter(
|
||||
(m) => m.user.id !== this.facade.me() && m.role !== ProjectRole.GUEST,
|
||||
)
|
||||
.map((m) => ({ id: m.user.id, name: m.user.name })),
|
||||
);
|
||||
/** The reminder being written: `datetime-local` value and note. */
|
||||
protected readonly reminderAt = signal('');
|
||||
protected readonly reminderNote = signal('');
|
||||
@@ -1792,6 +1992,44 @@ export class IssuePage {
|
||||
});
|
||||
}
|
||||
|
||||
protected openReviewRequest(): void {
|
||||
this.reviewer.set('');
|
||||
this.reviewNote.set('');
|
||||
this.drawer.open('Pedir revisión', this.reviewRequestForm());
|
||||
}
|
||||
|
||||
protected sendReviewRequest(): void {
|
||||
if (!this.reviewer()) return;
|
||||
this.facade.requestReview(this.reviewer(), this.reviewNote());
|
||||
this.drawer.closeAll();
|
||||
}
|
||||
|
||||
protected openReviewAnswer(reviewId: string, verdict: ReviewVerdict): void {
|
||||
this.answering.set({ reviewId, verdict });
|
||||
this.reviewAnswer.set('');
|
||||
this.drawer.open(
|
||||
verdict === ReviewVerdict.APPROVED ? 'Todo bien' : 'Pedir cambios',
|
||||
this.reviewAnswerForm(),
|
||||
);
|
||||
}
|
||||
|
||||
protected sendReviewAnswer(): void {
|
||||
const answering = this.answering();
|
||||
if (!answering) return;
|
||||
if (
|
||||
answering.verdict === ReviewVerdict.CHANGES &&
|
||||
!this.reviewAnswer().trim()
|
||||
)
|
||||
return;
|
||||
this.facade.answerReview(
|
||||
answering.reviewId,
|
||||
answering.verdict,
|
||||
this.reviewAnswer(),
|
||||
);
|
||||
this.answering.set(null);
|
||||
this.drawer.closeAll();
|
||||
}
|
||||
|
||||
protected openReminder(): void {
|
||||
const current = this.facade.issue()?.myReminder;
|
||||
this.reminderChoices.set(reminderChoices());
|
||||
|
||||
@@ -145,6 +145,31 @@ const VISIBLE = 12;
|
||||
}
|
||||
</section>
|
||||
} @else {
|
||||
@if (home.reviewing().length) {
|
||||
<section
|
||||
class="ts-panel ts-work__panel"
|
||||
aria-labelledby="reviewing-title"
|
||||
>
|
||||
<header class="ts-work__head">
|
||||
<h2 id="reviewing-title">Te piden revisión</h2>
|
||||
<span class="ts-muted ts-mono">{{ home.reviewing().length }}</span>
|
||||
</header>
|
||||
<ul class="ts-work__list">
|
||||
@for (issue of home.reviewing().slice(0, 6); track issue.id) {
|
||||
<li>
|
||||
<a
|
||||
class="ts-work__row ts-work__row--qa"
|
||||
[routerLink]="['/p', keyOf(issue.key), 'issues', issue.key]"
|
||||
[style.--type]="statusTone(issue)"
|
||||
>
|
||||
<span class="ts-key">{{ issue.key }}</span>
|
||||
<span class="ts-work__title">{{ issue.title }}</span>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
<section class="ts-panel ts-work__panel" aria-labelledby="qa-title">
|
||||
<header class="ts-work__head">
|
||||
<h2 id="qa-title">Esperando QA</h2>
|
||||
|
||||
@@ -32,6 +32,8 @@ const ICON: Record<NotificationKind, string> = {
|
||||
[NotificationKind.WATCHED]: 'focus',
|
||||
[NotificationKind.ADDED_TO_PROJECT]: 'briefcase',
|
||||
[NotificationKind.REMINDER]: 'calendar',
|
||||
[NotificationKind.REVIEW_REQUESTED]: 'eye',
|
||||
[NotificationKind.REVIEW_ANSWERED]: 'check',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user