diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index 1a8b006525..bdfae720c4 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -505,12 +505,14 @@ en: time_entry: project: 'Project' work_package: 'Work package' + work_package_required: 'Requires selecting a work package first.' activity: 'Activity' comment: 'Comment' duration: 'Duration' spent_on: 'Date' hours: 'Hours' edit: 'Edit time entry' + create: 'Create time entry' two_factor_authentication: label_two_factor_authentication: 'Two-factor authentication' diff --git a/frontend/src/app/components/states.service.ts b/frontend/src/app/components/states.service.ts index 9f6aeaa504..a74558e04e 100644 --- a/frontend/src/app/components/states.service.ts +++ b/frontend/src/app/components/states.service.ts @@ -37,7 +37,7 @@ export class States extends StatesGroup { /* /api/v3/statuses */ statuses = multiInput(); - /* /api/v3/projects */ + /* /api/v3/time_entries */ timeEntries:MultiInputState = multiInput(); /* /api/v3/versions */ diff --git a/frontend/src/app/components/time-entries/time-entry-changeset.ts b/frontend/src/app/components/time-entries/time-entry-changeset.ts new file mode 100644 index 0000000000..68a285bae3 --- /dev/null +++ b/frontend/src/app/components/time-entries/time-entry-changeset.ts @@ -0,0 +1,25 @@ +import {ResourceChangeset} from "core-app/modules/fields/changeset/resource-changeset"; +import { TimeEntryResource } from 'core-app/modules/hal/resources/time-entry-resource'; + +export class TimeEntryChangeset extends ResourceChangeset { + + public setValue(key:string, val:any) { + super.setValue(key, val); + + // Update the form for fields that may alter the form itself + // when the time entry is new. Otherwise, the save request afterwards + // will update the form automatically. + if (this.pristineResource.isNew && (key === 'workPackage')) { + this.updateForm().then(() => this.push()); + } + } + + protected buildPayloadFromChanges() { + let payload = super.buildPayloadFromChanges(); + + // we ignore the project and instead rely completely on the work package. + delete payload['_links']['project']; + + return payload; + } +} diff --git a/frontend/src/app/modules/calendar/openproject-calendar.module.ts b/frontend/src/app/modules/calendar/openproject-calendar.module.ts index a8a3d3f0a5..e631f7230f 100644 --- a/frontend/src/app/modules/calendar/openproject-calendar.module.ts +++ b/frontend/src/app/modules/calendar/openproject-calendar.module.ts @@ -37,6 +37,7 @@ import {TimeEntryCalendarComponent} from "core-app/modules/calendar/te-calendar/ import {TimeEntryEditModal} from "core-app/modules/calendar/te-calendar/edit/edit.modal"; import {TimeEntryEditService} from "core-app/modules/calendar/te-calendar/edit/edit.service"; import {OpenprojectFieldsModule} from "core-app/modules/fields/openproject-fields.module"; +import { TimeEntryCreateModal } from './te-calendar/create/create.modal'; const menuItemClass = 'calendar-menu-item'; @@ -80,12 +81,14 @@ export const CALENDAR_ROUTES:Ng2StateDeclaration[] = [ WorkPackagesCalendarController, TimeEntryCalendarComponent, TimeEntryEditModal, + TimeEntryCreateModal, ], entryComponents: [ WorkPackagesCalendarController, WorkPackagesCalendarEntryComponent, TimeEntryCalendarComponent, TimeEntryEditModal, + TimeEntryCreateModal, ], exports: [ WorkPackagesCalendarController, diff --git a/frontend/src/app/modules/calendar/te-calendar/create/create.modal.html b/frontend/src/app/modules/calendar/te-calendar/create/create.modal.html new file mode 100644 index 0000000000..0476c3a8f6 --- /dev/null +++ b/frontend/src/app/modules/calendar/te-calendar/create/create.modal.html @@ -0,0 +1,83 @@ +
+
+
+ + + + +

+
+ +
+ +
+ +
+
+ + +
+ +
+
+ + +
+ +
+
+ + +
+ +
+
+ + + + +
+ +
+
+ + +
+ + +
+
+ + +
+
+
+
+
+ + +
+
diff --git a/frontend/src/app/modules/calendar/te-calendar/create/create.modal.ts b/frontend/src/app/modules/calendar/te-calendar/create/create.modal.ts new file mode 100644 index 0000000000..eb9c1f4b55 --- /dev/null +++ b/frontend/src/app/modules/calendar/te-calendar/create/create.modal.ts @@ -0,0 +1,92 @@ +import {Component, ElementRef, Inject, ChangeDetectorRef, ViewChild} from "@angular/core"; +import {OpModalComponent} from "app/components/op-modals/op-modal.component"; +import {OpModalLocalsToken} from "app/components/op-modals/op-modal.service"; +import {OpModalLocalsMap} from "app/components/op-modals/op-modal.types"; +import {I18nService} from "core-app/modules/common/i18n/i18n.service"; +import {HalResourceEditingService} from "core-app/modules/fields/edit/services/hal-resource-editing.service"; +import {TimeEntryResource} from "core-app/modules/hal/resources/time-entry-resource"; +import {HalResource} from "core-app/modules/hal/resources/hal-resource"; +import {SchemaResource} from "core-app/modules/hal/resources/schema-resource"; +import {EditFormComponent} from "core-app/modules/fields/edit/edit-form/edit-form.component"; +import { untilComponentDestroyed } from 'ng2-rx-componentdestroyed'; + +@Component({ + templateUrl: './create.modal.html', + styleUrls: ['../edit/edit.modal.sass'], + providers: [ + HalResourceEditingService + ] +}) +export class TimeEntryCreateModal extends OpModalComponent { + + @ViewChild('editForm', { static: true }) editForm:EditFormComponent; + + text = { + title: this.i18n.t('js.time_entry.edit'), + attributes: { + comment: this.i18n.t('js.time_entry.comment'), + hours: this.i18n.t('js.time_entry.hours'), + activity: this.i18n.t('js.time_entry.activity'), + workPackage: this.i18n.t('js.time_entry.work_package'), + spentOn: this.i18n.t('js.time_entry.spent_on'), + }, + wpRequired: this.i18n.t('js.time_entry.work_package_required'), + create: this.i18n.t('js.label_create'), + close: this.i18n.t('js.button_close') + }; + + public closeOnEscape = false; + public closeOnOutsideClick = false; + public customFields:{key:string, label:string}[] = []; + public workPackageSelected:boolean = false; + + public createdEntry:TimeEntryResource; + + constructor(readonly elementRef:ElementRef, + @Inject(OpModalLocalsToken) readonly locals:OpModalLocalsMap, + readonly cdRef:ChangeDetectorRef, + readonly i18n:I18nService, + readonly halEditing:HalResourceEditingService) { + super(locals, cdRef, elementRef); + + halEditing + .temporaryEditResource(this.entry) + .values$() + .pipe( + untilComponentDestroyed(this) + ) + .subscribe(changeset => { + if (changeset && changeset.workPackage) { + this.workPackageSelected = true; + } + }); + } + + public get entry() { + return this.locals.entry; + } + + public createEntry() { + this.editForm.save() + .then(() => { + this.service.close(); + }); + } + + public setModifiedEntry($event:{savedResource:HalResource, isInital:boolean}) { + this.createdEntry = $event.savedResource as TimeEntryResource; + } + + ngOnInit() { + super.ngOnInit(); + this.setCustomFields(this.entry.schema); + } + + private setCustomFields(schema:SchemaResource) { + Object.entries(schema).forEach(([key, keySchema]) => { + if (key.match(/customField\d+/)) { + this.customFields.push({key: key, label: keySchema.name }); + } + }); + } +} diff --git a/frontend/src/app/modules/calendar/te-calendar/edit/edit.service.ts b/frontend/src/app/modules/calendar/te-calendar/edit/edit.service.ts index 8d6fc8bc02..dbe499c8f0 100644 --- a/frontend/src/app/modules/calendar/te-calendar/edit/edit.service.ts +++ b/frontend/src/app/modules/calendar/te-calendar/edit/edit.service.ts @@ -5,6 +5,12 @@ import {I18nService} from "core-app/modules/common/i18n/i18n.service"; import { TimeEntryResource } from 'core-app/modules/hal/resources/time-entry-resource'; import { TimeEntryEditModal } from './edit.modal'; import { take } from 'rxjs/operators'; +import {TimeEntryCreateModal} from "core-app/modules/calendar/te-calendar/create/create.modal"; +import {FormResource} from "core-app/modules/hal/resources/form-resource"; +import {TimeEntryDmService} from "core-app/modules/hal/dm-services/time-entry-dm.service"; +import {ResourceChangeset} from "core-app/modules/fields/changeset/resource-changeset"; +import {HalResourceEditingService} from "core-app/modules/fields/edit/services/hal-resource-editing.service"; +import { Moment } from 'moment'; @Injectable() export class TimeEntryEditService { @@ -12,6 +18,8 @@ export class TimeEntryEditService { constructor(readonly opModalService:OpModalService, readonly injector:Injector, readonly halResource:HalResourceService, + readonly timeEntryDm:TimeEntryDmService, + protected halEditing:HalResourceEditingService, readonly i18n:I18nService) { } @@ -35,4 +43,60 @@ export class TimeEntryEditService { }); }); } + + public create(date:Moment) { + return new Promise<{entry:TimeEntryResource, action:'create'}>((resolve, reject) => { + this + .createNewTimeEntry(date) + .then(changeset => { + const modal = this.opModalService.show(TimeEntryCreateModal, this.injector, { entry: changeset.pristineResource }); + + modal + .closingEvent + .pipe(take(1)) + .subscribe(() => { + if (modal.createdEntry) { + resolve({entry: modal.createdEntry, action: 'create'}); + } else { + reject(); + } + }); + + }); + }); + } + + public createNewTimeEntry(date:Moment) { + return this.timeEntryDm.createForm({ spentOn: date.format('YYYY-MM-DD') }).then(form => { + return this.fromCreateForm(form); + }); + } + + public fromCreateForm(form:FormResource):ResourceChangeset { + let entry = this.initializeNewResource(form); + + return this.halEditing.edit>(entry, form); + } + + private initializeNewResource(form:FormResource) { + let entry = this.halResource.createHalResourceOfType('TimeEntry', form.payload.$plain()); + + entry.$links['schema'] = form.schema; + entry.overriddenSchema = form.schema; + + entry['_type'] = 'TimeEntry'; + entry['id'] = 'new'; + entry['hours'] = 'PT1H'; + + // Set update link to form + entry['update'] = entry.$links['update'] = form.$links.self; + // Use POST /work_packages for saving link + entry['updateImmediately'] = entry.$links['updateImmediately'] = (payload:{}) => { + return this.timeEntryDm.create(payload); + }; + + entry.state.putValue(entry); + + return entry; + } } diff --git a/frontend/src/app/modules/calendar/te-calendar/te-calendar.component.ts b/frontend/src/app/modules/calendar/te-calendar/te-calendar.component.ts index 792c1bbda3..331ceeb453 100644 --- a/frontend/src/app/modules/calendar/te-calendar/te-calendar.component.ts +++ b/frontend/src/app/modules/calendar/te-calendar/te-calendar.component.ts @@ -20,6 +20,7 @@ import {CollectionResource} from "core-app/modules/hal/resources/collection-reso import { TimeEntryEditService } from './edit/edit.service'; import {TimeEntryCacheService} from "core-components/time-entries/time-entry-cache.service"; import interactionPlugin from '@fullcalendar/interaction'; +import {HalResourceEditingService} from "core-app/modules/fields/edit/services/hal-resource-editing.service"; interface CalendarViewEvent { @@ -28,6 +29,10 @@ interface CalendarViewEvent { jsEvent:MouseEvent; } +interface CalendarDateClickEvent { + date:Date; +} + interface CalendarMoveEvent { el:HTMLElement; event:EventApi; @@ -43,6 +48,10 @@ interface CalendarMoveEvent { styleUrls: ['./te-calendar.component.sass'], selector: 'te-calendar', encapsulation: ViewEncapsulation.None, + providers: [ + TimeEntryEditService, + HalResourceEditingService + ] }) export class TimeEntryCalendarComponent implements OnInit, OnDestroy, AfterViewInit { @ViewChild(FullCalendarComponent, { static: false }) ucCalendar:FullCalendarComponent; @@ -65,6 +74,7 @@ export class TimeEntryCalendarComponent implements OnInit, OnDestroy, AfterViewI public calendarDisplayEventTime = false; public calendarSlotEventOverlap = false; public calendarEditable = false; + public calendarEventOverlap = (stillEvent:any) => stillEvent.allDay; protected memoizedTimeEntries:{start:Date, end:Date, entries:Promise>}; @@ -96,6 +106,7 @@ export class TimeEntryCalendarComponent implements OnInit, OnDestroy, AfterViewI this.ucCalendar.getApi().setOption('eventRender', (event:CalendarViewEvent) => { this.addTooltip(event); }); this.ucCalendar.getApi().setOption('eventClick', (event:CalendarViewEvent) => { this.editEvent(event); }); this.ucCalendar.getApi().setOption('eventDrop', (event:CalendarMoveEvent) => { this.moveEvent(event); }); + this.ucCalendar.getApi().setOption('dateClick', (event:CalendarDateClickEvent) => { this.addEvent(event); }); } public calendarEventsFunction(fetchInfo:{ start:Date, end:Date }, @@ -230,6 +241,10 @@ export class TimeEntryCalendarComponent implements OnInit, OnDestroy, AfterViewI private editEvent(event:CalendarViewEvent) { let originalEntry = event.event.extendedProps.entry; + if (!originalEntry) { + return; + } + this .timeEntryEdit .edit(originalEntry) @@ -257,7 +272,21 @@ export class TimeEntryCalendarComponent implements OnInit, OnDestroy, AfterViewI }); } - private updateEventSet(event:TimeEntryResource, action:'update'|'destroy') { + private addEvent(event:CalendarDateClickEvent) { + let date = moment(event.date); + + this + .timeEntryEdit + .create(date) + .then(modificationAction => { + this.updateEventSet(modificationAction.entry, modificationAction.action); + }) + .catch(() => { + // do nothing, the user closed without changes + }); + } + + private updateEventSet(event:TimeEntryResource, action:'update'|'destroy'|'create') { this.memoizedTimeEntries.entries.then(collection => { let foundIndex = collection.elements.findIndex(x => x.id === event.id); @@ -268,6 +297,9 @@ export class TimeEntryCalendarComponent implements OnInit, OnDestroy, AfterViewI case 'destroy': collection.elements.splice(foundIndex, 1); break; + case 'create': + collection.elements.push(event); + break; } this.ucCalendar.getApi().refetchEvents(); diff --git a/frontend/src/app/modules/calendar/te-calendar/te-calendar.template.html b/frontend/src/app/modules/calendar/te-calendar/te-calendar.template.html index 272195d1f0..61c8e628cd 100644 --- a/frontend/src/app/modules/calendar/te-calendar/te-calendar.template.html +++ b/frontend/src/app/modules/calendar/te-calendar/te-calendar.template.html @@ -18,6 +18,7 @@ [displayEventTime]="calendarDisplayEventTime" [scrollTime]="calendarScrollTime" [events]="calendarEvents" + [eventOverlap]="calendarEventOverlap" [plugins]="calendarPlugins"> diff --git a/frontend/src/app/modules/common/autocomplete/create-autocompleter.component.html b/frontend/src/app/modules/common/autocomplete/create-autocompleter.component.html index e62f2e18c7..fbeae05a7c 100644 --- a/frontend/src/app/modules/common/autocomplete/create-autocompleter.component.html +++ b/frontend/src/app/modules/common/autocomplete/create-autocompleter.component.html @@ -10,6 +10,7 @@ [typeahead]="typeahead" [clearOnBackspace]="false" [appendTo]="appendTo" + [hideSelected]="hideSelected" [id]="id" (change)="changeModel($event)" (open)="opened()" diff --git a/frontend/src/app/modules/common/autocomplete/create-autocompleter.component.ts b/frontend/src/app/modules/common/autocomplete/create-autocompleter.component.ts index 18f309c912..3da5b80aa8 100644 --- a/frontend/src/app/modules/common/autocomplete/create-autocompleter.component.ts +++ b/frontend/src/app/modules/common/autocomplete/create-autocompleter.component.ts @@ -55,6 +55,7 @@ export class CreateAutocompleterComponent implements AfterViewInit { @Input() public id:string = ''; @Input() public classes:string = ''; @Input() public typeahead?:Subject; + @Input() public hideSelected:boolean = false; @Output() public onChange = new EventEmitter(); @Output() public onKeydown = new EventEmitter(); diff --git a/frontend/src/app/modules/common/path-helper/apiv3/time-entries/apiv3-time-entries-paths.ts b/frontend/src/app/modules/common/path-helper/apiv3/time-entries/apiv3-time-entries-paths.ts index c7e9dc5800..7db391517a 100644 --- a/frontend/src/app/modules/common/path-helper/apiv3/time-entries/apiv3-time-entries-paths.ts +++ b/frontend/src/app/modules/common/path-helper/apiv3/time-entries/apiv3-time-entries-paths.ts @@ -41,7 +41,5 @@ export class Apiv3TimeEntriesPaths extends SimpleResourceCollection { return new Apiv3TimeEntryPaths(this.path, timeEntryId); } - public form() { - return new SimpleResource(this.path, 'form'); - } + public readonly form = new SimpleResource(this.path, 'form'); } diff --git a/frontend/src/app/modules/fields/changeset/resource-changeset.ts b/frontend/src/app/modules/fields/changeset/resource-changeset.ts index 03f7634513..c885a4aa2c 100644 --- a/frontend/src/app/modules/fields/changeset/resource-changeset.ts +++ b/frontend/src/app/modules/fields/changeset/resource-changeset.ts @@ -300,12 +300,14 @@ export class ResourceChangeset { - return { href: a.href }; - }); + if (this.pristineResource.attachments) { + payload['_links']['attachments'] = this.pristineResource + .attachments + .elements + .map((a:HalResource) => { + return {href: a.href}; + }); + } } else { // Otherwise, simply use the bare minimum diff --git a/frontend/src/app/modules/fields/edit/field-types/select-edit-field.component.ts b/frontend/src/app/modules/fields/edit/field-types/select-edit-field.component.ts index c8a4371b10..f6d953c4e9 100644 --- a/frontend/src/app/modules/fields/edit/field-types/select-edit-field.component.ts +++ b/frontend/src/app/modules/fields/edit/field-types/select-edit-field.component.ts @@ -52,7 +52,7 @@ export class SelectEditFieldComponent extends EditFieldComponent implements OnIn public availableOptions:any[]; public valueOptions:ValueOption[]; - private valuesLoaded = false; + protected valuesLoaded = false; public text:{ requiredPlaceholder:string, placeholder:string }; @@ -78,20 +78,6 @@ export class SelectEditFieldComponent extends EditFieldComponent implements OnIn requiredPlaceholder: this.I18n.t('js.placeholders.selection'), placeholder: this.I18n.t('js.placeholders.default') }; - } - - protected initialValueLoading() { - return this.loadValues().toPromise(); - } - - public autocompleterComponent() { - let type = this.schema.type; - return this.selectAutocompleterRegister.getAutocompleterOfAttribute(type) || CreateAutocompleterComponent; - } - - public ngOnInit() { - super.ngOnInit(); - this.appendTo = this.overflowingSelector; let loadingPromise = this.change.getForm().then(() => { return this.initialValueLoading(); @@ -109,6 +95,21 @@ export class SelectEditFieldComponent extends EditFieldComponent implements OnIn }); } + protected initialValueLoading() { + this.valuesLoaded = false; + return this.loadValues().toPromise(); + } + + public autocompleterComponent() { + let type = this.schema.type; + return this.selectAutocompleterRegister.getAutocompleterOfAttribute(type) || CreateAutocompleterComponent; + } + + public ngOnInit() { + super.ngOnInit(); + this.appendTo = this.overflowingSelector; + } + public get selectedOption() { const href = this.value ? this.value.$href : null; return _.find(this.valueOptions, o => o.$href === href)!; @@ -158,7 +159,13 @@ export class SelectEditFieldComponent extends EditFieldComponent implements OnIn this.valuesLoaded = true; } }), - map(collection => collection.elements), + map(collection => { + if (collection.count === undefined || collection.total === undefined || (!query && collection.total === collection.count) || !this.value) { + return collection.elements; + } else { + return collection.elements.concat([this.value]); + } + }), tap(elements => this.setValues(elements)), map(() => this.valueOptions) ); diff --git a/frontend/src/app/modules/fields/edit/field-types/work-package-edit-field.component.html b/frontend/src/app/modules/fields/edit/field-types/work-package-edit-field.component.html index a49ff6c1ac..01ddef287d 100644 --- a/frontend/src/app/modules/fields/edit/field-types/work-package-edit-field.component.html +++ b/frontend/src/app/modules/fields/edit/field-types/work-package-edit-field.component.html @@ -7,6 +7,7 @@ typeahead: requests.input$, id: handler.htmlId, finishedLoading: true, + hideSelected: true, classes: 'inline-edit--field ' + handler.fieldName }" [ndcDynamicOutputs]="referenceOutputs"> diff --git a/frontend/src/app/modules/fields/edit/field-types/work-package-edit-field.component.ts b/frontend/src/app/modules/fields/edit/field-types/work-package-edit-field.component.ts index 82148d1e28..d895bd2057 100644 --- a/frontend/src/app/modules/fields/edit/field-types/work-package-edit-field.component.ts +++ b/frontend/src/app/modules/fields/edit/field-types/work-package-edit-field.component.ts @@ -44,6 +44,8 @@ export class WorkPackageEditFieldComponent extends SelectEditFieldComponent { ); protected initialValueLoading() { + this.valuesLoaded = false; + // Using this hack with the empty value to have the values loaded initially // while avoiding loading it multiple times. return new Promise((resolve) => { @@ -71,8 +73,12 @@ export class WorkPackageEditFieldComponent extends SelectEditFieldComponent { protected mapAllowedValue(value:WorkPackageResource|ValueOption):ValueOption { if ((value as WorkPackageResource).id) { + + let prefix = (value as WorkPackageResource).type ? `${(value as WorkPackageResource).type.name} ` : ''; + let suffix = (value as WorkPackageResource).subject || value.name; + return { - name: `${(value as WorkPackageResource).type.name } #${ (value as WorkPackageResource).id } ${ (value as WorkPackageResource).subject }`, + name: `${prefix}#${ (value as WorkPackageResource).id } ${suffix}`, $href: value.$href }; } else { diff --git a/frontend/src/app/modules/hal/dm-services/time-entry-dm.service.ts b/frontend/src/app/modules/hal/dm-services/time-entry-dm.service.ts index af08f080ff..3f7bd61d71 100644 --- a/frontend/src/app/modules/hal/dm-services/time-entry-dm.service.ts +++ b/frontend/src/app/modules/hal/dm-services/time-entry-dm.service.ts @@ -35,6 +35,7 @@ import {HalResource} from "core-app/modules/hal/resources/hal-resource"; import {HalResourceService} from "core-app/modules/hal/services/hal-resource.service"; import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service"; import {PayloadDmService} from "core-app/modules/hal/dm-services/payload-dm.service"; +import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource"; @Injectable() export class TimeEntryDmService extends AbstractDmService { @@ -65,6 +66,16 @@ export class TimeEntryDmService extends AbstractDmService { payload).toPromise(); } + public createForm(payload:{}) { + return this.halResourceService.post(this.pathHelper.api.v3.time_entries.form.toString(), payload).toPromise(); + } + + public create(payload:{}):Promise { + return this.halResourceService + .post(this.pathHelper.api.v3.time_entries.path, payload) + .toPromise(); + } + public extractPayload(resource:TimeEntryResource|null = null, schema:SchemaResource|null = null) { if (resource && schema) { return this.payloadDm.extract(resource, schema); diff --git a/frontend/src/app/modules/hal/resources/time-entry-resource.ts b/frontend/src/app/modules/hal/resources/time-entry-resource.ts index d0bc1a6cd2..023c101994 100644 --- a/frontend/src/app/modules/hal/resources/time-entry-resource.ts +++ b/frontend/src/app/modules/hal/resources/time-entry-resource.ts @@ -31,23 +31,37 @@ import {SchemaResource} from "core-app/modules/hal/resources/schema-resource"; import {SchemaCacheService} from "core-components/schemas/schema-cache.service"; export class TimeEntryResource extends HalResource { + // TODO: extract the whole overridden Schema stuff into halresource or use the schemaCacheService + // to place it there + readonly schemaCacheService:SchemaCacheService = this.injector.get(SchemaCacheService); - private schemaCacheService = this.injector.get(SchemaCacheService); + public overriddenSchema:SchemaResource|undefined = undefined; /** - * Get the schema of the time entry - * ensure that it's loaded + * Get the current schema, assuming it is either: + * 1. Overridden by the current loaded form + * 2. Available as a schema state + * + * If it is neither, an exception is raised. */ public get schema():SchemaResource { + if (this.hasOverriddenSchema) { + return this.overriddenSchema!; + } + const state = this.schemaCacheService.state(this as any); if (!state.hasValue()) { - throw `Accessing schema of time entry ${this.id} without it being loaded.`; + throw `Accessing schema of ${this.id} without it being loaded.`; } return state.value!; } + public get hasOverriddenSchema():boolean { + return this.overriddenSchema != null; + } + public get state() { return this.states.timeEntries.get(this.id!) as any; } diff --git a/frontend/src/app/modules/work_packages/openproject-work-packages.module.ts b/frontend/src/app/modules/work_packages/openproject-work-packages.module.ts index e3b03f218d..cb00d028e9 100644 --- a/frontend/src/app/modules/work_packages/openproject-work-packages.module.ts +++ b/frontend/src/app/modules/work_packages/openproject-work-packages.module.ts @@ -163,6 +163,7 @@ import {WorkPackageEditActionsBarComponent} from "core-app/modules/common/edit-a import {HalResource} from "core-app/modules/hal/resources/hal-resource"; import {WorkPackageChangeset} from "core-components/wp-edit/work-package-changeset"; import {WorkPackageSingleCardComponent} from "core-components/wp-card-view/wp-single-card/wp-single-card.component"; +import { TimeEntryChangeset } from 'core-app/components/time-entries/time-entry-changeset'; @NgModule({ @@ -515,11 +516,14 @@ export class OpenprojectWorkPackagesModule { /** Return specialized work package changeset for editing service */ hookService.register('halResourceChangesetClass', (resource:HalResource) => { - if (resource._type === 'WorkPackage') { - return WorkPackageChangeset; + switch (resource._type) { + case 'WorkPackage': + return WorkPackageChangeset; + case 'TimeEntry': + return TimeEntryChangeset; + default: + return null; } - - return null; }); }; } diff --git a/lib/api/v3/time_entries/time_entry_representer.rb b/lib/api/v3/time_entries/time_entry_representer.rb index 268f59cf87..968001e94f 100644 --- a/lib/api/v3/time_entries/time_entry_representer.rb +++ b/lib/api/v3/time_entries/time_entry_representer.rb @@ -86,10 +86,6 @@ module API exec_context: :decorator, getter: ->(*) do datetime_formatter.format_duration_from_hours(represented.hours) if represented.hours - end, - setter: ->(fragment:, **) do - represented.hours = datetime_formatter.parse_duration_to_hours(fragment, - 'hours') end date_time_property :created_on, @@ -130,6 +126,12 @@ module API def current_user_allowed_to(permission, context:) current_user.allowed_to?(permission, context) end + + def hours=(value) + represented.hours = datetime_formatter.parse_duration_to_hours(value, + 'hours', + allow_nil: true) + end end end end diff --git a/modules/my_page/spec/features/my/time_entries_current_user_spec.rb b/modules/my_page/spec/features/my/time_entries_current_user_spec.rb index 483f45c8b3..fde824c705 100644 --- a/modules/my_page/spec/features/my/time_entries_current_user_spec.rb +++ b/modules/my_page/spec/features/my/time_entries_current_user_spec.rb @@ -53,7 +53,7 @@ describe 'My page time entries current user widget spec', type: :feature, js: tr project: project, activity: activity, user: user, - spent_on: Date.today, + spent_on: Date.today.beginning_of_week, hours: 3, comments: 'My comment' end @@ -63,8 +63,7 @@ describe 'My page time entries current user widget spec', type: :feature, js: tr project: project, activity: activity, user: user, - # limit the date to ensure that it is on the current calendar sheet - spent_on: Date.today - [1, Date.today.wday].min.days, + spent_on: Date.today.beginning_of_week + 3.days, hours: 2, comments: 'My other comment' end @@ -95,7 +94,7 @@ describe 'My page time entries current user widget spec', type: :feature, js: tr let(:user) do FactoryBot.create(:user, member_in_project: project, - member_with_permissions: %i[view_time_entries edit_time_entries view_work_packages]) + member_with_permissions: %i[view_time_entries edit_time_entries view_work_packages log_time]) end let(:my_page) do Pages::My::Page.new @@ -103,6 +102,7 @@ describe 'My page time entries current user widget spec', type: :feature, js: tr let(:comments_field) { ::EditField.new(page, 'comment') } let(:activity_field) { ::EditField.new(page, 'activity') } let(:hours_field) { ::EditField.new(page, 'hours') } + let(:spent_on_field) { ::EditField.new(page, 'spentOn') } let(:wp_field) { ::EditField.new(page, 'workPackage') } let(:cf_field) { ::EditField.new(page, "customField#{custom_field.id}") } @@ -112,7 +112,7 @@ describe 'My page time entries current user widget spec', type: :feature, js: tr my_page.visit! end - it 'adds the widget and checks the displayed entries' do + it 'adds the widget which then displays time entries and allows manipulating them' do # within top-right area, add an additional widget my_page.add_widget(1, 1, :within, 'Spent time') @@ -157,16 +157,62 @@ describe 'My page time entries current user widget spec', type: :feature, js: tr .to have_content "Total: 5.00" within entries_area.area do - find(".fc-content-skeleton td:nth-of-type(#{Date.today.wday + 1}) .fc-event-container .fc-event").hover + find(".fc-content-skeleton td:nth-of-type(3) .fc-event-container .fc-event").hover end expect(page) .to have_selector('.ui-tooltip', text: "Project: #{project.name}") - # Editing an entry + # Adding a time entry + # Because of the structure of fullcalendar it is hard to pinpoint the area to click. + # The below will click on the wednesday, at around the 9 hours line. within entries_area.area do - find(".fc-content-skeleton td:nth-of-type(#{Date.today.wday + 1}) .fc-event-container .fc-event").click + find('.fc-time-grid tr.fc-minor:nth-of-type(32) .fc-widget-content:nth-of-type(2)').click + end + + expect(page) + .to have_content(I18n.t('js.time_entry.work_package_required')) + + spent_on_field.expect_value((Date.today.beginning_of_week + 2.days).strftime) + + wp_field.input_element.click + wp_field.set_value(other_work_package.subject) + + expect(page) + .to have_no_content(I18n.t('js.time_entry.work_package_required')) + + sleep(0.1) + + comments_field.set_value('Comment for new entry') + + activity_field.input_element.click + activity_field.set_value(activity.name) + + hours_field.set_value('4') + + sleep(0.1) + + click_button I18n.t('js.label_create') + + my_page.expect_and_dismiss_notification message: I18n.t(:notice_successful_create) + + within entries_area.area do + expect(page) + .to have_selector(".fc-content-skeleton td:nth-of-type(5) .fc-event-container .fc-event", + text: other_work_package.subject) + end + + expect(page) + .to have_content "Total: 9.00" + + expect(TimeEntry.count) + .to eql 5 + + ## Editing an entry + + within entries_area.area do + find(".fc-content-skeleton td:nth-of-type(3) .fc-event-container .fc-event").click end expect(page) @@ -206,7 +252,7 @@ describe 'My page time entries current user widget spec', type: :feature, js: tr my_page.expect_and_dismiss_notification message: I18n.t(:notice_successful_update) within entries_area.area do - find(".fc-content-skeleton td:nth-of-type(#{Date.today.wday + 1}) .fc-event-container .fc-event").hover + find(".fc-content-skeleton td:nth-of-type(3) .fc-event-container .fc-event").hover end expect(page) @@ -216,23 +262,23 @@ describe 'My page time entries current user widget spec', type: :feature, js: tr .to have_selector('.ui-tooltip', text: "Activity: #{other_activity.name}") expect(page) - .to have_content "Total: 9.00" + .to have_content "Total: 12.00" - # Removing the time entry + ## Removing the time entry within entries_area.area do - find(".fc-content-skeleton td:nth-of-type(#{Date.today.wday + 1}) .fc-event-container .fc-event").click + find(".fc-content-skeleton td:nth-of-type(6) .fc-event-container .fc-event").click end click_button 'Delete' within entries_area.area do expect(page) - .not_to have_selector(".fc-content-skeleton td:nth-of-type(#{Date.today.wday + 1}) .fc-event-container .fc-event") + .not_to have_selector(".fc-content-skeleton td:nth-of-type(6) .fc-event-container .fc-event") end expect(page) - .to have_content "Total: 3.00" + .to have_content "Total: 10.00" expect(TimeEntry.where(id: other_visible_time_entry.id)) .not_to be_exist diff --git a/spec/lib/api/v3/time_entries/time_entry_representer_parsing_spec.rb b/spec/lib/api/v3/time_entries/time_entry_representer_parsing_spec.rb index 80fdb29af3..5228a6df0f 100644 --- a/spec/lib/api/v3/time_entries/time_entry_representer_parsing_spec.rb +++ b/spec/lib/api/v3/time_entries/time_entry_representer_parsing_spec.rb @@ -33,13 +33,13 @@ describe ::API::V3::TimeEntries::TimeEntryRepresenter, 'rendering' do let(:time_entry) do FactoryBot.build_stubbed(:time_entry, - comments: 'blubs', - spent_on: Date.today - 3.days, - created_on: DateTime.now - 6.hours, - updated_on: DateTime.now - 3.hours, - activity: activity, - project: project, - user: user) + comments: 'blubs', + spent_on: Date.today - 3.days, + created_on: DateTime.now - 6.hours, + updated_on: DateTime.now - 3.hours, + activity: activity, + project: project, + user: user) end let(:project) { FactoryBot.build_stubbed(:project) } let(:project2) { FactoryBot.build_stubbed(:project) } @@ -143,6 +143,20 @@ describe ::API::V3::TimeEntries::TimeEntryRepresenter, 'rendering' do expect(time_entry.hours) .to eql(5.0) end + + context 'with null value' do + let(:hash) do + { + "hours" => nil + } + end + + it 'updates hours' do + time_entry = representer.from_hash(hash) + expect(time_entry.hours) + .to eql(nil) + end + end end context 'comment' do