feat(projects): polish archive flow per #7748

- Confirm dialog for archive (replaces snack+UNDO); navigation in
  work-context-menu now awaits confirmation.
- Archived-projects rows link to /project/<id>/tasks; project view
  shows an inline restore notice when the active project is archived.
- Unarchive snack split: when the project is still hidden from the
  menu, offer a "Show in menu" action; otherwise keep UNDO.
- Terminology: snack now reads "Project restored" to match the button.
- Tests: spec for archived-projects-page and project-task-page;
  defensive archive-filter coverage on selectAllTasksWithReminder /
  selectAllTasksWithDeadlineReminder / selectOverdueTasks; service
  spec covers confirm/cancel paths and the hidden-from-menu branch.
This commit is contained in:
Johannes Millan 2026-05-23 14:15:22 +02:00
parent c1d14d501c
commit 34af7b1036
16 changed files with 545 additions and 48 deletions

View file

@ -67,7 +67,7 @@ Archiving a project makes it fully **dormant**: the project and all its data are
All data is kept intact. Archiving is safe to use even if the project still has active tasks, open deadlines, or configured repeating tasks — everything is simply invisible until you unarchive.
To **unarchive** a project, click the eye icon in the Projects section header to open the visibility menu, then navigate to **Archived projects**. Find the project and click **Restore project** — all tasks, repeating configs, and reminders become active again immediately.
To **restore** a project, click the eye icon in the Projects section header to open the visibility menu, then navigate to **Archived projects**. From there you can either click the project title to open it (an archived banner appears at the top with a one-click restore) or click the **Restore project** icon directly in the row — all tasks, repeating configs, and reminders become active again immediately. If the project was hidden from the menu when archived, the snack message offers a **Show in menu** action so it reappears in the sidebar.
## Related

View file

@ -24,6 +24,7 @@ describe('WorkContextMenuComponent', () => {
'unarchive',
'getByIdOnce$',
]);
mockProjectService.archive.and.returnValue(Promise.resolve(true));
mockWorkContextService = { activeWorkContextId: undefined };
const mockShareService = jasmine.createSpyObj('ShareService', ['getShareSupport']);
@ -82,5 +83,12 @@ describe('WorkContextMenuComponent', () => {
expect(mockProjectService.archive).toHaveBeenCalledWith('project-123');
expect(router.navigateByUrl).not.toHaveBeenCalled();
});
it('does not navigate when the archive confirmation is cancelled', async () => {
mockProjectService.archive.and.returnValue(Promise.resolve(false));
mockWorkContextService.activeWorkContextId = 'project-123';
await component.archiveProject();
expect(router.navigateByUrl).not.toHaveBeenCalled();
});
});
});

View file

