diff --git a/MIGRATION.md b/MIGRATION.md index ef2d5cfa..f4c7e041 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -111,10 +111,6 @@ against this app's route files. Update this table as pages move between columns. - [ ] **Salesforce login callback** — Angular implementation exists in PR #56, but callback success/failure landing behavior still needs parity verification. -- [ ] **Public Data Sharing** — Angular implementation exists in PR #77, with reviewer request - `kflemin`; verify API behavior, permissions, translations, and real-data save/reload. -- [ ] **Portfolio Summary enhancement** — PR #56 remains in progress; verify goals, cycles, - partner approvals, Salesforce behavior, and remaining source-audit TODOs. The older “Not yet migrated” entries above are retained as historical route references; the daily source-audit table below is the current classification. @@ -181,20 +177,21 @@ its line to "Won't migrate" with a reason instead. ## Daily source-code parity snapshot -Snapshot refreshed **2026-08-03 04:07 PDT** from the legacy route/template inventory, the current -Angular source tree, local worktrees, and refreshed remote refs. Shared fragments and modal HTML -remain counted under their owning page rather than as separate pages. +Snapshot refreshed **2026-08-27 09:58 MDT** from the legacy route/template inventory, current GitHub +PR metadata, and refreshed branch refs. Local remote-tracking refs match the current core +`develop` and Angular `main` heads; +shared fragments and modal HTML remain counted under their owning page rather than as separate pages. | Inventory / status | Current count | Evidence | |---|---:|---| | Legacy partial HTML files | **166** | `seed/static/seed/partials/**/*.html` | | Unique route-owned legacy templates | **59** | 63 legacy states in `seed.js` collapse to 59 unique `partials/*.html` references | | Shared/modal legacy fragments | **107** | Parent-page burndown; 86 filenames contain `modal` | -| Angular application HTML templates | **198** | `src/app/**/*.html` | +| Angular application HTML templates | **216** | `src/app/**/*.html` on refreshed `origin/main` | | Angular shared HTML templates | **33** | `src/@seed/**/*.html` | -| Baseline migrated | **51 / 59** | Route/component exists on the baseline, subject to full parity sign-off | -| Ported but incomplete | **3 / 59** | Salesforce login, Public Data Sharing, Portfolio Summary enhancement | -| Needs port | **2 / 59** | Personal two-factor setup and full Program Setup | +| Baseline migrated | **54 / 59** | Includes merged Public Data Sharing (#77), Personal Two-Factor (#80), and Portfolio Summary improvements (#79/#83) | +| Ported but incomplete | **1 / 59** | Salesforce login callback | +| Needs port | **1 / 59** | Full Program Setup | | Won't migrate | **3 / 59** | Pairing settings, Inventory Plots, Sub-organizations | ```mermaid @@ -202,9 +199,24 @@ xychart-beta title "Legacy route-template migration burndown" x-axis ["Baseline migrated", "Incomplete", "Needs port", "Won't migrate"] y-axis "Unique route templates" 0 --> 59 - bar [51, 3, 2, 3] + bar [54, 1, 1, 3] ``` +### Current source walkthrough and next action + +Shared and modal HTML remains owned by the parent page and is not double-counted. + +| Page | Legacy source surface | Angular source/evidence | Status | +|---|---|---|---| +| Salesforce login callback | `partials/salesforce_login.html`, `salesforce_login_controller.js`, Salesforce/user services, callback parameters and return navigation | `src/app/modules/salesforce-login/salesforce-login.component.ts/.html`; success/failure banners, retry behavior, invalid parameters, redirects, translations, and backend contract still need verification | **Ported, incomplete** | +| Full Program Setup | `partials/program_setup.html`, `program_setup_controller.js`, `programs`/`program_setup` states, compliance metric services, org-settings permissions/navigation | `ProgramConfigComponent` only covers the embedded Insights picker; no full organization-level CRUD route/component | **Needs port** | +| Portfolio Summary | `partials/portfolio_summary.html`, controller, goal/cycle/Salesforce/bulk-note/partner-approval modal partials and services | `src/app/modules/insights/portfolio-summary/`; merged #79 and #83 added saved-goal selection, searchable goals, filters, Building Elements, and ECM improvements; no remaining TODO/logout gap found in current source audit | **Baseline migrated** | + +**Recommended next page: Salesforce login callback.** It is the only remaining incomplete route and has +an existing Angular implementation, so close parity with a bounded callback-contract and real-data test +pass before starting the larger Program Setup port. Current highest-priority review flag is **NLLONG: +SEED core #5303**. + ### Cross Cycles parity walkthrough Cross Cycles is now **functionally ported** (properties and tax lots, list + detail routes). The diff --git a/public/i18n/en_US.json b/public/i18n/en_US.json index d7ccc0f3..44b1a15b 100644 --- a/public/i18n/en_US.json +++ b/public/i18n/en_US.json @@ -466,6 +466,7 @@ "DIRECTIONS_FOR_UPDATING_MQ_API_KEY": "If you'd like to geocode your data using the MapQuest service, please provide a valid API key within your organization's settings.", "DISABLED": "DISABLED", "Dashboard": "Dashboard", + "Dark": "Dark", "Data": "Data", "Data Administrator Account Name Column": "Data Administrator Account Name Column", "Data Administrator Contact Field": "Data Administrator Contact Field", @@ -537,6 +538,7 @@ "Diesel": "Diesel", "Disabled": "Disabled", "Dismiss": "Dismiss", + "Display": "Display", "Display Columns": "Display Columns", "Display Fields": "Display Fields", "Display Name": "Display Name", @@ -936,6 +938,7 @@ "Left Half": "Left Half", "Level": "Level", "Level Instance": "Level Instance", + "Light": "Light", "Linking ID": "Linking ID", "Loading Summary Data...": "Loading Summary Data...", "Loading data...": "Loading data...", diff --git a/src/@seed/api/organization/organization.types.ts b/src/@seed/api/organization/organization.types.ts index b32b22a5..5860027a 100644 --- a/src/@seed/api/organization/organization.types.ts +++ b/src/@seed/api/organization/organization.types.ts @@ -125,6 +125,7 @@ export type OrganizationUser = { export type OrganizationUserSettings = { [key: string]: unknown; + colorScheme?: 'dark' | 'light'; cycleId?: number; sorts?: UserSettingsSorts; filters?: UserSettingsFilters; diff --git a/src/@seed/services/terms/terms.service.spec.ts b/src/@seed/services/terms/terms.service.spec.ts new file mode 100644 index 00000000..0a743733 --- /dev/null +++ b/src/@seed/services/terms/terms.service.spec.ts @@ -0,0 +1,62 @@ +import { TestBed } from '@angular/core/testing' +import { ConfirmationService } from '../confirmation' +import { TermsService } from './terms.service' + +const TEST_EMAIL = 'test@example.com' +const ACCEPTED_AT_KEY = `nlrTermsAcceptedAt:${TEST_EMAIL}` +const ACCEPTANCE_DAYS = 90 +const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000 + +describe('TermsService', () => { + let service: TermsService + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [{ provide: ConfirmationService, useValue: { open: jasmine.createSpy('open') } }], + }) + service = TestBed.inject(TermsService) + localStorage.removeItem(ACCEPTED_AT_KEY) + jasmine.clock().install() + jasmine.clock().mockDate(new Date('2026-01-01T00:00:00Z')) + }) + + afterEach(() => { + localStorage.removeItem(ACCEPTED_AT_KEY) + jasmine.clock().uninstall() + }) + + it('records acceptance in browser storage', () => { + service.recordTermsAcceptance(TEST_EMAIL) + + expect(localStorage.getItem(ACCEPTED_AT_KEY)).toBe(Date.now().toString()) + expect(service.hasAcceptedTerms(TEST_EMAIL)).toBeTrue() + }) + + it('keeps acceptance valid for less than 90 days', () => { + localStorage.setItem(ACCEPTED_AT_KEY, (Date.now() - ACCEPTANCE_DAYS * MILLISECONDS_PER_DAY + 1).toString()) + + expect(service.hasAcceptedTerms(TEST_EMAIL)).toBeTrue() + }) + + it('expires acceptance after 90 days', () => { + localStorage.setItem(ACCEPTED_AT_KEY, (Date.now() - ACCEPTANCE_DAYS * MILLISECONDS_PER_DAY).toString()) + + expect(service.hasAcceptedTerms(TEST_EMAIL)).toBeFalse() + }) + + it('rejects missing, malformed, and future acceptance dates', () => { + expect(service.hasAcceptedTerms(TEST_EMAIL)).toBeFalse() + + localStorage.setItem(ACCEPTED_AT_KEY, 'not-a-date') + expect(service.hasAcceptedTerms(TEST_EMAIL)).toBeFalse() + + localStorage.setItem(ACCEPTED_AT_KEY, (Date.now() + 1).toString()) + expect(service.hasAcceptedTerms(TEST_EMAIL)).toBeFalse() + }) + + it('does not share acceptance between different accounts', () => { + service.recordTermsAcceptance(TEST_EMAIL) + + expect(service.hasAcceptedTerms('other@example.com')).toBeFalse() + }) +}) diff --git a/src/@seed/services/terms/terms.service.ts b/src/@seed/services/terms/terms.service.ts index aafbacc8..3fc47796 100644 --- a/src/@seed/services/terms/terms.service.ts +++ b/src/@seed/services/terms/terms.service.ts @@ -1,10 +1,32 @@ import { inject, Injectable } from '@angular/core' import { ConfirmationService } from '../confirmation' +const NLR_TERMS_ACCEPTED_AT_KEY = 'nlrTermsAcceptedAt' +const NLR_TERMS_ACCEPTANCE_DAYS = 90 +const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000 + @Injectable({ providedIn: 'root' }) export class TermsService { private _confirmationService = inject(ConfirmationService) + hasAcceptedTerms(email: string): boolean { + const key = `${NLR_TERMS_ACCEPTED_AT_KEY}:${email.toLowerCase().trim()}` + const acceptedAt = Number(localStorage.getItem(key)) + const acceptanceAge = Date.now() - acceptedAt + + return ( + Number.isFinite(acceptedAt) + && acceptedAt > 0 + && acceptanceAge >= 0 + && acceptanceAge < NLR_TERMS_ACCEPTANCE_DAYS * MILLISECONDS_PER_DAY + ) + } + + recordTermsAcceptance(email: string): void { + const key = `${NLR_TERMS_ACCEPTED_AT_KEY}:${email.toLowerCase().trim()}` + localStorage.setItem(key, Date.now().toString()) + } + showTermsOfService(): void { this._confirmationService.open({ title: 'DOE Standard Energy Efficiency Data Platform | NLR Data Terms', diff --git a/src/app/layout/common/user/user.component.html b/src/app/layout/common/user/user.component.html index aa6fcb5f..abed88eb 100644 --- a/src/app/layout/common/user/user.component.html +++ b/src/app/layout/common/user/user.component.html @@ -20,7 +20,7 @@ Profile - + Settings diff --git a/src/app/layout/common/user/user.component.ts b/src/app/layout/common/user/user.component.ts index 57c3fafe..23890ddf 100644 --- a/src/app/layout/common/user/user.component.ts +++ b/src/app/layout/common/user/user.component.ts @@ -51,4 +51,8 @@ export class UserComponent implements OnInit, OnDestroy { goToProfile() { void this._router.navigate(['/profile']) } + + goToSettings(): void { + void this._router.navigate(['/profile/display']) + } } diff --git a/src/app/layout/layout.component.ts b/src/app/layout/layout.component.ts index 65a89d58..62605dbe 100644 --- a/src/app/layout/layout.component.ts +++ b/src/app/layout/layout.component.ts @@ -3,7 +3,7 @@ import type { OnDestroy, OnInit } from '@angular/core' import { Component, DOCUMENT, inject, isDevMode, Renderer2, ViewEncapsulation } from '@angular/core' import { ActivatedRoute, NavigationEnd, Router } from '@angular/router' import { combineLatest, filter, map, Subject, takeUntil } from 'rxjs' -import { VersionService } from '@seed/api' +import { UserService, VersionService } from '@seed/api' import type { Scheme, SEEDConfig } from '@seed/services' import { ConfigService, MediaWatcherService, PlatformService } from '@seed/services' import { DevSettingsComponent } from './common/dev-settings/dev-settings.component' @@ -27,6 +27,7 @@ export class LayoutComponent implements OnInit, OnDestroy { private _platformService = inject(PlatformService) private _renderer = inject(Renderer2) private _router = inject(Router) + private _userService = inject(UserService) private _versionService = inject(VersionService) config: SEEDConfig @@ -37,6 +38,11 @@ export class LayoutComponent implements OnInit, OnDestroy { private readonly _unsubscribeAll$ = new Subject() ngOnInit(): void { + this._userService.currentUser$.pipe(takeUntil(this._unsubscribeAll$)).subscribe(({ settings }) => { + const { colorScheme } = settings + this._configService.config = { scheme: colorScheme === 'dark' || colorScheme === 'light' ? colorScheme : 'auto' } + }) + // Set the theme and scheme based on the configuration combineLatest([ this._configService.config$, diff --git a/src/app/modules/auth/sign-in/sign-in.component.html b/src/app/modules/auth/sign-in/sign-in.component.html index 67c8d7ba..3c935f65 100644 --- a/src/app/modules/auth/sign-in/sign-in.component.html +++ b/src/app/modules/auth/sign-in/sign-in.component.html @@ -62,26 +62,34 @@ > - - - I agree with the - NLR Data Terms + + Already accepted NLR terms + Review terms + + } @else { + + - - @if (isTermsInvalid) { - You must accept the NLR Data Terms. - } - + I agree with the + NLR Data Terms + + @if (isTermsInvalid) { + You must accept the NLR Data Terms. + } + + } diff --git a/src/app/modules/auth/sign-in/sign-in.component.spec.ts b/src/app/modules/auth/sign-in/sign-in.component.spec.ts new file mode 100644 index 00000000..3f17b1e2 --- /dev/null +++ b/src/app/modules/auth/sign-in/sign-in.component.spec.ts @@ -0,0 +1,98 @@ +import type { ComponentFixture } from '@angular/core/testing' +import { TestBed } from '@angular/core/testing' +import { ActivatedRoute, Router } from '@angular/router' +import { of } from 'rxjs' +import { ConfigService } from '@seed/api' +import { TermsService } from '@seed/services' +import { AuthService } from 'app/core/auth/auth.service' +import { AuthSignInComponent } from './sign-in.component' + +describe('AuthSignInComponent', () => { + let fixture: ComponentFixture + let component: AuthSignInComponent + let hasAcceptedTerms: jasmine.Spy + let recordTermsAcceptance: jasmine.Spy + let signIn: jasmine.Spy + let navigateByUrl: jasmine.Spy + + beforeEach(async () => { + hasAcceptedTerms = jasmine.createSpy('hasAcceptedTerms').and.returnValue(false) + recordTermsAcceptance = jasmine.createSpy('recordTermsAcceptance') + signIn = jasmine.createSpy('signIn').and.returnValue(of({ access: 'access', refresh: 'refresh' })) + navigateByUrl = jasmine.createSpy('navigateByUrl').and.returnValue(Promise.resolve(true)) + + await TestBed.configureTestingModule({ + imports: [AuthSignInComponent], + providers: [ + { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: { get: () => null } } } }, + { provide: Router, useValue: { navigateByUrl } }, + { provide: ConfigService, useValue: { config$: of({ allow_signup: false }) } }, + { provide: AuthService, useValue: { signIn } }, + { + provide: TermsService, + useValue: { hasAcceptedTerms, recordTermsAcceptance, showTermsOfService: jasmine.createSpy('showTermsOfService') }, + }, + ], + }) + .overrideComponent(AuthSignInComponent, { set: { template: '' } }) + .compileComponents() + }) + + afterEach(() => { + fixture?.destroy() + }) + + function createComponent(): void { + fixture = TestBed.createComponent(AuthSignInComponent) + component = fixture.componentInstance + fixture.detectChanges() + } + + it('requires terms acceptance when the cached acceptance has expired', () => { + createComponent() + component.signInForm.patchValue({ email: 'user@example.com', password: 'password' }) + + expect(component.termsPreviouslyAccepted).toBeFalse() + expect(component.signInForm.invalid).toBeTrue() + + component.signInForm.controls.terms.setValue(true) + expect(component.signInForm.valid).toBeTrue() + }) + + it('uses a current cached acceptance without recording a new timestamp', () => { + hasAcceptedTerms.and.returnValue(true) + createComponent() + component.signInForm.setValue({ email: 'user@example.com', password: 'password', terms: true }) + + component.signIn() + + expect(recordTermsAcceptance).not.toHaveBeenCalled() + expect(navigateByUrl).toHaveBeenCalledOnceWith('/signed-in-redirect') + }) + + it('records a new acceptance after sign-in succeeds', () => { + createComponent() + component.signInForm.setValue({ email: 'user@example.com', password: 'password', terms: true }) + + component.signIn() + + expect(recordTermsAcceptance).toHaveBeenCalledTimes(1) + expect(component.termsPreviouslyAccepted).toBeTrue() + expect(navigateByUrl).toHaveBeenCalledOnceWith('/signed-in-redirect') + }) + + it('waits for successful two-factor verification before recording acceptance', () => { + signIn.and.returnValues(of({ two_factor_required: true, two_factor_method: 'email' }), of({ access: 'access', refresh: 'refresh' })) + createComponent() + component.signInForm.setValue({ email: 'user@example.com', password: 'password', terms: true }) + + component.signIn() + expect(recordTermsAcceptance).not.toHaveBeenCalled() + + component.otpForm.controls.otp_token.setValue('123456') + component.submitOtp() + + expect(recordTermsAcceptance).toHaveBeenCalledTimes(1) + expect(navigateByUrl).toHaveBeenCalledOnceWith('/signed-in-redirect') + }) +}) diff --git a/src/app/modules/auth/sign-in/sign-in.component.ts b/src/app/modules/auth/sign-in/sign-in.component.ts index 9f719fc9..7b3b4b5f 100644 --- a/src/app/modules/auth/sign-in/sign-in.component.ts +++ b/src/app/modules/auth/sign-in/sign-in.component.ts @@ -32,6 +32,7 @@ export class AuthSignInComponent implements OnInit, OnDestroy { alert: Alert allowSignUp = false showAlert = false + termsPreviouslyAccepted = false twoFactorStep = false twoFactorMethod: 'email' | 'token' = 'token' signInForm: FormGroup<{ @@ -53,6 +54,14 @@ export class AuthSignInComponent implements OnInit, OnDestroy { terms: [false, Validators.requiredTrue], }) + this.signInForm.controls.email.valueChanges.pipe(takeUntil(this._unsubscribeAll$)).subscribe((email) => { + const accepted = !this.signInForm.controls.email.hasError('email') && this._termsOfServiceService.hasAcceptedTerms(email) + if (accepted !== this.termsPreviouslyAccepted) { + this.termsPreviouslyAccepted = accepted + this.signInForm.controls.terms.setValue(accepted) + } + }) + this.otpForm = new FormGroup({ otp_token: new FormControl('', { nonNullable: true, validators: [Validators.required] }), }) @@ -104,6 +113,8 @@ export class AuthSignInComponent implements OnInit, OnDestroy { const redirectURL = this._route.snapshot.queryParamMap.get('redirectURL') || '/signed-in-redirect' + this._recordTermsAcceptance() + // Navigate to the redirect url void this._router.navigateByUrl(redirectURL) }, @@ -111,7 +122,7 @@ export class AuthSignInComponent implements OnInit, OnDestroy { // Re-enable the form this.signInForm.enable() - this.signInForm.reset() + this.signInForm.reset({ email: '', password: '', terms: this.termsPreviouslyAccepted }) // Set the alert this.alert = { @@ -137,6 +148,7 @@ export class AuthSignInComponent implements OnInit, OnDestroy { this._authService.signIn({ ...this._pendingCredentials, otp_token }).subscribe({ next: () => { const redirectURL = this._route.snapshot.queryParamMap.get('redirectURL') || '/signed-in-redirect' + this._recordTermsAcceptance() void this._router.navigateByUrl(redirectURL) }, error: () => { @@ -171,4 +183,11 @@ export class AuthSignInComponent implements OnInit, OnDestroy { this.otpForm.reset() this.showAlert = false } + + private _recordTermsAcceptance(): void { + if (this.termsPreviouslyAccepted) return + + this._termsOfServiceService.recordTermsAcceptance(this.signInForm.value.email!) + this.termsPreviouslyAccepted = true + } } diff --git a/src/app/modules/profile/profile.component.ts b/src/app/modules/profile/profile.component.ts index 7ab3e4c7..64489a85 100644 --- a/src/app/modules/profile/profile.component.ts +++ b/src/app/modules/profile/profile.component.ts @@ -11,7 +11,7 @@ import { SharedImports } from '@seed/directives' imports: [HorizontalNavigationComponent, SharedImports, RouterOutlet], }) export class ProfileComponent { - tabs = ['Profile Info', 'Security', 'Two Factor Profile', 'Developer', 'Admin'] + tabs = ['Profile Info', 'Security', 'Two Factor Profile', 'Developer', 'Admin', 'Display'] readonly navigation: NavigationItem[] = [ { @@ -49,5 +49,12 @@ export class ProfileComponent { icon: 'fa-solid:user-gear', link: '/profile/admin', }, + { + id: 'display', + title: 'Display', + type: 'basic', + icon: 'fa-solid:gear', + link: '/profile/display', + }, ] } diff --git a/src/app/modules/profile/profile.routes.ts b/src/app/modules/profile/profile.routes.ts index 38d353ea..a65b4e06 100644 --- a/src/app/modules/profile/profile.routes.ts +++ b/src/app/modules/profile/profile.routes.ts @@ -3,6 +3,7 @@ import { AdminComponent } from 'app/modules/profile/admin/admin.component' import { ProfileDeveloperComponent } from 'app/modules/profile/developer/developer.component' import { ProfileInfoComponent } from 'app/modules/profile/info/info.component' import { ProfileSecurityComponent } from 'app/modules/profile/security/security.component' +import { ProfileSettingsComponent } from 'app/modules/profile/settings/settings.component' import { ProfileTwoFactorComponent } from 'app/modules/profile/two-factor/two-factor.component' export default [ @@ -36,4 +37,9 @@ export default [ title: 'Admin', component: AdminComponent, }, + { + path: 'display', + title: 'Display', + component: ProfileSettingsComponent, + }, ] satisfies Routes diff --git a/src/app/modules/profile/settings/settings.component.html b/src/app/modules/profile/settings/settings.component.html new file mode 100644 index 00000000..a6df4a73 --- /dev/null +++ b/src/app/modules/profile/settings/settings.component.html @@ -0,0 +1,27 @@ + + + + + {{ t('Display') }} + + + + + + + {{ t('Light') }} + + + + {{ t('Dark') }} + + + + + diff --git a/src/app/modules/profile/settings/settings.component.spec.ts b/src/app/modules/profile/settings/settings.component.spec.ts new file mode 100644 index 00000000..888c9417 --- /dev/null +++ b/src/app/modules/profile/settings/settings.component.spec.ts @@ -0,0 +1,92 @@ +import type { ComponentFixture } from '@angular/core/testing' +import { TestBed } from '@angular/core/testing' +import type { Observable } from 'rxjs' +import { BehaviorSubject, of, throwError } from 'rxjs' +import type { CurrentUser, OrganizationUserResponse, OrganizationUserSettings } from '@seed/api' +import { OrganizationService, UserService } from '@seed/api' +import { ConfigService } from '@seed/services' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' +import { ProfileSettingsComponent } from './settings.component' + +describe('ProfileSettingsComponent', () => { + let component: ProfileSettingsComponent + let fixture: ComponentFixture + let currentUser: CurrentUser + let scheme$: BehaviorSubject<'dark' | 'light'> + let configChanges: { scheme: 'auto' | 'dark' | 'light' }[] + let updateOrganizationUser: jasmine.Spy + let showSuccess: jasmine.Spy + + beforeEach(async () => { + currentUser = { + id: 1, + org_id: 2, + org_user_id: 3, + settings: { colorScheme: 'light' }, + } as CurrentUser + scheme$ = new BehaviorSubject<'dark' | 'light'>('light') + configChanges = [] + updateOrganizationUser = jasmine + .createSpy('updateOrganizationUser') + .and.callFake((_orgUserId: number, _orgId: number, settings: OrganizationUserSettings): Observable => { + return of({ data: { settings }, status: 'success' } as OrganizationUserResponse) + }) + showSuccess = jasmine.createSpy('success') + + await TestBed.configureTestingModule({ + imports: [ProfileSettingsComponent], + providers: [ + { provide: UserService, useValue: { currentUser$: of(currentUser) } }, + { provide: OrganizationService, useValue: { updateOrganizationUser } }, + { + provide: ConfigService, + useValue: { + scheme$: scheme$.asObservable(), + set config(config: { scheme: 'auto' | 'dark' | 'light' }) { + configChanges.push(config) + scheme$.next(config.scheme === 'auto' ? 'light' : config.scheme) + }, + }, + }, + { provide: SnackBarService, useValue: { success: showSuccess } }, + ], + }) + .overrideComponent(ProfileSettingsComponent, { set: { template: '' } }) + .compileComponents() + + fixture = TestBed.createComponent(ProfileSettingsComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + afterEach(() => { + fixture.destroy() + }) + + it('applies and persists a selected scheme', () => { + component.setScheme('dark') + + expect(configChanges).toEqual([{ scheme: 'dark' }]) + expect(updateOrganizationUser).toHaveBeenCalledOnceWith(3, 2, { colorScheme: 'dark' }) + expect(currentUser.settings.colorScheme).toBe('dark') + expect(showSuccess).toHaveBeenCalledOnceWith('Changes Saved') + expect(component.saving).toBeFalse() + }) + + it('restores the previous scheme when persistence fails', () => { + updateOrganizationUser.and.returnValue(throwError(() => new Error('Save failed'))) + + component.setScheme('dark') + + expect(configChanges).toEqual([{ scheme: 'dark' }, { scheme: 'light' }]) + expect(currentUser.settings.colorScheme).toBe('light') + expect(component.saving).toBeFalse() + }) + + it('does not save an already-persisted scheme again', () => { + component.setScheme('light') + + expect(configChanges).toEqual([]) + expect(updateOrganizationUser).not.toHaveBeenCalled() + }) +}) diff --git a/src/app/modules/profile/settings/settings.component.ts b/src/app/modules/profile/settings/settings.component.ts new file mode 100644 index 00000000..adda3f6d --- /dev/null +++ b/src/app/modules/profile/settings/settings.component.ts @@ -0,0 +1,70 @@ +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import type { Observable } from 'rxjs' +import { Subject, takeUntil } from 'rxjs' +import type { CurrentUser } from '@seed/api' +import { OrganizationService, UserService } from '@seed/api' +import { SharedImports } from '@seed/directives' +import { MaterialImports } from '@seed/materials' +import type { Scheme } from '@seed/services' +import { ConfigService } from '@seed/services' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' + +type ColorScheme = Exclude + +@Component({ + selector: 'seed-profile-settings', + templateUrl: './settings.component.html', + imports: [MaterialImports, SharedImports], +}) +export class ProfileSettingsComponent implements OnInit, OnDestroy { + private _configService = inject(ConfigService) + private _organizationService = inject(OrganizationService) + private _snackBar = inject(SnackBarService) + private _userService = inject(UserService) + private readonly _unsubscribeAll$ = new Subject() + + currentUser: CurrentUser + saving = false + scheme: ColorScheme + scheme$: Observable = this._configService.scheme$ + + ngOnInit(): void { + this._userService.currentUser$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((currentUser) => { + this.currentUser = currentUser + }) + + this.scheme$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((scheme) => { + this.scheme = scheme + }) + } + + ngOnDestroy(): void { + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } + + setScheme(scheme: ColorScheme): void { + if (this.saving || this.currentUser.settings.colorScheme === scheme) return + + const previousScheme = this.currentUser.settings.colorScheme + const settings = { ...this.currentUser.settings, colorScheme: scheme } + + this.saving = true + this._configService.config = { scheme } + this._organizationService + .updateOrganizationUser(this.currentUser.org_user_id, this.currentUser.org_id, settings) + .pipe(takeUntil(this._unsubscribeAll$)) + .subscribe({ + next: ({ data }) => { + this.currentUser.settings = data.settings + this.saving = false + this._snackBar.success('Changes Saved') + }, + error: () => { + this._configService.config = { scheme: previousScheme ?? 'auto' } + this.saving = false + }, + }) + } +}