diff --git a/package.json b/package.json index bc335d16c1..52339d2ef0 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,13 @@ "test": "ng test --watch=false --browsers=ChromeHeadless", "test-basic": "ng test", "test:no-parallel": "ng test", - "lint": "ng lint", + "lint": "eslint -c .eslintrc.json", "e2e": "ng e2e", "postinstall": "npx jetify", "format:check": "prettier --write ./src", "prepare": "husky install", - "lint:diff": "eslint -c .eslintrc.json $(git diff --name-only --diff-filter=ACMRTUXB origin/$GITHUB_BASE_REF | grep -E \"(.js$|.ts$|.tsx$)\")" + "lint:diff": "eslint -c .eslintrc.json $(git diff --name-only --diff-filter=ACMRTUXB origin/$GITHUB_BASE_REF | grep -E \"(.js$|.ts$|.tsx$)\")", + "lint:diff:local": "eslint --fix -c .eslintrc.json $(git diff --name-only --diff-filter=ACMRTUXB origin/master | grep -E '(.js$|.ts$|.tsx$)')" }, "resolutions": { "webpack": "^5.0.0" diff --git a/src/app/app.component.ts b/src/app/app.component.ts index f21177b9af..7dc9aefdec 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,7 +1,7 @@ import { Component, OnInit, EventEmitter, NgZone, ViewChild } from '@angular/core'; import { Platform, MenuController, NavController } from '@ionic/angular'; -import { from, concat, Observable, noop } from 'rxjs'; -import { switchMap, shareReplay, filter } from 'rxjs/operators'; +import { from, concat, Observable, noop, forkJoin } from 'rxjs'; +import { switchMap, shareReplay, filter, take } from 'rxjs/operators'; import { Router, NavigationEnd, NavigationStart } from '@angular/router'; import { AuthService } from 'src/app/core/services/auth.service'; import { UserEventService } from 'src/app/core/services/user-event.service'; @@ -104,6 +104,7 @@ export class AppComponent implements OnInit { await StatusBar.setStyle({ style: Style.Default, }); + setTimeout(async () => await SplashScreen.hide(), 1000); /* @@ -147,8 +148,12 @@ export class AppComponent implements OnInit { ngOnInit(): void { this.setupNetworkWatcher(); + // This was done as a security fix for appknox + // eslint-disable-next-line if ((window as any) && (window as any).localStorage) { - const lstorage = (window as any).localStorage; + // eslint-disable-next-line + const lstorage = (window as any).localStorage as { removeItem: (key: string) => void }; + // eslint-disable-next-line Object.keys(lstorage) .filter((key) => key.match(/^fyle/)) .forEach((key) => lstorage.removeItem(key)); @@ -158,10 +163,13 @@ export class AppComponent implements OnInit { this.isOnline = isOnline; }); - from(this.routerAuthService.isLoggedIn()).subscribe((loggedInStatus) => { + forkJoin({ + loggedInStatus: this.routerAuthService.isLoggedIn(), + isOnline: this.isConnected$.pipe(take(1)), + }).subscribe(({ loggedInStatus, isOnline }) => { this.isUserLoggedIn = loggedInStatus; if (loggedInStatus) { - if (this.isOnline) { + if (isOnline) { this.sidemenuRef.showSideMenuOnline(); } else { this.sidemenuRef.showSideMenuOffline(); diff --git a/src/app/auth/switch-org/switch-org.page.ts b/src/app/auth/switch-org/switch-org.page.ts index 8d80770398..d5275f2bf5 100644 --- a/src/app/auth/switch-org/switch-org.page.ts +++ b/src/app/auth/switch-org/switch-org.page.ts @@ -29,7 +29,6 @@ import { ResendEmailVerification } from 'src/app/core/models/resend-email-verifi import { RouterAuthService } from 'src/app/core/services/router-auth.service'; import { TransactionService } from 'src/app/core/services/transaction.service'; import { DeepLinkService } from 'src/app/core/services/deep-link.service'; -import { UnflattenedTransaction } from 'src/app/core/models/unflattened-transaction.model'; import { ExpensesService } from 'src/app/core/services/platform/v1/spender/expenses.service'; @Component({ @@ -38,11 +37,11 @@ import { ExpensesService } from 'src/app/core/services/platform/v1/spender/expen styleUrls: ['./switch-org.page.scss'], }) export class SwitchOrgPage implements OnInit, AfterViewChecked { - @ViewChild('search') searchRef: ElementRef; + @ViewChild('search') searchRef: ElementRef; - @ViewChild('content') contentRef: ElementRef; + @ViewChild('content') contentRef: ElementRef; - @ViewChild('searchOrgsInput') searchOrgsInput: ElementRef; + @ViewChild('searchOrgsInput') searchOrgsInput: ElementRef; orgs$: Observable; @@ -88,15 +87,15 @@ export class SwitchOrgPage implements OnInit, AfterViewChecked { private expensesService: ExpensesService ) {} - ngOnInit() { + ngOnInit(): void { this.isIos = this.platform.is('ios'); } - ngAfterViewChecked() { + ngAfterViewChecked(): void { this.cdRef.detectChanges(); } - ionViewWillEnter() { + ionViewWillEnter(): void { const that = this; that.searchInput = ''; that.isLoading = true; @@ -108,11 +107,14 @@ export class SwitchOrgPage implements OnInit, AfterViewChecked { that.cdRef.detectChanges(); }); - const choose = that.activatedRoute.snapshot.params.choose && JSON.parse(that.activatedRoute.snapshot.params.choose); + const choose = + that.activatedRoute.snapshot.params.choose && + (JSON.parse(that.activatedRoute.snapshot.params.choose as string) as boolean); const isFromInviteLink: boolean = - that.activatedRoute.snapshot.params.invite_link && JSON.parse(that.activatedRoute.snapshot.params.invite_link); - const orgId = that.activatedRoute.snapshot.params.orgId; - const txnId = this.activatedRoute.snapshot.params.txnId; + that.activatedRoute.snapshot.params.invite_link && + (JSON.parse(that.activatedRoute.snapshot.params.invite_link as string) as boolean); + const orgId = that.activatedRoute.snapshot.params.orgId as string; + const txnId = this.activatedRoute.snapshot.params.txnId as string; if (orgId && txnId) { return this.redirectToExpensePage(orgId, txnId); @@ -148,15 +150,16 @@ export class SwitchOrgPage implements OnInit, AfterViewChecked { this.trackSwitchOrgLaunchTime(); }); - this.filteredOrgs$ = fromEvent(this.searchOrgsInput.nativeElement, 'keyup').pipe( - map((event: any) => event.srcElement.value), + // eslint-disable-next-line + this.filteredOrgs$ = fromEvent<{ srcElement: { value: string } }>(this.searchOrgsInput.nativeElement, 'keyup').pipe( + map((event) => event.srcElement.value), startWith(''), distinctUntilChanged(), switchMap((searchText) => currentOrgs$.pipe(map((orgs) => this.getOrgsWhichContainSearchText(orgs, searchText)))) ); } - setSentryUser(eou: ExtendedOrgUser) { + setSentryUser(eou: ExtendedOrgUser): void { if (eou) { Sentry.setUser({ id: eou.us.email + ' - ' + eou.ou.id, @@ -170,7 +173,7 @@ export class SwitchOrgPage implements OnInit, AfterViewChecked { return this.routerAuthService.resendVerificationLink(email, orgId); } - showToastNotification(msg: string) { + showToastNotification(msg: string): void { const toastMessageData = { message: msg, }; @@ -182,7 +185,7 @@ export class SwitchOrgPage implements OnInit, AfterViewChecked { this.trackingService.showToastMessage({ ToastContent: toastMessageData.message }); } - redirectToExpensePage(orgId: string, txnId: string) { + redirectToExpensePage(orgId: string, txnId: string): void { from(this.loaderService.showLoader()) .pipe( switchMap(() => this.orgService.switchOrg(orgId)), @@ -208,7 +211,7 @@ export class SwitchOrgPage implements OnInit, AfterViewChecked { }); } - logoutIfSingleOrg(orgs: Org[]) { + logoutIfSingleOrg(orgs: Org[]): void { /* * Case: When a user is added to an SSO org but hasn't verified their account through the link. * After showing the alert, the user will be redirected to the sign-in page since there is no other org they are a part of. @@ -219,12 +222,12 @@ export class SwitchOrgPage implements OnInit, AfterViewChecked { } } - handleDismissPopup(action = 'cancel', email: string, orgId: string, orgs: Org[]) { + handleDismissPopup(action = 'cancel', email: string, orgId: string, orgs: Org[]): void { if (action === 'resend') { // If user clicks on resend Button, Resend Invite to the user and then logout if user have only one org. this.resendInvite(email, orgId) .pipe( - catchError((error) => { + catchError((error: Error) => { this.showToastNotification('Verification link could not be sent. Please try again!'); return throwError(() => error); }) @@ -238,7 +241,7 @@ export class SwitchOrgPage implements OnInit, AfterViewChecked { } } - async showEmailNotVerifiedAlert() { + async showEmailNotVerifiedAlert(): Promise { const eou$ = from(this.authService.getEou()); forkJoin({ eou: eou$, @@ -266,13 +269,13 @@ export class SwitchOrgPage implements OnInit, AfterViewChecked { }); await popover.present(); - const { data } = await popover.onWillDismiss(); + const { data } = await popover.onWillDismiss<{ action: string }>(); this.handleDismissPopup(data?.action, email, orgId, orgs); }); } - navigateToSetupPage(roles: string[]) { + navigateToSetupPage(roles: string[]): void { if (roles.includes('OWNER')) { this.router.navigate(['/', 'post_verification', 'setup_account']); } else { @@ -338,7 +341,7 @@ export class SwitchOrgPage implements OnInit, AfterViewChecked { return of(null); } - async proceed(isFromInviteLink?: boolean) { + async proceed(isFromInviteLink?: boolean): Promise { const pendingDetails$ = this.userService.isPendingDetails().pipe(shareReplay(1)); const eou$ = from(this.authService.getEou()); const roles$ = from(this.authService.getRoles().pipe(shareReplay(1))); @@ -356,7 +359,7 @@ export class SwitchOrgPage implements OnInit, AfterViewChecked { this.checkUserAppVersion(); } - checkUserAppVersion() { + checkUserAppVersion(): void { this.deviceService .getDeviceInfo() .pipe( @@ -378,7 +381,7 @@ export class SwitchOrgPage implements OnInit, AfterViewChecked { }); } - trackSwitchOrg(org: Org, originalEou) { + trackSwitchOrg(org: Org, originalEou: ExtendedOrgUser): void { const isDestinationOrgActive = originalEou.ou && originalEou.ou.org_id === org.id; const isCurrentOrgPrimary = originalEou.ou && originalEou.ou.is_primary; from(this.authService.getEou()).subscribe((currentEou) => { @@ -398,7 +401,7 @@ export class SwitchOrgPage implements OnInit, AfterViewChecked { }); } - async switchOrg(org: Org) { + async switchOrg(org: Org): Promise { // Tracking the time on click of switch org performance.mark(PerfTrackers.onClickSwitchOrg); const originalEou = await this.authService.getEou(); @@ -414,7 +417,7 @@ export class SwitchOrgPage implements OnInit, AfterViewChecked { this.recentLocalStorageItemsService.clearRecentLocalStorageCache(); from(this.proceed()).subscribe(noop); }, - async (err) => { + async () => { await this.secureStorageService.clearAll(); await this.storageService.clearAll(); this.userEventService.logout(); @@ -424,7 +427,7 @@ export class SwitchOrgPage implements OnInit, AfterViewChecked { ); } - signOut() { + signOut(): void { try { forkJoin({ device: this.deviceService.getDeviceInfo(), @@ -452,36 +455,36 @@ export class SwitchOrgPage implements OnInit, AfterViewChecked { } } - getOrgsWhichContainSearchText(orgs: Org[], searchText: string) { + getOrgsWhichContainSearchText(orgs: Org[], searchText: string): Org[] { return orgs.filter((org) => Object.values(org) - .map((value) => value && value.toString().toLowerCase()) + .map((value: string | Date | number | boolean) => value && value.toString().toLowerCase()) .filter((value) => !!value) .some((value) => value.toLowerCase().includes(searchText.toLowerCase())) ); } - resetSearch() { + resetSearch(): void { this.searchInput = ''; - const searchInputElement = this.searchOrgsInput.nativeElement as HTMLInputElement; + const searchInputElement = this.searchOrgsInput.nativeElement; searchInputElement.value = ''; searchInputElement.dispatchEvent(new Event('keyup')); } - openSearchBar() { + openSearchBar(): void { this.contentRef.nativeElement.classList.add('switch-org__content-container__content-block--hide'); this.searchRef.nativeElement.classList.add('switch-org__content-container__search-block--show'); setTimeout(() => this.searchOrgsInput.nativeElement.focus(), 200); } - cancelSearch() { + cancelSearch(): void { this.resetSearch(); this.searchOrgsInput.nativeElement.blur(); this.contentRef.nativeElement.classList.remove('switch-org__content-container__content-block--hide'); this.searchRef.nativeElement.classList.remove('switch-org__content-container__search-block--show'); } - trackSwitchOrgLaunchTime() { + trackSwitchOrgLaunchTime(): void { try { if (performance.getEntriesByName('switch org launch time').length === 0) { // Time taken to land on switch org page after sign-in @@ -492,7 +495,7 @@ export class SwitchOrgPage implements OnInit, AfterViewChecked { const measureLaunchTime = performance.getEntriesByName('switch org launch time'); - // eslint-disable-next-line @typescript-eslint/dot-notation + // eslint-disable-next-line const loginMethod = performance.getEntriesByName('login start time')[0]['detail']; // Converting the duration to seconds and fix it to 3 decimal places @@ -500,9 +503,10 @@ export class SwitchOrgPage implements OnInit, AfterViewChecked { this.trackingService.switchOrgLaunchTime({ 'Switch org launch time': launchTimeDuration, + // eslint-disable-next-line 'Login method': loginMethod, }); } - } catch (error) {} + } catch (_) {} } } diff --git a/src/app/core/services/date.service.spec.ts b/src/app/core/services/date.service.spec.ts index cf2961e769..1f081f9e23 100644 --- a/src/app/core/services/date.service.spec.ts +++ b/src/app/core/services/date.service.spec.ts @@ -283,5 +283,111 @@ describe('DateService', () => { expect(dateService.fixDates>(data)).toEqual(updatedData); expect(dateService.getUTCDate).toHaveBeenCalledOnceWith(new Date('2023-02-13T01:00:00.000Z')); }); + + describe('date ingestion methods should work as expected', () => { + const americaTimezone = 'US/Pacific'; + const newZeaLandTimezone = 'Etc/GMT-12'; // ETC timezones are opposite of what they show. + + // describe('samples of timezone based date testing', () => { + // it('timezone mock is working as expected', () => { + // const df = dayjs('2024-05-14T00:00:00.000Z').tz(newZeaLandTimezone); + // const dateAheadOfUTC = df.get('date'); + + // const dl = dayjs('2024-05-14T00:00:00.000Z').tz(americaTimezone); + // const dateBehindUtc = dl.get('date'); + + // expect(dateAheadOfUTC).toEqual(dateBehindUtc + 1); + // }); + // }); + + describe('GET:', () => { + it('date is viewed in new zealand', () => { + const incomingDate = dateService.getUTCMidAfternoonDate( + dayjs('2024-05-14T00:00:00.000Z').tz(newZeaLandTimezone).toDate() + ); + const date = incomingDate.getDate(); + const month = incomingDate.getMonth() + 1; // js month is 0 - 11 + const year = incomingDate.getFullYear(); + + expect(date).toBe(14); + expect(month).toBe(5); + expect(year).toBe(2024); + }); + + it('date is viewed in america', () => { + const incomingDate = dateService.getUTCMidAfternoonDate( + dayjs('2024-05-14T00:00:00.000Z').tz(americaTimezone).toDate() + ); + const date = incomingDate.getDate(); + const month = incomingDate.getMonth() + 1; // js month is 0 - 11 + const year = incomingDate.getFullYear(); + + expect(date).toBe(14); + expect(month).toBe(5); + expect(year).toBe(2024); + }); + }); + + describe('POST:', () => { + it('new date created from new zealand', () => { + const outgoingDate = dayjs(new Date()).tz(newZeaLandTimezone).toDate(); + outgoingDate.setHours(12); + outgoingDate.setMinutes(0); + outgoingDate.setSeconds(0); + outgoingDate.setMilliseconds(0); + const transformedOutgoingDate = dateService.getUTCMidAfternoonDate(outgoingDate); + + const date = new Date().getDate(); + const month = new Date().getMonth() + 1; // js month is 0 - 11 + const year = new Date().getFullYear(); + + expect(transformedOutgoingDate.toISOString().split('T')[0]).toBe( + `${year}-${month < 10 ? `0${month}` : month}-${date}` + ); + }); + + it('new date created from america', () => { + const outgoingDate = dayjs(new Date()).tz(americaTimezone).toDate(); + outgoingDate.setHours(12); + outgoingDate.setMinutes(0); + outgoingDate.setSeconds(0); + outgoingDate.setMilliseconds(0); + const transformedOutgoingDate = dateService.getUTCMidAfternoonDate(outgoingDate); + + const date = new Date().getDate(); + const month = new Date().getMonth() + 1; // js month is 0 - 11 + const year = new Date().getFullYear(); + + expect(transformedOutgoingDate.toISOString().split('T')[0]).toBe( + `${year}-${month < 10 ? `0${month}` : month}-${date}` + ); + }); + + it('date edited in new zealand', () => { + const newDate = dayjs(new Date('2024-05-14T00:00:00.000Z')).tz(newZeaLandTimezone).toDate(); + newDate.setDate(16); + newDate.setHours(12); + newDate.setMinutes(0); + newDate.setSeconds(0); + newDate.setMilliseconds(0); + + const outgoingDate = dateService.getUTCMidAfternoonDate(newDate); + + expect(outgoingDate.toISOString().split('T')[0]).toBe('2024-05-16'); + }); + + it('date edited in america', () => { + const newDate = dayjs(new Date('2024-05-14T00:00:00.000Z')).tz(americaTimezone).toDate(); + newDate.setDate(16); + newDate.setHours(12); + newDate.setMinutes(0); + newDate.setSeconds(0); + newDate.setMilliseconds(0); + const outgoingDate = dateService.getUTCMidAfternoonDate(newDate); + + expect(outgoingDate.toISOString().split('T')[0]).toBe('2024-05-16'); + }); + }); + }); }); }); diff --git a/src/app/core/services/date.service.ts b/src/app/core/services/date.service.ts index d2b1627b60..722a20d877 100644 --- a/src/app/core/services/date.service.ts +++ b/src/app/core/services/date.service.ts @@ -250,4 +250,13 @@ export class DateService { isValidDate(date: string | Date): boolean { return dayjs(date).isValid(); } + + getUTCMidAfternoonDate(date: Date): Date { + const userTimezoneOffset = date.getTimezoneOffset() * 60000; + const newDate = new Date(date.getTime() + userTimezoneOffset + 12 * 60 * 60 * 1000); + newDate.setUTCDate(date.getDate()); + newDate.setUTCMonth(date.getMonth()); + newDate.setUTCFullYear(date.getFullYear()); + return newDate; + } } diff --git a/src/app/core/services/launch-darkly.service.ts b/src/app/core/services/launch-darkly.service.ts index 486b44f5ba..4008aac482 100644 --- a/src/app/core/services/launch-darkly.service.ts +++ b/src/app/core/services/launch-darkly.service.ts @@ -74,8 +74,14 @@ export class LaunchDarklyService { return this.getVariation('android-numeric-keypad', false); } + + getImmediate(key: string, defaultValue: boolean): boolean { + return this.ldClient.variation(key, defaultValue) as boolean; + } + checkIfManualFlaggingFeatureIsEnabled(): Observable<{ value: boolean }> { return this.getVariation('deprecate_manual_flagging', true).pipe(map((value) => ({ value }))); + } // Checks if the passed in user is the same as the user which is initialized to LaunchDarkly (if any) diff --git a/src/app/core/services/platform/v1/shared/date.service.spec.ts b/src/app/core/services/platform/v1/shared/date.service.spec.ts index 22449db15c..b68a73acde 100644 --- a/src/app/core/services/platform/v1/shared/date.service.spec.ts +++ b/src/app/core/services/platform/v1/shared/date.service.spec.ts @@ -2,33 +2,40 @@ import { TestBed } from '@angular/core/testing'; import { DateService } from './date.service'; +import * as dayjs from 'dayjs'; +import * as timezone from 'dayjs/plugin/timezone'; +import * as utc from 'dayjs/plugin/utc'; + +dayjs.extend(utc); +dayjs.extend(timezone); + describe('DateService', () => { - let service: DateService; + let dateService: DateService; beforeEach(() => { TestBed.configureTestingModule({}); - service = TestBed.inject(DateService); + dateService = TestBed.inject(DateService); }); it('should be created', () => { - expect(service).toBeTruthy(); + expect(dateService).toBeTruthy(); }); it('getUTCDate(): should get UTC date', () => { const date = new Date('2023-02-24T12:03:57.680Z'); const userTimezoneOffset = date.getTimezoneOffset() * 60000; - expect(service.getUTCDate(date)).toEqual(new Date(date.getTime() + userTimezoneOffset)); + expect(dateService.getUTCDate(date)).toEqual(new Date(date.getTime() + userTimezoneOffset)); }); describe('fixDates()', () => { it('should return the value as is in case it is a nullish value', () => { - expect(service.fixDates(null)).toBeNull(); - expect(service.fixDates(undefined)).toBeUndefined(); + expect(dateService.fixDates(null)).toBeNull(); + expect(dateService.fixDates(undefined)).toBeUndefined(); }); it('should return the value as is in case it is non-object value', () => { - expect(service.fixDates('string')).toEqual('string'); + expect(dateService.fixDates('string')).toEqual('string'); }); it('should convert date strings to date objects for keys ending with _at', () => { @@ -44,7 +51,7 @@ describe('DateService', () => { ], }; - const result = service.fixDates(mockObject); + const result = dateService.fixDates(mockObject); expect(result.spent_at).toEqual(jasmine.any(Date)); expect(result.data.updated_at).toEqual(jasmine.any(Date)); @@ -65,12 +72,118 @@ describe('DateService', () => { ], }; - const result = service.fixDates(mockObject); + const result = dateService.fixDates(mockObject); expect(result.date).toEqual(jasmine.any(Date)); expect(result.data.start_date).toEqual(jasmine.any(Date)); expect(result.array[0].invoice_dt).toEqual(jasmine.any(Date)); expect(result.array[0].end_date).toEqual(jasmine.any(Date)); }); + + describe('date ingestion methods should work as expected', () => { + const americaTimezone = 'US/Pacific'; + const newZeaLandTimezone = 'Etc/GMT-12'; // ETC timezones are opposite of what they show. + + // describe('samples of timezone based date testing', () => { + // it('timezone mock is working as expected', () => { + // const df = dayjs('2024-05-14T00:00:00.000Z').tz(newZeaLandTimezone); + // const dateAheadOfUTC = df.get('date'); + + // const dl = dayjs('2024-05-14T00:00:00.000Z').tz(americaTimezone); + // const dateBehindUtc = dl.get('date'); + + // expect(dateAheadOfUTC).toEqual(dateBehindUtc + 1); + // }); + // }); + + describe('GET:', () => { + it('date is viewed in new zealand', () => { + const incomingDate = dateService.getUTCMidAfternoonDate( + dayjs('2024-05-14T00:00:00.000Z').tz(newZeaLandTimezone).toDate() + ); + const date = incomingDate.getDate(); + const month = incomingDate.getMonth() + 1; // js month is 0 - 11 + const year = incomingDate.getFullYear(); + + expect(date).toBe(14); + expect(month).toBe(5); + expect(year).toBe(2024); + }); + + it('date is viewed in america', () => { + const incomingDate = dateService.getUTCMidAfternoonDate( + dayjs('2024-05-14T00:00:00.000Z').tz(americaTimezone).toDate() + ); + const date = incomingDate.getDate(); + const month = incomingDate.getMonth() + 1; // js month is 0 - 11 + const year = incomingDate.getFullYear(); + + expect(date).toBe(14); + expect(month).toBe(5); + expect(year).toBe(2024); + }); + }); + + describe('POST:', () => { + it('new date created from new zealand', () => { + const outgoingDate = dayjs(new Date()).tz(newZeaLandTimezone).toDate(); + outgoingDate.setHours(12); + outgoingDate.setMinutes(0); + outgoingDate.setSeconds(0); + outgoingDate.setMilliseconds(0); + const transformedOutgoingDate = dateService.getUTCMidAfternoonDate(outgoingDate); + + const date = new Date().getDate(); + const month = new Date().getMonth() + 1; // js month is 0 - 11 + const year = new Date().getFullYear(); + + expect(transformedOutgoingDate.toISOString().split('T')[0]).toBe( + `${year}-${month < 10 ? `0${month}` : month}-${date}` + ); + }); + + it('new date created from america', () => { + const outgoingDate = dayjs(new Date()).tz(americaTimezone).toDate(); + outgoingDate.setHours(12); + outgoingDate.setMinutes(0); + outgoingDate.setSeconds(0); + outgoingDate.setMilliseconds(0); + const transformedOutgoingDate = dateService.getUTCMidAfternoonDate(outgoingDate); + + const date = new Date().getDate(); + const month = new Date().getMonth() + 1; // js month is 0 - 11 + const year = new Date().getFullYear(); + + expect(transformedOutgoingDate.toISOString().split('T')[0]).toBe( + `${year}-${month < 10 ? `0${month}` : month}-${date}` + ); + }); + + it('date edited in new zealand', () => { + const newDate = dayjs(new Date('2024-05-14T00:00:00.000Z')).tz(newZeaLandTimezone).toDate(); + newDate.setDate(16); + newDate.setHours(12); + newDate.setMinutes(0); + newDate.setSeconds(0); + newDate.setMilliseconds(0); + + const outgoingDate = dateService.getUTCMidAfternoonDate(newDate); + + expect(outgoingDate.toISOString().split('T')[0]).toBe('2024-05-16'); + }); + + it('date edited in america', () => { + const newDate = dayjs(new Date('2024-05-14T00:00:00.000Z')).tz(americaTimezone).toDate(); + newDate.setDate(16); + newDate.setHours(12); + newDate.setMinutes(0); + newDate.setSeconds(0); + newDate.setMilliseconds(0); + const outgoingDate = dateService.getUTCMidAfternoonDate(newDate); + + expect(outgoingDate.toISOString().split('T')[0]).toBe('2024-05-16'); + }); + }); + }); }); }); diff --git a/src/app/core/services/platform/v1/shared/date.service.ts b/src/app/core/services/platform/v1/shared/date.service.ts index 34a35dc525..a209a94e42 100644 --- a/src/app/core/services/platform/v1/shared/date.service.ts +++ b/src/app/core/services/platform/v1/shared/date.service.ts @@ -13,6 +13,15 @@ export class DateService { return new Date(date.getTime() + userTimezoneOffset); } + getUTCMidAfternoonDate(date: Date): Date { + const userTimezoneOffset = date.getTimezoneOffset() * 60000; + const newDate = new Date(date.getTime() + userTimezoneOffset + 12 * 60 * 60 * 1000); + newDate.setUTCDate(date.getDate()); + newDate.setUTCMonth(date.getMonth()); + newDate.setUTCFullYear(date.getFullYear()); + return newDate; + } + fixDates(object: T): T { if (!object || typeof object !== 'object') { return object; diff --git a/src/app/core/services/transaction.service.spec.ts b/src/app/core/services/transaction.service.spec.ts index 5f03e5d278..3fcd1f55f1 100644 --- a/src/app/core/services/transaction.service.spec.ts +++ b/src/app/core/services/transaction.service.spec.ts @@ -61,6 +61,7 @@ import { cloneDeep } from 'lodash'; import { expensesCacheBuster$ } from '../cache-buster/expense-cache-buster'; import { ExpensesService } from './platform/v1/spender/expenses.service'; import { expenseData } from '../mock-data/platform/v1/expense.data'; +import { LaunchDarklyService } from './launch-darkly.service'; describe('TransactionService', () => { let transactionService: TransactionService; @@ -110,10 +111,15 @@ describe('TransactionService', () => { const orgSettingsServiceSpy = jasmine.createSpyObj('OrgSettingsService', ['get']); const accountsServiceSpy = jasmine.createSpyObj('AccountsService', ['getEMyAccounts']); const expensesServiceSpy = jasmine.createSpyObj('ExpensesService', ['attachReceiptsToExpense']); + const ldServiceSpy = jasmine.createSpyObj('LaunchDarklyService', ['getImmediate']); TestBed.configureTestingModule({ providers: [ TransactionService, + { + provide: LaunchDarklyService, + useValue: ldServiceSpy, + }, { provide: ApiService, useValue: apiServiceSpy, @@ -422,12 +428,6 @@ describe('TransactionService', () => { expect(transactionService.generateTypeOrFilter(filters)).toEqual(typeOrFilter); }); - it('fixDates(): should fix dates', () => { - const mockExpenseData = cloneDeep(expenseDataWithDateString); - // @ts-ignore - expect(transactionService.fixDates(mockExpenseData)).toEqual(expenseData1); - }); - it('getPaymentModeforEtxn(): should return payment mode for etxn', () => { spyOn(transactionService, 'isEtxnInPaymentMode').and.returnValue(true); const paymentModeList = [ diff --git a/src/app/core/services/transaction.service.ts b/src/app/core/services/transaction.service.ts index cd012e2ed9..34abe50f55 100644 --- a/src/app/core/services/transaction.service.ts +++ b/src/app/core/services/transaction.service.ts @@ -3,15 +3,12 @@ import { ApiService } from './api.service'; import { DateService } from './date.service'; import { map, switchMap, concatMap, reduce } from 'rxjs/operators'; import { StorageService } from './storage.service'; -import { NetworkService } from './network.service'; import { from, Observable, range, forkJoin, of } from 'rxjs'; import { ApiV2Service } from './api-v2.service'; -import { DataTransformService } from './data-transform.service'; import { AuthService } from './auth.service'; import { OrgUserSettingsService } from './org-user-settings.service'; import { TimezoneService } from 'src/app/core/services/timezone.service'; import { UtilityService } from 'src/app/core/services/utility.service'; -import { FileService } from 'src/app/core/services/file.service'; import { Expense } from '../models/expense.model'; import { Cacheable, CacheBuster } from 'ts-cacheable'; import { UserEventService } from './user-event.service'; @@ -43,6 +40,7 @@ import { CorporateCardTransactionRes } from '../models/platform/v1/corporate-car import { ExpenseFilters } from '../models/expense-filters.model'; import { ExpensesService } from './platform/v1/spender/expenses.service'; import { expensesCacheBuster$ } from '../cache-buster/expense-cache-buster'; +import { LaunchDarklyService } from './launch-darkly.service'; enum FilterState { READY_TO_REPORT = 'READY_TO_REPORT', @@ -62,23 +60,21 @@ type PaymentMode = { export class TransactionService { constructor( @Inject(PAGINATION_SIZE) private paginationSize: number, - private networkService: NetworkService, private storageService: StorageService, private apiService: ApiService, private apiV2Service: ApiV2Service, - private dataTransformService: DataTransformService, private dateService: DateService, private authService: AuthService, private orgUserSettingsService: OrgUserSettingsService, private timezoneService: TimezoneService, private utilityService: UtilityService, - private fileService: FileService, private spenderPlatformV1ApiService: SpenderPlatformV1ApiService, private userEventService: UserEventService, private paymentModesService: PaymentModesService, private orgSettingsService: OrgSettingsService, private accountsService: AccountsService, - private expensesService: ExpensesService + private expensesService: ExpensesService, + private ldService: LaunchDarklyService ) { expensesCacheBuster$.subscribe(() => { this.userEventService.clearTaskCache(); @@ -239,7 +235,12 @@ export class TransactionService { transaction.txn_dt.setMinutes(0); transaction.txn_dt.setSeconds(0); transaction.txn_dt.setMilliseconds(0); - transaction.txn_dt = this.timezoneService.convertToUtc(transaction.txn_dt, offset); + + if (this.ldService.getImmediate('timezone_fix', false)) { + transaction.txn_dt = this.dateService.getUTCMidAfternoonDate(transaction.txn_dt); + } else { + transaction.txn_dt = this.timezoneService.convertToUtc(transaction.txn_dt, offset); + } } if (transaction.from_dt) { @@ -839,7 +840,7 @@ export class TransactionService { org_id: expense.employee?.org_id, }, }; - this.dateService.fixDates(updatedExpense.tx); + return updatedExpense; } @@ -947,24 +948,6 @@ export class TransactionService { ); } - private fixDates(data: Expense): Expense { - data.tx_created_at = new Date(data.tx_created_at); - if (data.tx_txn_dt) { - data.tx_txn_dt = new Date(data.tx_txn_dt); - } - - if (data.tx_from_dt) { - data.tx_from_dt = new Date(data.tx_from_dt); - } - - if (data.tx_to_dt) { - data.tx_to_dt = new Date(data.tx_to_dt); - } - - data.tx_updated_at = new Date(data.tx_updated_at); - return data; - } - private generateStateOrFilter(filters: Partial, newQueryParamsCopy: FilterQueryParams): string[] { const stateOrFilter: string[] = []; if (filters.state) { diff --git a/src/app/shared/components/fy-location/fy-location-modal/fy-location-modal.component.spec.ts b/src/app/shared/components/fy-location/fy-location-modal/fy-location-modal.component.spec.ts index 9373bf3823..b73df0d63b 100644 --- a/src/app/shared/components/fy-location/fy-location-modal/fy-location-modal.component.spec.ts +++ b/src/app/shared/components/fy-location/fy-location-modal/fy-location-modal.component.spec.ts @@ -105,7 +105,7 @@ describe('FyLocationModalComponent', () => { it('getRecentlyUsedItems(): should return array of display if recentLocations is undefined but cacheName is defined', fakeAsync(() => { component.recentLocations = undefined; - recentLocalStorageItemsService.get.and.returnValue(Promise.resolve(['display1', 'display2'])); + recentLocalStorageItemsService.get.and.resolveTo(['display1', 'display2']); component.cacheName = ['display1', 'display2']; fixture.detectChanges(); const recentlyUsedItems = component.getRecentlyUsedItems(); @@ -132,7 +132,7 @@ describe('FyLocationModalComponent', () => { component.recentLocations = recentLocations; component.currentSelection = { display: 'Display1' }; spyOn(component, 'checkPermissionStatus'); - authService.getEou.and.returnValue(Promise.resolve(apiEouRes)); + authService.getEou.and.resolveTo(apiEouRes); locationService.getCurrentLocation.and.returnValue(of(coordinatesData2)); const text = 'Ben'; @@ -174,7 +174,7 @@ describe('FyLocationModalComponent', () => { component.recentLocations = recentLocations; component.currentSelection = { display: 'Display1' }; spyOn(component, 'checkPermissionStatus'); - authService.getEou.and.returnValue(Promise.resolve(apiEouRes)); + authService.getEou.and.resolveTo(apiEouRes); locationService.getCurrentLocation.and.returnValue(of(coordinatesData2)); const text = 'Ben'; @@ -215,7 +215,7 @@ describe('FyLocationModalComponent', () => { component.recentLocations = recentLocations; component.currentSelection = { display: 'Display1' }; spyOn(component, 'checkPermissionStatus'); - authService.getEou.and.returnValue(Promise.resolve(apiEouRes)); + authService.getEou.and.resolveTo(apiEouRes); locationService.getCurrentLocation.and.returnValue(of(coordinatesData1)); const event = new Event('keyup'); @@ -258,7 +258,7 @@ describe('FyLocationModalComponent', () => { component.recentLocations = recentLocations; component.currentSelection = { display: 'Display1' }; spyOn(component, 'checkPermissionStatus'); - authService.getEou.and.returnValue(Promise.resolve(apiEouRes)); + authService.getEou.and.resolveTo(apiEouRes); locationService.getCurrentLocation.and.returnValue(of(undefined) as any); const event = new Event('keyup'); @@ -295,7 +295,7 @@ describe('FyLocationModalComponent', () => { component.recentLocations = recentLocations; component.currentSelection = { display: 'Display1' }; spyOn(component, 'checkPermissionStatus'); - authService.getEou.and.returnValue(Promise.resolve(apiEouRes)); + authService.getEou.and.resolveTo(apiEouRes); locationService.getCurrentLocation.and.returnValue(of(undefined) as any); const event = new Event('keyup'); @@ -344,6 +344,7 @@ describe('FyLocationModalComponent', () => { component.onDoneClick(); expect(modalController.dismiss).toHaveBeenCalledOnceWith({ selection: 'selection1' }); }); + it('should call dismiss modal if currentSelection is undefined but value is defined', () => { component.currentSelection = ''; component.value = 'selection1'; @@ -351,6 +352,7 @@ describe('FyLocationModalComponent', () => { component.onDoneClick(); expect(modalController.dismiss).toHaveBeenCalledOnceWith({ selection: { display: 'selection1' } }); }); + it('should call dismiss modal with selection equals to currentSelection', () => { component.currentSelection = ''; component.value = ''; @@ -358,14 +360,15 @@ describe('FyLocationModalComponent', () => { component.onDoneClick(); expect(modalController.dismiss).toHaveBeenCalledOnceWith({ selection: null }); }); + it('should call recentLocalStorageItemsService and dismiss modal with currentSelection undefined and value is undefined', () => { - component.currentSelection = ''; - component.value = 'selection1'; + component.currentSelection = undefined; + component.value = undefined; component.cacheName = 'cache1'; fixture.detectChanges(); component.onDoneClick(); - expect(recentLocalStorageItemsService.post).toHaveBeenCalledOnceWith('cache1', { display: 'selection1' }); - expect(modalController.dismiss).toHaveBeenCalledOnceWith({ selection: { display: 'selection1' } }); + + expect(modalController.dismiss).toHaveBeenCalledOnceWith({ selection: null }); }); }); @@ -381,8 +384,8 @@ describe('FyLocationModalComponent', () => { const text = 'Example Location'; const userId = 'usvKA4X8Ugcr'; - loaderService.showLoader.and.returnValue(Promise.resolve()); - authService.getEou.and.returnValue(Promise.resolve(apiEouRes)); + loaderService.showLoader.and.resolveTo(); + authService.getEou.and.resolveTo(apiEouRes); locationService.getCurrentLocation.and.returnValue(of(null)); locationService.getAutocompletePredictions.and.returnValue(of(predictedLocation1)); locationService.getGeocode.and.returnValue(of(locationData1)); @@ -401,8 +404,8 @@ describe('FyLocationModalComponent', () => { })); it('should handle error and dismiss the modal with the input location', fakeAsync(() => { - loaderService.showLoader.and.returnValue(Promise.resolve()); - authService.getEou.and.returnValue(Promise.resolve(apiEouRes)); + loaderService.showLoader.and.resolveTo(); + authService.getEou.and.resolveTo(apiEouRes); locationService.getCurrentLocation.and.returnValue(of(null)); locationService.getAutocompletePredictions.and.returnValue(of([])); @@ -419,8 +422,8 @@ describe('FyLocationModalComponent', () => { })); it('should call necessary services and dismiss the modal if location is defined', fakeAsync(() => { - loaderService.showLoader.and.returnValue(Promise.resolve()); - authService.getEou.and.returnValue(Promise.resolve(apiEouRes)); + loaderService.showLoader.and.resolveTo(); + authService.getEou.and.resolveTo(apiEouRes); locationService.getCurrentLocation.and.returnValue(of(coordinatesData1)); locationService.getAutocompletePredictions.and.returnValue(of(predictedLocation1)); locationService.getGeocode.and.returnValue(of(locationData1)); @@ -446,8 +449,8 @@ describe('FyLocationModalComponent', () => { })); it('should call necessary services and dismiss the modal if locationService.getGeoCode returns null', fakeAsync(() => { - loaderService.showLoader.and.returnValue(Promise.resolve()); - authService.getEou.and.returnValue(Promise.resolve(apiEouRes)); + loaderService.showLoader.and.resolveTo(); + authService.getEou.and.resolveTo(apiEouRes); locationService.getCurrentLocation.and.returnValue(of(coordinatesData1)); locationService.getAutocompletePredictions.and.returnValue(of(predictedLocation1)); locationService.getGeocode.and.returnValue(of(undefined)); @@ -475,8 +478,8 @@ describe('FyLocationModalComponent', () => { it('should catch errors if getGeoCode returns error', fakeAsync(() => { const geocodedLocation = { display: 'Example Location' }; - loaderService.showLoader.and.returnValue(Promise.resolve()); - authService.getEou.and.returnValue(Promise.resolve(apiEouRes)); + loaderService.showLoader.and.resolveTo(); + authService.getEou.and.resolveTo(apiEouRes); locationService.getCurrentLocation.and.returnValue(of(coordinatesData1)); locationService.getAutocompletePredictions.and.returnValue(of(predictedLocation1)); locationService.getGeocode.and.returnValue(throwError(() => new Error('error message'))); @@ -583,8 +586,8 @@ describe('FyLocationModalComponent', () => { }); it('should fetch current location and dismiss the modal with the formatted location', fakeAsync(() => { - loaderService.showLoader.and.returnValue(Promise.resolve()); - loaderService.hideLoader.and.returnValue(Promise.resolve()); + loaderService.showLoader.and.resolveTo(); + loaderService.hideLoader.and.resolveTo(); locationService.getCurrentLocation.and.returnValue(of({ coords: { latitude: 12.345, longitude: 67.89 } }) as any); gmapsService.getGeocode.and.returnValue(of({ formatted_address: 'Example Address' }) as any); spyOn(component, 'formatGeocodeResponse').and.returnValue({ display: 'Example Address' }); @@ -601,8 +604,8 @@ describe('FyLocationModalComponent', () => { })); it('should fetch current location and dismiss the modal with the formatted location', fakeAsync(() => { - loaderService.showLoader.and.returnValue(Promise.resolve()); - loaderService.hideLoader.and.returnValue(Promise.resolve()); + loaderService.showLoader.and.resolveTo(); + loaderService.hideLoader.and.resolveTo(); locationService.getCurrentLocation.and.returnValue(of(undefined)); gmapsService.getGeocode.and.returnValue(of({ formatted_address: 'Example Address' }) as any); spyOn(component, 'formatGeocodeResponse').and.returnValue({ display: 'Example Address' }); @@ -621,8 +624,8 @@ describe('FyLocationModalComponent', () => { it('should handle error and set lookupFailed to true', fakeAsync(() => { const error = new Error('Some error'); - loaderService.showLoader.and.returnValue(Promise.resolve()); - loaderService.hideLoader.and.returnValue(Promise.resolve()); + loaderService.showLoader.and.resolveTo(); + loaderService.hideLoader.and.resolveTo(); locationService.getCurrentLocation.and.returnValue(of(null)); gmapsService.getGeocode.and.returnValue(throwError(() => error)); diff --git a/src/app/shared/components/sidemenu/sidemenu.component.ts b/src/app/shared/components/sidemenu/sidemenu.component.ts index 94101db9ca..ea22bf261d 100644 --- a/src/app/shared/components/sidemenu/sidemenu.component.ts +++ b/src/app/shared/components/sidemenu/sidemenu.component.ts @@ -22,6 +22,7 @@ import { MenuController } from '@ionic/angular'; import { SidemenuAllowedActions } from 'src/app/core/models/sidemenu-allowed-actions.model'; import { OrgSettings } from 'src/app/core/models/org-settings.model'; + @Component({ selector: 'app-sidemenu', templateUrl: './sidemenu.component.html', @@ -96,10 +97,11 @@ export class SidemenuComponent implements OnInit { }); } - async showSideMenuOnline(): Promise { + async showSideMenuOnline(): Promise { + const isLoggedIn = await this.routerAuthService.isLoggedIn(); if (!isLoggedIn) { - return 0; + return; } const orgs$ = this.orgService.getOrgs(); const currentOrg$ = this.orgService.getCurrentOrg().pipe(shareReplay(1)); @@ -180,6 +182,7 @@ export class SidemenuComponent implements OnInit { ); } + getCardOptions(): Partial[] { const cardOptions = [ { @@ -194,8 +197,10 @@ export class SidemenuComponent implements OnInit { return cardOptions.filter((cardOption) => cardOption.isVisible); } + getTeamOptions(): Partial[] { const showTeamReportsPage = this.primaryOrg?.id === (this.activeOrg as Org)?.id; + const { allowedReportsActions, allowedAdvancesActions } = this.allowedActions; const teamOptions = [ { diff --git a/src/app/shared/components/view-dependent-fields/view-dependent-fields.component.spec.ts b/src/app/shared/components/view-dependent-fields/view-dependent-fields.component.spec.ts index d613681fc1..ee33dd2f7d 100644 --- a/src/app/shared/components/view-dependent-fields/view-dependent-fields.component.spec.ts +++ b/src/app/shared/components/view-dependent-fields/view-dependent-fields.component.spec.ts @@ -3,7 +3,7 @@ import { IonicModule } from '@ionic/angular'; import { ViewDependentFieldsComponent } from './view-dependent-fields.component'; -xdescribe('ViewDependentFieldsComponent', () => { +describe('ViewDependentFieldsComponent', () => { let component: ViewDependentFieldsComponent; let fixture: ComponentFixture; @@ -18,7 +18,13 @@ xdescribe('ViewDependentFieldsComponent', () => { fixture.detectChanges(); })); - it('should create', () => { - expect(component).toBeTruthy(); + it('should set parentFieldIcon to building by default', () => { + expect(component.parentFieldIcon).toEqual('building'); + }); + + it('should set parentFieldIcon to list if parent field type is project', () => { + component.parentFieldType = 'PROJECT'; + component.ngOnInit(); + expect(component.parentFieldIcon).toEqual('list'); }); });