feat: archive a project instead of deleting it
The owner archives a finished project (archiveProject, POST
/projects/{project}/archive) and brings it back (unarchive). While
archived it stays readable, managers still read its rules and webhooks,
but no command changes it: contributing and managing checks refuse with
422, votes and public proposals too, and automations do not run. The web
shows a read-only notice on every page of it (with «Desarchivar» for the
owner), hides what edits, marks it on the home page and lists it last.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,7 @@ export const projectPageEvents = eventGroup({
|
||||
analyticsRequested: type<{ projectId: string; days: number }>(),
|
||||
burnupRequested: type<{ releaseId: string }>(),
|
||||
visibilityChangeRequested: type<{ projectId: string; isPublic: boolean }>(),
|
||||
archiveRequested: type<{ projectId: string; archived: boolean }>(),
|
||||
proposalsChangeRequested: type<{
|
||||
projectId: string;
|
||||
acceptsProposals: boolean;
|
||||
|
||||
@@ -162,6 +162,12 @@ export class ProjectFacade {
|
||||
this.dispatch.visibilityChangeRequested({ projectId, isPublic });
|
||||
}
|
||||
|
||||
/** Archives the project (read-only for everyone) or brings it back. Owner only. */
|
||||
setArchived(archived: boolean): void {
|
||||
const projectId = this.projectId();
|
||||
if (projectId) this.dispatch.archiveRequested({ projectId, archived });
|
||||
}
|
||||
|
||||
/** Take bug reports and ideas from anyone (while public). Owners and admins. */
|
||||
setAcceptsProposals(acceptsProposals: boolean): void {
|
||||
const projectId = this.projectId();
|
||||
|
||||
@@ -100,11 +100,17 @@ export const ProjectStore = signalStore(
|
||||
);
|
||||
return {
|
||||
myRole,
|
||||
/** Archived projects are read-only for everyone. */
|
||||
canManage: computed(
|
||||
() => myRole() === ProjectRole.OWNER || myRole() === ProjectRole.ADMIN,
|
||||
() =>
|
||||
!project()?.archivedAt &&
|
||||
(myRole() === ProjectRole.OWNER || myRole() === ProjectRole.ADMIN),
|
||||
),
|
||||
canContribute: computed(
|
||||
() => !!myRole() && myRole() !== ProjectRole.VIEWER,
|
||||
() =>
|
||||
!project()?.archivedAt &&
|
||||
!!myRole() &&
|
||||
myRole() !== ProjectRole.VIEWER,
|
||||
),
|
||||
members: computed(() => project()?.members ?? []),
|
||||
/** Statuses, types and areas of the project, with lookups. */
|
||||
@@ -433,6 +439,20 @@ export const ProjectStore = signalStore(
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(projectPageEvents.archiveRequested)
|
||||
.pipe(
|
||||
mergeMap(({ payload }) =>
|
||||
runCommand(
|
||||
projects.setArchived(payload.projectId, payload.archived),
|
||||
payload.archived
|
||||
? 'Proyecto archivado: queda en solo lectura'
|
||||
: 'Proyecto desarchivado',
|
||||
projectApiEvents.commandSucceeded,
|
||||
projectApiEvents.commandFailed,
|
||||
),
|
||||
),
|
||||
),
|
||||
events
|
||||
.on(projectPageEvents.proposalsChangeRequested)
|
||||
.pipe(
|
||||
|
||||
@@ -39,8 +39,13 @@ export const ProjectsStore = signalStore(
|
||||
error: null,
|
||||
}),
|
||||
withComputed(({ projects }) => ({
|
||||
/** By name, archived ones last. */
|
||||
sorted: computed(() =>
|
||||
[...projects()].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[...projects()].sort(
|
||||
(a, b) =>
|
||||
Number(!!a.archivedAt) - Number(!!b.archivedAt) ||
|
||||
a.name.localeCompare(b.name),
|
||||
),
|
||||
),
|
||||
})),
|
||||
withReducer(
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface Project {
|
||||
owner: UserRef;
|
||||
members: ProjectMember[];
|
||||
settings: ProjectSettings;
|
||||
/** Archived since then (read-only); null while active. */
|
||||
archivedAt: string | null;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
@@ -71,6 +71,11 @@ export abstract class ProjectsGateway {
|
||||
projectId: string,
|
||||
since: string | null,
|
||||
): Observable<ProjectInsights>;
|
||||
/** Archives the project (read-only) or brings it back. Owner only. */
|
||||
abstract setArchived(
|
||||
projectId: string,
|
||||
archived: boolean,
|
||||
): Observable<CommandResult>;
|
||||
/** Statistics of the last `days` days. */
|
||||
abstract analytics(
|
||||
projectId: string,
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
ChangeProjectMemberRoleDocument,
|
||||
CreateProjectDocument,
|
||||
DeleteProjectDocument,
|
||||
ArchiveProjectDocument,
|
||||
UnarchiveProjectDocument,
|
||||
MyProjectsDocument,
|
||||
ProjectAnalyticsDocument,
|
||||
ProjectInsightsDocument,
|
||||
@@ -102,6 +104,16 @@ export class GraphqlProjectsGateway extends ProjectsGateway {
|
||||
.pipe(map((d) => toCommandResult(d.updateProject)));
|
||||
}
|
||||
|
||||
setArchived(projectId: string, archived: boolean): Observable<CommandResult> {
|
||||
return archived
|
||||
? this.gql
|
||||
.mutate(ArchiveProjectDocument, { projectId })
|
||||
.pipe(map((d) => toCommandResult(d.archiveProject)))
|
||||
: this.gql
|
||||
.mutate(UnarchiveProjectDocument, { projectId })
|
||||
.pipe(map((d) => toCommandResult(d.unarchiveProject)));
|
||||
}
|
||||
|
||||
delete(projectId: string, confirmKey: string): Observable<CommandResult> {
|
||||
return this.gql
|
||||
.mutate(DeleteProjectDocument, { projectId, confirmKey })
|
||||
|
||||
@@ -10,6 +10,7 @@ fragment ProjectFields on Project {
|
||||
description
|
||||
isPublic
|
||||
acceptsProposals
|
||||
archivedAt
|
||||
updatedAt
|
||||
version
|
||||
owner {
|
||||
|
||||
@@ -527,6 +527,7 @@ export type ProjectFieldsFragment = {
|
||||
description: string | null;
|
||||
isPublic: boolean;
|
||||
acceptsProposals: boolean;
|
||||
archivedAt: string | null;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
owner: { id: string; name: string };
|
||||
@@ -1522,6 +1523,7 @@ export type MyProjectsQuery = {
|
||||
description: string | null;
|
||||
isPublic: boolean;
|
||||
acceptsProposals: boolean;
|
||||
archivedAt: string | null;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
owner: { id: string; name: string };
|
||||
@@ -1573,6 +1575,7 @@ export type ProjectQuery = {
|
||||
description: string | null;
|
||||
isPublic: boolean;
|
||||
acceptsProposals: boolean;
|
||||
archivedAt: string | null;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
owner: { id: string; name: string };
|
||||
@@ -1734,6 +1737,7 @@ export type PublicProjectsQuery = {
|
||||
description: string | null;
|
||||
isPublic: boolean;
|
||||
acceptsProposals: boolean;
|
||||
archivedAt: string | null;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
owner: { id: string; name: string };
|
||||
@@ -1781,6 +1785,22 @@ export type UpdateProjectMutation = {
|
||||
updateProject: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type ArchiveProjectMutationVariables = Exact<{
|
||||
projectId: string;
|
||||
}>;
|
||||
|
||||
export type ArchiveProjectMutation = {
|
||||
archiveProject: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type UnarchiveProjectMutationVariables = Exact<{
|
||||
projectId: string;
|
||||
}>;
|
||||
|
||||
export type UnarchiveProjectMutation = {
|
||||
unarchiveProject: { id: string; key: string | null; version: number };
|
||||
};
|
||||
|
||||
export type DeleteProjectMutationVariables = Exact<{
|
||||
projectId: string;
|
||||
confirmKey: string;
|
||||
@@ -2410,6 +2430,7 @@ export type ProjectChangedSubscription = {
|
||||
description: string | null;
|
||||
isPublic: boolean;
|
||||
acceptsProposals: boolean;
|
||||
archivedAt: string | null;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
owner: { id: string; name: string };
|
||||
@@ -2464,6 +2485,7 @@ export type MyProjectsChangedSubscription = {
|
||||
description: string | null;
|
||||
isPublic: boolean;
|
||||
acceptsProposals: boolean;
|
||||
archivedAt: string | null;
|
||||
updatedAt: string;
|
||||
version: number;
|
||||
owner: { id: string; name: string };
|
||||
@@ -2730,6 +2752,7 @@ export const ProjectFieldsFragmentDoc = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'isPublic' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'acceptsProposals' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'archivedAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||
{
|
||||
@@ -9475,6 +9498,7 @@ export const MyProjectsDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'isPublic' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'acceptsProposals' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'archivedAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||
{
|
||||
@@ -9722,6 +9746,7 @@ export const ProjectDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'isPublic' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'acceptsProposals' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'archivedAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||
{
|
||||
@@ -10820,6 +10845,7 @@ export const PublicProjectsDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'isPublic' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'acceptsProposals' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'archivedAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||
{
|
||||
@@ -11057,6 +11083,146 @@ export const UpdateProjectDocument = {
|
||||
UpdateProjectMutation,
|
||||
UpdateProjectMutationVariables
|
||||
>;
|
||||
export const ArchiveProjectDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'ArchiveProject' },
|
||||
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: 'archiveProject' },
|
||||
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: '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<
|
||||
ArchiveProjectMutation,
|
||||
ArchiveProjectMutationVariables
|
||||
>;
|
||||
export const UnarchiveProjectDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
{
|
||||
kind: 'OperationDefinition',
|
||||
operation: 'mutation',
|
||||
name: { kind: 'Name', value: 'UnarchiveProject' },
|
||||
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: 'unarchiveProject' },
|
||||
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: '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<
|
||||
UnarchiveProjectMutation,
|
||||
UnarchiveProjectMutationVariables
|
||||
>;
|
||||
export const DeleteProjectDocument = {
|
||||
kind: 'Document',
|
||||
definitions: [
|
||||
@@ -14206,6 +14372,7 @@ export const ProjectChangedDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'isPublic' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'acceptsProposals' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'archivedAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||
{
|
||||
@@ -14440,6 +14607,7 @@ export const MyProjectsChangedDocument = {
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'isPublic' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'acceptsProposals' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'archivedAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
|
||||
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||
{
|
||||
|
||||
@@ -139,6 +139,18 @@ mutation UpdateProject($input: UpdateProjectInput!) {
|
||||
}
|
||||
}
|
||||
|
||||
mutation ArchiveProject($projectId: ID!) {
|
||||
archiveProject(projectId: $projectId) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation UnarchiveProject($projectId: ID!) {
|
||||
unarchiveProject(projectId: $projectId) {
|
||||
...CommandResultFields
|
||||
}
|
||||
}
|
||||
|
||||
mutation DeleteProject($projectId: ID!, $confirmKey: String!) {
|
||||
deleteProject(projectId: $projectId, confirmKey: $confirmKey) {
|
||||
...CommandResultFields
|
||||
|
||||
@@ -101,12 +101,27 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy';
|
||||
<a routerLink="analytics" routerLinkActive="is-active">Estadísticas</a>
|
||||
<a routerLink="activity" routerLinkActive="is-active">Actividad</a>
|
||||
<a routerLink="members" routerLinkActive="is-active">Miembros</a>
|
||||
@if (facade.canManage()) {
|
||||
@if (facade.canManage() || facade.isOwner()) {
|
||||
<a routerLink="settings" routerLinkActive="is-active"
|
||||
>Configuración</a
|
||||
>
|
||||
}
|
||||
</nav>
|
||||
@if (project.archivedAt) {
|
||||
<p class="ts-archived" role="status">
|
||||
<strong>Proyecto archivado</strong>
|
||||
Se puede ver todo, pero es de solo lectura.
|
||||
@if (facade.isOwner()) {
|
||||
<button
|
||||
type="button"
|
||||
class="ts-plain-link"
|
||||
(click)="facade.setArchived(false)"
|
||||
>
|
||||
Desarchivar
|
||||
</button>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
<router-outlet />
|
||||
} @else if (facade.error(); as error) {
|
||||
<pi-note
|
||||
@@ -142,6 +157,19 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy';
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.ts-archived {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid var(--ts-line);
|
||||
border-radius: 0.625rem;
|
||||
background: var(--ts-sunken);
|
||||
color: var(--ts-ink-soft);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.ts-eyebrow .ts-pill {
|
||||
margin-left: 0.5rem;
|
||||
letter-spacing: 0;
|
||||
|
||||
@@ -693,6 +693,32 @@ function idFrom(name: string): string {
|
||||
}
|
||||
|
||||
@if (facade.isOwner() && facade.project(); as project) {
|
||||
<section
|
||||
class="ts-panel ts-block ts-visibility"
|
||||
aria-labelledby="archive-title"
|
||||
>
|
||||
<div>
|
||||
<h3 id="archive-title">Archivar el proyecto</h3>
|
||||
<p class="ts-muted">
|
||||
@if (project.archivedAt) {
|
||||
Archivado: se puede ver todo, pero nadie puede cambiar nada
|
||||
hasta que lo desarchives.
|
||||
} @else {
|
||||
Para un proyecto terminado: queda todo a la vista (tablero,
|
||||
tickets, historia, estadísticas) y en solo lectura. Se puede
|
||||
desarchivar cuando quieras.
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="ts-secondary"
|
||||
(click)="facade.setArchived(!project.archivedAt)"
|
||||
>
|
||||
{{ project.archivedAt ? 'Desarchivar' : 'Archivar' }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="ts-panel ts-block ts-danger"
|
||||
aria-labelledby="danger-title"
|
||||
|
||||
@@ -25,6 +25,9 @@ import { projectTone, PUBLIC_TONE, timeAgo } from './copy';
|
||||
@if (project().isPublic) {
|
||||
<span class="ts-pill" [style.--tone]="publicTone">Público</span>
|
||||
}
|
||||
@if (project().archivedAt) {
|
||||
<span class="ts-pill">Archivado</span>
|
||||
}
|
||||
<span class="ts-project__updated ts-mono">{{
|
||||
ago(project().updatedAt)
|
||||
}}</span>
|
||||
|
||||
Reference in New Issue
Block a user