feat: a trash for tickets
Deleting a ticket now sends it to the trash, for whoever opened it or an admin: it disappears from boards, searches and statistics, cannot change, and comes back from the ticket page or the trash page for 30 days. After that it is deleted for good with the usual cascade. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,7 @@ export const issuePageEvents = eventGroup({
|
||||
files?: File[];
|
||||
}>(),
|
||||
watchRequested: type<{ issueId: string; watching: boolean }>(),
|
||||
trashRequested: type<{ issueId: string; trashed: boolean }>(),
|
||||
/** Place the ticket under another one by its key (e.g. WEB-3); null: out. */
|
||||
parentRequested: type<{ issueId: string; parentKey: string | null }>(),
|
||||
subtaskCreateRequested: type<{
|
||||
|
||||
@@ -99,6 +99,13 @@ export class IssueFacade {
|
||||
);
|
||||
}
|
||||
|
||||
/** To the trash (true) or back from it (false). */
|
||||
setTrashed(trashed: boolean): void {
|
||||
this.withIssue((issueId) =>
|
||||
this.dispatch.trashRequested({ issueId, trashed }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Follow the ticket (or stop): be told when it moves or gets a comment. */
|
||||
setWatching(watching: boolean): void {
|
||||
this.withIssue((issueId) =>
|
||||
|
||||
@@ -280,6 +280,22 @@ export const IssueStore = signalStore(
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.trashRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
payload.trashed
|
||||
? issues.trash(payload.issueId)
|
||||
: issues.restore(payload.issueId),
|
||||
payload.trashed
|
||||
? 'Ticket en la papelera'
|
||||
: 'Ticket recuperado',
|
||||
done,
|
||||
failed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(issuePageEvents.watchRequested)
|
||||
.pipe(
|
||||
|
||||
@@ -119,6 +119,8 @@ export interface IssueSummary {
|
||||
/** Planned start and end (`YYYY-MM-DD`), for the roadmap. */
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
/** In the trash since then (null: not in it). */
|
||||
trashedAt: string | null;
|
||||
commentCount: number;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
|
||||
@@ -115,6 +115,11 @@ export abstract class IssuesGateway {
|
||||
parentId: string | null,
|
||||
): Observable<CommandResult>;
|
||||
abstract epics(projectId: string): Observable<Epic[]>;
|
||||
/** To the trash (whoever opened it, or a manager). */
|
||||
abstract trash(issueId: string): Observable<CommandResult>;
|
||||
abstract restore(issueId: string): Observable<CommandResult>;
|
||||
/** The project's tickets in the trash, newest first. */
|
||||
abstract trashed(projectId: string): Observable<IssueSummary[]>;
|
||||
/** The same change on several tickets; each may refuse on its own. */
|
||||
abstract bulkUpdate(
|
||||
issueIds: string[],
|
||||
|
||||
@@ -32,6 +32,9 @@ import {
|
||||
MyIssuesDocument,
|
||||
RankIssueDocument,
|
||||
RemoveChecklistItemDocument,
|
||||
RestoreIssueDocument,
|
||||
TrashDocument,
|
||||
TrashIssueDocument,
|
||||
SearchIssuesDocument,
|
||||
SetIssueParentDocument,
|
||||
SetIssueReleaseDocument,
|
||||
@@ -180,6 +183,24 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
||||
);
|
||||
}
|
||||
|
||||
trash(issueId: string): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(TrashIssueDocument, { issueId })
|
||||
.pipe(map((d) => toCommandResult(d.trashIssue)));
|
||||
}
|
||||
|
||||
restore(issueId: string): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(RestoreIssueDocument, { issueId })
|
||||
.pipe(map((d) => toCommandResult(d.restoreIssue)));
|
||||
}
|
||||
|
||||
trashed(projectId: string): Observable<IssueSummary[]> {
|
||||
return this.gql
|
||||
.query(TrashDocument, { projectId })
|
||||
.pipe(map((d) => d.trash.map(toIssueSummary)));
|
||||
}
|
||||
|
||||
bulkUpdate(issueIds: string[], change: BulkChange): Observable<BulkResult> {
|
||||
return this.gql
|
||||
.mutate(BulkUpdateIssuesDocument, {
|
||||
|
||||
@@ -87,6 +87,7 @@ fragment IssueSummaryFields on Issue {
|
||||
storyPoints
|
||||
startDate
|
||||
dueDate
|
||||
trashedAt
|
||||
}
|
||||
|
||||
fragment IssueFields on Issue {
|
||||
|
||||
@@ -481,6 +481,7 @@ export type IssueSummaryFieldsFragment = {
|
||||
storyPoints: number | null;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
trashedAt: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
sprint: { id: string; name: string } | null;
|
||||
@@ -509,6 +510,7 @@ export type IssueFieldsFragment = {
|
||||
storyPoints: number | null;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
trashedAt: string | null;
|
||||
reporter: { id: string; name: string };
|
||||
links: Array<{
|
||||
type: IssueLinkType;
|
||||
@@ -652,6 +654,7 @@ export type BoardQuery = {
|
||||
storyPoints: number | null;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
trashedAt: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
sprint: { id: string; name: string } | null;
|
||||
@@ -688,6 +691,7 @@ export type IssueQuery = {
|
||||
storyPoints: number | null;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
trashedAt: string | null;
|
||||
reporter: { id: string; name: string };
|
||||
links: Array<{
|
||||
type: IssueLinkType;
|
||||
@@ -747,6 +751,7 @@ export type SearchIssuesQuery = {
|
||||
storyPoints: number | null;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
trashedAt: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
sprint: { id: string; name: string } | null;
|
||||
@@ -903,6 +908,7 @@ export type MyIssuesQuery = {
|
||||
storyPoints: number | null;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
trashedAt: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
sprint: { id: string; name: string } | null;
|
||||
@@ -935,6 +941,7 @@ export type IssueChildrenQuery = {
|
||||
storyPoints: number | null;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
trashedAt: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
sprint: { id: string; name: string } | null;
|
||||
@@ -967,6 +974,7 @@ export type EpicsQuery = {
|
||||
storyPoints: number | null;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
trashedAt: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
sprint: { id: string; name: string } | null;
|
||||
@@ -1041,6 +1049,7 @@ export type BacklogQuery = {
|
||||
storyPoints: number | null;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
trashedAt: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
sprint: { id: string; name: string } | null;
|
||||
@@ -1068,6 +1077,53 @@ export type BulkUpdateIssuesMutation = {
|
||||
};
|
||||
};
|
||||
|
||||
export type TrashIssueMutationVariables = Exact<{
|
||||
issueId: string;
|
||||
}>;
|
||||
|
||||
export type TrashIssueMutation = {
|
||||
trashIssue: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type RestoreIssueMutationVariables = Exact<{
|
||||
issueId: string;
|
||||
}>;
|
||||
|
||||
export type RestoreIssueMutation = {
|
||||
restoreIssue: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type TrashQueryVariables = Exact<{
|
||||
projectId: string;
|
||||
}>;
|
||||
|
||||
export type TrashQuery = {
|
||||
trash: Array<{
|
||||
id: string;
|
||||
projectId: string;
|
||||
key: string;
|
||||
type: string;
|
||||
area: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
statusCategory: StatusCategory;
|
||||
priority: IssuePriority;
|
||||
labels: Array<string>;
|
||||
commentCount: number;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
rank: string;
|
||||
storyPoints: number | null;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
trashedAt: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
sprint: { id: string; name: string } | null;
|
||||
parent: { id: string; key: string; title: string } | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type NotificationFieldsFragment = {
|
||||
id: string;
|
||||
kind: NotificationKind;
|
||||
@@ -1714,6 +1770,7 @@ export type IssueChangedSubscription = {
|
||||
storyPoints: number | null;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
trashedAt: string | null;
|
||||
assignee: { id: string; name: string } | null;
|
||||
release: { id: string; name: string } | null;
|
||||
sprint: { id: string; name: string } | null;
|
||||
@@ -2323,6 +2380,7 @@ export const IssueSummaryFieldsFragmentDoc = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -2555,6 +2613,7 @@ export const IssueFieldsFragmentDoc = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -4010,6 +4069,7 @@ export const BoardDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -4156,6 +4216,7 @@ export const IssueDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -4436,6 +4497,7 @@ export const SearchIssuesDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -5682,6 +5744,7 @@ export const MyIssuesDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -5842,6 +5905,7 @@ export const IssueChildrenDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -6020,6 +6084,7 @@ export const EpicsDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -6502,6 +6567,7 @@ export const BacklogDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -6646,6 +6712,287 @@ export const BulkUpdateIssuesDocument = {
|
||||
BulkUpdateIssuesMutation,
|
||||
BulkUpdateIssuesMutationVariables
|
||||
>;
|
||||
export const TrashIssueDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'TrashIssue' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'trashIssue' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
},
|
||||
],
|
||||
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<TrashIssueMutation, TrashIssueMutationVariables>;
|
||||
export const RestoreIssueDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'RestoreIssue' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'restoreIssue' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'issueId' },
|
||||
},
|
||||
},
|
||||
],
|
||||
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<
|
||||
RestoreIssueMutation,
|
||||
RestoreIssueMutationVariables
|
||||
>;
|
||||
export const TrashDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'query',
|
||||
name: { kind: 'Name', value: 'Trash' },
|
||||
variableDefinitions: [
|
||||
{
|
||||
kind: 'VariableDefinition',
|
||||
variable: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
},
|
||||
type: {
|
||||
kind: 'NonNullType',
|
||||
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'trash' },
|
||||
arguments: [
|
||||
{
|
||||
kind: 'Argument',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
value: {
|
||||
kind: 'Variable',
|
||||
name: { kind: 'Name', value: 'projectId' },
|
||||
},
|
||||
},
|
||||
],
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
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: 'IssueSummaryFields' },
|
||||
typeCondition: {
|
||||
kind: 'NamedType',
|
||||
name: { kind: 'Name', value: 'Issue' },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'priority' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'labels' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'commentCount' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'assignee' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{
|
||||
kind: 'FragmentSpread',
|
||||
name: { kind: 'Name', value: 'UserRefFields' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'release' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'sprint' },
|
||||
selectionSet: {
|
||||
kind: 'SelectionSet',
|
||||
selections: [
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: 'Field',
|
||||
name: { kind: 'Name', value: 'parent' },
|
||||
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: 'Field', name: { kind: 'Name', value: 'rank' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<TrashQuery, TrashQueryVariables>;
|
||||
export const MyNotificationsDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -10421,6 +10768,7 @@ export const IssueChangedDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -221,3 +221,21 @@ mutation BulkUpdateIssues($input: BulkUpdateIssuesInput!) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutation TrashIssue($issueId: ID!) {
|
||||
trashIssue(issueId: $issueId) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation RestoreIssue($issueId: ID!) {
|
||||
restoreIssue(issueId: $issueId) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
query Trash($projectId: ID!) {
|
||||
trash(projectId: $projectId) {
|
||||
...IssueSummaryFields
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +223,9 @@ const NOBODY = '__none__';
|
||||
description="Los tickets nuevos aparecen aquí, al final, hasta que alguien los coge."
|
||||
/>
|
||||
}
|
||||
<p class="ts-backlog__trash">
|
||||
<a class="ts-plain-link" routerLink="../trash">Papelera</a>
|
||||
</p>
|
||||
`,
|
||||
styles: `
|
||||
.ts-backlog {
|
||||
@@ -275,6 +278,11 @@ const NOBODY = '__none__';
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.ts-backlog__trash {
|
||||
margin: 1rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
text-align: right;
|
||||
}
|
||||
.ts-backlog__pos {
|
||||
color: var(--ts-muted);
|
||||
font-size: 0.75rem;
|
||||
|
||||
@@ -520,3 +520,14 @@ a.ts-key {
|
||||
.ts-dev__when {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.ts-trashed {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background: color-mix(in srgb, var(--ts-c-red, #dc2626) 12%, transparent);
|
||||
}
|
||||
|
||||
@@ -94,6 +94,20 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
<a class="ts-back" routerLink="../../board">← Tablero</a>
|
||||
|
||||
@if (facade.issue(); as issue) {
|
||||
@if (issue.trashedAt) {
|
||||
<div class="ts-trashed" role="status">
|
||||
En la papelera desde {{ ago(issue.trashedAt) }}: no aparece en el
|
||||
tablero ni en las búsquedas, y se borrará del todo a los 30 días.
|
||||
@if (canTrash(issue)) {
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="primary"
|
||||
text="Recuperar"
|
||||
(execute)="facade.setTrashed(false)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="ts-issue">
|
||||
<section class="ts-issue__main">
|
||||
<header class="ts-issue__header">
|
||||
@@ -698,6 +712,15 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
<pi-label>Área</pi-label>
|
||||
</pi-select-field>
|
||||
|
||||
@if (!issue.trashedAt && canTrash(issue)) {
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="grey"
|
||||
appearance="ghost"
|
||||
text="Mover a la papelera"
|
||||
(execute)="trash()"
|
||||
/>
|
||||
}
|
||||
<dl class="ts-facts">
|
||||
<dt>Reportado por</dt>
|
||||
<dd>{{ issue.reporter.name }}</dd>
|
||||
@@ -1122,6 +1145,23 @@ export class IssuePage {
|
||||
}
|
||||
|
||||
/** A screenshot pasted anywhere on the ticket is attached to it. */
|
||||
/** Whoever opened it, or whoever manages the project. */
|
||||
protected canTrash(issue: { reporter: { id: string } }): boolean {
|
||||
return (
|
||||
this.project.canContribute() &&
|
||||
(issue.reporter.id === this.facade.me() || this.project.canManage())
|
||||
);
|
||||
}
|
||||
|
||||
protected trash(): void {
|
||||
if (
|
||||
confirm(
|
||||
'¿Mover el ticket a la papelera? Podrás recuperarlo durante 30 días.',
|
||||
)
|
||||
)
|
||||
this.facade.setTrashed(true);
|
||||
}
|
||||
|
||||
/** `e`: edit the ticket (not while typing, nor with a drawer open). */
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
protected onKey(event: KeyboardEvent): void {
|
||||
|
||||
130
libs/web/tracker/presenters/src/lib/pages/trash.page.ts
Normal file
130
libs/web/tracker/presenters/src/lib/pages/trash.page.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { PiActionButtonComponent, PiEmptyStateComponent } from '@pi/ui';
|
||||
import { ToastService } from '@task-stream/web/shared/ui';
|
||||
import { ProjectFacade } from '@task-stream/web/tracker/application';
|
||||
import { IssueSummary, IssuesGateway } from '@task-stream/web/tracker/domain';
|
||||
import { timeAgo } from '../ui/copy';
|
||||
|
||||
/** /p/:key/trash — tickets deleted in the last 30 days, to bring back. */
|
||||
@Component({
|
||||
selector: 'ts-trash-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink, PiActionButtonComponent, PiEmptyStateComponent],
|
||||
template: `
|
||||
<div class="ts-section-header">
|
||||
<p class="ts-muted">
|
||||
Tickets borrados en los últimos 30 días. Después se borran del todo, con
|
||||
sus comentarios.
|
||||
</p>
|
||||
</div>
|
||||
@if (issues().length) {
|
||||
<ul class="ts-trash ts-panel">
|
||||
@for (issue of issues(); track issue.id) {
|
||||
<li class="ts-trash__row">
|
||||
<a class="ts-key" [routerLink]="['..', 'issues', issue.key]">{{
|
||||
issue.key
|
||||
}}</a>
|
||||
<span class="ts-trash__title">{{ issue.title }}</span>
|
||||
<span class="ts-muted">{{
|
||||
issue.trashedAt ? ago(issue.trashedAt) : ''
|
||||
}}</span>
|
||||
@if (project.canContribute()) {
|
||||
<pi-action-button
|
||||
size="small"
|
||||
color="grey"
|
||||
appearance="outline"
|
||||
text="Recuperar"
|
||||
(execute)="restore(issue)"
|
||||
/>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else if (loaded()) {
|
||||
<pi-empty-state
|
||||
icon="trash"
|
||||
title="La papelera está vacía"
|
||||
description="Lo que se borre aparecerá aquí durante 30 días."
|
||||
/>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.ts-trash {
|
||||
margin: 0;
|
||||
padding: 0.25rem 0;
|
||||
list-style: none;
|
||||
}
|
||||
.ts-trash__row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-top: 1px solid var(--ts-line);
|
||||
&:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
}
|
||||
.ts-trash__title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class TrashPage {
|
||||
protected readonly project = inject(ProjectFacade);
|
||||
private readonly gateway = inject(IssuesGateway);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
protected readonly issues = signal<IssueSummary[]>([]);
|
||||
protected readonly loaded = signal(false);
|
||||
protected readonly ago = timeAgo;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const projectId = this.project.project()?.id;
|
||||
if (projectId) untracked(() => this.reload(projectId));
|
||||
});
|
||||
}
|
||||
|
||||
protected restore(issue: IssueSummary): void {
|
||||
this.gateway
|
||||
.restore(issue.id)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.toast.success(`${issue.key} recuperado`);
|
||||
this.issues.update((list) => list.filter((i) => i.id !== issue.id));
|
||||
},
|
||||
error: (error) =>
|
||||
this.toast.error(
|
||||
(error as { message?: string })?.message?.split('\n')[0] ||
|
||||
'No se pudo recuperar',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
private reload(projectId: string): void {
|
||||
this.gateway
|
||||
.trashed(projectId)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (issues) => {
|
||||
this.issues.set(issues);
|
||||
this.loaded.set(true);
|
||||
},
|
||||
error: () => this.loaded.set(true),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,12 @@ export const trackerRoutes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./pages/epics.page').then((m) => m.EpicsPage),
|
||||
},
|
||||
{
|
||||
path: 'trash',
|
||||
title: 'Papelera · TaskStream',
|
||||
loadComponent: () =>
|
||||
import('./pages/trash.page').then((m) => m.TrashPage),
|
||||
},
|
||||
{
|
||||
path: 'releases',
|
||||
title: 'Releases · TaskStream',
|
||||
|
||||
Reference in New Issue
Block a user