From 984815ba59f44e2dc70978b8ed2e4764a5d0fbc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Santiago=20C=C3=A1rdenas?= Date: Wed, 6 Sep 2023 09:52:06 -0500 Subject: [PATCH 1/5] TTL-910 Custom filters (#1001) * feat: TTL-910 add project dropdown * feat: TTL-910 add activity and project filters * refactor: TTL-910 if statements * test: TTL-910 add test * test: TTL-910 update tests * feat: TTL-910 update ui * test: TTL-910 update tests * test: TTL-910 fix tests * test: TTL-910 fix tests * test: TTL-910 fix tests * test: TTL-910 fix tests * test: TTL-910 fix tests * test: TTL-910 fix tests * test: TTL-910 fix tests * test: TTL-910 fix tests * test: TTL-910 fix tests * refactor: TTL-910 modify ngOnchange feature * refactor: TTL-910 modify ngOnChange feature * feat: TTL-925 update overlap message (#1002) --------- Co-authored-by: mmaquina --- src/app/app.module.ts | 4 + .../time-entries-table.component.html | 10 +- .../time-entries-table.component.spec.ts | 123 +++++++++++++----- .../time-entries-table.component.ts | 116 +++++++++++++---- .../time-range-custom.component.spec.ts | 45 +++---- .../time-range-custom.component.ts | 39 +++--- .../time-range-form.component.ts | 9 +- .../time-range.component.spec.ts | 56 ++++---- .../reports/pages/reports.component.html | 4 +- .../reports/pages/reports.component.ts | 8 ++ .../search-activity.component.html | 7 + .../search-activity.component.scss | 9 ++ .../search-activity.component.spec.ts | 34 +++++ .../search-activity.component.ts | 22 ++++ .../search-project.component.html | 7 + .../search-project.component.scss | 9 ++ .../search-project.component.spec.ts | 34 +++++ .../search-project.component.ts | 19 +++ .../search-user/search-user.component.html | 1 - .../search-user/search-user.component.scss | 2 +- .../time-clock/services/entry.service.ts | 57 +++++--- .../modules/time-clock/store/entry.actions.ts | 86 +++++------- .../modules/time-clock/store/entry.effects.ts | 2 +- .../pages/time-entries.component.ts | 2 +- 24 files changed, 485 insertions(+), 220 deletions(-) create mode 100644 src/app/modules/shared/components/search-activity/search-activity.component.html create mode 100644 src/app/modules/shared/components/search-activity/search-activity.component.scss create mode 100644 src/app/modules/shared/components/search-activity/search-activity.component.spec.ts create mode 100644 src/app/modules/shared/components/search-activity/search-activity.component.ts create mode 100644 src/app/modules/shared/components/search-project/search-project.component.html create mode 100644 src/app/modules/shared/components/search-project/search-project.component.scss create mode 100644 src/app/modules/shared/components/search-project/search-project.component.spec.ts create mode 100644 src/app/modules/shared/components/search-project/search-project.component.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 590ffcb1d..db76b1685 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -99,6 +99,8 @@ import { TimeRangeOptionsComponent } from './modules/reports/components/time-ran import { V2RedirectComponent } from './modules/v2-redirect/v2-redirect.component'; import { SpinnerOverlayComponent } from './modules/shared/components/spinner-overlay/spinner-overlay.component'; import { SpinnerInterceptor } from './modules/shared/interceptors/spinner.interceptor'; +import { SearchProjectComponent } from './modules/shared/components/search-project/search-project.component'; +import { SearchActivityComponent } from './modules/shared/components/search-activity/search-activity.component'; const maskConfig: Partial = { validation: false, @@ -140,6 +142,8 @@ const maskConfig: Partial = { SubstractDatePipeDisplayAsFloat, TechnologiesComponent, SearchUserComponent, + SearchProjectComponent, + SearchActivityComponent, TimeEntriesSummaryComponent, TimeDetailsPipe, InputLabelComponent, diff --git a/src/app/modules/reports/components/time-entries-table/time-entries-table.component.html b/src/app/modules/reports/components/time-entries-table/time-entries-table.component.html index 270c12fad..b10ea07ab 100644 --- a/src/app/modules/reports/components/time-entries-table/time-entries-table.component.html +++ b/src/app/modules/reports/components/time-entries-table/time-entries-table.component.html @@ -1,6 +1,12 @@ -
+
- + +
+
+ +
+
+ { describe('TimeEntriesTableComponent', () => { @@ -55,13 +63,13 @@ describe('Reports Page', () => { uri: 'custom uri', project_id: '123', project_name: 'Time-Tracker', - } + }, ]; const state: EntryState = { active: timeEntry, isLoading: false, - resultSumEntriesSelected: new TotalHours(), + resultSumEntriesSelected: new TotalHours(), message: '', createError: false, updateError: false, @@ -81,25 +89,37 @@ describe('Reports Page', () => { beforeEach( waitForAsync(() => { TestBed.configureTestingModule({ - imports: [NgxPaginationModule, DataTablesModule], - declarations: [TimeEntriesTableComponent, SubstractDatePipe, SubstractDatePipeDisplayAsFloat], + imports: [ + NgxPaginationModule, + DataTablesModule, + MatCheckboxModule, + NgSelectModule, + FormsModule, + ReactiveFormsModule, + ], + declarations: [ + TimeEntriesTableComponent, + SubstractDatePipe, + SubstractDatePipeDisplayAsFloat, + SearchUserComponent, + SearchProjectComponent, + SearchActivityComponent, + ], providers: [provideMockStore({ initialState: state }), { provide: ActionsSubject, useValue: actionSub }], }).compileComponents(); - }) ); - beforeEach( - () => { - fixture = TestBed.createComponent(TimeEntriesTableComponent); - component = fixture.componentInstance; - store = TestBed.inject(MockStore); - store.setState(state); - getReportDataSourceSelectorMock = (store.overrideSelector(getReportDataSource, state.reportDataSource), + beforeEach(() => { + fixture = TestBed.createComponent(TimeEntriesTableComponent); + component = fixture.componentInstance; + store = TestBed.inject(MockStore); + store.setState(state); + getReportDataSourceSelectorMock = + (store.overrideSelector(getReportDataSource, state.reportDataSource), store.overrideSelector(getResultSumEntriesSelected, state.resultSumEntriesSelected)); - fixture.detectChanges(); - } - ); + fixture.detectChanges(); + }); beforeEach(() => { row = 0; @@ -144,11 +164,10 @@ describe('Reports Page', () => { const params = [ { url: 'http://example.com', expected_value: true }, { url: 'https://example.com', expected_value: true }, - { url: 'no-url-example', expected_value: false } + { url: 'no-url-example', expected_value: false }, ]; params.map((param) => { it(`Given the url ${param.url}, the method isURL should return ${param.expected_value}`, () => { - expect(component.isURL(param.url)).toEqual(param.expected_value); }); }); @@ -160,22 +179,21 @@ describe('Reports Page', () => { }); it('when the rerenderDataTable method is called and dtElement and dtInstance are defined, the destroy and next methods are called ', - () => { - spyOn(component.dtTrigger, 'next'); + () => { + spyOn(component.dtTrigger, 'next'); - component.ngAfterViewInit(); + component.ngAfterViewInit(); - component.dtElement.dtInstance.then((dtInstance) => { - expect(component.dtTrigger.next).toHaveBeenCalled(); - }); + component.dtElement.dtInstance.then((dtInstance) => { + expect(component.dtTrigger.next).toHaveBeenCalled(); }); + }); it(`When the user method is called, the emit method is called`, () => { const userId = 'abc123'; spyOn(component.selectedUserId, 'emit'); component.user(userId); expect(component.selectedUserId.emit).toHaveBeenCalled(); - }); it('Should populate the users with the payload from the action executed', () => { @@ -183,27 +201,25 @@ describe('Reports Page', () => { const usersArray = []; const action = { type: UserActionTypes.LOAD_USERS_SUCCESS, - payload: usersArray + payload: usersArray, }; actionSubject.next(action); - expect(component.users).toEqual(usersArray); }); it('The sum of the data dates is equal to {"hours": 3, "minutes":20,"seconds":0}', () => { const { hours, minutes, seconds }: TotalHours = component.sumDates(timeEntryList); expect({ hours, minutes, seconds }).toEqual({ hours: 3, minutes: 20, seconds: 0 }); - }); it('the sume of hours of entries selected is equal to {hours:0, minutes:0, seconds:0}', () => { let checked = true; - let {hours, minutes, seconds}: TotalHours = component.sumHoursEntriesSelected(timeEntryList[0], checked); + let { hours, minutes, seconds }: TotalHours = component.sumHoursEntriesSelected(timeEntryList[0], checked); checked = false; - ({hours, minutes, seconds} = component.sumHoursEntriesSelected(timeEntryList[0], checked)); - expect({hours, minutes, seconds}).toEqual({hours: 0, minutes: 0, seconds: 0}); + ({ hours, minutes, seconds } = component.sumHoursEntriesSelected(timeEntryList[0], checked)); + expect({ hours, minutes, seconds }).toEqual({ hours: 0, minutes: 0, seconds: 0 }); }); it('should export data with the correct format', () => { @@ -228,7 +244,7 @@ describe('Reports Page', () => { "ng-reflect-ng-for-of": "git" }-->` + }-->`, ]; const dataFormat = [ ' ', @@ -245,7 +261,7 @@ describe('Reports Page', () => { 'Activity_Name', ' https://ioetec.atlassian.net/browse/CB-115 ', '', - ' git ' + ' git ', ]; data.forEach((value: any, index) => { @@ -257,7 +273,7 @@ describe('Reports Page', () => { it('Should render column header called Time Zone', () => { const table = document.querySelector('table#time-entries-table'); const tableHeaderElements = Array.from(table.getElementsByTagName('th')); - const tableHeaderTitles = tableHeaderElements.map(element => (element.textContent)); + const tableHeaderTitles = tableHeaderElements.map((element) => element.textContent); expect(tableHeaderTitles).toContain('Time zone'); }); @@ -272,6 +288,45 @@ describe('Reports Page', () => { expect(cell).toContain('UTC-5'); }); + it('Should populate the projects with the payload from the action executed', () => { + const actionSubject = TestBed.inject(ActionsSubject) as ActionsSubject; + const customerObj: Customer = { name: 'name' }; + const projectsArray: Project[] = [ + { + id: 'projectId', + customer_id: 'customer_id', + customer: customerObj, + name: 'name', + description: 'proejectDescription', + project_type_id: 'project_type_id', + status: 'active', + }, + ]; + const action = { + type: ProjectActionTypes.LOAD_PROJECTS_SUCCESS, + payload: projectsArray, + }; + actionSubject.next(action); + expect(component.projects).toEqual(projectsArray); + }); + + it('Should populate the activities with the payload from the action executed', () => { + const Subject = TestBed.inject(ActionsSubject) as ActionsSubject; + const activitiesArray: Activity[] = [ + { + id: 'activityId', + name: 'activityName', + description: 'activityDescription', + status: 'string' + }, + ]; + const action = { + type: ActivityManagementActionTypes.LOAD_ACTIVITIES_SUCCESS, + payload: activitiesArray, + }; + Subject.next(action); + expect(component.activities).toEqual(activitiesArray); + }); afterEach(() => { fixture.destroy(); diff --git a/src/app/modules/reports/components/time-entries-table/time-entries-table.component.ts b/src/app/modules/reports/components/time-entries-table/time-entries-table.component.ts index dfa94b872..2e4bcf945 100644 --- a/src/app/modules/reports/components/time-entries-table/time-entries-table.component.ts +++ b/src/app/modules/reports/components/time-entries-table/time-entries-table.component.ts @@ -5,7 +5,7 @@ import { DataTableDirective } from 'angular-datatables'; import * as moment from 'moment'; import { Observable, Subject, Subscription } from 'rxjs'; import { filter } from 'rxjs/operators'; -import { Entry } from 'src/app/modules/shared/models'; +import { Activity, Entry, Project } from 'src/app/modules/shared/models'; import { DataSource } from 'src/app/modules/shared/models/data-source.model'; import { EntryState } from '../../../time-clock/store/entry.reducer'; import { getReportDataSource, getResultSumEntriesSelected } from '../../../time-clock/store/entry.selectors'; @@ -13,6 +13,11 @@ import { TotalHours } from '../../models/total-hours-report'; import { User } from 'src/app/modules/users/models/users'; import { LoadUsers, UserActionTypes } from 'src/app/modules/users/store/user.actions'; import { ParseDateTimeOffset } from '../../../shared/formatters/parse-date-time-offset/parse-date-time-offset'; +import { + LoadProjects, + ProjectActionTypes, +} from 'src/app/modules/customer-management/components/projects/components/store/project.actions'; +import { ActivityManagementActionTypes, LoadActivities } from 'src/app/modules/activities-management/store'; @Component({ selector: 'app-time-entries-table', @@ -21,11 +26,15 @@ import { ParseDateTimeOffset } from '../../../shared/formatters/parse-date-time- }) export class TimeEntriesTableComponent implements OnInit, OnDestroy, AfterViewInit { @Output() selectedUserId = new EventEmitter(); + @Output() selectedProjectId = new EventEmitter(); + @Output() selectedActivityId = new EventEmitter(); selectOptionValues = [15, 30, 50, 100, -1]; selectOptionNames = [15, 30, 50, 100, 'All']; totalTimeSelected: moment.Duration; users: User[] = []; + projects: Project[] = []; + activities: Activity[] = []; removeFirstColumn = 'th:not(:first)'; dtOptions: any = { @@ -37,7 +46,7 @@ export class TimeEntriesTableComponent implements OnInit, OnDestroy, AfterViewIn { text: 'Column Visibility' + ' ▼', extend: 'colvis', - columns: ':not(.hidden-col)' + columns: ':not(.hidden-col)', }, { extend: 'print', @@ -49,27 +58,34 @@ export class TimeEntriesTableComponent implements OnInit, OnDestroy, AfterViewIn extend: 'excel', exportOptions: { format: { - body: this.bodyExportOptions + body: this.bodyExportOptions, }, columns: this.removeFirstColumn, }, text: 'Excel', - filename: `time-entries-${formatDate(new Date(), 'MM_dd_yyyy-HH_mm', 'en')}` + filename: `time-entries-${formatDate(new Date(), 'MM_dd_yyyy-HH_mm', 'en')}`, }, { extend: 'csv', exportOptions: { format: { - body: this.bodyExportOptions + body: this.bodyExportOptions, }, columns: this.removeFirstColumn, }, text: 'CSV', - filename: `time-entries-${formatDate(new Date(), 'MM_dd_yyyy-HH_mm', 'en')}` + filename: `time-entries-${formatDate(new Date(), 'MM_dd_yyyy-HH_mm', 'en')}`, }, ], - columnDefs: [{ type: 'date', targets: 3}, {orderable: false, targets: [0]}], - order: [[1, 'asc'], [2, 'desc'], [4, 'desc']] + columnDefs: [ + { type: 'date', targets: 3 }, + { orderable: false, targets: [0] }, + ], + order: [ + [1, 'asc'], + [2, 'desc'], + [4, 'desc'], + ], }; dtTrigger: Subject = new Subject(); @ViewChild(DataTableDirective, { static: false }) @@ -82,13 +98,19 @@ export class TimeEntriesTableComponent implements OnInit, OnDestroy, AfterViewIn resultSumEntriesSelected$: Observable; totalHoursSubscription: Subscription; dateTimeOffset: ParseDateTimeOffset; - - - constructor(private store: Store, private actionsSubject$: ActionsSubject, private storeUser: Store ) { - this.reportDataSource$ = this.store.pipe(select(getReportDataSource)); - this.resultSumEntriesSelected$ = this.store.pipe(select(getResultSumEntriesSelected)); - this.dateTimeOffset = new ParseDateTimeOffset(); - this.resultSumEntriesSelected = new TotalHours(); + listProjects: Project[] = []; + + constructor( + private store: Store, + private actionsSubject$: ActionsSubject, + private storeUser: Store, + private storeProject: Store, + private storeActivity: Store + ) { + this.reportDataSource$ = this.store.pipe(select(getReportDataSource)); + this.resultSumEntriesSelected$ = this.store.pipe(select(getResultSumEntriesSelected)); + this.dateTimeOffset = new ParseDateTimeOffset(); + this.resultSumEntriesSelected = new TotalHours(); } uploadUsers(): void { @@ -102,16 +124,53 @@ export class TimeEntriesTableComponent implements OnInit, OnDestroy, AfterViewIn }); } + uploadProjects(): void { + this.storeProject.dispatch(new LoadProjects()); + this.actionsSubject$ + .pipe(filter((action: any) => action.type === ProjectActionTypes.LOAD_PROJECTS_SUCCESS)) + .subscribe((action) => { + const sortProjects = [...action.payload]; + sortProjects.sort((a, b) => a.name.localeCompare(b.name)); + this.projects = sortProjects; + this.projects = this.projects.filter((project) => project.status === 'active'); + this.projects.sort((a, b) => { + const x = a.customer.name.toLowerCase(); + const y = b.customer.name.toLowerCase(); + if (x > y) {return 1; } + if (x < y) {return -1; } + return 0; + }); + this.projects.forEach((project) => { + const projectWithSearchField = { ...project }; + projectWithSearchField.search_field = `${project.customer.name} - ${project.name}`; + this.listProjects.push(projectWithSearchField); + }); + }); + } + + uploadActivities(): void { + this.storeActivity.dispatch(new LoadActivities()); + this.actionsSubject$ + .pipe(filter((action: any) => action.type === ActivityManagementActionTypes.LOAD_ACTIVITIES_SUCCESS)) + .subscribe((action) => { + const sortActivities = [...action.payload]; + sortActivities.sort((a, b) => a.name.localeCompare(b.name)); + this.activities = sortActivities; + }); + } + ngOnInit(): void { this.rerenderTableSubscription = this.reportDataSource$.subscribe((ds) => { this.totalHoursSubscription = this.resultSumEntriesSelected$.subscribe((actTotalHours) => { - this.resultSumEntriesSelected = actTotalHours; - this.totalTimeSelected = moment.duration(0); - }); + this.resultSumEntriesSelected = actTotalHours; + this.totalTimeSelected = moment.duration(0); + }); this.sumDates(ds.data); this.rerenderDataTable(); }); this.uploadUsers(); + this.uploadProjects(); + this.uploadActivities(); } ngAfterViewInit(): void { @@ -147,20 +206,17 @@ export class TimeEntriesTableComponent implements OnInit, OnDestroy, AfterViewIn return data.toString().replace(/<((.|\n){0,200}?)>/gi, '') || ''; } - sumDates(arrayData: Entry[]): TotalHours { this.resultSum = new TotalHours(); const arrayDurations = new Array(); - arrayData.forEach(entry => { + arrayData.forEach((entry) => { const start = moment(entry.end_date).diff(moment(entry.start_date)); arrayDurations.push(moment.utc(start).format('HH:mm:ss')); }); - const totalDurations = arrayDurations.slice(1) - .reduce((prev, cur) => { - return prev.add(cur); - }, - moment.duration(arrayDurations[0])); + const totalDurations = arrayDurations.slice(1).reduce((prev, cur) => { + return prev.add(cur); + }, moment.duration(arrayDurations[0])); const daysInHours = totalDurations.days() * 24; this.resultSum.hours = totalDurations.hours() + daysInHours; this.resultSum.minutes = totalDurations.minutes(); @@ -172,7 +228,15 @@ export class TimeEntriesTableComponent implements OnInit, OnDestroy, AfterViewIn this.selectedUserId.emit(userId); } - sumHoursEntriesSelected(entry: Entry, checked: boolean){ + project(projectId: string) { + this.selectedProjectId.emit(projectId); + } + + activity(activityId: string) { + this.selectedActivityId.emit(activityId); + } + + sumHoursEntriesSelected(entry: Entry, checked: boolean) { this.resultSumEntriesSelected = new TotalHours(); const duration = moment.duration(moment(entry.end_date).diff(moment(entry.start_date))); this.totalTimeSelected = checked ? this.totalTimeSelected.add(duration) : this.totalTimeSelected.subtract(duration); diff --git a/src/app/modules/reports/components/time-range-custom/time-range-custom.component.spec.ts b/src/app/modules/reports/components/time-range-custom/time-range-custom.component.spec.ts index fa2e8e9d5..b9f024567 100644 --- a/src/app/modules/reports/components/time-range-custom/time-range-custom.component.spec.ts +++ b/src/app/modules/reports/components/time-range-custom/time-range-custom.component.spec.ts @@ -8,7 +8,6 @@ import * as entryActions from '../../../time-clock/store/entry.actions'; import * as moment from 'moment'; import { SimpleChange } from '@angular/core'; - describe('TimeRangeCustomComponent', () => { let component: TimeRangeCustomComponent; let fixture: ComponentFixture; @@ -16,7 +15,7 @@ describe('TimeRangeCustomComponent', () => { const toastrServiceStub = { error: () => { return 'test error'; - } + }, }; const timeEntry = { @@ -27,7 +26,7 @@ describe('TimeRangeCustomComponent', () => { technologies: ['react', 'redux'], comments: 'any comment', uri: 'TT-123', - project_id: '1' + project_id: '1', }; const state = { @@ -44,13 +43,9 @@ describe('TimeRangeCustomComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [FormsModule, ReactiveFormsModule], - declarations: [ TimeRangeCustomComponent ], - providers: [ - provideMockStore({ initialState: state }), - { provide: ToastrService, useValue: toastrServiceStub } - ], - }) - .compileComponents(); + declarations: [TimeRangeCustomComponent], + providers: [provideMockStore({ initialState: state }), { provide: ToastrService, useValue: toastrServiceStub }], + }).compileComponents(); store = TestBed.inject(MockStore); }); @@ -81,10 +76,12 @@ describe('TimeRangeCustomComponent', () => { component.onSubmit(); - expect(store.dispatch).toHaveBeenCalledWith(new entryActions.LoadEntriesByTimeRange({ - start_date: end.startOf('day'), - end_date: start.endOf('day') - })); + expect(store.dispatch).toHaveBeenCalledWith( + new entryActions.LoadEntriesByTimeRange({ + start_date: end.startOf('day'), + end_date: start.endOf('day'), + }) + ); }); it('shows an error when the end date is before the start date', () => { @@ -108,7 +105,6 @@ describe('TimeRangeCustomComponent', () => { expect(component.range.controls.start.setValue).toHaveBeenCalled(); expect(component.range.controls.end.setValue).toHaveBeenCalled(); - }); it('triggers onSubmit to set initial data', () => { @@ -119,32 +115,25 @@ describe('TimeRangeCustomComponent', () => { expect(component.onSubmit).toHaveBeenCalled(); }); - it('When the ngOnChanges method is called, the onSubmit method is called', () => { - const userIdCalled = 'test-user-1'; - spyOn(component, 'onSubmit'); - - component.ngOnChanges({userId: new SimpleChange(null, userIdCalled, false)}); - - expect(component.onSubmit).toHaveBeenCalled(); - }); - it('When the ngOnChanges method is the first change, the onSubmit method is not called', () => { - const userIdNotCalled = 'test-user-2'; spyOn(component, 'onSubmit'); - component.ngOnChanges({userId: new SimpleChange(null, userIdNotCalled, true)}); + component.ngOnChanges({ + userId: new SimpleChange(null, 'userId', true), + projectId: new SimpleChange(null, 'projectId', true), + activityId: new SimpleChange(null, 'activityId', true), + }); expect(component.onSubmit).not.toHaveBeenCalled(); }); it('should call range form and delete variable local storage ', () => { spyOn(localStorage, 'removeItem').withArgs('rangeDatePicker'); - component.range.setValue({start: null, end: null}); + component.range.setValue({ start: null, end: null }); jasmine.clock().install(); component.dateRangeChange(); jasmine.clock().tick(200); expect(localStorage.removeItem).toHaveBeenCalledWith('rangeDatePicker'); jasmine.clock().uninstall(); }); - }); diff --git a/src/app/modules/reports/components/time-range-custom/time-range-custom.component.ts b/src/app/modules/reports/components/time-range-custom/time-range-custom.component.ts index dded53994..5c3f0cecc 100644 --- a/src/app/modules/reports/components/time-range-custom/time-range-custom.component.ts +++ b/src/app/modules/reports/components/time-range-custom/time-range-custom.component.ts @@ -1,13 +1,6 @@ import { formatDate } from '@angular/common'; -import { - ChangeDetectionStrategy, - Component, - Input, - OnChanges, - OnInit, - SimpleChanges, -} from '@angular/core'; -import {FormGroup, FormControl} from '@angular/forms'; +import { ChangeDetectionStrategy, Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { FormGroup, FormControl } from '@angular/forms'; import { Store } from '@ngrx/store'; import * as moment from 'moment'; import { ToastrService } from 'ngx-toastr'; @@ -16,7 +9,6 @@ import { DATE_FORMAT } from 'src/environments/environment'; import * as entryActions from '../../../time-clock/store/entry.actions'; import { TimeRangeHeaderComponent } from './time-range-header/time-range-header.component'; - @Component({ selector: 'app-time-range-custom', templateUrl: './time-range-custom.component.html', @@ -25,21 +17,23 @@ import { TimeRangeHeaderComponent } from './time-range-header/time-range-header. }) export class TimeRangeCustomComponent implements OnInit, OnChanges { @Input() userId: string; + @Input() projectId: string; + @Input() activityId: string; customHeader = TimeRangeHeaderComponent; range = new FormGroup({ start: new FormControl(null), end: new FormControl(null), }); - constructor(private store: Store, private toastrService: ToastrService) { - } + constructor(private store: Store, private toastrService: ToastrService) {} ngOnInit(): void { this.setInitialDataOnScreen(); } - ngOnChanges(changes: SimpleChanges){ - if (!changes.userId.firstChange){ + ngOnChanges(changes: SimpleChanges) { + const firstChange = Object.values(changes)[0].firstChange; + if (!firstChange) { this.onSubmit(); } } @@ -47,7 +41,7 @@ export class TimeRangeCustomComponent implements OnInit, OnChanges { setInitialDataOnScreen() { this.range.setValue({ start: formatDate(moment().startOf('isoWeek').format('l'), DATE_FORMAT, 'en'), - end: formatDate(moment().format('l'), DATE_FORMAT, 'en') + end: formatDate(moment().format('l'), DATE_FORMAT, 'en'), }); localStorage.setItem('rangeDatePicker', 'custom'); this.onSubmit(); @@ -59,10 +53,17 @@ export class TimeRangeCustomComponent implements OnInit, OnChanges { if (endDate.isBefore(startDate)) { this.toastrService.error('The end date should be after the start date'); } else { - this.store.dispatch(new entryActions.LoadEntriesByTimeRange({ - start_date: moment(this.range.getRawValue().start).startOf('day'), - end_date: moment(this.range.getRawValue().end).endOf('day'), - }, this.userId)); + this.store.dispatch( + new entryActions.LoadEntriesByTimeRange( + { + start_date: moment(this.range.getRawValue().start).startOf('day'), + end_date: moment(this.range.getRawValue().end).endOf('day'), + }, + this.userId, + this.projectId, + this.activityId + ) + ); } } diff --git a/src/app/modules/reports/components/time-range-form/time-range-form.component.ts b/src/app/modules/reports/components/time-range-form/time-range-form.component.ts index 5abb95bc8..09c2d37c6 100644 --- a/src/app/modules/reports/components/time-range-form/time-range-form.component.ts +++ b/src/app/modules/reports/components/time-range-form/time-range-form.component.ts @@ -16,6 +16,8 @@ import { DateAdapter } from '@angular/material/core'; export class TimeRangeFormComponent implements OnInit, OnChanges { @Input() userId: string; + @Input() projectId: string; + @Input() activityId: string; public reportForm: FormGroup; private startDate = new FormControl(''); @@ -33,8 +35,9 @@ export class TimeRangeFormComponent implements OnInit, OnChanges { this.setInitialDataOnScreen(); } - ngOnChanges(changes: SimpleChanges){ - if (!changes.userId.firstChange){ + ngOnChanges(changes: SimpleChanges) { + const firstChange = Object.values(changes)[0].firstChange; + if (!firstChange) { this.onSubmit(); } } @@ -56,7 +59,7 @@ export class TimeRangeFormComponent implements OnInit, OnChanges { this.store.dispatch(new entryActions.LoadEntriesByTimeRange({ start_date: moment(this.startDate.value).startOf('day'), end_date: moment(this.endDate.value).endOf('day'), - }, this.userId)); + }, this.userId, this.projectId, this.activityId)); } } } diff --git a/src/app/modules/reports/components/time-range-form/time-range.component.spec.ts b/src/app/modules/reports/components/time-range-form/time-range.component.spec.ts index 89436d6c7..58bb5f4bd 100644 --- a/src/app/modules/reports/components/time-range-form/time-range.component.spec.ts +++ b/src/app/modules/reports/components/time-range-form/time-range.component.spec.ts @@ -17,7 +17,7 @@ describe('Reports Page', () => { let store: MockStore; const toastrServiceStub = { - error: (message?: string, title?: string, override?: Partial) => { } + error: (message?: string, title?: string, override?: Partial) => {}, }; const timeEntry = { @@ -28,7 +28,7 @@ describe('Reports Page', () => { technologies: ['react', 'redux'], comments: 'any comment', uri: 'custom uri', - project_id: '123' + project_id: '123', }; const state = { @@ -42,18 +42,20 @@ describe('Reports Page', () => { entriesForReport: [timeEntry], }; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [FormsModule, ReactiveFormsModule], - declarations: [TimeRangeFormComponent, InputDateComponent], - providers: [ - provideMockStore({ initialState: state }), - { provide: ToastrService, useValue: toastrServiceStub }, - { provide: DateAdapter, useClass: DateAdapter } - ], - }).compileComponents(); - store = TestBed.inject(MockStore); - })); + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [FormsModule, ReactiveFormsModule], + declarations: [TimeRangeFormComponent, InputDateComponent], + providers: [ + provideMockStore({ initialState: state }), + { provide: ToastrService, useValue: toastrServiceStub }, + { provide: DateAdapter, useClass: DateAdapter }, + ], + }).compileComponents(); + store = TestBed.inject(MockStore); + }) + ); beforeEach(() => { fixture = TestBed.createComponent(TimeRangeFormComponent); @@ -74,10 +76,12 @@ describe('Reports Page', () => { component.onSubmit(); - expect(store.dispatch).toHaveBeenCalledWith(new entryActions.LoadEntriesByTimeRange({ - start_date: yesterday.startOf('day'), - end_date: today.endOf('day') - })); + expect(store.dispatch).toHaveBeenCalledWith( + new entryActions.LoadEntriesByTimeRange({ + start_date: yesterday.startOf('day'), + end_date: today.endOf('day'), + }) + ); }); it('setInitialDataOnScreen on ngOnInit', () => { @@ -127,20 +131,14 @@ describe('Reports Page', () => { expect(component.onSubmit).toHaveBeenCalled(); }); - it('When the ngOnChanges method is called, the onSubmit method is called', () => { - const userId = 'abcd'; - spyOn(component, 'onSubmit'); - - component.ngOnChanges({userId: new SimpleChange(null, userId, false)}); - - expect(component.onSubmit).toHaveBeenCalled(); - }); - it('When the ngOnChanges method is the first change, the onSubmit method is not called', () => { - const userId = 'abcd'; spyOn(component, 'onSubmit'); - component.ngOnChanges({userId: new SimpleChange(null, userId, true)}); + component.ngOnChanges({ + userId: new SimpleChange(null, 'user_id', true), + projectId: new SimpleChange(null, 'project_id', true), + activityId: new SimpleChange(null, 'activity_id', true), + }); expect(component.onSubmit).not.toHaveBeenCalled(); }); diff --git a/src/app/modules/reports/pages/reports.component.html b/src/app/modules/reports/pages/reports.component.html index a9b65ded9..931c46752 100644 --- a/src/app/modules/reports/pages/reports.component.html +++ b/src/app/modules/reports/pages/reports.component.html @@ -1,2 +1,2 @@ - - \ No newline at end of file + + \ No newline at end of file diff --git a/src/app/modules/reports/pages/reports.component.ts b/src/app/modules/reports/pages/reports.component.ts index 6b02adef8..1885b8c53 100644 --- a/src/app/modules/reports/pages/reports.component.ts +++ b/src/app/modules/reports/pages/reports.component.ts @@ -8,8 +8,16 @@ import { Component } from '@angular/core'; export class ReportsComponent { userId: string; + projectId: string; + activityId: string; user(userId: string){ this.userId = userId; } + activity(activityId: string){ + this.activityId = activityId; + } + project(projectId: string){ + this.projectId = projectId; + } } diff --git a/src/app/modules/shared/components/search-activity/search-activity.component.html b/src/app/modules/shared/components/search-activity/search-activity.component.html new file mode 100644 index 000000000..f1960b694 --- /dev/null +++ b/src/app/modules/shared/components/search-activity/search-activity.component.html @@ -0,0 +1,7 @@ +
+ + + {{activity.name}} + + +
\ No newline at end of file diff --git a/src/app/modules/shared/components/search-activity/search-activity.component.scss b/src/app/modules/shared/components/search-activity/search-activity.component.scss new file mode 100644 index 000000000..d21fef41b --- /dev/null +++ b/src/app/modules/shared/components/search-activity/search-activity.component.scss @@ -0,0 +1,9 @@ +label { + width: 225px; +} +.selectActivity { + display: inline-block; + width: 300px; + padding: 0 12px 15px 12px; +} + diff --git a/src/app/modules/shared/components/search-activity/search-activity.component.spec.ts b/src/app/modules/shared/components/search-activity/search-activity.component.spec.ts new file mode 100644 index 000000000..f1f8a9cb6 --- /dev/null +++ b/src/app/modules/shared/components/search-activity/search-activity.component.spec.ts @@ -0,0 +1,34 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; + +import { SearchActivityComponent } from './search-activity.component'; +import { NgSelectModule } from '@ng-select/ng-select'; + +describe('SearchActivityComponent', () => { + let component: SearchActivityComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ FormsModule, NgSelectModule ], + declarations: [ SearchActivityComponent ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SearchActivityComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should emit changedFilterValue event #changeFilterValue', () => { + component.selectedActivity = 'angular'; + spyOn(component.selectedActivityId, 'emit'); + component.updateActivity(); + expect(component.selectedActivityId.emit).toHaveBeenCalled(); + }); +}); diff --git a/src/app/modules/shared/components/search-activity/search-activity.component.ts b/src/app/modules/shared/components/search-activity/search-activity.component.ts new file mode 100644 index 000000000..6ab8b55a7 --- /dev/null +++ b/src/app/modules/shared/components/search-activity/search-activity.component.ts @@ -0,0 +1,22 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +@Component({ + selector: 'app-search-activity', + templateUrl: './search-activity.component.html', + styleUrls: ['./search-activity.component.scss'], +}) + +export class SearchActivityComponent { + + readonly ALLOW_SELECT_MULTIPLE = false; + selectedActivity: string; + + @Input() activities: string[] = []; + + @Output() selectedActivityId = new EventEmitter(); + + updateActivity() { + this.selectedActivityId.emit(this.selectedActivity || '*' ); + } +} + diff --git a/src/app/modules/shared/components/search-project/search-project.component.html b/src/app/modules/shared/components/search-project/search-project.component.html new file mode 100644 index 000000000..c97e53194 --- /dev/null +++ b/src/app/modules/shared/components/search-project/search-project.component.html @@ -0,0 +1,7 @@ +
+ + + {{project.customer.name}} - {{project.name}} + + +
\ No newline at end of file diff --git a/src/app/modules/shared/components/search-project/search-project.component.scss b/src/app/modules/shared/components/search-project/search-project.component.scss new file mode 100644 index 000000000..786299355 --- /dev/null +++ b/src/app/modules/shared/components/search-project/search-project.component.scss @@ -0,0 +1,9 @@ +label { + width: 225px; +} +.selectProject { + display: inline-block; + width: 600px; + padding: 0 12px 15px 12px; +} + diff --git a/src/app/modules/shared/components/search-project/search-project.component.spec.ts b/src/app/modules/shared/components/search-project/search-project.component.spec.ts new file mode 100644 index 000000000..276c39049 --- /dev/null +++ b/src/app/modules/shared/components/search-project/search-project.component.spec.ts @@ -0,0 +1,34 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; + +import { SearchProjectComponent } from './search-project.component'; +import { NgSelectModule } from '@ng-select/ng-select'; + +describe('SearchActivityComponent', () => { + let component: SearchProjectComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ FormsModule, NgSelectModule ], + declarations: [ SearchProjectComponent ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SearchProjectComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should emit changedFilterValue event #changeFilterValue', () => { + component.selectedProject = 'angular'; + spyOn(component.selectedProjectId, 'emit'); + component.updateProject(); + expect(component.selectedProjectId.emit).toHaveBeenCalled(); + }); +}); diff --git a/src/app/modules/shared/components/search-project/search-project.component.ts b/src/app/modules/shared/components/search-project/search-project.component.ts new file mode 100644 index 000000000..97e04197e --- /dev/null +++ b/src/app/modules/shared/components/search-project/search-project.component.ts @@ -0,0 +1,19 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +@Component({ + selector: 'app-search-project', + templateUrl: './search-project.component.html', + styleUrls: ['./search-project.component.scss'], +}) +export class SearchProjectComponent { + readonly ALLOW_SELECT_MULTIPLE = false; + selectedProject: string; + + @Input() projects: string[] = []; + + @Output() selectedProjectId = new EventEmitter(); + + updateProject() { + this.selectedProjectId.emit(this.selectedProject || '*'); + } +} diff --git a/src/app/modules/shared/components/search-user/search-user.component.html b/src/app/modules/shared/components/search-user/search-user.component.html index b141cf65b..2b9c7916d 100644 --- a/src/app/modules/shared/components/search-user/search-user.component.html +++ b/src/app/modules/shared/components/search-user/search-user.component.html @@ -1,5 +1,4 @@
- 👤{{user.name}}📨{{ user.email}} diff --git a/src/app/modules/shared/components/search-user/search-user.component.scss b/src/app/modules/shared/components/search-user/search-user.component.scss index 6d3b6756d..d36c9b4d5 100644 --- a/src/app/modules/shared/components/search-user/search-user.component.scss +++ b/src/app/modules/shared/components/search-user/search-user.component.scss @@ -3,7 +3,7 @@ label { } .selectUser { display: inline-block; - width: 350px; + width: 500px; padding: 0 12px 15px 12px; } diff --git a/src/app/modules/time-clock/services/entry.service.ts b/src/app/modules/time-clock/services/entry.service.ts index c93aba502..feda2f9d6 100644 --- a/src/app/modules/time-clock/services/entry.service.ts +++ b/src/app/modules/time-clock/services/entry.service.ts @@ -11,15 +11,24 @@ import { Entry } from '../../shared/models'; import * as moment from 'moment'; +interface QueryParams { + start_date: string; + end_date: string; + user_id: string | string[]; + limit: string; + timezone_offset: string; + project_id?: string; + activity_id?: string; +} + export const MAX_NUMBER_OF_ENTRIES_FOR_REPORTS = 9999; + @Injectable({ providedIn: 'root', }) export class EntryService { - - constructor(private http: HttpClient, private datePipe: DatePipe) { - } + constructor(private http: HttpClient, private datePipe: DatePipe) {} static TIME_ENTRIES_DATE_TIME_FORMAT = 'yyyy-MM-ddTHH:mm:ssZZZZZ'; baseUrl = `${environment.timeTrackerApiUrl}/time-entries`; @@ -39,7 +48,7 @@ export class EntryService { } updateEntry(entryData): Observable { - const {id} = entryData; + const { id } = entryData; return this.http.put(`${this.baseUrl}/${id}`, entryData); } @@ -49,8 +58,9 @@ export class EntryService { } stopEntryRunning(idEntry: string): Observable { - return (this.urlInProductionLegacy ? - this.http.post(`${this.baseUrl}/${idEntry}/stop/`, null) : this.http.put(`${this.baseUrl}/stop/`, null) ); + return this.urlInProductionLegacy + ? this.http.post(`${this.baseUrl}/${idEntry}/stop/`, null) + : this.http.put(`${this.baseUrl}/stop/`, null); } restartEntry(idEntry: string): Observable { @@ -71,23 +81,32 @@ export class EntryService { return this.http.get(findEntriesByProjectURL); } - loadEntriesByTimeRange(range: TimeEntriesTimeRange, userId: string[] | string ): Observable { + + loadEntriesByTimeRange( + range: TimeEntriesTimeRange, + userId: string[] | string, + projectId?: string, + activityId?: string + ): Observable { + const loadEntriesByTimeRangeURL = this.urlInProductionLegacy ? this.baseUrl : this.baseUrl + '/report/'; - return this.http.get(loadEntriesByTimeRangeURL, - { - params: { - start_date: this.datePipe.transform(range.start_date, EntryService.TIME_ENTRIES_DATE_TIME_FORMAT), - end_date: this.datePipe.transform(range.end_date, EntryService.TIME_ENTRIES_DATE_TIME_FORMAT), - user_id: userId, - limit: `${MAX_NUMBER_OF_ENTRIES_FOR_REPORTS}`, - timezone_offset : new Date().getTimezoneOffset().toString(), - } - } - ); + const queryParams: QueryParams = { + start_date: this.datePipe.transform(range.start_date, EntryService.TIME_ENTRIES_DATE_TIME_FORMAT), + end_date: this.datePipe.transform(range.end_date, EntryService.TIME_ENTRIES_DATE_TIME_FORMAT), + user_id: userId, + limit: `${MAX_NUMBER_OF_ENTRIES_FOR_REPORTS}`, + timezone_offset: new Date().getTimezoneOffset().toString(), + }; + if (projectId !== '*') {queryParams.project_id = projectId; } + if (activityId !== '*') {queryParams.activity_id = activityId; } + + return this.http.get(loadEntriesByTimeRangeURL, { + params: { ...queryParams }, + }); } getDateLastMonth() { - return (moment().subtract(1, 'months')).format(); + return moment().subtract(1, 'months').format(); } getCurrentDate() { diff --git a/src/app/modules/time-clock/store/entry.actions.ts b/src/app/modules/time-clock/store/entry.actions.ts index f58378ffa..734515c59 100644 --- a/src/app/modules/time-clock/store/entry.actions.ts +++ b/src/app/modules/time-clock/store/entry.actions.ts @@ -70,8 +70,7 @@ export class LoadEntriesSummary implements Action { export class LoadEntriesSummarySuccess implements Action { readonly type = EntryActionTypes.LOAD_ENTRIES_SUMMARY_SUCCESS; - constructor(readonly payload: TimeEntriesSummary) { - } + constructor(readonly payload: TimeEntriesSummary) {} } export class LoadEntriesSummaryFail implements Action { @@ -85,43 +84,37 @@ export class LoadActiveEntry implements Action { export class LoadActiveEntrySuccess implements Action { readonly type = EntryActionTypes.LOAD_ACTIVE_ENTRY_SUCCESS; - constructor(readonly payload) { - } + constructor(readonly payload) {} } export class LoadActiveEntryFail implements Action { public readonly type = EntryActionTypes.LOAD_ACTIVE_ENTRY_FAIL; - constructor(public error: string) { - } + constructor(public error: string) {} } export class LoadEntries implements Action { public readonly type = EntryActionTypes.LOAD_ENTRIES; - constructor(public month: number, public year: number) { - } + constructor(public month: number, public year: number) {} } export class LoadEntriesSuccess implements Action { readonly type = EntryActionTypes.LOAD_ENTRIES_SUCCESS; - constructor(readonly payload: Entry[]) { - } + constructor(readonly payload: Entry[]) {} } export class LoadEntriesFail implements Action { public readonly type = EntryActionTypes.LOAD_ENTRIES_FAIL; - constructor(public error: string) { - } + constructor(public error: string) {} } export class CreateEntry implements Action { public readonly type = EntryActionTypes.CREATE_ENTRY; - constructor(public payload: NewEntry) { - } + constructor(public payload: NewEntry) {} } export class CreateEntrySuccess implements Action { @@ -132,104 +125,89 @@ export class CreateEntrySuccess implements Action { export class CreateEntryFail implements Action { public readonly type = EntryActionTypes.CREATE_ENTRY_FAIL; - constructor(public error: string) { - } + constructor(public error: string) {} } export class DeleteEntry implements Action { public readonly type = EntryActionTypes.DELETE_ENTRY; - constructor(public entryId: string) { - } + constructor(public entryId: string) {} } export class DeleteEntrySuccess implements Action { public readonly type = EntryActionTypes.DELETE_ENTRY_SUCCESS; - constructor(public entryId: string) { - } + constructor(public entryId: string) {} } export class DeleteEntryFail implements Action { public readonly type = EntryActionTypes.DELETE_ENTRY_FAIL; - constructor(public error: string) { - } + constructor(public error: string) {} } export class UpdateEntryRunning implements Action { public readonly type = EntryActionTypes.UPDATE_ENTRY_RUNNING; - constructor(public payload) { - } + constructor(public payload) {} } export class UpdateEntry implements Action { public readonly type = EntryActionTypes.UPDATE_ENTRY; - constructor(public payload) { - } + constructor(public payload) {} } export class UpdateEntrySuccess implements Action { public readonly type = EntryActionTypes.UPDATE_ENTRY_SUCCESS; - constructor(public payload: Entry) { - } + constructor(public payload: Entry) {} } export class UpdateEntryFail implements Action { public readonly type = EntryActionTypes.UPDATE_ENTRY_FAIL; - constructor(public error: string) { - } + constructor(public error: string) {} } export class UpdateCurrentOrLastEntry implements Action { public readonly type = EntryActionTypes.UPDATE_CURRENT_OR_LAST_ENTRY; - constructor(public payload) { - } + constructor(public payload) {} } export class UpdateCurrentOrLastEntryFail implements Action { public readonly type = EntryActionTypes.UPDATE_CURRENT_OR_LAST_ENTRY_FAIL; - constructor(public error: string) { - } + constructor(public error: string) {} } export class StopTimeEntryRunning implements Action { public readonly type = EntryActionTypes.STOP_TIME_ENTRY_RUNNING; - constructor(readonly payload: string) { - } + constructor(readonly payload: string) {} } export class StopTimeEntryRunningSuccess implements Action { public readonly type = EntryActionTypes.STOP_TIME_ENTRY_RUNNING_SUCCESS; - constructor(readonly payload) { - } + constructor(readonly payload) {} } export class StopTimeEntryRunningFail implements Action { public readonly type = EntryActionTypes.STOP_TIME_ENTRY_RUNNING_FAILED; - constructor(public error: string) { - } + constructor(public error: string) {} } export class CleanEntryCreateError implements Action { public readonly type = EntryActionTypes.CLEAN_ENTRY_CREATE_ERROR; - constructor(public error: boolean) { - } + constructor(public error: boolean) {} } export class CleanEntryUpdateError implements Action { public readonly type = EntryActionTypes.CLEAN_ENTRY_UPDATE_ERROR; - constructor(public error: boolean) { - } + constructor(public error: boolean) {} } export class DefaultEntry implements Action { @@ -239,15 +217,18 @@ export class DefaultEntry implements Action { export class LoadEntriesByTimeRange implements Action { public readonly type = EntryActionTypes.LOAD_ENTRIES_BY_TIME_RANGE; - constructor(readonly timeRange: TimeEntriesTimeRange, readonly userId: string = '*') { - } + constructor( + readonly timeRange: TimeEntriesTimeRange, + readonly userId: string = '*', + readonly projectId: string = '*', + readonly activityId: string = '*' + ) {} } export class LoadEntriesByTimeRangeSuccess implements Action { readonly type = EntryActionTypes.LOAD_ENTRIES_BY_TIME_RANGE_SUCCESS; - constructor(readonly payload: Entry[]) { - } + constructor(readonly payload: Entry[]) {} } export class LoadEntriesByTimeRangeFail implements Action { @@ -257,22 +238,19 @@ export class LoadEntriesByTimeRangeFail implements Action { export class RestartEntry implements Action { readonly type = EntryActionTypes.RESTART_ENTRY; - constructor(readonly entry: Entry) { - } + constructor(readonly entry: Entry) {} } export class RestartEntrySuccess implements Action { readonly type = EntryActionTypes.RESTART_ENTRY_SUCCESS; - constructor(readonly payload: Entry) { - } + constructor(readonly payload: Entry) {} } export class RestartEntryFail implements Action { public readonly type = EntryActionTypes.RESTART_ENTRY_FAIL; - constructor(public error: string) { - } + constructor(public error: string) {} } export type EntryActions = diff --git a/src/app/modules/time-clock/store/entry.effects.ts b/src/app/modules/time-clock/store/entry.effects.ts index 1fea3ba58..180893600 100644 --- a/src/app/modules/time-clock/store/entry.effects.ts +++ b/src/app/modules/time-clock/store/entry.effects.ts @@ -258,7 +258,7 @@ export class EntryEffects { ofType(actions.EntryActionTypes.LOAD_ENTRIES_BY_TIME_RANGE), map((action: actions.LoadEntriesByTimeRange) => action), mergeMap((action) => - this.entryService.loadEntriesByTimeRange(action.timeRange, action.userId).pipe( + this.entryService.loadEntriesByTimeRange(action.timeRange, action.userId, action.projectId, action.activityId).pipe( map((response) => { if (response.length >= MAX_NUMBER_OF_ENTRIES_FOR_REPORTS){ this.toastrService.warning( 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 99a3845b8..9ce87dff5 100644 --- a/src/app/modules/time-entries/pages/time-entries.component.ts +++ b/src/app/modules/time-entries/pages/time-entries.component.ts @@ -146,7 +146,7 @@ export class TimeEntriesComponent implements OnInit, OnDestroy, AfterViewInit { this.checkIfActiveEntryOverlapping(isEditingEntryEqualToActiveEntry, startDateAsLocalDate); 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.toastrService.error(`There is an overlap with another time entry, please modify the time.`); this.isActiveEntryOverlapping = false; } else { this.doSave(event); From 14bdb1b8a5e1dd7b29233f986c82bab250cf47f9 Mon Sep 17 00:00:00 2001 From: mmaquina Date: Wed, 20 Sep 2023 11:25:18 -0300 Subject: [PATCH 2/5] fix: TTL-985 add debouncing to clock in buttons (#1006) --- .../details-fields/details-fields.component.html | 2 +- .../details-fields/details-fields.component.ts | 8 ++++++++ .../project-list-hover.component.ts | 13 +++++++++++++ tsconfig.json | 3 ++- 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/app/modules/shared/components/details-fields/details-fields.component.html b/src/app/modules/shared/components/details-fields/details-fields.component.html index d0c4ea2dc..77fc6d3e6 100644 --- a/src/app/modules/shared/components/details-fields/details-fields.component.html +++ b/src/app/modules/shared/components/details-fields/details-fields.component.html @@ -151,7 +151,7 @@
diff --git a/src/app/modules/shared/components/details-fields/details-fields.component.ts b/src/app/modules/shared/components/details-fields/details-fields.component.ts index ca66f7cbf..e41801a7f 100644 --- a/src/app/modules/shared/components/details-fields/details-fields.component.ts +++ b/src/app/modules/shared/components/details-fields/details-fields.component.ts @@ -333,6 +333,14 @@ export class DetailsFieldsComponent implements OnChanges, OnInit { } onSubmit() { + + // Debounce submit (Save) button + const button = document.querySelector('#submitButton'); + button.setAttribute('disabled', 'true'); + setTimeout(() => { + button.removeAttribute('disabled'); + }, 3000); + const emptyValue = ''; const { project_name, uri, description } = this.entryForm.value; const areEmptyValues = [uri, description].every(item => item === emptyValue); diff --git a/src/app/modules/time-clock/components/project-list-hover/project-list-hover.component.ts b/src/app/modules/time-clock/components/project-list-hover/project-list-hover.component.ts index 66207c63d..301aa33d5 100644 --- a/src/app/modules/time-clock/components/project-list-hover/project-list-hover.component.ts +++ b/src/app/modules/time-clock/components/project-list-hover/project-list-hover.component.ts @@ -106,6 +106,18 @@ export class ProjectListHoverComponent implements OnInit, OnDestroy { } clockIn(selectedProject, customerName, name) { + + // Debounce 'Clock In' buttons + const buttons = document.getElementsByClassName('btn btn-sm btn-primary btn-select'); + for (const button of buttons) { + button.setAttribute('disabled', 'true'); + } + setTimeout(() => { + for (const button of buttons) { + button.removeAttribute('disabled'); + } + }, 3000); + const entry = { project_id: selectedProject, start_date: new Date().toISOString(), @@ -113,6 +125,7 @@ export class ProjectListHoverComponent implements OnInit, OnDestroy { technologies: [], activity_id: head(this.activities).id, }; + this.store.dispatch(new entryActions.ClockIn(entry)); this.projectsForm.setValue({ project_id: `${customerName} - ${name}` }); setTimeout(() => { diff --git a/tsconfig.json b/tsconfig.json index 80a8e1cb8..bf3e900b6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,8 @@ ], "lib": [ "es2018", - "dom" + "dom", + "dom.Iterable" ] }, "angularCompilerOptions": { From efbb5f3dd52d4edd7be6734e86985b6264aa3f40 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 20 Sep 2023 14:27:37 +0000 Subject: [PATCH 3/5] chore(release): 2.5.6 [skip ci]nn --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0a1b4b172..ababf10a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "time-tracker", - "version": "2.5.1", + "version": "2.5.6", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 11c49411d..5701c4d1c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "time-tracker", - "version": "2.5.1", + "version": "2.5.6", "scripts": { "preinstall": "npx npm-force-resolutions", "ng": "ng", From 0430cad0e6071cb1ae0f2461369cba765c9692b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Santiago=20C=C3=A1rdenas?= Date: Tue, 3 Oct 2023 11:19:29 -0500 Subject: [PATCH 4/5] feat: TTL-666 add santiago to key ring (#1007) Co-authored-by: mmaquina --- .../0D57F1A8D53A81F7400B003B23FA3416519EEE05.gpg | Bin 603 -> 0 bytes .../9122E40E0400D921898F3B395582EDA0BCA797BC.gpg | Bin 0 -> 603 bytes .../0D57F1A8D53A81F7400B003B23FA3416519EEE05.gpg | Bin 606 -> 0 bytes .../9122E40E0400D921898F3B395582EDA0BCA797BC.gpg | 5 +++++ .../0D57F1A8D53A81F7400B003B23FA3416519EEE05.gpg | Bin 597 -> 0 bytes .../9122E40E0400D921898F3B395582EDA0BCA797BC.gpg | Bin 0 -> 597 bytes 6 files changed, 5 insertions(+) delete mode 100644 .git-crypt/keys/PROD/0/0D57F1A8D53A81F7400B003B23FA3416519EEE05.gpg create mode 100644 .git-crypt/keys/PROD/0/9122E40E0400D921898F3B395582EDA0BCA797BC.gpg delete mode 100644 .git-crypt/keys/STAGE/0/0D57F1A8D53A81F7400B003B23FA3416519EEE05.gpg create mode 100644 .git-crypt/keys/STAGE/0/9122E40E0400D921898F3B395582EDA0BCA797BC.gpg delete mode 100644 .git-crypt/keys/default/0/0D57F1A8D53A81F7400B003B23FA3416519EEE05.gpg create mode 100644 .git-crypt/keys/default/0/9122E40E0400D921898F3B395582EDA0BCA797BC.gpg diff --git a/.git-crypt/keys/PROD/0/0D57F1A8D53A81F7400B003B23FA3416519EEE05.gpg b/.git-crypt/keys/PROD/0/0D57F1A8D53A81F7400B003B23FA3416519EEE05.gpg deleted file mode 100644 index 41c46fa732a4249bf8e7d795b0da6e39a8a3bbec..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 603 zcmV-h0;K(g0gMCskVzP|>n5QA3;>KGU-SoA1ty0Tvcn`L=Qs+g%<|a&crnXolccr~ zQxovRPi*!NE>Z1naN9*sB(*~rd~$Y*i4R+dfQFL7d%EM}t- z>iA#5CBgHgup=c9cxf}k#c#bypS`fP32(C-`yc@ipv$heL62s{+!-we-O z4(xv;y^eHF!C$Ml6f^*VeCdjSwX^k~r^uv^4NpBAG}q$5S;|ai&TK*YV7TCv-5(-8 z$GsjB-e8ePI$TY@Z52r*hNq>{L%3q=U?6~E1&u!2m1Nwr#U5{@VXFpBZ?j4R(% zivK8aLzQPxyP7j9eiG`yv0gmNak@xT@XqKAbHvgeNqs2@+QpA*GV0GJ{)XI(>(cva z*X@9X1}LYj&vrFRI?gU8%>{IPa=G|KiwgqV&DGvO>snacsobvHNv7&@^by5DPDtYe za_HFO9OS;;{rGEuZ^J3NL;R)~>hf3yR^j0jTC?50j)y!gSXAxA7);W@2?5-Txc?kp zAWDWfy!36$(d$(nbRHdzQ*usyY#MNohAm$(Ru2;5V diff --git a/.git-crypt/keys/PROD/0/9122E40E0400D921898F3B395582EDA0BCA797BC.gpg b/.git-crypt/keys/PROD/0/9122E40E0400D921898F3B395582EDA0BCA797BC.gpg new file mode 100644 index 0000000000000000000000000000000000000000..0b2e82c54ab9a1f1b0b3562200174e68bcca2682 GIT binary patch literal 603 zcmV-h0;K(g0gMBsvA!HG>t=Gea%2F!X2 zmf*^5Ll%Vl`ur38KLw<_xtzFAkCdL;EI8IVHm_ejSV_3gueqH+4xR>#R_Mx~w=3Kp z7x)mc_4}ozu50{g!v1nyG5(n0X!@E(?Ch7JcfNgf9TS|H8|H_qBV=}NRLtQ z(vIM5^wUT>^3+Tl7=R+U_J9U6gbZ*k2;)On729(A)h>BWh^p-bG?aw?TavPgeTKGfH7OX{-5X} z7|O&t8{&YcqvpQD-dXGq{5I|^?`PA?LnmVBAWm{9H@QvGKjtYKAGeOx#%{q?&1$t1 p-tHp)=N_M-yh+SIP$one%Dtz{-5!!E@paXl+_T?h&`GfomPWqcG3EdO literal 0 HcmV?d00001 diff --git a/.git-crypt/keys/STAGE/0/0D57F1A8D53A81F7400B003B23FA3416519EEE05.gpg b/.git-crypt/keys/STAGE/0/0D57F1A8D53A81F7400B003B23FA3416519EEE05.gpg deleted file mode 100644 index 4d6c1e8d0485a258e5bdc6028c438653e8cef24e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 606 zcmV-k0-^nd0gMCskVzP|>n5QA3;P6byys2m&~StN1xUXi?bh@mz%Ebw@C=|t4SpgY z7-M&Aigub!rrBtTFGAA1i(hiJ(8zqg?xTArteOZ9^(r7+fK0_%n)UV1@&^(5x@7r$ zxPI@^&DQrUOX;JlKvS`7P^zi6k>X96(s#<)`#>u4J!r6226F3}YS*gFrAfv?VOe{p zalC!C^O!uUT;)5Yofpf3yXJ4%*JlD{U^uGk;T~!;NzmUfcfLIR&|pnDB|r^rTMJxp=QIru#cAat<1Q;C zhcgutj3yvMk<|bh_wL1Dc~{KlhI#YlqI8-y>K2#0>s(4y?MMdwhP2Ya3;}Ax3zo2| zQuwh%xo8cDRNW(S1jo5R^)`Ne{p#IN&zqt0ZVs-`T-vAdg7;JXhQ=Op$F!ee{WLnjA sZyQ{H@fe84p*t|^3s21taJ=LhsGdAlYY~C*&PO)jv-{*i1X?c;f1yq@t^fc4 diff --git a/.git-crypt/keys/STAGE/0/9122E40E0400D921898F3B395582EDA0BCA797BC.gpg b/.git-crypt/keys/STAGE/0/9122E40E0400D921898F3B395582EDA0BCA797BC.gpg new file mode 100644 index 000000000..b1e200052 --- /dev/null +++ b/.git-crypt/keys/STAGE/0/9122E40E0400D921898F3B395582EDA0BCA797BC.gpg @@ -0,0 +1,5 @@ +.. Z˹32$q|:k tQH,9GRAw8_+KVS0R!73*#R1K+PuقV SL F2jovrrtx^j +Rp5PCIm0gMCskVzP|>n5QA3;$THB6FZtqd;{xoH8INDWl!J>lW&Ao!a7~ziq33 z`;ZKJ_b5}HLlRDe4WM_?$&VCw3~d`O0jntenU05f)W!dDa(XFQeB$HA!iK-a(f@*L z?v&X0_D(R;PgqiJjN$92vKr3a?F5rK4Z-~(iU3+zkY}P7xeiFa@XZh}vBLWuyHDF?yhVXjh?>$& z`;ipsn9i?EWIWG2EXTc)25_q@G)%)&4q2D0KWu@Sc(!G)aY9!rPl+8Z63 zI?1&N3IU#C;-~`vWD;&Y7&zaG26acPw_}aIFa5F-06z5*Sqf8>f+R{ulvKfhiWbO0 zZz|9e=2QDloSmWWzUcZ!uMg9I<1iJSz_g}`oYwcn?mk(zMV3tJAMet@0|7EMrB>X} zHdn**93}DGA3b5goF`NG#T&QLCR^-^K3~gO+4LIel3SmpNaO@DnQkCN%5X(7kk!v) z)Y+b##J<5r+Y=#CBfL=4cQxQ9b_9jae6AoL$6J#H7Nu(E?q`VUW*d}B4^E?19qS0$ zUk3V#A;`22Kc<0GXR)gwNxJIA`k@2p3_%Q3(Lwmd+Em<#G jRofK;yFp~LNIJ9{NkI(*$SAo^(33fp=i{R)K`td#2)QRL diff --git a/.git-crypt/keys/default/0/9122E40E0400D921898F3B395582EDA0BCA797BC.gpg b/.git-crypt/keys/default/0/9122E40E0400D921898F3B395582EDA0BCA797BC.gpg new file mode 100644 index 0000000000000000000000000000000000000000..505ec33b67dc8c571254e6a0cb08fd3aafd18399 GIT binary patch literal 597 zcmV-b0;>Im0gMBsvA!HG{rDV3+pU)*vl$ z`(d?1jV;$ywdEqN?i(*TjJGZx%2uG~Wp%%+V(j$vHF+(n0KbbwquHn_`v*sdlLbHI zF#C(40ru!qa*_qVRvJs)Ufyc8lyq;Vv+V%y9~zTGm*A&GK=2IqEP4+P)Q9k&U#BDM zZGdnYx?{sFasSJAE(ja|_sldisFShmWpgIyWf)yU2Qfi@n(Cd2$Vijy8~iViPrr^4 zmFOT6>|Est7(gK$ zZlRMO*(Sc?V2C3iw8YJct|_A(2rc$uucs`{utd|{EC7}#X8Sd`u$a=o0|6bptm`;F z1|gn_Jp{(kG|KUPnHUAX?!dd{--)7`#-_4^T| z$yuT)gebm^mvNv!ngMpOR3!2Mcf{$58FIds>ITzG`DaIY7V?*uHu$3H3g0yM*(FdVFVceLYWdMKfFb-Z1zOxaZN Date: Tue, 3 Oct 2023 16:21:34 +0000 Subject: [PATCH 5/5] chore(release): 2.6.0 [skip ci]nn --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index ababf10a2..4da8f3b3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "time-tracker", - "version": "2.5.6", + "version": "2.6.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 5701c4d1c..6e6bb6a32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "time-tracker", - "version": "2.5.6", + "version": "2.6.0", "scripts": { "preinstall": "npx npm-force-resolutions", "ng": "ng",