@ -126,8 +126,8 @@ export class WorkContextMenuComponent implements OnInit {
async archiveProject(): Promise<void> {
const activeId = this._workContextService.activeWorkContextId;
this._projectService.archive(this.contextId);
if (activeId === this.contextId) {
const wasArchived = await this._projectService.archive(this.contextId);
if (wasArchived && activeId === this.contextId) {
await this._router.navigateByUrl('/');
}
}

View file

@ -1,6 +1,7 @@
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { ProjectService } from './project.service';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { selectTaskFeatureState } from '../tasks/store/task.selectors';
import { TaskState } from '../tasks/task.model';
import { TaskService } from '../tasks/task.service';
@ -27,6 +28,8 @@ describe('ProjectService', () => {
let snackService: jasmine.SpyObj<SnackService>;
let workContextService: jasmine.SpyObj<WorkContextService>;
let timeTrackingService: jasmine.SpyObj<TimeTrackingService>;
let matDialog: jasmine.SpyObj<MatDialog>;
let confirmResult$: any;
/* eslint-disable @typescript-eslint/naming-convention */
const initialTaskState: TaskState = {
@ -112,8 +115,10 @@ describe('ProjectService', () => {
projects: {
ids: ['project-1', 'project-2'],
entities: {
project1: createProject({ id: 'project-1', title: 'Project 1' }),
project2: createProject({ id: 'project-2', title: 'Project 2' }),
/* eslint-disable @typescript-eslint/naming-convention */
'project-1': createProject({ id: 'project-1', title: 'Project 1' }),
'project-2': createProject({ id: 'project-2', title: 'Project 2' }),
/* eslint-enable @typescript-eslint/naming-convention */
},
},
},
@ -148,8 +153,17 @@ describe('ProjectService', () => {
getStartOfNextDayDiffMs: () => 0,
},
},
{
provide: MatDialog,
useValue: jasmine.createSpyObj('MatDialog', ['open']),
},
],
});
matDialog = TestBed.inject(MatDialog) as jasmine.SpyObj<MatDialog>;
confirmResult$ = of(true);
matDialog.open.and.callFake(
() => ({ afterClosed: () => confirmResult$ }) as MatDialogRef<unknown>,
);
workContextService.activeWorkContext$ = EMPTY;
workContextService.activeWorkContextTypeAndId$ = of({
activeId: 'project-1',
@ -282,30 +296,33 @@ describe('ProjectService', () => {
});
describe('archive', () => {
it('should dispatch archiveProject action', () => {
it('opens a confirm dialog and dispatches archiveProject when confirmed', async () => {
confirmResult$ = of(true);
const dispatchSpy = spyOn(store, 'dispatch').and.callThrough();
service.archive('project-1');
const result = await service.archive('project-1');
expect(matDialog.open).toHaveBeenCalled();
const types = dispatchSpy.calls.allArgs().map((args: any) => args[0]?.type);
expect(types).toContain('[Project] Archive Project');
expect(result).toBe(true);
});
it('should show snack with archive icon and UNDO action', () => {
service.archive('project-1');
expect(snackService.open).toHaveBeenCalledWith(
jasmine.objectContaining({
ico: 'archive',
msg: T.F.PROJECT.S.ARCHIVED,
actionStr: T.G.UNDO,
}),
);
it('shows a snack without an UNDO action after confirmation', async () => {
confirmResult$ = of(true);
await service.archive('project-1');
expect(snackService.open).toHaveBeenCalledWith({
ico: 'archive',
msg: T.F.PROJECT.S.ARCHIVED,
});
});
it('should call unarchive when snack actionFn is triggered', () => {
spyOn(service, 'unarchive');
service.archive('project-1');
const callArgs = snackService.open.calls.mostRecent().args[0] as any;
callArgs.actionFn();
expect(service.unarchive).toHaveBeenCalledWith('project-1');
it('does not dispatch or snack when the dialog is cancelled', async () => {
confirmResult$ = of(false);
const dispatchSpy = spyOn(store, 'dispatch').and.callThrough();
const result = await service.archive('project-1');
const types = dispatchSpy.calls.allArgs().map((args: any) => args[0]?.type);
expect(types).not.toContain('[Project] Archive Project');
expect(snackService.open).not.toHaveBeenCalled();
expect(result).toBe(false);
});
});
@ -328,12 +345,55 @@ describe('ProjectService', () => {
);
});
it('should call archive when snack actionFn is triggered', () => {
spyOn(service, 'archive');
it('re-archives without confirmation when the UNDO action fires', () => {
const dispatchSpy = spyOn(store, 'dispatch').and.callThrough();
service.unarchive('project-1');
const callArgs = snackService.open.calls.mostRecent().args[0] as any;
dispatchSpy.calls.reset();
callArgs.actionFn();
expect(service.archive).toHaveBeenCalledWith('project-1');
expect(matDialog.open).not.toHaveBeenCalled();
const types = dispatchSpy.calls.allArgs().map((args: any) => args[0]?.type);
expect(types).toContain('[Project] Archive Project');
});
describe('when project is still hidden from the menu', () => {
beforeEach(() => {
store.setState({
projects: {
ids: ['project-1'],
entities: {
/* eslint-disable @typescript-eslint/naming-convention */
'project-1': createProject({
id: 'project-1',
title: 'Hidden Project',
isHiddenFromMenu: true,
}),
/* eslint-enable @typescript-eslint/naming-convention */
},
},
});
});
it('should show the hidden-from-menu snack message', () => {
service.unarchive('project-1');
expect(snackService.open).toHaveBeenCalledWith(
jasmine.objectContaining({
ico: 'unarchive',
msg: T.F.PROJECT.S.UNARCHIVED_HIDDEN_FROM_MENU,
actionStr: T.F.PROJECT.ARCHIVED_PROJECTS.SHOW_IN_MENU,
}),
);
});
it('should dispatch toggleHideFromMenu when the snack action is invoked', () => {
const dispatchSpy = spyOn(store, 'dispatch').and.callThrough();
service.unarchive('project-1');
const callArgs = snackService.open.calls.mostRecent().args[0] as any;
dispatchSpy.calls.reset();
callArgs.actionFn();
const types = dispatchSpy.calls.allArgs().map((args: any) => args[0]?.type);
expect(types).toContain('[Project] Toggle hide from menu');
});
});
});
});

View file

@ -24,6 +24,7 @@ import {
moveProjectTaskToBacklogList,
moveProjectTaskToBacklogListAuto,
moveProjectTaskToRegularListAuto,
toggleHideFromMenu,
unarchiveProject,
updateProject,
updateProjectOrder,
@ -141,23 +142,64 @@ export class ProjectService {
);
}
archive(projectId: string): void {
this._store$.dispatch(archiveProject({ id: projectId }));
this._snackService.open({
ico: 'archive',
msg: T.F.PROJECT.S.ARCHIVED,
actionStr: T.G.UNDO,
actionFn: () => this.unarchive(projectId),
});
async archive(projectId: string): Promise<boolean> {
const project = await firstValueFrom(this.getByIdOnce$(projectId));
if (!project) {
return false;
}
const isConfirmed = await firstValueFrom(
this._matDialog
.open(DialogConfirmComponent, {
restoreFocus: true,
data: {
message: T.F.PROJECT.D_CONFIRM_ARCHIVE.MSG,
okTxt: T.F.PROJECT.D_CONFIRM_ARCHIVE.OK,
translateParams: { title: project.title },
},
})
.afterClosed(),
);
if (!isConfirmed) {
return false;
}
this._archiveNow(projectId);
return true;
}
unarchive(projectId: string): void {
this._store$.dispatch(unarchiveProject({ id: projectId }));
// NgRx `Store.select` is BehaviorSubject-backed so `take(1).subscribe()`
// emits synchronously — we read the snapshot before opening the snack.
let isHiddenFromMenu = false;
this._store$
.select(selectProjectById, { id: projectId })
.pipe(take(1))
.subscribe((p) => {
isHiddenFromMenu = !!p?.isHiddenFromMenu;
});
const snack = isHiddenFromMenu
? {
ico: 'unarchive',
msg: T.F.PROJECT.S.UNARCHIVED_HIDDEN_FROM_MENU,
actionStr: T.F.PROJECT.ARCHIVED_PROJECTS.SHOW_IN_MENU,
actionFn: () => this._store$.dispatch(toggleHideFromMenu({ id: projectId })),
}
: {
ico: 'unarchive',
msg: T.F.PROJECT.S.UNARCHIVED,
actionStr: T.G.UNDO,
// Undo skips the archive confirm dialog — the user is explicitly
// reversing the unarchive they just performed.
actionFn: () => this._archiveNow(projectId),
};
this._snackService.open(snack);
}
private _archiveNow(projectId: string): void {
this._store$.dispatch(archiveProject({ id: projectId }));
this._snackService.open({
ico: 'unarchive',
msg: T.F.PROJECT.S.UNARCHIVED,
actionStr: T.G.UNDO,
actionFn: () => this.archive(projectId),
ico: 'archive',
msg: T.F.PROJECT.S.ARCHIVED,
});
}

View file

@ -1042,4 +1042,111 @@ describe('Task Selectors', () => {
expect(result.every((t) => !t.isDone)).toBe(true);
});
});
// Defensive regression coverage: these selectors compose from
// `selectAllTasksInActiveProjects` and therefore must inherit the
// archived-project filter. If a future refactor swaps their input back to
// `selectAllTasks`, these tests catch the regression.
describe('Archive filter pass-through on derived selectors', () => {
const archivedTaskWithReminder: Task = {
...DEFAULT_TASK,
id: 'reminderInArchived',
title: 'Reminder in archived project',
projectId: 'projectArchived',
remindAt: Date.now() + 60_000,
created: Date.now(),
subTaskIds: [],
tagIds: [],
timeSpentOnDay: {},
};
const activeTaskWithReminder: Task = {
...DEFAULT_TASK,
id: 'reminderInActive',
title: 'Reminder in active project',
projectId: 'project2',
remindAt: Date.now() + 60_000,
created: Date.now(),
subTaskIds: [],
tagIds: [],
timeSpentOnDay: {},
};
const archivedTaskWithDeadline: Task = {
...DEFAULT_TASK,
id: 'deadlineInArchived',
title: 'Deadline reminder in archived project',
projectId: 'projectArchived',
deadlineRemindAt: Date.now() + 60_000,
created: Date.now(),
subTaskIds: [],
tagIds: [],
timeSpentOnDay: {},
};
const archivedOverdueTask: Task = {
...DEFAULT_TASK,
id: 'overdueInArchived',
title: 'Overdue in archived project',
projectId: 'projectArchived',
dueDay: yesterday,
created: Date.now(),
subTaskIds: [],
tagIds: [],
timeSpentOnDay: {},
};
const archivedState = {
...mockState,
[TASK_FEATURE_NAME]: {
...mockTaskState,
ids: [
...mockTaskState.ids,
archivedTaskWithReminder.id,
activeTaskWithReminder.id,
archivedTaskWithDeadline.id,
archivedOverdueTask.id,
],
entities: {
...mockTaskState.entities,
[archivedTaskWithReminder.id]: archivedTaskWithReminder,
[activeTaskWithReminder.id]: activeTaskWithReminder,
[archivedTaskWithDeadline.id]: archivedTaskWithDeadline,
[archivedOverdueTask.id]: archivedOverdueTask,
},
},
[PROJECT_FEATURE_NAME]: {
...mockState[PROJECT_FEATURE_NAME],
ids: [...mockState[PROJECT_FEATURE_NAME].ids, 'projectArchived'],
entities: {
...mockState[PROJECT_FEATURE_NAME].entities,
projectArchived: {
id: 'projectArchived',
title: 'Archived Project',
isHiddenFromMenu: false,
isArchived: true,
},
},
},
};
it('selectAllTasksWithReminder excludes tasks from archived projects', () => {
const ids = fromSelectors
.selectAllTasksWithReminder(archivedState as any)
.map((t) => t.id);
expect(ids).toContain('reminderInActive');
expect(ids).not.toContain('reminderInArchived');
});
it('selectAllTasksWithDeadlineReminder excludes tasks from archived projects', () => {
const ids = fromSelectors
.selectAllTasksWithDeadlineReminder(archivedState as any)
.map((t) => t.id);
expect(ids).not.toContain('deadlineInArchived');
});
it('selectOverdueTasks excludes tasks from archived projects', () => {
const ids = fromSelectors.selectOverdueTasks(archivedState as any).map((t) => t.id);
expect(ids).not.toContain('overdueInArchived');
// task6 (overdue, in active project1) still shows up
expect(ids).toContain('task6');
});
});
});

View file

@ -33,13 +33,18 @@
>
@for (project of filteredProjects(); track project.id) {
<li class="project-row">
<mat-icon
class="project-icon"
aria-hidden="true"
[style.color]="project.theme?.primary || null"
>{{ project.icon || DEFAULT_PROJECT_ICON }}</mat-icon
<a
class="project-link"
[routerLink]="['/project', project.id, 'tasks']"
>
<span class="project-title">{{ project.title }}</span>
<mat-icon
class="project-icon"
aria-hidden="true"
[style.color]="project.theme?.primary || null"
>{{ project.icon || DEFAULT_PROJECT_ICON }}</mat-icon
>
<span class="project-title">{{ project.title }}</span>
</a>
<button
mat-icon-button
[matTooltip]="T.F.PROJECT.ARCHIVED_PROJECTS.UNARCHIVE | translate"

View file

@ -23,8 +23,8 @@
.project-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
gap: var(--s);
padding: var(--s) 0;
border-bottom: 1px solid var(--divider-color);
&:last-child {
@ -32,6 +32,24 @@
}
}
.project-link {
display: flex;
align-items: center;
gap: var(--s);
flex: 1;
min-width: 0;
color: inherit;
text-decoration: none;
border-radius: var(--s-half);
padding: var(--s-half);
margin: calc(var(--s-half) * -1);
&:hover,
&:focus-visible {
background: var(--state-hover);
}
}
.project-icon {
opacity: 0.5;
flex-shrink: 0;
@ -39,5 +57,7 @@
.project-title {
flex: 1;
font-size: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View file

@ -0,0 +1,93 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideMockStore } from '@ngrx/store/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { By } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { ArchivedProjectsPageComponent } from './archived-projects-page.component';
import { ProjectService } from '../../features/project/project.service';
import { Project } from '../../features/project/project.model';
import { selectArchivedProjectsSortedByTitle } from '../../features/project/store/project.selectors';
const makeProject = (id: string, title: string): Project =>
({
id,
title,
isArchived: true,
isHiddenFromMenu: false,
theme: { primary: '#fff' },
}) as unknown as Project;
describe('ArchivedProjectsPageComponent', () => {
let fixture: ComponentFixture<ArchivedProjectsPageComponent>;
let component: ArchivedProjectsPageComponent;
let projectService: jasmine.SpyObj<ProjectService>;
const setUp = async (projects: Project[]): Promise<void> => {
projectService = jasmine.createSpyObj('ProjectService', ['unarchive']);
await TestBed.configureTestingModule({
imports: [
ArchivedProjectsPageComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
provideRouter([]),
provideMockStore({
selectors: [{ selector: selectArchivedProjectsSortedByTitle, value: projects }],
}),
{ provide: ProjectService, useValue: projectService },
],
}).compileComponents();
fixture = TestBed.createComponent(ArchivedProjectsPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
};
it('renders the empty state when there are no archived projects', async () => {
await setUp([]);
const empty = fixture.debugElement.query(By.css('.empty-state'));
expect(empty).not.toBeNull();
expect(fixture.debugElement.query(By.css('.project-list'))).toBeNull();
});
it('renders one row per archived project, alphabetised by the selector', async () => {
await setUp([makeProject('p-a', 'Alpha'), makeProject('p-b', 'Beta')]);
const rows = fixture.debugElement.queryAll(By.css('.project-row'));
expect(rows.length).toBe(2);
expect(rows[0].nativeElement.textContent).toContain('Alpha');
expect(rows[1].nativeElement.textContent).toContain('Beta');
});
it('filters rows by the search term (case-insensitive substring)', async () => {
await setUp([
makeProject('p-a', 'Alpha'),
makeProject('p-b', 'Beta'),
makeProject('p-c', 'Gamma'),
]);
component.searchTerm.set('ET');
fixture.detectChanges();
const rows = fixture.debugElement.queryAll(By.css('.project-row'));
expect(rows.length).toBe(1);
expect(rows[0].nativeElement.textContent).toContain('Beta');
});
it('links each row to /project/<id>/tasks', async () => {
await setUp([makeProject('p-a', 'Alpha')]);
const link = fixture.debugElement.query(By.css('.project-link'));
expect(link).not.toBeNull();
// RouterLink directive sets the resolved href attribute.
expect(link.nativeElement.getAttribute('href')).toBe('/project/p-a/tasks');
});
it('calls ProjectService.unarchive when the row button is clicked', async () => {
await setUp([makeProject('p-a', 'Alpha')]);
const btn = fixture.debugElement.query(By.css('.project-row button'));
btn.nativeElement.click();
expect(projectService.unarchive).toHaveBeenCalledWith('p-a');
});
});

View file

@ -12,6 +12,7 @@ import { MatTooltip } from '@angular/material/tooltip';
import { MatFormField, MatLabel, MatSuffix } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { selectArchivedProjectsSortedByTitle } from '../../features/project/store/project.selectors';
import { ProjectService } from '../../features/project/project.service';
@ -34,6 +35,7 @@ import { T } from '../../t.const';
MatInput,
MatSuffix,
FormsModule,
RouterLink,
TranslatePipe,
],
})

View file

@ -1,3 +1,25 @@
@if (currentProject()?.isArchived) {
<div
class="archived-notice"
role="status"
aria-live="polite"
>
<mat-icon
class="archived-notice-icon"
aria-hidden="true"
>archive</mat-icon
>
<span class="archived-notice-text">{{
T.F.PROJECT.ARCHIVED_PROJECTS.ARCHIVED_NOTICE | translate
}}</span>
<button
mat-button
(click)="restoreProject()"
>
{{ T.F.PROJECT.ARCHIVED_PROJECTS.UNARCHIVE | translate }}
</button>
</div>
}
<work-view
[backlogTasks]="backlogTasks()"
[doneTasks]="doneTasks()"

View file

@ -1,3 +1,22 @@
:host {
overflow: hidden !important;
}
.archived-notice {
display: flex;
align-items: center;
gap: var(--s);
padding: var(--s) var(--s2);
background: var(--state-hover);
border-bottom: 1px solid var(--divider-color);
}
.archived-notice-icon {
opacity: 0.6;
flex-shrink: 0;
}
.archived-notice-text {
flex: 1;
opacity: 0.85;
}

View file

@ -0,0 +1,86 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { By } from '@angular/platform-browser';
import { BehaviorSubject, EMPTY } from 'rxjs';
import { ProjectTaskPageComponent } from './project-task-page.component';
import { WorkContextService } from '../../features/work-context/work-context.service';
import { ProjectService } from '../../features/project/project.service';
import { Project } from '../../features/project/project.model';
const makeProject = (overrides: Partial<Project> = {}): Project =>
({
id: 'p-1',
title: 'P',
isArchived: false,
isHiddenFromMenu: false,
...overrides,
}) as Project;
describe('ProjectTaskPageComponent', () => {
let fixture: ComponentFixture<ProjectTaskPageComponent>;
let projectService: jasmine.SpyObj<ProjectService>;
let currentProject$: BehaviorSubject<Project | null>;
const setUp = async (project: Project | null): Promise<void> => {
currentProject$ = new BehaviorSubject<Project | null>(project);
projectService = jasmine.createSpyObj('ProjectService', ['unarchive'], {
currentProject$,
});
await TestBed.configureTestingModule({
imports: [
ProjectTaskPageComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{
provide: WorkContextService,
useValue: {
activeWorkContext$: EMPTY,
backlogTasks$: EMPTY,
doneTasks$: EMPTY,
undoneTasks$: EMPTY,
},
},
{ provide: ProjectService, useValue: projectService },
],
})
// Trim the heavy <work-view> child but keep the archived-notice logic.
.overrideComponent(ProjectTaskPageComponent, {
set: {
template: `
@if (currentProject()?.isArchived) {
<div class="archived-notice" role="status" aria-live="polite">
<button class="restore" (click)="restoreProject()">Restore</button>
</div>
}
`,
},
})
.compileComponents();
fixture = TestBed.createComponent(ProjectTaskPageComponent);
fixture.detectChanges();
};
it('does not render the archived notice for an active project', async () => {
await setUp(makeProject({ isArchived: false }));
expect(fixture.debugElement.query(By.css('.archived-notice'))).toBeNull();
});
it('renders the archived notice with polite aria-live when archived', async () => {
await setUp(makeProject({ isArchived: true }));
const notice = fixture.debugElement.query(By.css('.archived-notice'));
expect(notice).not.toBeNull();
expect(notice.nativeElement.getAttribute('role')).toBe('status');
expect(notice.nativeElement.getAttribute('aria-live')).toBe('polite');
});
it('calls projectService.unarchive(id) when Restore is clicked', async () => {
await setUp(makeProject({ id: 'p-archived', isArchived: true }));
fixture.debugElement.query(By.css('.restore')).nativeElement.click();
expect(projectService.unarchive).toHaveBeenCalledWith('p-archived');
});
});

View file

@ -1,18 +1,26 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatButton } from '@angular/material/button';
import { TranslatePipe } from '@ngx-translate/core';
import { WorkContextService } from '../../features/work-context/work-context.service';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs/operators';
import { WorkViewComponent } from '../../features/work-view/work-view.component';
import { ProjectService } from '../../features/project/project.service';
import { T } from '../../t.const';
@Component({
selector: 'work-view-page',
templateUrl: './project-task-page.component.html',
styleUrls: ['./project-task-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [WorkViewComponent],
imports: [MatIcon, MatButton, TranslatePipe, WorkViewComponent],
})
export class ProjectTaskPageComponent {
workContextService = inject(WorkContextService);
private readonly _projectService = inject(ProjectService);
readonly T = T;
isShowBacklog = toSignal(
this.workContextService.activeWorkContext$.pipe(
@ -24,4 +32,15 @@ export class ProjectTaskPageComponent {
backlogTasks = toSignal(this.workContextService.backlogTasks$, { initialValue: [] });
doneTasks = toSignal(this.workContextService.doneTasks$, { initialValue: [] });
undoneTasks = toSignal(this.workContextService.undoneTasks$, { initialValue: [] });
readonly currentProject = toSignal(this._projectService.currentProject$, {
initialValue: null,
});
restoreProject(): void {
const project = this.currentProject();
if (project) {
this._projectService.unarchive(project.id);
}
}
}

View file

@ -910,6 +910,10 @@ const T = {
BREAK_IS_DONE: 'F.POMODORO.BREAK_IS_DONE',
},
PROJECT: {
D_CONFIRM_ARCHIVE: {
MSG: 'F.PROJECT.D_CONFIRM_ARCHIVE.MSG',
OK: 'F.PROJECT.D_CONFIRM_ARCHIVE.OK',
},
D_CONFIRM_DUPLICATE_BIG_PROJECT: {
CANCEL: 'F.PROJECT.D_CONFIRM_DUPLICATE_BIG_PROJECT.CANCEL',
MSG: 'F.PROJECT.D_CONFIRM_DUPLICATE_BIG_PROJECT.MSG',
@ -957,13 +961,16 @@ const T = {
CREATED: 'F.PROJECT.S.CREATED',
DELETED: 'F.PROJECT.S.DELETED',
UNARCHIVED: 'F.PROJECT.S.UNARCHIVED',
UNARCHIVED_HIDDEN_FROM_MENU: 'F.PROJECT.S.UNARCHIVED_HIDDEN_FROM_MENU',
UPDATED: 'F.PROJECT.S.UPDATED',
},
ARCHIVED_PROJECTS: {
ARCHIVED_NOTICE: 'F.PROJECT.ARCHIVED_PROJECTS.ARCHIVED_NOTICE',
EMPTY: 'F.PROJECT.ARCHIVED_PROJECTS.EMPTY',
UNARCHIVE: 'F.PROJECT.ARCHIVED_PROJECTS.UNARCHIVE',
LINK_LABEL: 'F.PROJECT.ARCHIVED_PROJECTS.LINK_LABEL',
SEARCH: 'F.PROJECT.ARCHIVED_PROJECTS.SEARCH',
SHOW_IN_MENU: 'F.PROJECT.ARCHIVED_PROJECTS.SHOW_IN_MENU',
},
},
PROJECT_FOLDER: {

View file

@ -897,6 +897,10 @@
"BREAK_IS_DONE": "Break is over!"
},
"PROJECT": {
"D_CONFIRM_ARCHIVE": {
"MSG": "Archive the project \"{{title}}\"? Tasks, repeating configs and reminders are paused until you restore it.",
"OK": "Archive"
},
"D_CONFIRM_DUPLICATE_BIG_PROJECT": {
"CANCEL": "Cancel",
"MSG": "This project is large and may take a while to duplicate. Do you want to proceed?",
@ -942,13 +946,16 @@
"ARCHIVED": "Archived Project",
"CREATED": "Created project <strong>{{title}}</strong>. You can select it from the menu on the top left.",
"DELETED": "Deleted Project",
"UNARCHIVED": "Project unarchived. It may still be hidden from the menu.",
"UNARCHIVED": "Project restored",
"UNARCHIVED_HIDDEN_FROM_MENU": "Project restored, but still hidden from the menu",
"UPDATED": "Updated project settings"
},
"ARCHIVED_PROJECTS": {
"ARCHIVED_NOTICE": "This project is archived. Restore it to resume reminders and repeating tasks.",
"EMPTY": "No archived projects.",
"LINK_LABEL": "Archived projects",
"SEARCH": "Search archived projects",
"SHOW_IN_MENU": "Show in menu",
"UNARCHIVE": "Restore project"
}
},