Merge pull request #7954 from opf/feature/timelog_on_widget
Feature/timelog on widgetpull/7992/head
commit
d08a441f46
@ -0,0 +1,45 @@ |
||||
#-- encoding: UTF-8 |
||||
|
||||
#-- copyright |
||||
# OpenProject is a project management system. |
||||
# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License version 3. |
||||
# |
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: |
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang |
||||
# Copyright (C) 2010-2013 the ChiliProject Team |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License |
||||
# as published by the Free Software Foundation; either version 2 |
||||
# of the License, or (at your option) any later version. |
||||
# |
||||
# This program is distributed in the hope that it will be useful, |
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
# GNU General Public License for more details. |
||||
# |
||||
# You should have received a copy of the GNU General Public License |
||||
# along with this program; if not, write to the Free Software |
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||||
# |
||||
# See docs/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
|
||||
module TimeEntry::Scopes |
||||
class OfUserAndDay |
||||
def self.fetch(user, date, excluding: nil) |
||||
scope = TimeEntry |
||||
.where(spent_on: date, |
||||
user: user) |
||||
|
||||
if excluding |
||||
scope = scope.where.not(id: excluding.id) |
||||
end |
||||
|
||||
scope |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,71 @@ |
||||
// -- copyright
|
||||
// OpenProject is a project management system.
|
||||
// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
|
||||
//
|
||||
// This program is free software; you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License version 3.
|
||||
//
|
||||
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
// Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
// Copyright (C) 2010-2013 the ChiliProject Team
|
||||
//
|
||||
// This program is free software; you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License
|
||||
// as published by the Free Software Foundation; either version 2
|
||||
// of the License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program; if not, write to the Free Software
|
||||
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
//
|
||||
// See doc/COPYRIGHT.rdoc for more details.
|
||||
// ++
|
||||
|
||||
import {MultiInputState} from 'reactivestates'; |
||||
import {States} from '../states.service'; |
||||
import {StateCacheService} from '../states/state-cache.service'; |
||||
import {Injectable} from '@angular/core'; |
||||
import {SchemaCacheService} from "core-components/schemas/schema-cache.service"; |
||||
import {TimeEntryResource} from "core-app/modules/hal/resources/time-entry-resource"; |
||||
import {TimeEntryDmService} from "core-app/modules/hal/dm-services/time-entry-dm.service"; |
||||
|
||||
@Injectable() |
||||
export class TimeEntryCacheService extends StateCacheService<TimeEntryResource> { |
||||
|
||||
constructor(readonly states:States, |
||||
readonly schemaCacheService:SchemaCacheService, |
||||
readonly timeEntryDm:TimeEntryDmService) { |
||||
super(); |
||||
} |
||||
|
||||
protected loadAll(ids:string[]):Promise<undefined> { |
||||
return Promise |
||||
.all(ids.map(id => this.load(id))) |
||||
.then(_ => undefined); |
||||
} |
||||
|
||||
updateValue(id:string, val:TimeEntryResource) { |
||||
this.schemaCacheService.ensureLoaded(val).then(() => { |
||||
super.updateValue(id, val); |
||||
}); |
||||
} |
||||
|
||||
protected load(id:string):Promise<TimeEntryResource> { |
||||
return this |
||||
.timeEntryDm |
||||
.one(parseInt(id)) |
||||
.then((timeEntry) => { |
||||
return this.schemaCacheService.ensureLoaded(timeEntry).then(() => timeEntry); |
||||
}); |
||||
} |
||||
|
||||
protected get multiState():MultiInputState<TimeEntryResource> { |
||||
return this.states.timeEntries; |
||||
} |
||||
|
||||
} |
@ -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; |
||||
} |
||||
} |
@ -0,0 +1,92 @@ |
||||
.fc-view-container |
||||
overflow-x: auto |
||||
|
||||
.fc-view |
||||
min-width: 800px |
||||
|
||||
.fc-head |
||||
display: table-footer-group |
||||
|
||||
.fc-event |
||||
border-radius: 0 |
||||
margin-right: 8px |
||||
margin-left: 8px |
||||
|
||||
.fc-head-container |
||||
border-color: white |
||||
|
||||
.fc-day-header |
||||
padding-top: 5px |
||||
|
||||
.fc-widget-content |
||||
border-left-color: white |
||||
|
||||
.fc-day |
||||
border-left: 0 |
||||
border-right: 0 |
||||
|
||||
.fc-timeGrid-view .fc-day-grid .fc-row |
||||
min-height: 1em |
||||
|
||||
.fc-timeGrid-view .fc-day-grid .fc-row .fc-content-skeleton |
||||
padding-bottom: 0 |
||||
|
||||
.fc-time-grid .fc-slats .fc-minor td |
||||
border-top: 0 |
||||
|
||||
.te-calendar--day-sum |
||||
border: none |
||||
background-color: initial |
||||
color: #000000 |
||||
text-align: center |
||||
font-size: 1em |
||||
font-weight: bold |
||||
|
||||
.fc-title |
||||
// as this is the height of the day sum element |
||||
line-height: 22px |
||||
|
||||
.te-calendar--add-entry |
||||
text-align: center |
||||
font-weight: bold |
||||
opacity: 0 |
||||
background: none |
||||
border: none |
||||
|
||||
&:hover |
||||
opacity: 1 |
||||
transition: opacity 1s ease |
||||
background: #EAEAEA |
||||
|
||||
.fc-content |
||||
position: sticky |
||||
color: black |
||||
width: 100% |
||||
top: 200px |
||||
font-weight: normal |
||||
font-size: 1.5rem |
||||
|
||||
.te-calendar--time-entry |
||||
.fc-content |
||||
height: 100% |
||||
|
||||
.fc-fadeout |
||||
position: relative |
||||
bottom: 2em |
||||
height: 2em |
||||
z-index: 5 |
||||
|
||||
&.fc-short |
||||
.fc-fadeout |
||||
display: none |
||||
|
||||
.fc-duration |
||||
border-right: 1px solid white |
||||
border-bottom: 1px solid white |
||||
margin-left: -1px |
||||
padding-left: 1px |
||||
display: inline-block |
||||
margin-right: 5px |
||||
padding-right: 5px |
||||
font-weight: bold |
||||
|
@ -0,0 +1,471 @@ |
||||
import {Component, ElementRef, Input, OnDestroy, OnInit, SecurityContext, ViewChild, AfterViewInit, Output, EventEmitter, Injector, ViewEncapsulation, ChangeDetectionStrategy} from "@angular/core"; |
||||
import {FullCalendarComponent} from '@fullcalendar/angular'; |
||||
import {States} from "core-components/states.service"; |
||||
import * as moment from "moment"; |
||||
import { Moment } from 'moment'; |
||||
import {StateService} from "@uirouter/core"; |
||||
import {I18nService} from "core-app/modules/common/i18n/i18n.service"; |
||||
import {NotificationsService} from "core-app/modules/common/notifications/notifications.service"; |
||||
import {DomSanitizer} from "@angular/platform-browser"; |
||||
import timeGrid from '@fullcalendar/timegrid'; |
||||
import { EventInput, EventApi, Duration, View } from '@fullcalendar/core'; |
||||
import { EventSourceError } from '@fullcalendar/core/structs/event-source'; |
||||
import { ToolbarInput } from '@fullcalendar/core/types/input-types'; |
||||
import {ConfigurationService} from "core-app/modules/common/config/configuration.service"; |
||||
import {TimeEntryDmService} from "core-app/modules/hal/dm-services/time-entry-dm.service"; |
||||
import {FilterOperator} from "core-components/api/api-v3/api-v3-filter-builder"; |
||||
import {TimeEntryResource} from "core-app/modules/hal/resources/time-entry-resource"; |
||||
import {TimezoneService} from "core-components/datetime/timezone.service"; |
||||
import {CollectionResource} from "core-app/modules/hal/resources/collection-resource"; |
||||
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"; |
||||
import {TimeEntryEditService} from "core-app/modules/time_entries/edit/edit.service"; |
||||
import {TimeEntryCreateService} from "core-app/modules/time_entries/create/create.service"; |
||||
import {ColorsService} from "core-app/modules/common/colors/colors.service"; |
||||
import {BrowserDetector} from "core-app/modules/common/browser/browser-detector.service"; |
||||
|
||||
interface CalendarViewEvent { |
||||
el:HTMLElement; |
||||
event:EventApi; |
||||
jsEvent:MouseEvent; |
||||
} |
||||
|
||||
interface CalendarMoveEvent { |
||||
el:HTMLElement; |
||||
event:EventApi; |
||||
oldEvent:EventApi; |
||||
delta:Duration; |
||||
revert:() => void; |
||||
jsEvent:Event; |
||||
view:View; |
||||
} |
||||
|
||||
const TIME_ENTRY_CLASS_NAME = 'te-calendar--time-entry'; |
||||
const DAY_SUM_CLASS_NAME = 'te-calendar--day-sum'; |
||||
const ADD_ENTRY_CLASS_NAME = 'te-calendar--add-entry'; |
||||
|
||||
@Component({ |
||||
templateUrl: './te-calendar.template.html', |
||||
styleUrls: ['./te-calendar.component.sass'], |
||||
selector: 'te-calendar', |
||||
encapsulation: ViewEncapsulation.None, |
||||
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
providers: [ |
||||
TimeEntryEditService, |
||||
TimeEntryCreateService, |
||||
HalResourceEditingService |
||||
] |
||||
}) |
||||
export class TimeEntryCalendarComponent implements OnInit, OnDestroy, AfterViewInit { |
||||
@ViewChild(FullCalendarComponent, { static: false }) ucCalendar:FullCalendarComponent; |
||||
@Input() projectIdentifier:string; |
||||
@Input() static:boolean = false; |
||||
@Output() entries = new EventEmitter<CollectionResource<TimeEntryResource>>(); |
||||
|
||||
public calendarPlugins = [timeGrid, interactionPlugin]; |
||||
public calendarEvents:Function; |
||||
public calendarHeader:ToolbarInput|boolean = { |
||||
right: '', |
||||
center: 'title', |
||||
left: 'prev,next today' |
||||
}; |
||||
public calendarSlotLabelFormat = (info:any) => 24 - info.date.hour; |
||||
public calendarScrollTime = '24:00:00'; |
||||
public calendarContentHeight = 545; |
||||
public calendarAllDaySlot = false; |
||||
public calendarAllDayText = ''; |
||||
public calendarDisplayEventTime = false; |
||||
public calendarSlotEventOverlap = false; |
||||
public calendarEditable = false; |
||||
public calendarEventOverlap = (stillEvent:any) => !stillEvent.classNames.includes(TIME_ENTRY_CLASS_NAME); |
||||
|
||||
protected memoizedTimeEntries:{start:Date, end:Date, entries:Promise<CollectionResource<TimeEntryResource>>}; |
||||
protected memoizedCreateAllowed:boolean = false; |
||||
|
||||
constructor(readonly states:States, |
||||
readonly timeEntryDm:TimeEntryDmService, |
||||
readonly $state:StateService, |
||||
private element:ElementRef, |
||||
readonly i18n:I18nService, |
||||
readonly injector:Injector, |
||||
readonly notificationsService:NotificationsService, |
||||
private sanitizer:DomSanitizer, |
||||
private configuration:ConfigurationService, |
||||
private timezone:TimezoneService, |
||||
private timeEntryEdit:TimeEntryEditService, |
||||
private timeEntryCreate:TimeEntryCreateService, |
||||
private timeEntryCache:TimeEntryCacheService, |
||||
private colors:ColorsService, |
||||
private browserDetector:BrowserDetector) { } |
||||
|
||||
ngOnInit() { |
||||
this.initializeCalendar(); |
||||
} |
||||
|
||||
ngOnDestroy() { |
||||
// nothing to do
|
||||
} |
||||
|
||||
ngAfterViewInit() { |
||||
// The full-calendar component's outputs do not seem to work
|
||||
// see: https://github.com/fullcalendar/fullcalendar-angular/issues/228#issuecomment-523505044
|
||||
// Therefore, setting the outputs via the underlying API
|
||||
this.ucCalendar.getApi().setOption('eventRender', (event:CalendarViewEvent) => { this.alterEventEntry(event); }); |
||||
this.ucCalendar.getApi().setOption('eventDestroy', (event:CalendarViewEvent) => { this.beforeEventRemove(event); }); |
||||
this.ucCalendar.getApi().setOption('eventClick', (event:CalendarViewEvent) => { this.dispatchEventClick(event); }); |
||||
this.ucCalendar.getApi().setOption('eventDrop', (event:CalendarMoveEvent) => { this.moveEvent(event); }); |
||||
} |
||||
|
||||
public calendarEventsFunction(fetchInfo:{ start:Date, end:Date }, |
||||
successCallback:(events:EventInput[]) => void, |
||||
failureCallback:(error:EventSourceError) => void ):void | PromiseLike<EventInput[]> { |
||||
|
||||
this.fetchTimeEntries(fetchInfo.start, fetchInfo.end) |
||||
.then((collection) => { |
||||
this.entries.emit(collection); |
||||
|
||||
successCallback(this.buildEntries(collection.elements, fetchInfo)); |
||||
}); |
||||
} |
||||
|
||||
protected fetchTimeEntries(start:Date, end:Date) { |
||||
if (!this.memoizedTimeEntries || |
||||
this.memoizedTimeEntries.start.getTime() !== start.getTime() || |
||||
this.memoizedTimeEntries.end.getTime() !== end.getTime()) { |
||||
let promise = this |
||||
.timeEntryDm |
||||
.list({ filters: this.dmFilters(start, end), pageSize: 500 }) |
||||
.then(collection => { |
||||
this.memoizedCreateAllowed = !!collection.createTimeEntry; |
||||
|
||||
collection.elements.forEach(timeEntry => this.timeEntryCache.updateValue(timeEntry.id!, timeEntry)); |
||||
|
||||
return collection; |
||||
}); |
||||
|
||||
this.memoizedTimeEntries = { start: start, end: end, entries: promise }; |
||||
} |
||||
|
||||
return this.memoizedTimeEntries.entries; |
||||
} |
||||
|
||||
private buildEntries(entries:TimeEntryResource[], fetchInfo:{ start:Date, end:Date }) { |
||||
return this.buildTimeEntryEntries(entries) |
||||
.concat(this.buildAuxEntries(entries, fetchInfo)); |
||||
} |
||||
|
||||
private buildTimeEntryEntries(entries:TimeEntryResource[]) { |
||||
let hoursDistribution:{ [key:string]:Moment } = {}; |
||||
|
||||
return entries.map((entry) => { |
||||
let start:Moment; |
||||
let end:Moment; |
||||
let hours = this.timezone.toHours(entry.hours); |
||||
|
||||
if (hoursDistribution[entry.spentOn]) { |
||||
start = hoursDistribution[entry.spentOn].clone().subtract(hours, 'h'); |
||||
end = hoursDistribution[entry.spentOn].clone(); |
||||
} else { |
||||
start = moment(entry.spentOn).add(24 - hours, 'h'); |
||||
end = moment(entry.spentOn).add(24, 'h'); |
||||
} |
||||
|
||||
hoursDistribution[entry.spentOn] = start; |
||||
|
||||
const color = this.colors.toHsl(this.entryName(entry)); |
||||
|
||||
return this.timeEntry(entry, hours, start, end); |
||||
}) as EventInput[]; |
||||
} |
||||
|
||||
private buildAuxEntries(entries:TimeEntryResource[], fetchInfo:{ start:Date, end:Date }) { |
||||
let dateSums:{ [key:string]:number } = {}; |
||||
|
||||
entries.forEach((entry) => { |
||||
let hours = this.timezone.toHours(entry.hours); |
||||
|
||||
if (dateSums[entry.spentOn]) { |
||||
dateSums[entry.spentOn] += hours; |
||||
} else { |
||||
dateSums[entry.spentOn] = hours; |
||||
} |
||||
}); |
||||
|
||||
let calendarEntries:EventInput[] = []; |
||||
|
||||
for (let m = moment(fetchInfo.start); m.diff(fetchInfo.end, 'days') <= 0; m.add(1, 'days')) { |
||||
let duration = dateSums[m.format('YYYY-MM-DD')] || 0; |
||||
|
||||
calendarEntries.push(this.sumEntry(m, duration)); |
||||
|
||||
if (this.memoizedCreateAllowed && duration < 24) { |
||||
calendarEntries.push(this.addEntry(m, duration)); |
||||
} |
||||
} |
||||
|
||||
return calendarEntries; |
||||
} |
||||
|
||||
protected timeEntry(entry:TimeEntryResource, hours:number, start:Moment, end:Moment) { |
||||
const color = this.colors.toHsl(this.entryName(entry)); |
||||
|
||||
return { |
||||
title: hours < 0.5 ? '' : this.entryName(entry), |
||||
startEditable: !!entry.update, |
||||
start: start.format(), |
||||
end: end.format(), |
||||
backgroundColor: color, |
||||
borderColor: color, |
||||
classNames: TIME_ENTRY_CLASS_NAME, |
||||
entry: entry |
||||
}; |
||||
} |
||||
|
||||
protected sumEntry(date:Moment, duration:number) { |
||||
return { |
||||
title: this.i18n.t('js.units.hour', { count: this.formatNumber(duration) }), |
||||
start: date.clone().add(24 - Math.min(duration, 23.5) - 0.5, 'h').format(), |
||||
end: date.clone().add(24 - Math.min(duration, 23.5), 'h').format(), |
||||
classNames: DAY_SUM_CLASS_NAME |
||||
}; |
||||
} |
||||
|
||||
protected addEntry(date:Moment, duration:number) { |
||||
return { |
||||
title: '+', |
||||
start: date.clone().format(), |
||||
end: date.clone().add(24 - Math.min(duration, 22.5) - 0.5, 'h').format(), |
||||
classNames: ADD_ENTRY_CLASS_NAME |
||||
}; |
||||
} |
||||
|
||||
protected dmFilters(start:Date, end:Date):Array<[string, FilterOperator, string[]]> { |
||||
let startDate = moment(start).format('YYYY-MM-DD'); |
||||
let endDate = moment(end).subtract(1, 'd').format('YYYY-MM-DD'); |
||||
return [['spentOn', '<>d', [startDate, endDate]] as [string, FilterOperator, string[]], |
||||
['user_id', '=', ['me']] as [string, FilterOperator, [string]]]; |
||||
} |
||||
|
||||
private initializeCalendar() { |
||||
this.calendarEvents = this.calendarEventsFunction.bind(this); |
||||
} |
||||
|
||||
public get calendarEventLimit() { |
||||
return false; |
||||
} |
||||
|
||||
public get calendarLocale() { |
||||
return this.i18n.locale; |
||||
} |
||||
|
||||
public get calendarFixedWeekCount() { |
||||
return false; |
||||
} |
||||
|
||||
public get calendarDefaultView() { |
||||
return 'timeGridWeek'; |
||||
} |
||||
|
||||
public get calendarFirstDay() { |
||||
return this.configuration.startOfWeek(); |
||||
} |
||||
|
||||
private get calendarElement() { |
||||
return jQuery(this.element.nativeElement).find('.fc-view-container'); |
||||
} |
||||
|
||||
private dispatchEventClick(event:CalendarViewEvent) { |
||||
if (event.event.extendedProps.entry) { |
||||
this.editEvent(event.event.extendedProps.entry); |
||||
} else if (event.event.start) { |
||||
this.addEvent(moment(event.event.start)); |
||||
} |
||||
} |
||||
|
||||
private editEvent(entry:TimeEntryResource) { |
||||
this |
||||
.timeEntryEdit |
||||
.edit(entry) |
||||
.then(modificationAction => { |
||||
this.updateEventSet(modificationAction.entry, modificationAction.action); |
||||
}) |
||||
.catch(() => { |
||||
// do nothing, the user closed without changes
|
||||
}); |
||||
} |
||||
|
||||
private moveEvent(event:CalendarMoveEvent) { |
||||
let entry = event.event.extendedProps.entry; |
||||
|
||||
entry.spentOn = moment(event.event.start!).format('YYYY-MM-DD'); |
||||
|
||||
this |
||||
.timeEntryDm |
||||
.update(entry, entry.schema) |
||||
.then(event => { |
||||
this.updateEventSet(event, 'update'); |
||||
}) |
||||
.catch(() => { |
||||
event.revert(); |
||||
}); |
||||
} |
||||
|
||||
private addEvent(date:Moment) { |
||||
if (!this.memoizedCreateAllowed) { |
||||
return; |
||||
} |
||||
|
||||
this |
||||
.timeEntryCreate |
||||
.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); |
||||
|
||||
switch (action) { |
||||
case 'update': |
||||
collection.elements[foundIndex] = event; |
||||
break; |
||||
case 'destroy': |
||||
collection.elements.splice(foundIndex, 1); |
||||
break; |
||||
case 'create': |
||||
collection.elements.push(event); |
||||
break; |
||||
} |
||||
|
||||
this.ucCalendar.getApi().refetchEvents(); |
||||
}); |
||||
} |
||||
|
||||
private alterEventEntry(event:CalendarViewEvent) { |
||||
if (!event.event.extendedProps.entry) { |
||||
return; |
||||
} |
||||
|
||||
this.addTooltip(event); |
||||
this.prependDuration(event); |
||||
this.appendFadeout(event); |
||||
} |
||||
|
||||
private addTooltip(event:CalendarViewEvent) { |
||||
if (this.browserDetector.isMobile) { |
||||
return; |
||||
} |
||||
|
||||
jQuery(event.el).tooltip({ |
||||
content: this.tooltipContentString(event.event.extendedProps.entry), |
||||
items: '.fc-event', |
||||
close: function () { jQuery(".ui-helper-hidden-accessible").remove(); }, |
||||
track: true |
||||
}); |
||||
} |
||||
|
||||
private removeTooltip(event:CalendarViewEvent) { |
||||
jQuery(event.el).tooltip('disable'); |
||||
} |
||||
|
||||
private prependDuration(event:CalendarViewEvent) { |
||||
let formattedDuration = this.timezone.formattedDuration(event.event.extendedProps.entry.hours); |
||||
|
||||
jQuery(event.el) |
||||
.find('.fc-title') |
||||
.prepend(`<div class="fc-duration">${formattedDuration}</div>`); |
||||
} |
||||
|
||||
/* Fade out event text to the bottom to avoid it being cut of weirdly. |
||||
* Multiline ellipsis with an unknown height is not possible, hence we blur the text. |
||||
* The gradient needs to take the background color of the element into account (hashed over the event |
||||
* title) which is why the style is set in code. |
||||
* |
||||
* We do not print anything on short entries (< 0.5 hours), |
||||
* which leads to the fc-short class not being applied by full calendar. For other short events, the css rules |
||||
* need to deactivate the fc-fadeout. |
||||
*/ |
||||
private appendFadeout(event:CalendarViewEvent) { |
||||
let timeEntry = event.event.extendedProps.entry; |
||||
|
||||
if (this.timezone.toHours(timeEntry.hours) < 0.5) { |
||||
return; |
||||
} |
||||
|
||||
let $element = jQuery(event.el); |
||||
let fadeout = jQuery(`<div class="fc-fadeout"></div>`); |
||||
|
||||
let hslaStart = this.colors.toHsla(this.entryName(timeEntry), 0); |
||||
let hslaEnd = this.colors.toHsla(this.entryName(timeEntry), 100); |
||||
|
||||
fadeout.css('background', `-webkit-linear-gradient(${hslaStart} 0%, ${hslaEnd} 100%`); |
||||
|
||||
['-moz-linear-gradient', '-o-linear-gradient', 'linear-gradient', '-ms-linear-gradient'].forEach((style => { |
||||
fadeout.css('background-image', `${style}(${hslaStart} 0%, ${hslaEnd} 100%`); |
||||
})); |
||||
|
||||
$element |
||||
.append(fadeout); |
||||
} |
||||
|
||||
private beforeEventRemove(event:CalendarViewEvent) { |
||||
if (!event.event.extendedProps.entry) { |
||||
return; |
||||
} |
||||
|
||||
this.removeTooltip(event); |
||||
} |
||||
|
||||
private entryName(entry:TimeEntryResource) { |
||||
let name = entry.project.name; |
||||
if (entry.workPackage) { |
||||
name += ` - ${this.workPackageName(entry)}`; |
||||
} |
||||
|
||||
return this.sanitizedValue(name) || '-'; |
||||
} |
||||
|
||||
private workPackageName(entry:TimeEntryResource) { |
||||
return `#${entry.workPackage.idFromLink}: ${entry.workPackage.name}`; |
||||
} |
||||
|
||||
private tooltipContentString(entry:TimeEntryResource) { |
||||
return ` |
||||
<ul class="tooltip--map"> |
||||
<li class="tooltip--map--item"> |
||||
<span class="tooltip--map--key">${this.i18n.t('js.time_entry.project')}:</span> |
||||
<span class="tooltip--map--value">${this.sanitizedValue(entry.project.name)}</span> |
||||
</li> |
||||
<li class="tooltip--map--item"> |
||||
<span class="tooltip--map--key">${this.i18n.t('js.time_entry.work_package')}:</span> |
||||
<span class="tooltip--map--value">${entry.workPackage ? this.sanitizedValue(this.workPackageName(entry)) : this.i18n.t('js.placeholders.default')}</span> |
||||
</li> |
||||
<li class="tooltip--map--item"> |
||||
<span class="tooltip--map--key">${this.i18n.t('js.time_entry.activity')}:</span> |
||||
<span class="tooltip--map--value">${this.sanitizedValue(entry.activity.name)}</span> |
||||
</li> |
||||
<li class="tooltip--map--item"> |
||||
<span class="tooltip--map--key">${this.i18n.t('js.time_entry.duration')}:</span> |
||||
<span class="tooltip--map--value">${this.timezone.formattedDuration(entry.hours)}</span> |
||||
</li> |
||||
<li class="tooltip--map--item"> |
||||
<span class="tooltip--map--key">${this.i18n.t('js.time_entry.comment')}:</span> |
||||
<span class="tooltip--map--value">${entry.comment.raw || this.i18n.t('js.placeholders.default')}</span> |
||||
</li> |
||||
`;
|
||||
} |
||||
|
||||
private sanitizedValue(value:string) { |
||||
return this.sanitizer.sanitize(SecurityContext.HTML, value); |
||||
} |
||||
|
||||
protected formatNumber(value:number):string { |
||||
return this.i18n.toNumber(value, { precision: 2 }); |
||||
} |
||||
} |
@ -0,0 +1,24 @@ |
||||
<!-- position: relative added in order for the loading indicator to be positioned correctly --> |
||||
<div class="te-calendar--container loading-indicator--location" |
||||
[attr.data-indicator-name]="'table'" |
||||
style="position: relative"> |
||||
<full-calendar #ucCalendar |
||||
[editable]="calendarEditable" |
||||
[eventLimit]="calendarEventLimit" |
||||
[locale]="calendarLocale" |
||||
[fixedWeekCount]="calendarFixedWeekCount" |
||||
[header]="calendarHeader" |
||||
[defaultView]="calendarDefaultView" |
||||
[firstDay]="calendarFirstDay" |
||||
[contentHeight]="calendarContentHeight" |
||||
[slotLabelFormat]="calendarSlotLabelFormat" |
||||
[slotEventOverlap]="calendarSlotEventOverlap" |
||||
[allDaySlot]="calendarAllDaySlot" |
||||
[allDayText]="calendarAllDayText" |
||||
[displayEventTime]="calendarDisplayEventTime" |
||||
[scrollTime]="calendarScrollTime" |
||||
[events]="calendarEvents" |
||||
[eventOverlap]="calendarEventOverlap" |
||||
[plugins]="calendarPlugins"> |
||||
</full-calendar> |
||||
</div> |
@ -0,0 +1,40 @@ |
||||
// -- copyright
|
||||
// OpenProject is a project management system.
|
||||
// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
|
||||
//
|
||||
// This program is free software; you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License version 3.
|
||||
//
|
||||
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
// Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
// Copyright (C) 2010-2013 the ChiliProject Team
|
||||
//
|
||||
// This program is free software; you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License
|
||||
// as published by the Free Software Foundation; either version 2
|
||||
// of the License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program; if not, write to the Free Software
|
||||
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
//
|
||||
// See doc/COPYRIGHT.rdoc for more details.
|
||||
// ++
|
||||
|
||||
import { |
||||
AfterViewInit, |
||||
Component, |
||||
} from '@angular/core'; |
||||
import {CreateAutocompleterComponent} from "core-app/modules/common/autocomplete/create-autocompleter.component"; |
||||
|
||||
@Component({ |
||||
templateUrl: './create-autocompleter.component.html', |
||||
selector: 'wp-autocompleter' |
||||
}) |
||||
export class WorkPackageAutocompleterComponent extends CreateAutocompleterComponent implements AfterViewInit { |
||||
} |
@ -0,0 +1,21 @@ |
||||
import { Injectable } from '@angular/core'; |
||||
|
||||
@Injectable() |
||||
export class ColorsService { |
||||
public toHsl(value:string) { |
||||
return `hsl(${this.valueHash(value)}, 50%, 50%)`; |
||||
} |
||||
|
||||
public toHsla(value:string, opacity:number) { |
||||
return `hsla(${this.valueHash(value)}, 50%, 50%, ${opacity}%)`; |
||||
} |
||||
|
||||
protected valueHash(value:string) { |
||||
let hash = 0; |
||||
for (let i = 0; i < value.length; i++) { |
||||
hash = value.charCodeAt(i) + ((hash << 5) - hash); |
||||
} |
||||
|
||||
return hash % 360; |
||||
} |
||||
} |
@ -0,0 +1,69 @@ |
||||
// -- copyright
|
||||
// OpenProject is a project management system.
|
||||
// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
|
||||
//
|
||||
// This program is free software; you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License version 3.
|
||||
//
|
||||
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
// Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
// Copyright (C) 2010-2013 the ChiliProject Team
|
||||
//
|
||||
// This program is free software; you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License
|
||||
// as published by the Free Software Foundation; either version 2
|
||||
// of the License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program; if not, write to the Free Software
|
||||
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
//
|
||||
// See doc/COPYRIGHT.rdoc for more details.
|
||||
// ++
|
||||
|
||||
import {StateService} from '@uirouter/core'; |
||||
import {KeepTabService} from 'core-components/wp-single-view-tabs/keep-tab/keep-tab.service'; |
||||
import {UiStateLinkBuilder} from "core-components/wp-fast-table/builders/ui-state-link-builder"; |
||||
import {WorkPackageDisplayField} from "core-app/modules/fields/display/field-types/work-package-display-field.module"; |
||||
|
||||
export class LinkedWorkPackageDisplayField extends WorkPackageDisplayField { |
||||
|
||||
public text = { |
||||
linkTitle: this.I18n.t('js.work_packages.message_successful_show_in_fullscreen'), |
||||
none: this.I18n.t('js.filter.noneElement') |
||||
}; |
||||
|
||||
private $state:StateService = this.$injector.get(StateService); |
||||
private keepTab:KeepTabService = this.$injector.get(KeepTabService); |
||||
|
||||
private uiStateBuilder:UiStateLinkBuilder = new UiStateLinkBuilder(this.$state, this.keepTab); |
||||
|
||||
public render(element:HTMLElement, displayText:string):void { |
||||
if (this.isEmpty()) { |
||||
element.innerText = this.placeholder; |
||||
return; |
||||
} |
||||
|
||||
let link = this.uiStateBuilder.linkToShow( |
||||
this.wpId, |
||||
this.text.linkTitle, |
||||
this.valueString |
||||
); |
||||
|
||||
element.innerHTML = ''; |
||||
element.appendChild(link); |
||||
} |
||||
|
||||
public get writable():boolean { |
||||
return false; |
||||
} |
||||
|
||||
public get valueString() { |
||||
return '#' + this.wpId; |
||||
} |
||||
} |
@ -0,0 +1,40 @@ |
||||
// -- copyright
|
||||
// OpenProject is a project management system.
|
||||
// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
|
||||
//
|
||||
// This program is free software; you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License version 3.
|
||||
//
|
||||
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
// Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
// Copyright (C) 2010-2013 the ChiliProject Team
|
||||
//
|
||||
// This program is free software; you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License
|
||||
// as published by the Free Software Foundation; either version 2
|
||||
// of the License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program; if not, write to the Free Software
|
||||
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
//
|
||||
// See doc/COPYRIGHT.rdoc for more details.
|
||||
// ++
|
||||
|
||||
import {DisplayField} from "core-app/modules/fields/display/display-field.module"; |
||||
|
||||
export class PlainFormattableDisplayField extends DisplayField { |
||||
public get value() { |
||||
if (!this.schema) { |
||||
return null; |
||||
} |
||||
const element = this.resource[this.name]; |
||||
|
||||
return element && element.raw || ''; |
||||
} |
||||
} |
@ -0,0 +1,51 @@ |
||||
// -- copyright
|
||||
// OpenProject is a project management system.
|
||||
// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
|
||||
//
|
||||
// This program is free software; you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License version 3.
|
||||
//
|
||||
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
// Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
// Copyright (C) 2010-2013 the ChiliProject Team
|
||||
//
|
||||
// This program is free software; you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License
|
||||
// as published by the Free Software Foundation; either version 2
|
||||
// of the License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program; if not, write to the Free Software
|
||||
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
//
|
||||
// See doc/COPYRIGHT.rdoc for more details.
|
||||
// ++
|
||||
|
||||
import {Component} from "@angular/core"; |
||||
import {EditFieldComponent} from "core-app/modules/fields/edit/edit-field.component"; |
||||
|
||||
@Component({ |
||||
templateUrl: './text-edit-field.component.html' |
||||
}) |
||||
export class PlainFormattableEditFieldComponent extends EditFieldComponent { |
||||
// only exists because the template is reused and the property is required there.
|
||||
public shouldFocus = false; |
||||
|
||||
public get value() { |
||||
if (!this.schema) { |
||||
return ''; |
||||
} |
||||
const element = this.resource[this.name]; |
||||
|
||||
return element && element.raw || ''; |
||||
} |
||||
|
||||
public set value(newValue:string) { |
||||
this.resource[this.name] = { raw: newValue }; |
||||
} |
||||
} |
@ -0,0 +1,44 @@ |
||||
// -- copyright
|
||||
// OpenProject is a project management system.
|
||||
// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
|
||||
//
|
||||
// This program is free software; you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License version 3.
|
||||
//
|
||||
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
// Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
// Copyright (C) 2010-2013 the ChiliProject Team
|
||||
//
|
||||
// This program is free software; you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License
|
||||
// as published by the Free Software Foundation; either version 2
|
||||
// of the License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program; if not, write to the Free Software
|
||||
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
//
|
||||
// See doc/COPYRIGHT.rdoc for more details.
|
||||
// ++
|
||||
|
||||
import {Component} from "@angular/core"; |
||||
import {WorkPackageEditFieldComponent} from "core-app/modules/fields/edit/field-types/work-package-edit-field.component"; |
||||
|
||||
@Component({ |
||||
templateUrl: './work-package-edit-field.component.html' |
||||
}) |
||||
export class TimeEntryWorkPackageEditFieldComponent extends WorkPackageEditFieldComponent { |
||||
// Although the schema states the work packages to not be required,
|
||||
// as time entries can also be assigned to a project, we want to only assign
|
||||
// time entries to work packages and thus require a value.
|
||||
// The back end will have to be changed in due time but not as long as there is still a rails based
|
||||
// time entry view in the application.
|
||||
protected isRequired() { |
||||
return true; |
||||
} |
||||
} |
@ -0,0 +1,10 @@ |
||||
<input type="text" |
||||
class="inline-edit--field" |
||||
[focus]="shouldFocus" |
||||
[attr.aria-required]="required" |
||||
[attr.required]="required" |
||||
[disabled]="inFlight" |
||||
[(ngModel)]="value" |
||||
(keydown)="handler.handleUserKeydown($event)" |
||||
(focusout)="handler.onFocusOut()" |
||||
[id]="handler.htmlId" /> |
@ -0,0 +1,13 @@ |
||||
<ndc-dynamic [ndcDynamicComponent]="autocompleterComponent()" |
||||
[ndcDynamicInputs]="{ availableValues: requests.output$ | async, |
||||
appendTo: appendTo, |
||||
model: selectedOption ? selectedOption : '', |
||||
required: required, |
||||
disabled: inFlight, |
||||
typeahead: requests.input$, |
||||
id: handler.htmlId, |
||||
finishedLoading: true, |
||||
hideSelected: true, |
||||
classes: 'inline-edit--field ' + handler.fieldName }" |
||||
[ndcDynamicOutputs]="referenceOutputs"> |
||||
</ndc-dynamic> |
@ -1,13 +0,0 @@ |
||||
import {Component, OnInit} from "@angular/core"; |
||||
import {FilterOperator} from "core-components/api/api-v3/api-v3-filter-builder"; |
||||
import {WidgetTimeEntriesListComponent} from "core-app/modules/grids/widgets/time-entries-current-user/list/time-entries-list.component"; |
||||
|
||||
@Component({ |
||||
templateUrl: '../list/time-entries-list.component.html', |
||||
}) |
||||
export class WidgetTimeEntriesCurrentUserComponent extends WidgetTimeEntriesListComponent implements OnInit { |
||||
protected dmFilters():Array<[string, FilterOperator, [string]]> { |
||||
return [['spentOn', '>t-', ['7']] as [string, FilterOperator, [string]], |
||||
['user_id', '=', ['me']] as [string, FilterOperator, [string]]]; |
||||
} |
||||
} |
@ -0,0 +1,18 @@ |
||||
<widget-header |
||||
[name]="widgetName" |
||||
(onRenamed)="renameWidget($event)"> |
||||
|
||||
<widget-menu |
||||
[resource]="resource"> |
||||
</widget-menu> |
||||
</widget-header> |
||||
|
||||
<te-calendar |
||||
(entries)="updateEntries($event)" |
||||
></te-calendar> |
||||
|
||||
<ng-container> |
||||
<div class="total-hours"> |
||||
<p>Total: <span [textContent]="total"></span></p> |
||||
</div> |
||||
</ng-container> |
@ -0,0 +1,45 @@ |
||||
import {Component, Injector, ChangeDetectionStrategy, ChangeDetectorRef} from "@angular/core"; |
||||
import { TimeEntryResource } from 'core-app/modules/hal/resources/time-entry-resource'; |
||||
import {CollectionResource} from "core-app/modules/hal/resources/collection-resource"; |
||||
import {TimezoneService} from "core-components/datetime/timezone.service"; |
||||
import {I18nService} from "core-app/modules/common/i18n/i18n.service"; |
||||
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service"; |
||||
import {AbstractWidgetComponent} from "core-app/modules/grids/widgets/abstract-widget.component"; |
||||
|
||||
@Component({ |
||||
templateUrl: './time-entries-current-user.component.html', |
||||
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
}) |
||||
export class WidgetTimeEntriesCurrentUserComponent extends AbstractWidgetComponent { |
||||
public entries:TimeEntryResource[] = []; |
||||
|
||||
constructor(protected readonly injector:Injector, |
||||
readonly timezone:TimezoneService, |
||||
readonly i18n:I18nService, |
||||
readonly pathHelper:PathHelperService, |
||||
protected readonly cdr:ChangeDetectorRef) { |
||||
super(i18n, injector); |
||||
} |
||||
|
||||
public updateEntries(entries:CollectionResource<TimeEntryResource>) { |
||||
this.entries = entries.elements; |
||||
|
||||
this.cdr.detectChanges(); |
||||
} |
||||
|
||||
public get total() { |
||||
let duration = this.entries.reduce((current, entry) => { |
||||
return current + this.timezone.toHours(entry.hours); |
||||
}, 0); |
||||
|
||||
if (duration > 0) { |
||||
return this.i18n.t('js.units.hour', { count: this.formatNumber(duration) }); |
||||
} else { |
||||
return this.i18n.t('js.placeholders.default'); |
||||
} |
||||
} |
||||
|
||||
protected formatNumber(value:number):string { |
||||
return this.i18n.toNumber(value, { precision: 2 }); |
||||
} |
||||
} |
@ -1,6 +1,6 @@ |
||||
import {Component, OnInit, Injector, ChangeDetectorRef} from "@angular/core"; |
||||
import {FilterOperator} from "core-components/api/api-v3/api-v3-filter-builder"; |
||||
import {WidgetTimeEntriesListComponent} from "core-app/modules/grids/widgets/time-entries-current-user/list/time-entries-list.component"; |
||||
import {WidgetTimeEntriesListComponent} from "core-app/modules/grids/widgets/time-entries/list/time-entries-list.component"; |
||||
import {TimeEntryDmService} from "core-app/modules/hal/dm-services/time-entry-dm.service"; |
||||
import {TimezoneService} from "core-components/datetime/timezone.service"; |
||||
import {I18nService} from "core-app/modules/common/i18n/i18n.service"; |
@ -0,0 +1,37 @@ |
||||
<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"> |
||||
<te-form #editForm |
||||
[entry]="entry" |
||||
(modifiedEntry)="setModifiedEntry($event)"> |
||||
</te-form> |
||||
</div> |
||||
|
||||
<div class="op-modal--modal-footer"> |
||||
<button class="button -highlight" |
||||
(click)="createEntry()" |
||||
[textContent]="text.create" |
||||
[attr.title]="text.create"> |
||||
</button> |
||||
|
||||
<button class="button" |
||||
(click)="closeMe($event)" |
||||
[textContent]="text.cancel" |
||||
[attr.title]="text.cancel"> |
||||
</button> |
||||
</div> |
||||
</div> |
||||
</div> |
@ -0,0 +1,57 @@ |
||||
import {Component, ElementRef, Inject, ChangeDetectorRef, ViewChild, ChangeDetectionStrategy} 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 {TimeEntryFormComponent} from "core-app/modules/time_entries/form/form.component"; |
||||
|
||||
@Component({ |
||||
templateUrl: './create.modal.html', |
||||
styleUrls: ['../edit/edit.modal.sass'], |
||||
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
providers: [ |
||||
HalResourceEditingService |
||||
] |
||||
}) |
||||
export class TimeEntryCreateModal extends OpModalComponent { |
||||
|
||||
@ViewChild('editForm', { static: true }) editForm:TimeEntryFormComponent; |
||||
|
||||
text = { |
||||
title: this.i18n.t('js.time_entry.create'), |
||||
create: this.i18n.t('js.label_create'), |
||||
close: this.i18n.t('js.button_close'), |
||||
cancel: this.i18n.t('js.button_cancel') |
||||
}; |
||||
|
||||
public closeOnEscape = false; |
||||
public closeOnOutsideClick = 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); |
||||
} |
||||
|
||||
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; |
||||
} |
||||
} |
@ -0,0 +1,80 @@ |
||||
import {Injectable, Injector} from "@angular/core"; |
||||
import {OpModalService} from "app/components/op-modals/op-modal.service"; |
||||
import {HalResourceService} from "app/modules/hal/services/hal-resource.service"; |
||||
import {I18nService} from "core-app/modules/common/i18n/i18n.service"; |
||||
import { TimeEntryResource } from 'core-app/modules/hal/resources/time-entry-resource'; |
||||
import { take } from 'rxjs/operators'; |
||||
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'; |
||||
import {TimeEntryCreateModal} from "core-app/modules/time_entries/create/create.modal"; |
||||
|
||||
@Injectable() |
||||
export class TimeEntryCreateService { |
||||
|
||||
constructor(readonly opModalService:OpModalService, |
||||
readonly injector:Injector, |
||||
readonly halResource:HalResourceService, |
||||
readonly timeEntryDm:TimeEntryDmService, |
||||
protected halEditing:HalResourceEditingService, |
||||
readonly i18n:I18nService) { |
||||
} |
||||
|
||||
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; |
||||
} |
||||
} |
@ -0,0 +1,40 @@ |
||||
<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"> |
||||
<te-form [entry]="entry" |
||||
(modifiedEntry)="setModifiedEntry($event)"> |
||||
</te-form> |
||||
</div> |
||||
|
||||
<div class="op-modal--modal-footer"> |
||||
<button class="button -highlight" |
||||
(click)="closeMe($event)" |
||||
[textContent]="text.close" |
||||
[attr.title]="text.close"> |
||||
</button> |
||||
|
||||
<button class="button" |
||||
*ngIf="deleteAllowed" |
||||
(click)="destroy()" |
||||
[attr.title]="text.delete"> |
||||
<op-icon icon-classes="button--icon icon-delete"></op-icon> |
||||
<span class="button--text" |
||||
[textContent]="text.delete" |
||||
aria-hidden="true"></span> |
||||
</button> |
||||
</div> |
||||
</div> |
||||
</div> |
@ -0,0 +1,9 @@ |
||||
.op-modal--modal-container |
||||
max-width: 90vw |
||||
width: 800px |
||||
|
||||
.op-modal--modal-footer |
||||
margin-top: 2em |
||||
|
||||
.button |
||||
margin-bottom: 0 |
@ -0,0 +1,56 @@ |
||||
import {Component, ElementRef, Inject, ChangeDetectorRef, ChangeDetectionStrategy} 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"; |
||||
|
||||
@Component({ |
||||
templateUrl: './edit.modal.html', |
||||
styleUrls: ['./edit.modal.sass'], |
||||
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
providers: [ |
||||
HalResourceEditingService |
||||
] |
||||
}) |
||||
export class TimeEntryEditModal extends OpModalComponent { |
||||
|
||||
text = { |
||||
title: this.i18n.t('js.time_entry.edit'), |
||||
close: this.i18n.t('js.button_close'), |
||||
delete: this.i18n.t('js.button_delete') |
||||
}; |
||||
|
||||
public closeOnEscape = false; |
||||
public closeOnOutsideClick = false; |
||||
|
||||
public modifiedEntry:TimeEntryResource; |
||||
public destroyedEntry:TimeEntryResource; |
||||
|
||||
constructor(readonly elementRef:ElementRef, |
||||
@Inject(OpModalLocalsToken) readonly locals:OpModalLocalsMap, |
||||
readonly cdRef:ChangeDetectorRef, |
||||
readonly i18n:I18nService) { |
||||
super(locals, cdRef, elementRef); |
||||
} |
||||
|
||||
public get entry() { |
||||
return this.locals.entry; |
||||
} |
||||
|
||||
public setModifiedEntry($event:{savedResource:HalResource, isInital:boolean}) { |
||||
this.modifiedEntry = $event.savedResource as TimeEntryResource; |
||||
} |
||||
|
||||
public get deleteAllowed() { |
||||
return !!this.entry.delete; |
||||
} |
||||
|
||||
public destroy() { |
||||
this.destroyedEntry = this.entry; |
||||
this.service.close(); |
||||
} |
||||
} |
@ -0,0 +1,40 @@ |
||||
import {Injectable, Injector} from "@angular/core"; |
||||
import {OpModalService} from "app/components/op-modals/op-modal.service"; |
||||
import {HalResourceService} from "app/modules/hal/services/hal-resource.service"; |
||||
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 {HalResourceEditingService} from "core-app/modules/fields/edit/services/hal-resource-editing.service"; |
||||
|
||||
@Injectable() |
||||
export class TimeEntryEditService { |
||||
|
||||
constructor(readonly opModalService:OpModalService, |
||||
readonly injector:Injector, |
||||
readonly halResource:HalResourceService, |
||||
protected halEditing:HalResourceEditingService, |
||||
readonly i18n:I18nService) { |
||||
} |
||||
|
||||
public edit(entry:TimeEntryResource) { |
||||
return new Promise<{entry:TimeEntryResource, action:'update'|'destroy'}>((resolve, reject) => { |
||||
const modal = this.opModalService.show(TimeEntryEditModal, this.injector, { entry: entry }); |
||||
|
||||
modal |
||||
.closingEvent |
||||
.pipe(take(1)) |
||||
.subscribe(() => { |
||||
if (modal.destroyedEntry) { |
||||
modal.destroyedEntry.delete().then(() => { |
||||
resolve({entry: modal.destroyedEntry, action: 'destroy'}); |
||||
}); |
||||
} else if (modal.modifiedEntry) { |
||||
resolve({ entry: modal.modifiedEntry, action: 'update' }); |
||||
} else { |
||||
reject(); |
||||
} |
||||
}); |
||||
}); |
||||
} |
||||
} |
@ -0,0 +1,56 @@ |
||||
<edit-form |
||||
#editForm |
||||
[resource]="entry" |
||||
[inEditMode]="inEditMode" |
||||
(onSaved)="signalModifiedEntry($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> |
@ -0,0 +1,82 @@ |
||||
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 {I18nService} from "core-app/modules/common/i18n/i18n.service"; |
||||
import { ViewEncapsulation, Component, Input, EventEmitter, Output, OnInit, OnDestroy, ViewChild, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; |
||||
import { untilComponentDestroyed } from 'ng2-rx-componentdestroyed'; |
||||
import {SchemaResource} from "core-app/modules/hal/resources/schema-resource"; |
||||
import {HalResource} from "core-app/modules/hal/resources/hal-resource"; |
||||
import { EditFormComponent } from 'core-app/modules/fields/edit/edit-form/edit-form.component'; |
||||
|
||||
@Component({ |
||||
templateUrl: './form.component.html', |
||||
selector: 'te-form', |
||||
encapsulation: ViewEncapsulation.None, |
||||
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
}) |
||||
export class TimeEntryFormComponent implements OnInit, OnDestroy { |
||||
@Input() entry:TimeEntryResource; |
||||
|
||||
@Output() modifiedEntry = new EventEmitter<{savedResource:TimeEntryResource, isInital:boolean}>(); |
||||
|
||||
@ViewChild('editForm', { static: true }) editForm:EditFormComponent; |
||||
|
||||
text = { |
||||
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') |
||||
}; |
||||
|
||||
public workPackageSelected:boolean = false; |
||||
public customFields:{key:string, label:string}[] = []; |
||||
|
||||
constructor(readonly halEditing:HalResourceEditingService, |
||||
readonly cdRef:ChangeDetectorRef, |
||||
readonly i18n:I18nService) { |
||||
} |
||||
|
||||
ngOnInit() { |
||||
this.halEditing |
||||
.temporaryEditResource(this.entry) |
||||
.values$() |
||||
.pipe( |
||||
untilComponentDestroyed(this) |
||||
) |
||||
.subscribe(changeset => { |
||||
if (changeset && changeset.workPackage) { |
||||
this.workPackageSelected = true; |
||||
this.cdRef.markForCheck(); |
||||
} |
||||
}); |
||||
|
||||
this.setCustomFields(this.entry.schema); |
||||
this.cdRef.detectChanges(); |
||||
} |
||||
|
||||
ngOnDestroy() { |
||||
// nothing to do
|
||||
} |
||||
|
||||
public signalModifiedEntry($event:{savedResource:HalResource, isInital:boolean}) { |
||||
this.modifiedEntry.emit($event as {savedResource:TimeEntryResource, isInital:boolean}); |
||||
} |
||||
public save() { |
||||
return this.editForm.save(); |
||||
} |
||||
|
||||
public get inEditMode() { |
||||
return this.entry.isNew; |
||||
} |
||||
|
||||
private setCustomFields(schema:SchemaResource) { |
||||
Object.entries(schema).forEach(([key, keySchema]) => { |
||||
if (key.match(/customField\d+/)) { |
||||
this.customFields.push({key: key, label: keySchema.name }); |
||||
} |
||||
}); |
||||
} |
||||
} |
@ -0,0 +1,60 @@ |
||||
// -- copyright
|
||||
// OpenProject is an open source project management software.
|
||||
// Copyright (C) 2012-2020 the OpenProject GmbH
|
||||
//
|
||||
// This program is free software; you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License version 3.
|
||||
//
|
||||
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
// Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
// Copyright (C) 2010-2013 the ChiliProject Team
|
||||
//
|
||||
// This program is free software; you can redistribute it and/or
|
||||
// modify it under the terms of the GNU General Public License
|
||||
// as published by the Free Software Foundation; either version 2
|
||||
// of the License, or (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program; if not, write to the Free Software
|
||||
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
//
|
||||
// See docs/COPYRIGHT.rdoc for more details.
|
||||
// ++
|
||||
|
||||
import {OpenprojectCommonModule} from 'core-app/modules/common/openproject-common.module'; |
||||
import {NgModule} from '@angular/core'; |
||||
import {OpenprojectFieldsModule} from "core-app/modules/fields/openproject-fields.module"; |
||||
import {TimeEntryEditService} from "core-app/modules/time_entries/edit/edit.service"; |
||||
import {TimeEntryCreateModal} from "core-app/modules/time_entries/create/create.modal"; |
||||
import {TimeEntryEditModal} from "core-app/modules/time_entries/edit/edit.modal"; |
||||
import {TimeEntryFormComponent} from "core-app/modules/time_entries/form/form.component"; |
||||
|
||||
@NgModule({ |
||||
imports: [ |
||||
// Commons
|
||||
OpenprojectCommonModule, |
||||
|
||||
// Editable fields e.g. for modals
|
||||
OpenprojectFieldsModule, |
||||
], |
||||
providers: [ |
||||
], |
||||
declarations: [ |
||||
TimeEntryEditModal, |
||||
TimeEntryCreateModal, |
||||
TimeEntryFormComponent |
||||
], |
||||
entryComponents: [ |
||||
TimeEntryEditModal, |
||||
TimeEntryCreateModal, |
||||
], |
||||
exports: [ |
||||
] |
||||
}) |
||||
export class OpenprojectTimeEntriesModule { |
||||
} |
@ -0,0 +1,51 @@ |
||||
#-- copyright |
||||
# OpenProject is a project management system. |
||||
# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License version 3. |
||||
# |
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: |
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang |
||||
# Copyright (C) 2010-2013 the ChiliProject Team |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License |
||||
# as published by the Free Software Foundation; either version 2 |
||||
# of the License, or (at your option) any later version. |
||||
# |
||||
# This program is distributed in the hope that it will be useful, |
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
# GNU General Public License for more details. |
||||
# |
||||
# You should have received a copy of the GNU General Public License |
||||
# along with this program; if not, write to the Free Software |
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||||
# |
||||
# See docs/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
|
||||
module API |
||||
module V3 |
||||
module TimeEntries |
||||
module AvailableWorkPackagesHelper |
||||
def available_work_packages_collection(allowed_scope) |
||||
service = WorkPackageCollectionFromQueryParamsService |
||||
.new(current_user, scope: allowed_scope) |
||||
.call(params) |
||||
|
||||
if service.success? |
||||
service.result |
||||
else |
||||
api_errors = service.errors.full_messages.map do |message| |
||||
::API::Errors::InvalidQuery.new(message) |
||||
end |
||||
|
||||
raise ::API::Errors::MultipleErrors.create_if_many api_errors |
||||
end |
||||
end |
||||
end |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,54 @@ |
||||
#-- copyright |
||||
# OpenProject is a project management system. |
||||
# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License version 3. |
||||
# |
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: |
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang |
||||
# Copyright (C) 2010-2013 the ChiliProject Team |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License |
||||
# as published by the Free Software Foundation; either version 2 |
||||
# of the License, or (at your option) any later version. |
||||
# |
||||
# This program is distributed in the hope that it will be useful, |
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
# GNU General Public License for more details. |
||||
# |
||||
# You should have received a copy of the GNU General Public License |
||||
# along with this program; if not, write to the Free Software |
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||||
# |
||||
# See docs/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
|
||||
module API |
||||
module V3 |
||||
module TimeEntries |
||||
class AvailableWorkPackagesOnCreateAPI < ::API::OpenProjectAPI |
||||
after_validation do |
||||
authorize_any %i[log_time], |
||||
global: true |
||||
end |
||||
|
||||
helpers AvailableWorkPackagesHelper |
||||
|
||||
helpers do |
||||
def allowed_scope |
||||
WorkPackage.where(project_id: Project.allowed_to(User.current, :log_time)) |
||||
end |
||||
end |
||||
|
||||
resources :available_work_packages do |
||||
get do |
||||
available_work_packages_collection(allowed_scope) |
||||
end |
||||
end |
||||
end |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,57 @@ |
||||
#-- copyright |
||||
# OpenProject is a project management system. |
||||
# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License version 3. |
||||
# |
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: |
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang |
||||
# Copyright (C) 2010-2013 the ChiliProject Team |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License |
||||
# as published by the Free Software Foundation; either version 2 |
||||
# of the License, or (at your option) any later version. |
||||
# |
||||
# This program is distributed in the hope that it will be useful, |
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
# GNU General Public License for more details. |
||||
# |
||||
# You should have received a copy of the GNU General Public License |
||||
# along with this program; if not, write to the Free Software |
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||||
# |
||||
# See docs/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
|
||||
module API |
||||
module V3 |
||||
module TimeEntries |
||||
class AvailableWorkPackagesOnEditAPI < ::API::OpenProjectAPI |
||||
after_validation do |
||||
authorize_any %i[edit_time_entries edit_own_time_entries], |
||||
projects: @time_entry.project |
||||
end |
||||
|
||||
helpers AvailableWorkPackagesHelper |
||||
|
||||
helpers do |
||||
def allowed_scope |
||||
edit_scope = WorkPackage.where(project_id: Project.allowed_to(User.current, :edit_time_entries)) |
||||
edit_own_scope = WorkPackage.where(project_id: Project.allowed_to(User.current, :edit_own_time_entries)) |
||||
|
||||
edit_scope.or(edit_own_scope) |
||||
end |
||||
end |
||||
|
||||
resources :available_work_packages do |
||||
get do |
||||
available_work_packages_collection(allowed_scope) |
||||
end |
||||
end |
||||
end |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,74 @@ |
||||
#-- encoding: UTF-8 |
||||
|
||||
#-- copyright |
||||
# OpenProject is a project management system. |
||||
# Copyright (C) 2012-2018 the OpenProject Foundation (OPF) |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License version 3. |
||||
# |
||||
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: |
||||
# Copyright (C) 2006-2017 Jean-Philippe Lang |
||||
# Copyright (C) 2010-2013 the ChiliProject Team |
||||
# |
||||
# This program is free software; you can redistribute it and/or |
||||
# modify it under the terms of the GNU General Public License |
||||
# as published by the Free Software Foundation; either version 2 |
||||
# of the License, or (at your option) any later version. |
||||
# |
||||
# This program is distributed in the hope that it will be useful, |
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
# GNU General Public License for more details. |
||||
# |
||||
# You should have received a copy of the GNU General Public License |
||||
# along with this program; if not, write to the Free Software |
||||
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
||||
# |
||||
# See docs/COPYRIGHT.rdoc for more details. |
||||
#++ |
||||
|
||||
require 'spec_helper' |
||||
|
||||
describe TimeEntry::Scopes::OfUserAndDay, type: :model do |
||||
let(:user) { FactoryBot.create(:user) } |
||||
let(:spent_on) { Date.today } |
||||
let!(:time_entry) do |
||||
FactoryBot.create(:time_entry, |
||||
user: user, |
||||
spent_on: spent_on) |
||||
end |
||||
let!(:other_time_entry) do |
||||
FactoryBot.create(:time_entry, |
||||
user: user, |
||||
spent_on: spent_on) |
||||
end |
||||
let!(:other_user_time_entry) do |
||||
FactoryBot.create(:time_entry, |
||||
user: FactoryBot.create(:user), |
||||
spent_on: spent_on) |
||||
end |
||||
let!(:other_date_time_entry) do |
||||
FactoryBot.create(:time_entry, |
||||
user: user, |
||||
spent_on: spent_on - 3.days) |
||||
end |
||||
|
||||
describe '.fetch' do |
||||
subject { described_class.fetch(user, spent_on) } |
||||
|
||||
it 'are all the time entries of the user on the date' do |
||||
is_expected |
||||
.to match_array([time_entry, other_time_entry]) |
||||
end |
||||
|
||||
context 'if excluding a time entry' do |
||||
subject { described_class.fetch(user, spent_on, excluding: other_time_entry) } |
||||
|
||||
it 'does not include the time entry' do |
||||
is_expected |
||||
.to match_array([time_entry]) |
||||
end |
||||
end |
||||
end |
||||
end |
Loading…
Reference in new issue