mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
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:
parent
c1d14d501c
commit
34af7b1036
16 changed files with 545 additions and 48 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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('/');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue