diff --git a/angular.json b/angular.json index 0bbf290fe..8b8a156e4 100644 --- a/angular.json +++ b/angular.json @@ -31,7 +31,8 @@ "src/styles.scss", "node_modules/ngx-toastr/toastr.css", "./node_modules/ngx-ui-switch/ui-switch.component.css", - "node_modules/datatables.net-buttons-dt/css/buttons.dataTables.css" + "node_modules/datatables.net-buttons-dt/css/buttons.dataTables.css", + "node_modules/@ng-select/ng-select/themes/default.theme.css" ], "scripts": [ "node_modules/jquery/dist/jquery.js", diff --git a/package-lock.json b/package-lock.json index 77fd2bac4..b761c4bb3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "time-tracker", - "version": "1.48.1", + "version": "1.49.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -2636,6 +2636,21 @@ "resolved": "https://registry.npmjs.org/@mattlewis92/dom-autoscroller/-/dom-autoscroller-2.4.2.tgz", "integrity": "sha512-YbrUWREPGEjE/FU6foXcAT1YbVwqD/jkYnY1dFb0o4AxtP3s4xKBthlELjndZih8uwsDWgQZx1eNskRNe2BgZQ==" }, + "@ng-select/ng-select": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@ng-select/ng-select/-/ng-select-7.2.0.tgz", + "integrity": "sha512-o4A8DAIV36lOy3xzIE0cYH5psACcIYDOfU3XzQQ+/WCKO1ChNH0cXUWtOAI1B/AF1yW/NpADxPPbFcrknAuYaA==", + "requires": { + "tslib": "^2.0.0" + }, + "dependencies": { + "tslib": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" + } + } + }, "@ngrx/effects": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/@ngrx/effects/-/effects-10.0.1.tgz", diff --git a/package.json b/package.json index 13383bf0d..36b110128 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "time-tracker", - "version": "1.48.1", + "version": "1.49.0", "scripts": { "preinstall": "npx npm-force-resolutions", "ng": "ng", @@ -27,6 +27,7 @@ "@angular/router": "10.2.2", "@azure/app-configuration": "1.1.0", "@azure/identity": "1.1.0", + "@ng-select/ng-select": "7.2.0", "@ngrx/effects": "10.0.1", "@ngrx/store": "10.0.1", "@ngrx/store-devtools": "10.0.1", diff --git a/src/app/app.module.ts b/src/app/app.module.ts index c1259c812..2fb23cb82 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -85,6 +85,7 @@ import { TechnologyReportTableComponent } from './modules/technology-report/comp import { TechnologyReportComponent } from './modules/technology-report/pages/technology-report.component'; import { CalendarComponent } from './modules/time-entries/components/calendar/calendar.component'; import { DropdownComponent } from './modules/shared/components/dropdown/dropdown.component'; +import { NgSelectModule } from '@ng-select/ng-select'; const maskConfig: Partial = { validation: false, @@ -181,6 +182,7 @@ const maskConfig: Partial = { provide: DateAdapter, useFactory: adapterFactory, }), + NgSelectModule ], providers: [ { 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 8d9d5be91..3c9c1839a 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 @@ -157,9 +157,9 @@ 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 e0f3e9206..b6b8e6610 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 @@ -51,6 +51,7 @@ export class DetailsFieldsComponent implements OnChanges, OnInit { activities$: Observable; goingToWorkOnThis = false; shouldRestartEntry = false; + isTechnologiesDisabled = true; projectKeyForLocalStorage = PROJECTS_KEY_FOR_LOCAL_STORAGE; constructor( @@ -121,6 +122,7 @@ export class DetailsFieldsComponent implements OnChanges, OnInit { } onClearedComponent(event) { + this.isTechnologiesDisabled = true; this.entryForm.patchValue({ project_id: '', project_name: '', @@ -128,6 +130,7 @@ export class DetailsFieldsComponent implements OnChanges, OnInit { } onSelectedProject(item) { + this.isTechnologiesDisabled = false; this.projectSelected.emit({ projectId: item.id }); this.entryForm.patchValue({ project_id: item.id, @@ -155,6 +158,7 @@ export class DetailsFieldsComponent implements OnChanges, OnInit { this.shouldRestartEntry = false; if (this.entryToEdit) { + this.isTechnologiesDisabled = false; this.selectedTechnologies = this.entryToEdit.technologies; const projectFound = this.listProjects.find((project) => project.id === this.entryToEdit.project_id); this.entryForm.setValue({ @@ -185,13 +189,13 @@ export class DetailsFieldsComponent implements OnChanges, OnInit { ); } else { this.cleanForm(); + this.isTechnologiesDisabled = true; this.activities$ = this.selectActiveActivities(); } } cleanForm(skipProject: boolean = false): void { this.selectedTechnologies = []; - this.technologies.query = ''; const projectNameField = this.project_name.value; const projectName = get(projectNameField, 'search_field', projectNameField); this.entryForm.reset({ diff --git a/src/app/modules/shared/components/technologies/technologies.component.html b/src/app/modules/shared/components/technologies/technologies.component.html index 1ce0c616d..2fece04eb 100644 --- a/src/app/modules/shared/components/technologies/technologies.component.html +++ b/src/app/modules/shared/components/technologies/technologies.component.html @@ -1,39 +1,24 @@
- -
- -
+ -
-
- {{ item.name }} -
-
-
-
-
-
- - {{ technology }} -
-
-
- -
+
- - - diff --git a/src/app/modules/shared/components/technologies/technologies.component.spec.ts b/src/app/modules/shared/components/technologies/technologies.component.spec.ts index 22abff7cf..c4be71686 100644 --- a/src/app/modules/shared/components/technologies/technologies.component.spec.ts +++ b/src/app/modules/shared/components/technologies/technologies.component.spec.ts @@ -40,49 +40,34 @@ describe('Technologies component', () => { expect(component).toBeTruthy(); }); - it('when a new technology is added, it should be added to the selectedTechnologies list', () => { - const name = 'ngrx'; - component.selectedTechnologies = ['java', 'javascript']; - component.selectedTechnologies.indexOf(name); - length = component.selectedTechnologies.length; - component.addTechnology(name); - expect(component.selectedTechnologies.length).toBe(3); - }); + it('When technologies are updated, technolgyUpdated should emit an event with new Technologies', () => { + const selectedTechnologies = ['java', 'angular']; + const technologyUpdatedSpy = spyOn(component.technologyUpdated, 'emit'); + component.selectedTechnologies = selectedTechnologies; - it('when the max number of technologies is reached, then adding technologies is not allowed', () => { - const name = 'ngrx'; - component.selectedTechnologies = [ - 'java', - 'javascript', - 'angular', - 'angular-ui', - 'typescript', - 'scss', - 'bootstrap', - 'jasmine', - 'karma', - 'github', - ]; - length = component.selectedTechnologies.length; - component.addTechnology(name); - expect(component.selectedTechnologies.length).toBe(10); - }); + component.updateTechnologies(); - it('when a technology is removed, then it should be removed from the technologies list', () => { - const index = 1; - component.selectedTechnologies = ['java', 'angular']; - component.removeTechnology(index); - expect(component.selectedTechnologies.length).toBe(1); + expect(technologyUpdatedSpy).toHaveBeenCalled(); + expect(technologyUpdatedSpy).toHaveBeenCalledWith(selectedTechnologies); + expect(component.technologies).toEqual([]); }); it('when querying technologies, then a FindTechnology action should be dispatched', () => { const query = 'react'; - const target = {value: query}; - const event = new InputEvent('input'); - spyOnProperty(event, 'target').and.returnValue(target); spyOn(store, 'dispatch'); - component.queryTechnologies(event); + component.searchTechnologies(query); expect(store.dispatch).toHaveBeenCalledWith(new actions.FindTechnology(query)); }); + + it('calls unsubscribe on ngDestroy', () => { + + const technologyInputSpy = spyOn(component.technologiesInputSubscription, 'unsubscribe'); + const technologiesSpy = spyOn(component.technologiesSubscription, 'unsubscribe'); + + component.ngOnDestroy(); + + expect(technologyInputSpy).toHaveBeenCalled(); + expect(technologiesSpy).toHaveBeenCalled(); + }); }); diff --git a/src/app/modules/shared/components/technologies/technologies.component.ts b/src/app/modules/shared/components/technologies/technologies.component.ts index 8ae7d8a49..2e2584b37 100644 --- a/src/app/modules/shared/components/technologies/technologies.component.ts +++ b/src/app/modules/shared/components/technologies/technologies.component.ts @@ -1,71 +1,70 @@ -import { Component, ElementRef, EventEmitter, Input, OnInit, Output, Renderer2, ViewChild } from '@angular/core'; +import { Component, EventEmitter, Input, OnInit, Output, OnDestroy } from '@angular/core'; import { select, Store } from '@ngrx/store'; -import { Technology } from '../../models'; +import { Subject, Subscription } from 'rxjs'; import * as actions from '../../store/technology.actions'; import { TechnologyState } from '../../store/technology.reducers'; import { allTechnologies } from '../../store/technology.selectors'; +import { debounceTime, distinctUntilChanged, filter } from 'rxjs/operators'; @Component({ selector: 'app-technologies', templateUrl: './technologies.component.html', styleUrls: ['./technologies.component.scss'] }) -export class TechnologiesComponent implements OnInit { - private readonly MAX_NUM_TECHNOLOGIES = 10; - private readonly MIN_QUERY_LENGTH = 2; - public query = ''; - showList = false; +export class TechnologiesComponent implements OnInit, OnDestroy { + readonly MAX_NUM_TECHNOLOGIES = 10; + readonly NO_RESULTS_MESSAGE = 'No technologies found'; + readonly TECHNOLOGIES_PLACEHOLDER = 'Time Entry Technologies'; + readonly ALLOW_SELECT_MULTIPLE = true; + readonly ALLOW_SEARCH = true; + readonly MIN_SEARCH_TERM_LENGTH = 2; + readonly TYPE_TO_SEARCH_TEXT = 'Please enter 2 or more characters'; + readonly WAITING_TIME_AFTER_KEY_UP = 400; + isLoading = false; - technology: Technology; + technologies: string[]; + technologiesInput$ = new Subject(); + + technologiesInputSubscription: Subscription; + technologiesSubscription: Subscription; + + @Input() + isDisabled: boolean; @Input() selectedTechnologies: string[] = []; - @ViewChild('technologiesDropdown') list: ElementRef; - @Output() - technologyRemoved: EventEmitter = new EventEmitter(); - @Output() - technologyAdded: EventEmitter = new EventEmitter(); - constructor(private store: Store, private renderer: Renderer2) { - this.renderer.listen('window', 'click', (e: Event) => { - if (this.showList && !this.list.nativeElement.contains(e.target)) { - this.showList = false; - } - }); - } + @Output() + technologyUpdated: EventEmitter = new EventEmitter(); - queryTechnologies(event: Event) { - const inputElement: HTMLInputElement = (event.target) as HTMLInputElement; - const query: string = inputElement.value; - if (query.length >= this.MIN_QUERY_LENGTH) { - this.showList = true; - this.store.dispatch(new actions.FindTechnology(query)); - } - } + constructor(private store: Store) {} ngOnInit(): void { const technologies$ = this.store.pipe(select(allTechnologies)); - technologies$.subscribe((response) => { - this.isLoading = response.isLoading; - if ( response.technologyList.items ) { - const filteredItems = response.technologyList.items.filter(item => !this.selectedTechnologies.includes(item.name)); - this.technology = {items: filteredItems}; - } else { - this.technology = {items: []}; - } + + this.technologiesInputSubscription = this.technologiesInput$.pipe( + filter(searchQuery => searchQuery && searchQuery.length >= this.MIN_SEARCH_TERM_LENGTH), + distinctUntilChanged(), + debounceTime(this.WAITING_TIME_AFTER_KEY_UP) + ).subscribe((searchQuery) => this.searchTechnologies(searchQuery)); + + this.technologiesSubscription = technologies$.subscribe(({ isLoading, technologyList }) => { + const technologyItems = technologyList?.items; + this.isLoading = isLoading; + this.technologies = technologyItems ? technologyItems : []; }); } - addTechnology(name: string) { - if (this.selectedTechnologies.length < this.MAX_NUM_TECHNOLOGIES) { - this.selectedTechnologies = [...this.selectedTechnologies, name]; - this.technologyAdded.emit(this.selectedTechnologies); - this.showList = false; - this.query = ''; - } + searchTechnologies(searchQuery) { + this.store.dispatch(new actions.FindTechnology(searchQuery)); + } + + updateTechnologies() { + this.technologyUpdated.emit(this.selectedTechnologies); + this.technologies = []; } - removeTechnology(index: number) { - this.selectedTechnologies = this.selectedTechnologies.filter((item) => item !== this.selectedTechnologies[index]); - this.technologyRemoved.emit(this.selectedTechnologies); + ngOnDestroy() { + this.technologiesSubscription.unsubscribe(); + this.technologiesInputSubscription.unsubscribe(); } } diff --git a/src/app/modules/time-clock/components/entry-fields/entry-fields.component.html b/src/app/modules/time-clock/components/entry-fields/entry-fields.component.html index 827dd040a..bf894cf81 100644 --- a/src/app/modules/time-clock/components/entry-fields/entry-fields.component.html +++ b/src/app/modules/time-clock/components/entry-fields/entry-fields.component.html @@ -6,7 +6,7 @@ class="form-control" formControlName="activity_id" [class.is-invalid]="activity_id.invalid && activity_id.touched" - #autofocus + #autofocus required> @@ -44,8 +44,7 @@ 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 32ef74547..c9ca37d15 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 @@ -295,21 +295,11 @@ describe('EntryFieldsComponent', () => { expect(store.dispatch).toHaveBeenCalledTimes(1); }); - it('when a technology is added, then dispatch UpdateActiveEntry', () => { + it('when a technology is added or removed, then dispatch UpdateActiveEntry', () => { const addedTechnologies = ['react']; spyOn(store, 'dispatch'); - component.onTechnologyAdded(addedTechnologies); - expect(store.dispatch).toHaveBeenCalled(); - - }); - - it('when a technology is removed, then dispatch UpdateActiveEntry', () => { - const addedTechnologies = ['react']; - - spyOn(store, 'dispatch'); - - component.onTechnologyAdded(addedTechnologies); + component.onTechnologyUpdated(addedTechnologies); expect(store.dispatch).toHaveBeenCalled(); }); @@ -335,7 +325,7 @@ describe('EntryFieldsComponent', () => { it('dispatches an action when onTechnologyRemoved is called', () => { spyOn(store, 'dispatch'); - component.onTechnologyRemoved(['foo']); + component.onTechnologyUpdated(['foo']); expect(store.dispatch).toHaveBeenCalled(); }); 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 e1c164b2f..8501cdf81 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 @@ -181,11 +181,7 @@ export class EntryFieldsComponent implements OnInit, OnDestroy { this.showTimeInbuttons = false; } - onTechnologyAdded($event: string[]) { - this.store.dispatch(new entryActions.UpdateEntryRunning({ ...this.newData, technologies: $event })); - } - - onTechnologyRemoved($event: string[]) { + onTechnologyUpdated($event: string[]) { this.store.dispatch(new entryActions.UpdateEntryRunning({ ...this.newData, technologies: $event })); } diff --git a/src/app/modules/time-entries/components/calendar/calendar.component.html b/src/app/modules/time-entries/components/calendar/calendar.component.html index 58d4e2fea..3be76abcc 100644 --- a/src/app/modules/time-entries/components/calendar/calendar.component.html +++ b/src/app/modules/time-entries/components/calendar/calendar.component.html @@ -144,6 +144,7 @@ [viewDate]="currentDate" [events]="timeEntriesAsEvent" [cellTemplate]="timeEntriesInsideMonthCalendarTemplate" + [weekStartsOn]="WEEK_START_DAY" > ; 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 07d3687ea..52c4b4976 100644 --- a/src/app/modules/time-entries/pages/time-entries.component.ts +++ b/src/app/modules/time-entries/pages/time-entries.component.ts @@ -39,7 +39,6 @@ export class TimeEntriesComponent implements OnInit, OnDestroy { selectedMonthAsText: string; isActiveEntryOverlapping = false; readonly NO_DATA_MESSAGE: string = 'No data available in table'; - timeEntry: DataSource; constructor( private store: Store, private toastrService: ToastrService, diff --git a/src/styles.scss b/src/styles.scss index 97e5bc7b8..514ed1fbb 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -43,3 +43,21 @@ Overwritten calendar style .cal-today:hover { background-color: darken($background-calendar-cell, 7%) !important; } + +.cal-week-view .cal-day-headers { + position: sticky; + top: 0; + z-index: 10; + background: $background-calendar-cell; +} + +.cal-month-view .cal-header { + position: sticky; + top: 0; + z-index: 10; + background: $background-calendar-cell; +} + +.cal-header .cal-cell { + border: 0.1px solid lighten($primary-text, 30%); +}