diff --git a/src/app/core/persistence/storage-keys.const.ts b/src/app/core/persistence/storage-keys.const.ts index 2afcd9ecf..4e9686f2b 100644 --- a/src/app/core/persistence/storage-keys.const.ts +++ b/src/app/core/persistence/storage-keys.const.ts @@ -55,6 +55,11 @@ export enum LS { NAV_SIDEBAR_EXPANDED = 'SUP_NAV_SIDEBAR_EXPANDED', NAV_SIDEBAR_WIDTH = 'SUP_NAV_SIDEBAR_WIDTH', RIGHT_PANEL_WIDTH = 'SUP_RIGHT_PANEL_WIDTH', + + // Task view customizer + TASK_VIEW_CUSTOMIZER_SORT = 'SUP_TASK_VIEW_CUSTOMIZER_SORT', + TASK_VIEW_CUSTOMIZER_GROUP = 'SUP_TASK_VIEW_CUSTOMIZER_GROUP', + TASK_VIEW_CUSTOMIZER_FILTER = 'SUP_TASK_VIEW_CUSTOMIZER_FILTER', } // SESSION STORAGE diff --git a/src/app/features/task-view-customizer/task-view-customizer.service.spec.ts b/src/app/features/task-view-customizer/task-view-customizer.service.spec.ts index 4d34979f7..121c1d71b 100644 --- a/src/app/features/task-view-customizer/task-view-customizer.service.spec.ts +++ b/src/app/features/task-view-customizer/task-view-customizer.service.spec.ts @@ -27,6 +27,7 @@ import { } from './types'; import { DateAdapter } from '@angular/material/core'; import { DEFAULT_FIRST_DAY_OF_WEEK } from 'src/app/core/locale.constants'; +import { LS } from '../../core/persistence/storage-keys.const'; describe('TaskViewCustomizerService', () => { let service: TaskViewCustomizerService; @@ -114,6 +115,9 @@ describe('TaskViewCustomizerService', () => { ]; beforeEach(() => { + // Clear localStorage before each test + localStorage.clear(); + mockWorkContextService = { activeWorkContextId: null, activeWorkContextType: null, @@ -498,4 +502,176 @@ describe('TaskViewCustomizerService', () => { expect(service.selectedFilter()).toEqual(DEFAULT_OPTIONS.filter); }); }); + + describe('localStorage persistence', () => { + it('should initialize with default values when localStorage is empty', () => { + expect(service.selectedSort()).toEqual(DEFAULT_OPTIONS.sort); + expect(service.selectedGroup()).toEqual(DEFAULT_OPTIONS.group); + expect(service.selectedFilter()).toEqual(DEFAULT_OPTIONS.filter); + }); + + it('should restore sort option from localStorage on initialization', () => { + const savedSort: SortOption = { + type: SORT_OPTION_TYPE.name, + order: SORT_ORDER.ASC, + label: 'Name', + }; + localStorage.setItem(LS.TASK_VIEW_CUSTOMIZER_SORT, JSON.stringify(savedSort)); + + // Reset TestBed to create a new service instance + TestBed.resetTestingModule(); + const dateAdapter = jasmine.createSpyObj>('DateAdapter', [], { + getFirstDayOfWeek: () => DEFAULT_FIRST_DAY_OF_WEEK, + }); + + TestBed.configureTestingModule({ + providers: [ + TaskViewCustomizerService, + { provide: DateAdapter, useValue: dateAdapter }, + { provide: WorkContextService, useValue: mockWorkContextService }, + { provide: ProjectService, useValue: { update: projectUpdateSpy } }, + { provide: TagService, useValue: { updateTag: tagUpdateSpy } }, + provideMockStore({ + selectors: [ + { selector: selectAllProjects, value: mockProjects }, + { selector: selectAllTags, value: mockTags }, + ], + }), + ], + }); + + const newService = TestBed.inject(TaskViewCustomizerService); + (newService as any)._allProjects = mockProjects; + (newService as any)._allTags = mockTags; + + expect(newService.selectedSort()).toEqual(savedSort); + }); + + it('should restore group option from localStorage on initialization', () => { + const savedGroup: GroupOption = { type: GROUP_OPTION_TYPE.tag, label: 'Tag' }; + localStorage.setItem(LS.TASK_VIEW_CUSTOMIZER_GROUP, JSON.stringify(savedGroup)); + + TestBed.resetTestingModule(); + const dateAdapter = jasmine.createSpyObj>('DateAdapter', [], { + getFirstDayOfWeek: () => DEFAULT_FIRST_DAY_OF_WEEK, + }); + + TestBed.configureTestingModule({ + providers: [ + TaskViewCustomizerService, + { provide: DateAdapter, useValue: dateAdapter }, + { provide: WorkContextService, useValue: mockWorkContextService }, + { provide: ProjectService, useValue: { update: projectUpdateSpy } }, + { provide: TagService, useValue: { updateTag: tagUpdateSpy } }, + provideMockStore({ + selectors: [ + { selector: selectAllProjects, value: mockProjects }, + { selector: selectAllTags, value: mockTags }, + ], + }), + ], + }); + + const newService = TestBed.inject(TaskViewCustomizerService); + (newService as any)._allProjects = mockProjects; + (newService as any)._allTags = mockTags; + + expect(newService.selectedGroup()).toEqual(savedGroup); + }); + + it('should restore filter option from localStorage on initialization', () => { + const savedFilter: FilterOption = { + type: FILTER_OPTION_TYPE.tag, + preset: 'Tag A', + label: 'Tag', + }; + localStorage.setItem(LS.TASK_VIEW_CUSTOMIZER_FILTER, JSON.stringify(savedFilter)); + + TestBed.resetTestingModule(); + const dateAdapter = jasmine.createSpyObj>('DateAdapter', [], { + getFirstDayOfWeek: () => DEFAULT_FIRST_DAY_OF_WEEK, + }); + + TestBed.configureTestingModule({ + providers: [ + TaskViewCustomizerService, + { provide: DateAdapter, useValue: dateAdapter }, + { provide: WorkContextService, useValue: mockWorkContextService }, + { provide: ProjectService, useValue: { update: projectUpdateSpy } }, + { provide: TagService, useValue: { updateTag: tagUpdateSpy } }, + provideMockStore({ + selectors: [ + { selector: selectAllProjects, value: mockProjects }, + { selector: selectAllTags, value: mockTags }, + ], + }), + ], + }); + + const newService = TestBed.inject(TaskViewCustomizerService); + (newService as any)._allProjects = mockProjects; + (newService as any)._allTags = mockTags; + + expect(newService.selectedFilter()).toEqual(savedFilter); + }); + + it('should persist sort option to localStorage when changed', (done) => { + const newSort: SortOption = { + type: SORT_OPTION_TYPE.name, + order: SORT_ORDER.ASC, + label: 'Name', + }; + service.setSort(newSort); + + // Wait for effect to run + setTimeout(() => { + const stored = localStorage.getItem(LS.TASK_VIEW_CUSTOMIZER_SORT); + expect(stored).toBeTruthy(); + expect(JSON.parse(stored!)).toEqual(newSort); + done(); + }, 50); + }); + + it('should persist group option to localStorage when changed', (done) => { + const newGroup: GroupOption = { type: GROUP_OPTION_TYPE.tag, label: 'Tag' }; + service.setGroup(newGroup); + + setTimeout(() => { + const stored = localStorage.getItem(LS.TASK_VIEW_CUSTOMIZER_GROUP); + expect(stored).toBeTruthy(); + expect(JSON.parse(stored!)).toEqual(newGroup); + done(); + }, 50); + }); + + it('should persist filter option to localStorage when changed', (done) => { + const newFilter: FilterOption = { + type: FILTER_OPTION_TYPE.tag, + preset: 'Tag A', + label: 'Tag', + }; + service.setFilter(newFilter); + + setTimeout(() => { + const stored = localStorage.getItem(LS.TASK_VIEW_CUSTOMIZER_FILTER); + expect(stored).toBeTruthy(); + expect(JSON.parse(stored!)).toEqual(newFilter); + done(); + }, 50); + }); + + it('should fallback to defaults when localStorage contains invalid JSON', () => { + localStorage.setItem(LS.TASK_VIEW_CUSTOMIZER_SORT, 'invalid json{'); + localStorage.setItem(LS.TASK_VIEW_CUSTOMIZER_GROUP, '{broken'); + localStorage.setItem(LS.TASK_VIEW_CUSTOMIZER_FILTER, 'not json'); + + const newService = TestBed.inject(TaskViewCustomizerService); + (newService as any)._allProjects = mockProjects; + (newService as any)._allTags = mockTags; + + expect(newService.selectedSort()).toEqual(DEFAULT_OPTIONS.sort); + expect(newService.selectedGroup()).toEqual(DEFAULT_OPTIONS.group); + expect(newService.selectedFilter()).toEqual(DEFAULT_OPTIONS.filter); + }); + }); }); diff --git a/src/app/features/task-view-customizer/task-view-customizer.service.ts b/src/app/features/task-view-customizer/task-view-customizer.service.ts index 85b6605b3..11828ba4b 100644 --- a/src/app/features/task-view-customizer/task-view-customizer.service.ts +++ b/src/app/features/task-view-customizer/task-view-customizer.service.ts @@ -1,4 +1,4 @@ -import { Injectable, signal, inject } from '@angular/core'; +import { Injectable, signal, inject, effect } from '@angular/core'; import { Observable, animationFrameScheduler, combineLatest } from 'rxjs'; import { map, observeOn, take } from 'rxjs/operators'; import { TaskWithSubTasks } from '../tasks/task.model'; @@ -28,6 +28,8 @@ import { FILTER_COMMON, } from './types'; import { DateAdapter } from '@angular/material/core'; +import { lsGetJSON, lsSetJSON } from '../../util/ls-util'; +import { LS } from '../../core/persistence/storage-keys.const'; @Injectable({ providedIn: 'root' }) export class TaskViewCustomizerService { @@ -37,9 +39,18 @@ export class TaskViewCustomizerService { private _projectService = inject(ProjectService); private _tagService = inject(TagService); - public selectedSort = signal(DEFAULT_OPTIONS.sort); - public selectedGroup = signal(DEFAULT_OPTIONS.group); - public selectedFilter = signal(DEFAULT_OPTIONS.filter); + public selectedSort = signal( + lsGetJSON(LS.TASK_VIEW_CUSTOMIZER_SORT, DEFAULT_OPTIONS.sort) ?? + DEFAULT_OPTIONS.sort, + ); + public selectedGroup = signal( + lsGetJSON(LS.TASK_VIEW_CUSTOMIZER_GROUP, DEFAULT_OPTIONS.group) ?? + DEFAULT_OPTIONS.group, + ); + public selectedFilter = signal( + lsGetJSON(LS.TASK_VIEW_CUSTOMIZER_FILTER, DEFAULT_OPTIONS.filter) ?? + DEFAULT_OPTIONS.filter, + ); isCustomized = computed(() => { return [ @@ -52,6 +63,18 @@ export class TaskViewCustomizerService { constructor() { this._initProjects(); this._initTags(); + + effect(() => { + lsSetJSON(LS.TASK_VIEW_CUSTOMIZER_SORT, this.selectedSort()); + }); + + effect(() => { + lsSetJSON(LS.TASK_VIEW_CUSTOMIZER_GROUP, this.selectedGroup()); + }); + + effect(() => { + lsSetJSON(LS.TASK_VIEW_CUSTOMIZER_FILTER, this.selectedFilter()); + }); } private _allProjects: Project[] = [];