mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
feat(misc): extend default start page to projects, planner, schedule, boards
Widens MiscConfig.defaultStartPage from number to number | string so users can pick any project as their startup screen in addition to Today (0), legacy Inbox (1), Planner (2), Schedule (3), or Boards (4). Builds on #6354. - New StartPageSelectComponent (Formly type start-page-select) lists built-in pages plus all non-archived, non-hidden projects. A legacy Inbox option is rendered only while the stored value is still 1 so existing users see their selection without any silent config write. - Feature-gated options (Planner/Schedule/Boards) are hidden from the dropdown when the corresponding appFeatures flag is off. - DefaultStartPageGuard validates the selection against current state: project must exist and not be archived or hidden; feature pages only route when enabled; empty strings, missing projects, and disabled features all fall back to Today instead of stranding the user. - Deleting a project clears misc.defaultStartPage alongside the existing tasks.defaultProjectId cleanup so a stale pointer can't linger. - Unit tests for DefaultStartPageGuard cover every branch (including empty-string and archived/hidden regressions) and the new effect cleanup paths.
This commit is contained in:
parent
3ed287516a
commit
0dd97e122d
10 changed files with 379 additions and 17 deletions
166
src/app/app.guard.spec.ts
Normal file
166
src/app/app.guard.spec.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { Router, UrlTree } from '@angular/router';
|
||||
import { BehaviorSubject, of, throwError } from 'rxjs';
|
||||
import { DefaultStartPageGuard } from './app.guard';
|
||||
import { DataInitStateService } from './core/data-init/data-init-state.service';
|
||||
import { GlobalConfigService } from './features/config/global-config.service';
|
||||
import { ProjectService } from './features/project/project.service';
|
||||
import { TODAY_TAG } from './features/tag/tag.const';
|
||||
import { INBOX_PROJECT } from './features/project/project.const';
|
||||
import { Project } from './features/project/project.model';
|
||||
|
||||
describe('DefaultStartPageGuard', () => {
|
||||
let guard: DefaultStartPageGuard;
|
||||
let router: Router;
|
||||
let misc$: BehaviorSubject<{ defaultStartPage?: number | string } | undefined>;
|
||||
let appFeatures: jasmine.Spy;
|
||||
let getByIdOnce$: jasmine.Spy;
|
||||
|
||||
const TODAY_URL = `/tag/${TODAY_TAG.id}/tasks`;
|
||||
|
||||
const runGuard = async (): Promise<UrlTree> => {
|
||||
const result = await guard.canActivate({} as any, {} as any).toPromise();
|
||||
if (!result) {
|
||||
throw new Error('guard returned falsy');
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const fakeProject = (overrides: Partial<Project> = {}): Project =>
|
||||
({
|
||||
id: 'p1',
|
||||
title: 'Project',
|
||||
isArchived: false,
|
||||
isHiddenFromMenu: false,
|
||||
...overrides,
|
||||
}) as Project;
|
||||
|
||||
beforeEach(() => {
|
||||
misc$ = new BehaviorSubject<{ defaultStartPage?: number | string } | undefined>(
|
||||
undefined,
|
||||
);
|
||||
appFeatures = jasmine.createSpy('appFeatures').and.returnValue({
|
||||
isPlannerEnabled: true,
|
||||
isSchedulerEnabled: true,
|
||||
isBoardsEnabled: true,
|
||||
});
|
||||
getByIdOnce$ = jasmine.createSpy('getByIdOnce$').and.returnValue(of(undefined));
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
DefaultStartPageGuard,
|
||||
{
|
||||
provide: DataInitStateService,
|
||||
useValue: { isAllDataLoadedInitially$: of(true) },
|
||||
},
|
||||
{
|
||||
provide: GlobalConfigService,
|
||||
useValue: { misc$, appFeatures },
|
||||
},
|
||||
{
|
||||
provide: ProjectService,
|
||||
useValue: { getByIdOnce$ },
|
||||
},
|
||||
],
|
||||
});
|
||||
guard = TestBed.inject(DefaultStartPageGuard);
|
||||
router = TestBed.inject(Router);
|
||||
});
|
||||
|
||||
const expectUrl = (tree: UrlTree, path: string): void => {
|
||||
expect(router.serializeUrl(tree)).toBe(path);
|
||||
};
|
||||
|
||||
it('routes undefined → Today', async () => {
|
||||
misc$.next({ defaultStartPage: undefined });
|
||||
expectUrl(await runGuard(), TODAY_URL);
|
||||
});
|
||||
|
||||
it('routes DefaultStartPage.Today → Today', async () => {
|
||||
misc$.next({ defaultStartPage: 0 });
|
||||
expectUrl(await runGuard(), TODAY_URL);
|
||||
});
|
||||
|
||||
it('routes legacy Inbox (1) → /project/INBOX_PROJECT/tasks', async () => {
|
||||
misc$.next({ defaultStartPage: 1 });
|
||||
expectUrl(await runGuard(), `/project/${INBOX_PROJECT.id}/tasks`);
|
||||
});
|
||||
|
||||
it('routes Planner when feature enabled', async () => {
|
||||
misc$.next({ defaultStartPage: 2 });
|
||||
expectUrl(await runGuard(), '/planner');
|
||||
});
|
||||
|
||||
it('falls back to Today when Planner disabled', async () => {
|
||||
appFeatures.and.returnValue({
|
||||
isPlannerEnabled: false,
|
||||
isSchedulerEnabled: true,
|
||||
isBoardsEnabled: true,
|
||||
});
|
||||
misc$.next({ defaultStartPage: 2 });
|
||||
expectUrl(await runGuard(), TODAY_URL);
|
||||
});
|
||||
|
||||
it('falls back to Today when Schedule disabled', async () => {
|
||||
appFeatures.and.returnValue({
|
||||
isPlannerEnabled: true,
|
||||
isSchedulerEnabled: false,
|
||||
isBoardsEnabled: true,
|
||||
});
|
||||
misc$.next({ defaultStartPage: 3 });
|
||||
expectUrl(await runGuard(), TODAY_URL);
|
||||
});
|
||||
|
||||
it('falls back to Today when Boards disabled', async () => {
|
||||
appFeatures.and.returnValue({
|
||||
isPlannerEnabled: true,
|
||||
isSchedulerEnabled: true,
|
||||
isBoardsEnabled: false,
|
||||
});
|
||||
misc$.next({ defaultStartPage: 4 });
|
||||
expectUrl(await runGuard(), TODAY_URL);
|
||||
});
|
||||
|
||||
it('routes to project when project exists', async () => {
|
||||
getByIdOnce$.and.returnValue(of(fakeProject({ id: 'p1' })));
|
||||
misc$.next({ defaultStartPage: 'p1' });
|
||||
expectUrl(await runGuard(), '/project/p1/tasks');
|
||||
});
|
||||
|
||||
it('falls back to Today when project is missing', async () => {
|
||||
getByIdOnce$.and.returnValue(of(undefined));
|
||||
misc$.next({ defaultStartPage: 'does-not-exist' });
|
||||
expectUrl(await runGuard(), TODAY_URL);
|
||||
});
|
||||
|
||||
it('falls back to Today when project is archived', async () => {
|
||||
getByIdOnce$.and.returnValue(of(fakeProject({ id: 'p1', isArchived: true })));
|
||||
misc$.next({ defaultStartPage: 'p1' });
|
||||
expectUrl(await runGuard(), TODAY_URL);
|
||||
});
|
||||
|
||||
it('falls back to Today when project is hidden from menu', async () => {
|
||||
getByIdOnce$.and.returnValue(of(fakeProject({ id: 'p1', isHiddenFromMenu: true })));
|
||||
misc$.next({ defaultStartPage: 'p1' });
|
||||
expectUrl(await runGuard(), TODAY_URL);
|
||||
});
|
||||
|
||||
it('falls back to Today when getByIdOnce$ errors', async () => {
|
||||
getByIdOnce$.and.returnValue(throwError(() => new Error('boom')));
|
||||
misc$.next({ defaultStartPage: 'p1' });
|
||||
expectUrl(await runGuard(), TODAY_URL);
|
||||
});
|
||||
|
||||
// Regression: empty-string ids previously threw synchronously in
|
||||
// ProjectService.getByIdOnce$ and blocked the guard entirely.
|
||||
it('falls back to Today when defaultStartPage is an empty string', async () => {
|
||||
misc$.next({ defaultStartPage: '' });
|
||||
expectUrl(await runGuard(), TODAY_URL);
|
||||
expect(getByIdOnce$).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to Today when misc config itself is undefined', async () => {
|
||||
misc$.next(undefined);
|
||||
expectUrl(await runGuard(), TODAY_URL);
|
||||
});
|
||||
});
|
||||
|
|
@ -16,6 +16,7 @@ import { Store } from '@ngrx/store';
|
|||
import { selectIsOverlayShown } from './features/focus-mode/store/focus-mode.selectors';
|
||||
import { DataInitStateService } from './core/data-init/data-init-state.service';
|
||||
import { GlobalConfigService } from './features/config/global-config.service';
|
||||
import { DefaultStartPage } from './features/config/default-start-page.const';
|
||||
import { TODAY_TAG } from './features/tag/tag.const';
|
||||
import { INBOX_PROJECT } from './features/project/project.const';
|
||||
|
||||
|
|
@ -107,9 +108,13 @@ export class ValidProjectIdGuard {
|
|||
@Injectable({ providedIn: 'root' })
|
||||
export class DefaultStartPageGuard {
|
||||
private _globalConfigService = inject(GlobalConfigService);
|
||||
private _projectService = inject(ProjectService);
|
||||
private _dataInitStateService = inject(DataInitStateService);
|
||||
private _router = inject(Router);
|
||||
|
||||
private readonly _todayUrl = (): UrlTree =>
|
||||
this._router.parseUrl(`/tag/${TODAY_TAG.id}/tasks`);
|
||||
|
||||
canActivate(
|
||||
next: ActivatedRouteSnapshot,
|
||||
state: RouterStateSnapshot,
|
||||
|
|
@ -117,15 +122,56 @@ export class DefaultStartPageGuard {
|
|||
return this._dataInitStateService.isAllDataLoadedInitially$.pipe(
|
||||
concatMap(() => this._globalConfigService.misc$),
|
||||
take(1),
|
||||
map((miscCfg) => {
|
||||
const TODAY = 0;
|
||||
const INBOX = 1;
|
||||
if ((miscCfg?.defaultStartPage ?? TODAY) === INBOX) {
|
||||
return this._router.parseUrl(`/project/${INBOX_PROJECT.id}/tasks`);
|
||||
} else {
|
||||
return this._router.parseUrl(`/tag/${TODAY_TAG.id}/tasks`);
|
||||
}
|
||||
}),
|
||||
concatMap((miscCfg) => this._resolve(miscCfg?.defaultStartPage)),
|
||||
);
|
||||
}
|
||||
|
||||
private _resolve(startPage: number | string | undefined): Observable<UrlTree> {
|
||||
if (typeof startPage === 'string' && startPage.length > 0) {
|
||||
// Project id. Fall back to Today if the project is missing, archived,
|
||||
// or hidden from the menu — same cases where the dropdown omits it.
|
||||
return this._projectService.getByIdOnce$(startPage).pipe(
|
||||
catchError((err) => {
|
||||
Log.warn(
|
||||
`DefaultStartPageGuard: failed to look up project '${startPage}'`,
|
||||
err,
|
||||
);
|
||||
return of(undefined);
|
||||
}),
|
||||
map((project) =>
|
||||
project && !project.isArchived && !project.isHiddenFromMenu
|
||||
? this._router.parseUrl(`/project/${startPage}/tasks`)
|
||||
: this._todayUrl(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const appFeatures = this._globalConfigService.appFeatures();
|
||||
switch (startPage ?? DefaultStartPage.Today) {
|
||||
case DefaultStartPage.Inbox:
|
||||
// Legacy numeric value preserved for old configs.
|
||||
return of(this._router.parseUrl(`/project/${INBOX_PROJECT.id}/tasks`));
|
||||
case DefaultStartPage.Planner:
|
||||
return of(
|
||||
appFeatures.isPlannerEnabled
|
||||
? this._router.parseUrl('/planner')
|
||||
: this._todayUrl(),
|
||||
);
|
||||
case DefaultStartPage.Schedule:
|
||||
return of(
|
||||
appFeatures.isSchedulerEnabled
|
||||
? this._router.parseUrl('/schedule')
|
||||
: this._todayUrl(),
|
||||
);
|
||||
case DefaultStartPage.Boards:
|
||||
return of(
|
||||
appFeatures.isBoardsEnabled
|
||||
? this._router.parseUrl('/boards')
|
||||
: this._todayUrl(),
|
||||
);
|
||||
case DefaultStartPage.Today:
|
||||
default:
|
||||
return of(this._todayUrl());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
src/app/features/config/default-start-page.const.ts
Normal file
10
src/app/features/config/default-start-page.const.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// Numeric IDs for built-in start pages.
|
||||
// String values of `MiscConfig.defaultStartPage` are treated as project IDs.
|
||||
// Values are persisted in user config — do not change existing numbers.
|
||||
export enum DefaultStartPage {
|
||||
Today = 0,
|
||||
Inbox = 1, // Legacy — new configs store INBOX_PROJECT.id as a string instead.
|
||||
Planner = 2,
|
||||
Schedule = 3,
|
||||
Boards = 4,
|
||||
}
|
||||
|
|
@ -104,14 +104,10 @@ export const MISC_SETTINGS_FORM_CFG: ConfigFormSection<MiscConfig> = {
|
|||
: []) as LimitedFormlyFieldConfig<MiscConfig>[]),
|
||||
{
|
||||
key: 'defaultStartPage',
|
||||
type: 'select',
|
||||
type: 'start-page-select',
|
||||
defaultValue: 0,
|
||||
templateOptions: {
|
||||
label: T.GCF.MISC.DEFAULT_START_PAGE,
|
||||
options: [
|
||||
{ label: T.G.TODAY_TAG_TITLE, value: 0 },
|
||||
{ label: T.G.INBOX_PROJECT_TITLE, value: 1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@ export type MiscConfig = Readonly<{
|
|||
isTrayShowCurrentCountdown?: boolean;
|
||||
isUseCustomWindowTitleBar?: boolean;
|
||||
customTheme?: string;
|
||||
defaultStartPage?: number;
|
||||
// number: one of DefaultStartPage. string: project id.
|
||||
defaultStartPage?: number | string;
|
||||
unsplashApiKey?: string | null;
|
||||
|
||||
// @todo: remove deprecated items in future major releases, after giving users time to migrate
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
<mat-select
|
||||
[errorStateMatcher]="errorStateMatcher"
|
||||
[formControl]="formControl"
|
||||
[formlyAttributes]="field"
|
||||
[id]="id"
|
||||
>
|
||||
<mat-option [value]="DefaultStartPage.Today">{{
|
||||
T.G.TODAY_TAG_TITLE | translate
|
||||
}}</mat-option>
|
||||
|
||||
<!-- Legacy: render only when the user still has the old numeric Inbox value,
|
||||
so the dropdown reflects their stored selection. Picking any other
|
||||
option removes it from the list on the next change detection pass. -->
|
||||
@if (formControl.value === DefaultStartPage.Inbox) {
|
||||
<mat-option [value]="DefaultStartPage.Inbox">{{
|
||||
T.G.INBOX_PROJECT_TITLE | translate
|
||||
}}</mat-option>
|
||||
}
|
||||
|
||||
@if (appFeatures().isPlannerEnabled) {
|
||||
<mat-option [value]="DefaultStartPage.Planner">{{
|
||||
T.MH.PLANNER | translate
|
||||
}}</mat-option>
|
||||
}
|
||||
@if (appFeatures().isSchedulerEnabled) {
|
||||
<mat-option [value]="DefaultStartPage.Schedule">{{
|
||||
T.MH.SCHEDULE | translate
|
||||
}}</mat-option>
|
||||
}
|
||||
@if (appFeatures().isBoardsEnabled) {
|
||||
<mat-option [value]="DefaultStartPage.Boards">{{
|
||||
T.MH.BOARDS | translate
|
||||
}}</mat-option>
|
||||
}
|
||||
|
||||
@for (opt of projects(); track opt.id) {
|
||||
<mat-option [value]="opt.id">{{ opt.title }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { FieldType } from '@ngx-formly/material';
|
||||
import { FormlyFieldConfig, FormlyModule } from '@ngx-formly/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { MatOption, MatSelect } from '@angular/material/select';
|
||||
|
||||
import { ProjectService } from '../../project/project.service';
|
||||
import { GlobalConfigService } from '../global-config.service';
|
||||
import { DefaultStartPage } from '../default-start-page.const';
|
||||
import { T } from 'src/app/t.const';
|
||||
|
||||
@Component({
|
||||
selector: 'start-page-select',
|
||||
templateUrl: './start-page-select.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
FormlyModule,
|
||||
TranslatePipe,
|
||||
MatSelect,
|
||||
MatOption,
|
||||
],
|
||||
})
|
||||
export class StartPageSelectComponent extends FieldType<FormlyFieldConfig> {
|
||||
private readonly _projectService = inject(ProjectService);
|
||||
private readonly _globalConfigService = inject(GlobalConfigService);
|
||||
|
||||
readonly T = T;
|
||||
readonly DefaultStartPage = DefaultStartPage;
|
||||
|
||||
// Signal-based (CLAUDE.md prefers signals over observables).
|
||||
// listSortedForUI filters archived + hidden-from-menu projects.
|
||||
readonly projects = this._projectService.listSortedForUI;
|
||||
readonly appFeatures = this._globalConfigService.appFeatures;
|
||||
}
|
||||
|
|
@ -133,6 +133,57 @@ describe('ProjectEffects', () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reset misc.defaultStartPage when deleted project was the start page', (done) => {
|
||||
globalConfigServiceMock.cfg.and.returnValue({
|
||||
...DEFAULT_GLOBAL_CONFIG,
|
||||
misc: {
|
||||
...DEFAULT_GLOBAL_CONFIG.misc,
|
||||
defaultStartPage: 'project-1',
|
||||
},
|
||||
});
|
||||
|
||||
effects.deleteProjectRelatedData.subscribe(() => {
|
||||
expect(globalConfigServiceMock.updateSection).toHaveBeenCalledWith('misc', {
|
||||
defaultStartPage: 0,
|
||||
});
|
||||
done();
|
||||
});
|
||||
|
||||
actions$.next(
|
||||
TaskSharedActions.deleteProject({
|
||||
projectId: 'project-1',
|
||||
noteIds: [],
|
||||
allTaskIds: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT reset misc.defaultStartPage when deleted project does not match', (done) => {
|
||||
globalConfigServiceMock.cfg.and.returnValue({
|
||||
...DEFAULT_GLOBAL_CONFIG,
|
||||
misc: {
|
||||
...DEFAULT_GLOBAL_CONFIG.misc,
|
||||
defaultStartPage: 'project-1',
|
||||
},
|
||||
});
|
||||
|
||||
effects.deleteProjectRelatedData.subscribe(() => {
|
||||
expect(globalConfigServiceMock.updateSection).not.toHaveBeenCalledWith(
|
||||
'misc',
|
||||
jasmine.any(Object),
|
||||
);
|
||||
done();
|
||||
});
|
||||
|
||||
actions$.next(
|
||||
TaskSharedActions.deleteProject({
|
||||
projectId: 'project-2',
|
||||
noteIds: [],
|
||||
allTaskIds: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveAllProjectToTodayListWhenBacklogIsDisabled$', () => {
|
||||
|
|
|
|||
|
|
@ -33,11 +33,19 @@ export class ProjectEffects {
|
|||
this._actions$.pipe(
|
||||
ofType(TaskSharedActions.deleteProject),
|
||||
tap(({ projectId }) => {
|
||||
// Clear defaultProjectId if the deleted project was the default
|
||||
const cfg = this._globalConfigService.cfg();
|
||||
if (cfg && projectId === cfg.tasks.defaultProjectId) {
|
||||
if (!cfg) {
|
||||
return;
|
||||
}
|
||||
// Clear defaultProjectId if the deleted project was the default
|
||||
if (projectId === cfg.tasks.defaultProjectId) {
|
||||
this._globalConfigService.updateSection('tasks', { defaultProjectId: null });
|
||||
}
|
||||
// Clear misc.defaultStartPage if it pointed at the deleted project,
|
||||
// otherwise users land on a dead route next app launch.
|
||||
if (projectId === cfg.misc.defaultStartPage) {
|
||||
this._globalConfigService.updateSection('misc', { defaultStartPage: 0 });
|
||||
}
|
||||
}),
|
||||
),
|
||||
{ dispatch: false },
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { FormlyTagSelectionComponent } from './formly-tag-selection/formly-tag-s
|
|||
import { FormlyBtnComponent } from './formly-button/formly-btn.component';
|
||||
import { FormlyImageInputComponent } from './formly-image-input/formly-image-input.component';
|
||||
import { ColorInputComponent } from '../features/config/color-input/color-input.component';
|
||||
import { StartPageSelectComponent } from '../features/config/start-page-select/start-page-select.component';
|
||||
import { FormlySlideToggleComponent } from './formly-slide-toggle/formly-slide-toggle.component';
|
||||
import { FormlyDatePickerComponent } from './formly-date-picker/formly-date-picker.component';
|
||||
|
||||
|
|
@ -84,6 +85,12 @@ import { FormlyDatePickerComponent } from './formly-date-picker/formly-date-pick
|
|||
extends: 'input',
|
||||
wrappers: ['form-field'],
|
||||
},
|
||||
{
|
||||
name: 'start-page-select',
|
||||
component: StartPageSelectComponent,
|
||||
extends: 'input',
|
||||
wrappers: ['form-field'],
|
||||
},
|
||||
{
|
||||
name: 'tag-select',
|
||||
component: FormlyTagSelectionComponent,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue