wip - creating time entries

pull/7954/head
ulferts 5 years ago
parent d1acccd464
commit 9d2ebdf22a
No known key found for this signature in database
GPG Key ID: A205708DE1284017
  1. 2
      config/locales/js-en.yml
  2. 2
      frontend/src/app/components/states.service.ts
  3. 25
      frontend/src/app/components/time-entries/time-entry-changeset.ts
  4. 3
      frontend/src/app/modules/calendar/openproject-calendar.module.ts
  5. 83
      frontend/src/app/modules/calendar/te-calendar/create/create.modal.html
  6. 92
      frontend/src/app/modules/calendar/te-calendar/create/create.modal.ts
  7. 64
      frontend/src/app/modules/calendar/te-calendar/edit/edit.service.ts
  8. 34
      frontend/src/app/modules/calendar/te-calendar/te-calendar.component.ts
  9. 1
      frontend/src/app/modules/calendar/te-calendar/te-calendar.template.html
  10. 1
      frontend/src/app/modules/common/autocomplete/create-autocompleter.component.html
  11. 1
      frontend/src/app/modules/common/autocomplete/create-autocompleter.component.ts
  12. 4
      frontend/src/app/modules/common/path-helper/apiv3/time-entries/apiv3-time-entries-paths.ts
  13. 14
      frontend/src/app/modules/fields/changeset/resource-changeset.ts
  14. 39
      frontend/src/app/modules/fields/edit/field-types/select-edit-field.component.ts
  15. 1
      frontend/src/app/modules/fields/edit/field-types/work-package-edit-field.component.html
  16. 8
      frontend/src/app/modules/fields/edit/field-types/work-package-edit-field.component.ts
  17. 11
      frontend/src/app/modules/hal/dm-services/time-entry-dm.service.ts
  18. 22
      frontend/src/app/modules/hal/resources/time-entry-resource.ts
  19. 12
      frontend/src/app/modules/work_packages/openproject-work-packages.module.ts
  20. 10
      lib/api/v3/time_entries/time_entry_representer.rb
  21. 74
      modules/my_page/spec/features/my/time_entries_current_user_spec.rb
  22. 28
      spec/lib/api/v3/time_entries/time_entry_representer_parsing_spec.rb

@ -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'

@ -37,7 +37,7 @@ export class States extends StatesGroup {
/* /api/v3/statuses */
statuses = multiInput<StatusResource>();
/* /api/v3/projects */
/* /api/v3/time_entries */
timeEntries:MultiInputState<TimeEntryResource> = multiInput<TimeEntryResource>();
/* /api/v3/versions */

@ -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<TimeEntryResource> {
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;
}
}

@ -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,

@ -0,0 +1,83 @@
<div class="op-modal--portal ngdialog-theme-openproject">
<div class="op-modal--modal-container confirm-dialog--modal loading-indicator--location"
data-indicator-name="modal"
tabindex="0">
<div class="op-modal--modal-header">
<a class="op-modal--modal-close-button">
<i
class="icon-close"
(click)="closeMe($event)"
[attr.title]="text.close">
</i>
</a>
<h3 class="icon-context icon-attention" [textContent]="text.title"></h3>
</div>
<div class="ngdialog-body op-modal--modal-body">
<edit-form
#editForm
[resource]="entry"
[inEditMode]="true"
(onSaved)="setModifiedEntry($event)">
<div class="attributes-map">
<div class="attributes-map--key" [textContent]="text.attributes.spentOn"></div>
<div class="attributes-map--value">
<editable-attribute-field [resource]="entry"
[fieldName]="'spentOn'">
</editable-attribute-field>
</div>
<div class="attributes-map--key" [textContent]="text.attributes.hours"></div>
<div class="attributes-map--value">
<editable-attribute-field [resource]="entry"
[fieldName]="'hours'">
</editable-attribute-field>
</div>
<div class="attributes-map--key" [textContent]="text.attributes.workPackage"></div>
<div class="attributes-map--value">
<editable-attribute-field [resource]="entry"
[fieldName]="'workPackage'">
</editable-attribute-field>
</div>
<div class="attributes-map--key" [textContent]="text.attributes.activity"></div>
<div class="attributes-map--value">
<editable-attribute-field *ngIf="workPackageSelected"
[resource]="entry"
[fieldName]="'activity'">
</editable-attribute-field>
<i *ngIf="!workPackageSelected"
[textContent]="text.wpRequired">
</i>
</div>
<div class="attributes-map--key" [textContent]="text.attributes.comment"></div>
<div class="attributes-map--value">
<editable-attribute-field [resource]="entry"
[fieldName]="'comment'">
</editable-attribute-field>
</div>
<ng-container *ngFor="let cf of customFields">
<div class="attributes-map--key" [textContent]="cf.label"></div>
<div class="attributes-map--value">
<editable-attribute-field [resource]="entry"
[fieldName]="cf.key">
</editable-attribute-field>
</div>
</ng-container>
</div>
</edit-form>
</div>
<div class="op-modal--modal-footer">
<button class="button -highlight"
(click)="createEntry()"
[textContent]="text.create"
[attr.title]="text.create">
</button>
</div>
</div>
</div>

@ -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 });
}
});
}
}

@ -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<TimeEntryResource, ResourceChangeset<TimeEntryResource>>(entry, form);
}
private initializeNewResource(form:FormResource) {
let entry = this.halResource.createHalResourceOfType<TimeEntryResource>('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;
}
}

@ -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<CollectionResource<TimeEntryResource>>};
@ -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();

@ -18,6 +18,7 @@
[displayEventTime]="calendarDisplayEventTime"
[scrollTime]="calendarScrollTime"
[events]="calendarEvents"
[eventOverlap]="calendarEventOverlap"
[plugins]="calendarPlugins">
</full-calendar>
</div>

@ -10,6 +10,7 @@
[typeahead]="typeahead"
[clearOnBackspace]="false"
[appendTo]="appendTo"
[hideSelected]="hideSelected"
[id]="id"
(change)="changeModel($event)"
(open)="opened()"

@ -55,6 +55,7 @@ export class CreateAutocompleterComponent implements AfterViewInit {
@Input() public id:string = '';
@Input() public classes:string = '';
@Input() public typeahead?:Subject<string>;
@Input() public hideSelected:boolean = false;
@Output() public onChange = new EventEmitter<HalResource>();
@Output() public onKeydown = new EventEmitter<JQuery.TriggeredEvent>();

@ -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');
}

@ -300,12 +300,14 @@ export class ResourceChangeset<T extends HalResource|{ [key:string]:unknown; } =
// Add attachments to be assigned.
// They will already be created on the server but now
// we need to claim them for the newly created work package.
payload['_links']['attachments'] = this.pristineResource
.attachments
.elements
.map((a:HalResource) => {
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

@ -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)
);

@ -7,6 +7,7 @@
typeahead: requests.input$,
id: handler.htmlId,
finishedLoading: true,
hideSelected: true,
classes: 'inline-edit--field ' + handler.fieldName }"
[ndcDynamicOutputs]="referenceOutputs">
</ndc-dynamic>

@ -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<ValueOption[]>((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 {

@ -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<TimeEntryResource> {
@ -65,6 +66,16 @@ export class TimeEntryDmService extends AbstractDmService<TimeEntryResource> {
payload).toPromise();
}
public createForm(payload:{}) {
return this.halResourceService.post<FormResource>(this.pathHelper.api.v3.time_entries.form.toString(), payload).toPromise();
}
public create(payload:{}):Promise<TimeEntryResource> {
return this.halResourceService
.post<TimeEntryResource>(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);

@ -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;
}

@ -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;
});
};
}

@ -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

@ -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

@ -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

Loading…
Cancel
Save