diff --git a/src/app/modules/time-clock/components/entry-fields/entry-fields.component.spec.ts b/src/app/modules/time-clock/components/entry-fields/entry-fields.component.spec.ts index b9ded1ec..436928ff 100644 --- a/src/app/modules/time-clock/components/entry-fields/entry-fields.component.spec.ts +++ b/src/app/modules/time-clock/components/entry-fields/entry-fields.component.spec.ts @@ -26,8 +26,8 @@ describe('EntryFieldsComponent', () => { let entryForm; const actionSub: ActionsSubject = new ActionsSubject(); const toastrServiceStub = { - error: (message?: string, title?: string, override?: Partial) => { }, - warning: (message?: string, title?: string, override?: Partial) => { } + error: (message?: string, title?: string, override?: Partial) => {}, + warning: (message?: string, title?: string, override?: Partial) => {}, }; const mockDate = '2020-12-01T12:00:00'; const lastDate = moment(mockDate).format(DATE_FORMAT_YEAR); @@ -77,6 +77,8 @@ describe('EntryFieldsComponent', () => { uri: 'abc', start_date: moment().toISOString(), end_date: moment().toISOString(), + project_name: 'project_name', + customer_name: 'customer_name', }, { activity_id: 'xyz', @@ -87,9 +89,11 @@ describe('EntryFieldsComponent', () => { uri: 'abc', start_date: lastStartHourEntryEntered, end_date: lastEndHourEntryEntered, - } - ] - } + project_name: 'project_name', + customer_name: 'customer name', + }, + ], + }, }, }; @@ -100,28 +104,31 @@ describe('EntryFieldsComponent', () => { description: 'description for active entry', uri: 'abc', start_date: moment(mockDate).format(DATE_FORMAT_YEAR), - start_hour: moment(mockDate).format('HH:mm') + start_hour: moment(mockDate).format('HH:mm'), + customer_name: 'ioet', }; const mockEntryOverlap = { - update_last_entry_if_overlap: true + update_last_entry_if_overlap: true, }; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - declarations: [EntryFieldsComponent], - providers: [ - provideMockStore({ initialState: state }), - { provide: ActionsSubject, useValue: actionSub }, - { provide: ToastrService, useValue: toastrServiceStub } - ], - imports: [FormsModule, ReactiveFormsModule, NgxMaterialTimepickerModule], - }).compileComponents(); - store = TestBed.inject(MockStore); - entryForm = TestBed.inject(FormBuilder); - mockTechnologySelector = store.overrideSelector(allTechnologies, state.technologies); - mockProjectsSelector = store.overrideSelector(getCustomerProjects, state.projects); - })); + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [EntryFieldsComponent], + providers: [ + provideMockStore({ initialState: state }), + { provide: ActionsSubject, useValue: actionSub }, + { provide: ToastrService, useValue: toastrServiceStub }, + ], + imports: [FormsModule, ReactiveFormsModule, NgxMaterialTimepickerModule], + }).compileComponents(); + store = TestBed.inject(MockStore); + entryForm = TestBed.inject(FormBuilder); + mockTechnologySelector = store.overrideSelector(allTechnologies, state.technologies); + mockProjectsSelector = store.overrideSelector(getCustomerProjects, state.projects); + }) + ); beforeEach(() => { fixture = TestBed.createComponent(EntryFieldsComponent); @@ -144,15 +151,13 @@ describe('EntryFieldsComponent', () => { spyOn(component.entryForm, 'patchValue'); component.setDataToUpdate(entry); expect(component.entryForm.patchValue).toHaveBeenCalledTimes(1); - expect(component.entryForm.patchValue).toHaveBeenCalledWith( - { - description: entryDataForm.description, - uri: entryDataForm.uri, - activity_id: entryDataForm.activity_id, - start_hour: formatDate(entry.start_date, 'HH:mm', 'en'), - start_date: moment(mockDate).format(DATE_FORMAT_YEAR), - } - ); + expect(component.entryForm.patchValue).toHaveBeenCalledWith({ + description: entryDataForm.description, + uri: entryDataForm.uri, + activity_id: entryDataForm.activity_id, + start_hour: formatDate(entry.start_date, 'HH:mm', 'en'), + start_date: moment(mockDate).format(DATE_FORMAT_YEAR), + }); expect(component.selectedTechnologies).toEqual([]); }); @@ -164,7 +169,7 @@ describe('EntryFieldsComponent', () => { const mockEntry = { ...entry, start_date: startMoment.format(DATE_FORMAT_YEAR), - start_hour: startMoment.format('HH:mm') + start_hour: startMoment.format('HH:mm'), }; component.newData = mockEntry; @@ -210,11 +215,9 @@ describe('EntryFieldsComponent', () => { component.cancelTimeInUpdate(); expect(component.showTimeInbuttons).toEqual(false); - expect(component.entryForm.patchValue).toHaveBeenCalledWith( - { - start_hour: component.newData.start_hour - } - ); + expect(component.entryForm.patchValue).toHaveBeenCalledWith({ + start_hour: component.newData.start_hour, + }); }); it('should reset to current start_date when start_date has an error', () => { @@ -228,11 +231,9 @@ describe('EntryFieldsComponent', () => { spyOn(component.entryForm, 'patchValue'); component.onUpdateStartHour(); - expect(component.entryForm.patchValue).toHaveBeenCalledWith( - { - start_hour: component.newData.start_hour - } - ); + expect(component.entryForm.patchValue).toHaveBeenCalledWith({ + start_hour: component.newData.start_hour, + }); expect(component.showTimeInbuttons).toEqual(false); }); @@ -244,7 +245,7 @@ describe('EntryFieldsComponent', () => { const mockEntry = { ...entry, start_date: startMoment.format(DATE_FORMAT_YEAR), - start_hour: startMoment.format('HH:mm') + start_hour: startMoment.format('HH:mm'), }; component.newData = mockEntry; component.activeEntry = mockEntry; @@ -256,11 +257,9 @@ describe('EntryFieldsComponent', () => { spyOn(component.entryForm, 'patchValue'); component.onUpdateStartHour(); - expect(component.entryForm.patchValue).toHaveBeenCalledWith( - { - start_hour: component.newData.start_hour - } - ); + expect(component.entryForm.patchValue).toHaveBeenCalledWith({ + start_hour: component.newData.start_hour, + }); expect(component.showTimeInbuttons).toEqual(false); }); @@ -279,17 +278,20 @@ describe('EntryFieldsComponent', () => { expect(component.showTimeInbuttons).toEqual(false); }); - it('When start_time is updated, component.last_entry is equal to time entry in the position 1', waitForAsync(() => { - component.newData = mockEntryOverlap; - component.activeEntry = entry; - component.setDataToUpdate(entry); - const updatedTime = moment(mockDate).format('HH:mm'); + it( + 'When start_time is updated, component.last_entry is equal to time entry in the position 1', + waitForAsync(() => { + component.newData = mockEntryOverlap; + component.activeEntry = entry; + component.setDataToUpdate(entry); + const updatedTime = moment(mockDate).format('HH:mm'); - component.entryForm.patchValue({ start_hour: updatedTime }); - component.onUpdateStartHour(); + component.entryForm.patchValue({ start_hour: updatedTime }); + component.onUpdateStartHour(); - expect(component.lastEntry).toBe(state.entries.timeEntriesDataSource.data[1]); - })); + expect(component.lastEntry).toBe(state.entries.timeEntriesDataSource.data[1]); + }) + ); it('When start_time is updated for a time entry. UpdateCurrentOrLastEntry action is dispatched', () => { component.newData = mockEntryOverlap; @@ -310,10 +312,10 @@ describe('EntryFieldsComponent', () => { component.onTechnologyUpdated(addedTechnologies); expect(store.dispatch).toHaveBeenCalled(); - }); it('uses the form to check if is valid or not', () => { + component.activeEntry = entry; entryForm.valid = false; const result = component.entryFormIsValidate(); @@ -339,7 +341,6 @@ describe('EntryFieldsComponent', () => { expect(store.dispatch).toHaveBeenCalled(); }); - it('sets the technologies on the class when entry has technologies', () => { const entryData = { ...entry, technologies: ['foo'] }; @@ -348,7 +349,6 @@ describe('EntryFieldsComponent', () => { expect(component.selectedTechnologies).toEqual(entryData.technologies); }); - it('activites are populated using the payload of the action', () => { const actionSubject = TestBed.inject(ActionsSubject) as ActionsSubject; const action = { @@ -366,17 +366,17 @@ describe('EntryFieldsComponent', () => { { id: '004', name: 'Meeting', - description: 'Some description' + description: 'Some description', }, { id: '005', name: 'ABCD', - description: 'Some description' + description: 'Some description', }, { id: '006', name: 'XYZA', - description: 'Some description' + description: 'Some description', }, ]; @@ -384,17 +384,17 @@ describe('EntryFieldsComponent', () => { { id: '005', name: 'ABCD', - description: 'Some description' + description: 'Some description', }, { id: '004', name: 'Meeting', - description: 'Some description' + description: 'Some description', }, { id: '006', name: 'XYZA', - description: 'Some description' + description: 'Some description', }, ]; @@ -488,17 +488,19 @@ describe('EntryFieldsComponent', () => { }); it('when a activity is not register in DB should show activatefocus in select activity', () => { - const activitiesMock = [{ - id: 'xyz', - name: 'test', - description: 'test1' - }]; + const activitiesMock = [ + { + id: 'xyz', + name: 'test', + description: 'test1', + }, + ]; const data = { activity_id: 'xyz', description: '', start_date: moment().format(DATE_FORMAT_YEAR), start_hour: moment().format('HH:mm'), - uri: '' + uri: '', }; component.activities = activitiesMock; component.entryForm.patchValue({ @@ -517,6 +519,18 @@ describe('EntryFieldsComponent', () => { expect(autofocus).toHaveBeenCalled(); }); }); -}); + it('should show an error message if description and ticket fields are empty for internal apps', () => { + spyOn(toastrServiceStub, 'error'); + const result = component.requiredFieldsForInternalAppExist('ioet'); + expect(toastrServiceStub.error).toHaveBeenCalled(); + expect(result).toBe(false); + }); + it('should return true if customer name does not contain ioet ', () => { + spyOn(toastrServiceStub, 'error'); + const result = component.requiredFieldsForInternalAppExist('Project Name'); + expect(toastrServiceStub.error).not.toHaveBeenCalled(); + expect(result).toBe(true); + }); +}); diff --git a/src/app/modules/time-clock/components/entry-fields/entry-fields.component.ts b/src/app/modules/time-clock/components/entry-fields/entry-fields.component.ts index fd753086..f6a10f20 100644 --- a/src/app/modules/time-clock/components/entry-fields/entry-fields.component.ts +++ b/src/app/modules/time-clock/components/entry-fields/entry-fields.component.ts @@ -1,6 +1,6 @@ import { ActivityManagementActionTypes } from './../../../activities-management/store/activity-management.actions'; import { EntryActionTypes, LoadActiveEntry } from './../../store/entry.actions'; -import { filter} from 'rxjs/operators'; +import { filter } from 'rxjs/operators'; import { Component, OnDestroy, OnInit, ElementRef, ViewChild } from '@angular/core'; import { FormBuilder, FormGroup } from '@angular/forms'; import { Store, ActionsSubject, select } from '@ngrx/store'; @@ -15,7 +15,7 @@ import { ToastrService } from 'ngx-toastr'; import { formatDate } from '@angular/common'; import { getTimeEntriesDataSource } from '../../store/entry.selectors'; import { DATE_FORMAT } from 'src/environments/environment'; -import { Subscription, } from 'rxjs'; +import { Subscription } from 'rxjs'; type Merged = TechnologyState & ProjectState & ActivityState; @@ -25,7 +25,6 @@ type Merged = TechnologyState & ProjectState & ActivityState; styleUrls: ['./entry-fields.component.scss'], }) export class EntryFieldsComponent implements OnInit, OnDestroy { - @ViewChild('autofocus') autofocus!: ElementRef; entryForm: FormGroup; @@ -44,7 +43,7 @@ export class EntryFieldsComponent implements OnInit, OnDestroy { private formBuilder: FormBuilder, private store: Store, private actionsSubject$: ActionsSubject, - private toastrService: ToastrService, + private toastrService: ToastrService ) { this.entryForm = this.formBuilder.group({ description: '', @@ -58,12 +57,14 @@ export class EntryFieldsComponent implements OnInit, OnDestroy { ngOnInit(): void { this.store.dispatch(new LoadActivities()); this.store.dispatch(new entryActions.LoadEntries(new Date().getMonth() + 1, new Date().getFullYear())); - this.loadActivitiesSubscription = this.actionsSubject$ + this.loadActivitiesSubscription = this.actionsSubject$ .pipe(filter((action: any) => action.type === ActivityManagementActionTypes.LOAD_ACTIVITIES_SUCCESS)) .subscribe((action) => { - this.activities = action.payload.filter((item) => item.status !== 'inactive').sort((a, b) => { - return (a.name).localeCompare(b.name); - }); + this.activities = action.payload + .filter((item) => item.status !== 'inactive') + .sort((a, b) => { + return a.name.localeCompare(b.name); + }); this.store.dispatch(new LoadActiveEntry()); }); @@ -96,7 +97,7 @@ export class EntryFieldsComponent implements OnInit, OnDestroy { uri: this.activeEntry.uri, activity_id: this.activeEntry.activity_id, start_date: this.activeEntry.start_date, - start_hour: formatDate(this.activeEntry.start_date, 'HH:mm', 'en') + start_hour: formatDate(this.activeEntry.start_date, 'HH:mm', 'en'), }; this.activateFocus(); }); @@ -108,8 +109,8 @@ export class EntryFieldsComponent implements OnInit, OnDestroy { return this.entryForm.get('start_hour'); } - activateFocus(){ - if ((this.activities.length > 0) && (this.entryForm.value.activity_id === head(this.activities).id)){ + activateFocus() { + if (this.activities.length > 0 && this.entryForm.value.activity_id === head(this.activities).id) { this.autofocus.nativeElement.focus(); } } @@ -132,11 +133,16 @@ export class EntryFieldsComponent implements OnInit, OnDestroy { } entryFormIsValidate() { - return this.entryForm.valid; + let customerName = ''; + this.store.pipe(select(getTimeEntriesDataSource)).subscribe((ds) => { + const dataToUse = ds.data.find((item) => item.project_id === this.activeEntry.project_id); + customerName = dataToUse.customer_name; + }); + return this.requiredFieldsForInternalAppExist(customerName) && this.entryForm.valid; } onSubmit() { - if (this.entryFormIsValidate()){ + if (this.entryFormIsValidate()) { this.store.dispatch(new entryActions.UpdateEntryRunning({ ...this.newData, ...this.entryForm.value })); } } @@ -192,4 +198,15 @@ export class EntryFieldsComponent implements OnInit, OnDestroy { this.loadActiveEntrySubscription.unsubscribe(); this.actionSetDateSubscription.unsubscribe(); } + + requiredFieldsForInternalAppExist(customerName) { + const emptyFields = this.entryForm.value.uri === '' && this.entryForm.value.description === ''; + const isInternalApp = customerName.includes('ioet'); + if (isInternalApp && emptyFields) { + const message = 'The description field or ticket field should not be empty'; + this.toastrService.error(`Some fields are empty, ${message}.`); + return false; + } + return true; + } } diff --git a/src/app/modules/time-entries/pages/time-entries.component.spec.ts b/src/app/modules/time-entries/pages/time-entries.component.spec.ts index e6fa633b..fb4fe4e0 100644 --- a/src/app/modules/time-entries/pages/time-entries.component.spec.ts +++ b/src/app/modules/time-entries/pages/time-entries.component.spec.ts @@ -24,6 +24,8 @@ import { FeatureToggle } from './../../../../environments/enum'; import { CalendarView } from 'angular-calendar'; import * as moment from 'moment'; import { TotalHours } from '../../reports/models/total-hours-report'; +import { event } from 'jquery'; +import { ProjectSelectedEvent } from '../../shared/components/details-fields/project-selected-event'; describe('TimeEntriesComponent', () => { type Merged = TechnologyState & ProjectState & EntryState; @@ -36,66 +38,66 @@ describe('TimeEntriesComponent', () => { let state: EntryState; let entry: Entry; + const toastrService = { - error: () => { - }, + error: () => {}, }; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - declarations: [ - EmptyStateComponent, - DetailsFieldsComponent, - GroupByDatePipe, - MonthPickerComponent, - TimeEntriesComponent, - TimeEntriesSummaryComponent, - SubstractDatePipe - ], - providers: [provideMockStore({ initialState: state }), - { provide: ToastrService, useValue: toastrService }, - ], - imports: [FormsModule, ReactiveFormsModule, AutocompleteLibModule, NgxMaterialTimepickerModule], - }).compileComponents(); - store = TestBed.inject(MockStore); - entry = { - id: 'entry_1', - project_id: 'abc', - project_name: 'Time-tracker', - start_date: new Date('2020-02-05T15:36:15.887Z'), - end_date: new Date('2020-02-05T18:36:15.887Z'), - customer_name: 'ioet Inc.', - activity_id: 'development', - technologies: ['Angular', 'TypeScript'], - description: 'No comments', - uri: 'EY-25', - }; - state = { - timeEntriesSummary: null, - createError: false, - updateError: false, - isLoading: false, - resultSumEntriesSelected: new TotalHours(), - message: 'any-message', - active: { - start_date: new Date('2019-01-01T15:36:15.887Z'), - id: 'active-entry', - technologies: ['rxjs', 'angular'], - project_name: 'time-tracker' - }, - timeEntriesDataSource: { + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + EmptyStateComponent, + DetailsFieldsComponent, + GroupByDatePipe, + MonthPickerComponent, + TimeEntriesComponent, + TimeEntriesSummaryComponent, + SubstractDatePipe, + ], + providers: [provideMockStore({ initialState: state }), { provide: ToastrService, useValue: toastrService }], + imports: [FormsModule, ReactiveFormsModule, AutocompleteLibModule, NgxMaterialTimepickerModule], + }).compileComponents(); + store = TestBed.inject(MockStore); + entry = { + id: 'entry_1', + project_id: 'abc', + project_name: 'Time-tracker', + start_date: new Date('2020-02-05T15:36:15.887Z'), + end_date: new Date('2020-02-05T18:36:15.887Z'), + customer_name: 'ioet Inc.', + activity_id: 'development', + technologies: ['Angular', 'TypeScript'], + description: 'No comments', + uri: 'EY-25', + }; + state = { + timeEntriesSummary: null, + createError: false, + updateError: false, isLoading: false, - data: [entry] - }, - reportDataSource: { - isLoading: false, - data: [entry] - } - }; - mockEntriesSelector = store.overrideSelector(getTimeEntriesDataSource, state.timeEntriesDataSource); - injectedToastrService = TestBed.inject(ToastrService); - cookieService = TestBed.inject(CookieService); - })); + resultSumEntriesSelected: new TotalHours(), + message: 'any-message', + active: { + start_date: new Date('2019-01-01T15:36:15.887Z'), + id: 'active-entry', + technologies: ['rxjs', 'angular'], + project_name: 'time-tracker', + }, + timeEntriesDataSource: { + isLoading: false, + data: [entry], + }, + reportDataSource: { + isLoading: false, + data: [entry], + }, + }; + mockEntriesSelector = store.overrideSelector(getTimeEntriesDataSource, state.timeEntriesDataSource); + injectedToastrService = TestBed.inject(ToastrService); + cookieService = TestBed.inject(CookieService); + }) + ); beforeEach(() => { fixture = TestBed.createComponent(TimeEntriesComponent); @@ -119,21 +121,27 @@ describe('TimeEntriesComponent', () => { expect(component).toBeTruthy(); }); - it('on loading the component the time entries should be loaded', waitForAsync(() => { - component.timeEntriesDataSource$.subscribe(ds => { - expect(ds.data.length).toEqual(1); - }); - })); + it( + 'on loading the component the time entries should be loaded', + waitForAsync(() => { + component.timeEntriesDataSource$.subscribe((ds) => { + expect(ds.data.length).toEqual(1); + }); + }) + ); - it('Time entries data should be populated on ngOnInit()', waitForAsync(() => { - mockEntriesSelector = store.overrideSelector(getTimeEntriesDataSource, state.timeEntriesDataSource); + it( + 'Time entries data should be populated on ngOnInit()', + waitForAsync(() => { + mockEntriesSelector = store.overrideSelector(getTimeEntriesDataSource, state.timeEntriesDataSource); - component.ngOnInit(); + component.ngOnInit(); - component.timeEntriesDataSource$.subscribe(ds => { - expect(ds.data.length).toEqual(1); - }); - })); + component.timeEntriesDataSource$.subscribe((ds) => { + expect(ds.data.length).toEqual(1); + }); + }) + ); it('should initialize the table when the component is initialized', () => { spyOn(component.dtTrigger, 'next'); @@ -149,14 +157,17 @@ describe('TimeEntriesComponent', () => { entry: { project_id: 'project-id', end_date: '2010-05-05T10:04', + project_name: 'Time-tracker', start_date: null, timezone_offset: 300, - }, shouldRestartEntry: false + }, + shouldRestartEntry: false, }; + const project = { projectId: 'abc' }; component.activeTimeEntry = null; spyOn(store, 'dispatch'); - component.ngOnInit(); - + component.newEntry(); + component.projectSelected(project); component.saveEntry(entryToSave); expect(store.dispatch).toHaveBeenCalledWith(new entryActions.CreateEntry(entryToSave.entry)); }); @@ -242,8 +253,10 @@ describe('TimeEntriesComponent', () => { it('given a list of entries having an entry running when editing the last entry it can be marked as WIP ', () => { const anEntryId = '1'; const anotherEntryId = '2'; - state.timeEntriesDataSource.data = [{ ...entry, running: true, id: anEntryId }, - { ...entry, running: false, id: anotherEntryId }]; + state.timeEntriesDataSource.data = [ + { ...entry, running: true, id: anEntryId }, + { ...entry, running: false, id: anotherEntryId }, + ]; mockEntriesSelector = store.overrideSelector(getTimeEntriesDataSource, state.timeEntriesDataSource); component.editEntry(anEntryId); @@ -261,7 +274,8 @@ describe('TimeEntriesComponent', () => { description: 'description', technologies: [], uri: 'abc', - }, shouldRestartEntry: false + }, + shouldRestartEntry: false, }; component.entryId = 'new-entry'; spyOn(injectedToastrService, 'error'); @@ -281,7 +295,8 @@ describe('TimeEntriesComponent', () => { description: 'description', technologies: [], uri: 'abc', - }, shouldRestartEntry: false + }, + shouldRestartEntry: false, }; component.entryId = 'new-entry'; spyOn(injectedToastrService, 'error'); @@ -292,7 +307,13 @@ describe('TimeEntriesComponent', () => { }); it('should dispatch an action when entry is going to be saved', () => { - component.entry = { start_date: new Date(), id: '1234', technologies: [], project_name: 'time-tracker' }; + component.entry = { + start_date: new Date(), + id: '1234', + technologies: [], + project_name: 'time-tracker', + customer_name: 'customer name', + }; const newEntry = { entry: { project_id: 'p-id', @@ -301,8 +322,9 @@ describe('TimeEntriesComponent', () => { description: 'description', technologies: [], uri: 'abc', + customer_name: 'customer name', }, - shouldRestartEntry: false + shouldRestartEntry: false, }; component.entryId = 'active-entry'; spyOn(store, 'dispatch'); @@ -321,11 +343,14 @@ describe('TimeEntriesComponent', () => { technologies: [], uri: 'abc', timezone_offset: 300, - }, shouldRestartEntry: false + }, + shouldRestartEntry: false, }; component.entryId = undefined; spyOn(store, 'dispatch'); - + const project = { projectId: 'abc' }; + component.newEntry(); + component.projectSelected(project); component.saveEntry(newEntry); expect(store.dispatch).toHaveBeenCalledWith(new entryActions.CreateEntry(newEntry.entry)); @@ -347,23 +372,29 @@ describe('TimeEntriesComponent', () => { expect(store.dispatch).toHaveBeenCalledWith(new entryActions.LoadEntries(month + 1, year)); }); - it('doSave when activeTimeEntry === null', waitForAsync(() => { - const entryToSave = { - entry: { - project_id: 'project-id', - start_date: '2010-05-05T10:04', - description: 'description', - technologies: [], - uri: 'abc', - }, shouldRestartEntry: false - }; - spyOn(component, 'doSave'); - component.activeTimeEntry = null; - - component.saveEntry(entryToSave); - - expect(component.doSave).toHaveBeenCalledWith(entryToSave); - })); + it( + 'doSave when activeTimeEntry === null', + waitForAsync(() => { + const entryToSave = { + entry: { + project_id: 'project-id', + start_date: '2010-05-05T10:04', + description: 'description', + technologies: [], + uri: 'abc', + }, + shouldRestartEntry: false, + }; + spyOn(component, 'doSave'); + const project = { projectId: 'abc' }; + component.activeTimeEntry = null; + component.newEntry(); + component.projectSelected(project); + component.saveEntry(entryToSave); + + expect(component.doSave).toHaveBeenCalledWith(entryToSave); + }) + ); it('when event contains should restart as true, then a restart Entry action should be triggered', () => { component.entry = { start_date: new Date(), id: '1234', technologies: [], project_name: 'time-tracker' }; @@ -376,8 +407,8 @@ describe('TimeEntriesComponent', () => { description: 'description', technologies: [], uri: 'abc', - - }, shouldRestartEntry: true + }, + shouldRestartEntry: true, }; component.entryId = '123'; spyOn(store, 'dispatch'); @@ -387,64 +418,64 @@ describe('TimeEntriesComponent', () => { expect(store.dispatch).toHaveBeenCalledWith(new entryActions.RestartEntry(entryToSave.entry)); }); - it('should preload data of last entry when a project is selected while creating new entry ', waitForAsync(() => { - component.entry = null; - component.entryId = null; - const defaultSeconds = 0; - const currentDate = new Date(); - currentDate.setSeconds(defaultSeconds); - currentDate.setMilliseconds(defaultSeconds); - const lastEntry = { - description: 'testing is fun', - technologies: [], - uri: 'http://testing.is.fun', - activity_id: 'sss', - project_id: 'id', - start_date: currentDate, - end_date: currentDate - }; - state.timeEntriesDataSource.data = [lastEntry]; - mockEntriesSelector = store.overrideSelector(getTimeEntriesDataSource, state.timeEntriesDataSource); - - component.projectSelected({ projectId: 'id' }); - expect(component.entry).toEqual(lastEntry); - })); - - it('when the data source is loaded, the table should to show the appropriated column titles', waitForAsync(() => { - component.timeEntriesDataSource$.subscribe(() => { - - fixture.detectChanges(); - - const expectedColumnTitles = [ - 'Date', - 'Time in - out', - 'Duration', - 'Customer', - 'Project', - 'Activity', - '', - ]; - - const columnTitles: string[] = []; - - const HTMLTimeEntriesDebugElement: DebugElement = fixture.debugElement; - const HTMLTimeEntriesElement: HTMLElement = HTMLTimeEntriesDebugElement.nativeElement; - const HTMLTimeEntriesTable = HTMLTimeEntriesElement.querySelector('.table') as HTMLTableElement; - const HTMLTableHead = HTMLTimeEntriesTable.rows[0]; - - Array.from(HTMLTableHead.cells).forEach(columnTitle => { - columnTitles.push(columnTitle.innerText); + it( + 'should preload data of last entry when a project is selected while creating new entry ', + waitForAsync(() => { + component.entry = null; + component.entryId = null; + const defaultSeconds = 0; + const currentDate = new Date(); + currentDate.setSeconds(defaultSeconds); + currentDate.setMilliseconds(defaultSeconds); + const lastEntry = { + description: 'testing is fun', + technologies: [], + uri: 'http://testing.is.fun', + activity_id: 'sss', + project_id: 'id', + start_date: currentDate, + end_date: currentDate, + customer_name: 'customer name', + }; + state.timeEntriesDataSource.data = [lastEntry]; + mockEntriesSelector = store.overrideSelector(getTimeEntriesDataSource, state.timeEntriesDataSource); + component.projectSelected({ projectId: 'id' }); + expect(component.entry).toEqual(lastEntry); + }) + ); + + it( + 'when the data source is loaded, the table should to show the appropriated column titles', + waitForAsync(() => { + component.timeEntriesDataSource$.subscribe(() => { + fixture.detectChanges(); + + const expectedColumnTitles = ['Date', 'Time in - out', 'Duration', 'Customer', 'Project', 'Activity', '']; + + const columnTitles: string[] = []; + + const HTMLTimeEntriesDebugElement: DebugElement = fixture.debugElement; + const HTMLTimeEntriesElement: HTMLElement = HTMLTimeEntriesDebugElement.nativeElement; + const HTMLTimeEntriesTable = HTMLTimeEntriesElement.querySelector('.table') as HTMLTableElement; + const HTMLTableHead = HTMLTimeEntriesTable.rows[0]; + + Array.from(HTMLTableHead.cells).forEach((columnTitle) => { + columnTitles.push(columnTitle.innerText); + }); + expect(expectedColumnTitles).toEqual(columnTitles); }); - expect(expectedColumnTitles).toEqual(columnTitles); - }); - })); - - it('when the data source is loaded, the entry should to have customer_name field', waitForAsync(() => { - component.timeEntriesDataSource$.subscribe(dataSource => { - const entryData = dataSource.data[0]; - expect(entryData.customer_name).toContain('ioet Inc.'); - }); - })); + }) + ); + + it( + 'when the data source is loaded, the entry should to have customer_name field', + waitForAsync(() => { + component.timeEntriesDataSource$.subscribe((dataSource) => { + const entryData = dataSource.data[0]; + expect(entryData.customer_name).toContain('ioet Inc.'); + }); + }) + ); it('Should the entry be null if the flag is true', () => { component.wasEditingExistingTimeEntry = true; @@ -466,9 +497,9 @@ describe('TimeEntriesComponent', () => { const dragEndEventStub = { source: { _dragRef: { - reset: () => { } - } - } + reset: () => {}, + }, + }, }; spyOn(dragEndEventStub.source._dragRef, 'reset'); component.resetDraggablePosition(dragEndEventStub); @@ -478,8 +509,20 @@ describe('TimeEntriesComponent', () => { it('component.doSave shouldn´t be called when saving the runningEntry with start_date overlapped', () => { const startDate = new Date(2021, 6, 1, 10, 0); const endDate = new Date(2021, 6, 1, 10, 55); - const newRunningEntry = { start_date: endDate, id: '1234', technologies: [], project_name: 'time-tracker', running: true }; - const newEntry = { start_date: startDate, end_date: endDate, id: '4321', technologies: [], project_name: 'time-tracker'}; + const newRunningEntry = { + start_date: endDate, + id: '1234', + technologies: [], + project_name: 'time-tracker', + running: true, + }; + const newEntry = { + start_date: startDate, + end_date: endDate, + id: '4321', + technologies: [], + project_name: 'time-tracker', + }; state.timeEntriesDataSource.data = [newRunningEntry, newEntry]; component.activeTimeEntry = newRunningEntry; @@ -493,15 +536,15 @@ describe('TimeEntriesComponent', () => { id: '1234', technologies: ['py'], project_name: 'time-tracker', - running: true - }, shouldRestartEntry: false + running: true, + }, + shouldRestartEntry: false, }; component.saveEntry(RunningEntryModified); expect(component.doSave).toHaveBeenCalledTimes(0); }); - it('set true in displayGridView when its initial value is false and call onDisplayModeChange', () => { const expectedValue = true; const initialValue = false; @@ -535,7 +578,7 @@ describe('TimeEntriesComponent', () => { const year = 2021; const eventData = { monthIndex, - year + year, }; const dateMoment: moment.Moment = moment().month(monthIndex).year(year); jasmine.clock().mockDate(dateMoment.toDate()); @@ -550,7 +593,7 @@ describe('TimeEntriesComponent', () => { const incomingDate = new Date('2021-06-07'); const incomingMoment: moment.Moment = moment(incomingDate); const eventData = { - date: incomingDate + date: incomingDate, }; spyOn(component, 'dateSelected'); component.selectedDate = moment(incomingMoment).subtract(1, 'day'); @@ -565,11 +608,11 @@ describe('TimeEntriesComponent', () => { const incomingDate = new Date('2021-01-07'); const incomingMoment: moment.Moment = moment(incomingDate); const eventData = { - date: incomingDate + date: incomingDate, }; const selectedDate = { monthIndex: incomingMoment.month(), - year: incomingMoment.year() + year: incomingMoment.year(), }; spyOn(component, 'dateSelected'); component.selectedDate = moment(new Date('2021-07-07')); @@ -584,7 +627,7 @@ describe('TimeEntriesComponent', () => { const selectedDate: Date = new Date(2021, 2, 1); const eventDate = { monthIndex: selectedDate.getMonth(), - year: selectedDate.getFullYear() + year: selectedDate.getFullYear(), }; component.actualDate = actualDate; component.dateSelected(eventDate); @@ -594,7 +637,7 @@ describe('TimeEntriesComponent', () => { it('change component calendarView from Month to Day when call changeView', () => { const fakeCalendarView: CalendarView = CalendarView.Day; const eventView = { - calendarView: fakeCalendarView + calendarView: fakeCalendarView, }; component.calendarView = CalendarView.Month; component.changeView(eventView); @@ -604,7 +647,7 @@ describe('TimeEntriesComponent', () => { it('change component calendarView to Month if undefined when call changeView', () => { component.calendarView = CalendarView.Week; const eventView = { - calendarView: undefined + calendarView: undefined, }; component.changeView(eventView); expect(component.calendarView).toBe(CalendarView.Month); @@ -673,12 +716,77 @@ describe('TimeEntriesComponent', () => { start_date: null, timezone_offset: 300, }, - shouldRestartEntry: true + shouldRestartEntry: true, }; spyOn(component, 'doSave'); + const project = { projectId: 'abc' }; + component.newEntry(); + component.projectSelected(project); component.activeTimeEntry = activeEntry; component.saveEntry(entryToSave); expect(component.doSave).toHaveBeenCalledWith(entryToSave); }); + it('should raise an error if description and ticket fields are empty for internal apps', () => { + const newEntry = { + entry: { + project_id: 'projectId', + start_date: '2010-05-05T10:04', + description: '', + technologies: [], + uri: '', + timezone_offset: 300, + project_name: '(Applications)', + }, + shouldRestartEntry: false, + }; + const project = { projectId: 'abc' }; + component.newEntry(); + component.projectSelected(project); + spyOn(injectedToastrService, 'error'); + component.saveEntry(newEntry); + expect(injectedToastrService.error).toHaveBeenCalled(); + }); + + it('should save an entry if description field is not empty for internal apps', () => { + const newEntry = { + entry: { + project_id: 'projectId', + start_date: '2010-05-05T10:04', + description: 'Description', + technologies: [], + uri: '', + timezone_offset: 300, + project_name: '(Applications)', + }, + shouldRestartEntry: false, + }; + const project = { projectId: 'abc' }; + component.newEntry(); + component.projectSelected(project); + spyOn(component, 'doSave'); + component.saveEntry(newEntry); + expect(component.doSave).toHaveBeenCalledWith(newEntry); + }); + + it('should save an entry ticket field is not empty for internal apps', () => { + const newEntry = { + entry: { + project_id: 'projectId', + start_date: '2010-05-05T10:04', + description: '', + technologies: [], + uri: 'TTL-886', + timezone_offset: 300, + project_name: '(Applications)', + }, + shouldRestartEntry: false, + }; + const project = { projectId: 'abc' }; + component.newEntry(); + component.projectSelected(project); + spyOn(component, 'doSave'); + component.saveEntry(newEntry); + expect(component.doSave).toHaveBeenCalledWith(newEntry); + }); }); diff --git a/src/app/modules/time-entries/pages/time-entries.component.ts b/src/app/modules/time-entries/pages/time-entries.component.ts index 99a3845b..aa87794b 100644 --- a/src/app/modules/time-entries/pages/time-entries.component.ts +++ b/src/app/modules/time-entries/pages/time-entries.component.ts @@ -24,8 +24,8 @@ import { ParseDateTimeOffset } from '../../shared/formatters/parse-date-time-off }) export class TimeEntriesComponent implements OnInit, OnDestroy, AfterViewInit { dtOptions: any = { - order: [[ 0, 'desc' ]], - columnDefs: [{orderable: false, targets: [6]}], + order: [[0, 'desc']], + columnDefs: [{ orderable: false, targets: [6] }], destroy: true, }; dtTrigger: Subject = new Subject(); @@ -56,7 +56,8 @@ export class TimeEntriesComponent implements OnInit, OnDestroy, AfterViewInit { private store: Store, private toastrService: ToastrService, private actionsSubject$: ActionsSubject, - private cookiesService: CookieService) { + private cookiesService: CookieService + ) { this.displayGridView = false; this.selectedDate = moment(new Date()); this.actualDate = new Date(); @@ -66,17 +67,19 @@ export class TimeEntriesComponent implements OnInit, OnDestroy, AfterViewInit { ngOnInit(): void { this.loadActiveEntry(); - this.entriesSubscription = this.actionsSubject$.pipe( - filter((action: any) => ( - action.type === EntryActionTypes.CREATE_ENTRY_SUCCESS || - action.type === EntryActionTypes.UPDATE_ENTRY_SUCCESS || - action.type === EntryActionTypes.DELETE_ENTRY_SUCCESS + this.entriesSubscription = this.actionsSubject$ + .pipe( + filter( + (action: any) => + action.type === EntryActionTypes.CREATE_ENTRY_SUCCESS || + action.type === EntryActionTypes.UPDATE_ENTRY_SUCCESS || + action.type === EntryActionTypes.DELETE_ENTRY_SUCCESS + ) ) - ) - ).subscribe((action) => { - this.loadActiveEntry(); - this.store.dispatch(new entryActions.LoadEntries(this.selectedMonth, this.selectedYear)); - }); + .subscribe((action) => { + this.loadActiveEntry(); + this.store.dispatch(new entryActions.LoadEntries(this.selectedMonth, this.selectedYear)); + }); this.rerenderTableSubscription = this.timeEntriesDataSource$.subscribe((ds) => { this.dtTrigger.next(); }); @@ -96,12 +99,12 @@ export class TimeEntriesComponent implements OnInit, OnDestroy, AfterViewInit { this.entry = null; } this.entryId = null; - this.store.pipe(select(getTimeEntriesDataSource)).subscribe(ds => { + this.store.pipe(select(getTimeEntriesDataSource)).subscribe((ds) => { this.canMarkEntryAsWIP = !this.isThereAnEntryRunning(ds.data); }); } private getEntryRunning(entries: Entry[]) { - const runningEntry: Entry = entries.find(entry => entry.running === true); + const runningEntry: Entry = entries.find((entry) => entry.running === true); return runningEntry; } private isThereAnEntryRunning(entries: Entry[]) { @@ -109,10 +112,11 @@ export class TimeEntriesComponent implements OnInit, OnDestroy, AfterViewInit { } editEntry(entryId: string) { this.entryId = entryId; - this.store.pipe(select(getTimeEntriesDataSource)).subscribe(ds => { - this.entry = {... ds.data.find((entry) => entry.id === entryId)}; - this.canMarkEntryAsWIP = this.isEntryRunningEqualsToEntryToEdit(this.getEntryRunning(ds.data), this.entry) - || this.isTheEntryToEditTheLastOne(ds.data); + this.store.pipe(select(getTimeEntriesDataSource)).subscribe((ds) => { + this.entry = { ...ds.data.find((entry) => entry.id === entryId) }; + this.canMarkEntryAsWIP = + this.isEntryRunningEqualsToEntryToEdit(this.getEntryRunning(ds.data), this.entry) || + this.isTheEntryToEditTheLastOne(ds.data); }); this.wasEditingExistingTimeEntry = true; } @@ -144,21 +148,25 @@ export class TimeEntriesComponent implements OnInit, OnDestroy, AfterViewInit { const isEndDateGreaterThanActiveEntry = endDateAsLocalDate > activeEntryAsLocalDate; const isTimeEntryOverlapping = isStartDateGreaterThanActiveEntry || isEndDateGreaterThanActiveEntry; this.checkIfActiveEntryOverlapping(isEditingEntryEqualToActiveEntry, startDateAsLocalDate); - if (!isEditingEntryEqualToActiveEntry && isTimeEntryOverlapping || this.isActiveEntryOverlapping ) { + if ((!isEditingEntryEqualToActiveEntry && isTimeEntryOverlapping) || this.isActiveEntryOverlapping) { const message = this.isActiveEntryOverlapping ? 'try another "Time in"' : 'try with earlier times'; this.toastrService.error(`You are on the clock and this entry overlaps it, ${message}.`); this.isActiveEntryOverlapping = false; } else { - this.doSave(event); + if (this.requiredFieldsForInternalAppExist(event)) { + this.doSave(event); + } } } else { - this.doSave(event); + if (this.requiredFieldsForInternalAppExist(event)) { + this.doSave(event); + } } } projectSelected(event: ProjectSelectedEvent): void { this.wasEditingExistingTimeEntry = false; - this.store.pipe(select(getTimeEntriesDataSource)).subscribe(ds => { - const dataToUse = ds.data.find(item => item.project_id === event.projectId); + this.store.pipe(select(getTimeEntriesDataSource)).subscribe((ds) => { + const dataToUse = ds.data.find((item) => item.project_id === event.projectId); if (dataToUse && this.isNewEntry()) { const defaultSeconds = 0; const currentDate = new Date(); @@ -169,9 +177,10 @@ export class TimeEntriesComponent implements OnInit, OnDestroy, AfterViewInit { technologies: dataToUse.technologies ? dataToUse.technologies : [], uri: dataToUse.uri ? dataToUse.uri : '', activity_id: dataToUse.activity_id, + customer_name: dataToUse.customer_name, project_id: dataToUse.project_id, start_date: currentDate, - end_date: currentDate + end_date: currentDate, }; this.entry = entry; } @@ -208,26 +217,26 @@ export class TimeEntriesComponent implements OnInit, OnDestroy, AfterViewInit { this.selectedMonthAsText = moment().month(event.monthIndex).format('MMMM'); this.store.dispatch(new entryActions.LoadEntries(this.selectedMonth, this.selectedYear)); this.selectedDate = moment().month(event.monthIndex).year(event.year); - if (this.actualDate.getMonth() !== event.monthIndex){ + if (this.actualDate.getMonth() !== event.monthIndex) { this.selectedDate = this.selectedDate.startOf('month'); } } - changeDate(event: { date: Date }){ + changeDate(event: { date: Date }) { const newDate: moment.Moment = moment(event.date); - if (this.selectedDate.month() !== newDate.month()){ + if (this.selectedDate.month() !== newDate.month()) { const monthSelected = newDate.month(); const yearSelected = newDate.year(); const selectedDate = { monthIndex: monthSelected, - year: yearSelected + year: yearSelected, }; this.dateSelected(selectedDate); } this.selectedDate = newDate; } - changeView(event: { calendarView: CalendarView }){ + changeView(event: { calendarView: CalendarView }) { this.calendarView = event.calendarView || CalendarView.Month; } @@ -243,13 +252,25 @@ export class TimeEntriesComponent implements OnInit, OnDestroy, AfterViewInit { checkIfActiveEntryOverlapping(isEditingEntryEqualToActiveEntry: boolean, startDateAsLocalDate: Date) { if (isEditingEntryEqualToActiveEntry) { - this.store.pipe(select(getTimeEntriesDataSource)).subscribe(ds => { + this.store.pipe(select(getTimeEntriesDataSource)).subscribe((ds) => { const overlappingEntry = ds.data.find((item) => { const itemEndDate = new Date(item.end_date); - return startDateAsLocalDate < itemEndDate; + return startDateAsLocalDate < itemEndDate; }); this.isActiveEntryOverlapping = overlappingEntry ? true : false; }); } } + + // Check required fields for internal apps (Ticket number or Description field should exist). + requiredFieldsForInternalAppExist(event) { + const emptyFields = event.entry.uri === '' && event.entry.description === ''; + const isInternalApp = this.entry.customer_name.includes('ioet'); + if (isInternalApp && emptyFields) { + const message = 'The description field or ticket field should not be empty'; + this.toastrService.error(`Some fields are empty, ${message}.`); + return false; + } + return true; + } }