Compare commits
56 Commits
dev
...
feature/42
Author | SHA1 | Date |
---|---|---|
Benjamin Bädorf | 6ce6cfaa06 | 2 years ago |
Benjamin Bädorf | 7e89961485 | 2 years ago |
Benjamin Bädorf | 8c1ec9600b | 2 years ago |
Benjamin Bädorf | 72a8976ab5 | 2 years ago |
Benjamin Bädorf | c218f1c26d | 2 years ago |
Benjamin Bädorf | ba52f73307 | 2 years ago |
Benjamin Bädorf | df49549857 | 2 years ago |
Benjamin Bädorf | 581d1b7ea9 | 2 years ago |
Dombi Attila | 2c885576cc | 2 years ago |
Benjamin Bädorf | 532f8b9779 | 2 years ago |
Benjamin Bädorf | 9ec00019e1 | 2 years ago |
Benjamin Bädorf | d2ab657f2c | 2 years ago |
Benjamin Bädorf | bade5147f3 | 2 years ago |
Dombi Attila | df74324938 | 2 years ago |
Benjamin Bädorf | 1ad534b8e9 | 2 years ago |
Benjamin Bädorf | 8d9cf86115 | 2 years ago |
Benjamin Bädorf | a341f0b798 | 2 years ago |
Benjamin Bädorf | 09af5cf34e | 2 years ago |
Benjamin Bädorf | 3291a882ed | 2 years ago |
Yule | d67d18c8f5 | 2 years ago |
Benjamin Bädorf | 1a32034950 | 2 years ago |
Benjamin Bädorf | eacbc7528d | 2 years ago |
Benjamin Bädorf | dc6417e14a | 2 years ago |
Benjamin Bädorf | 7401f7c749 | 2 years ago |
Benjamin Bädorf | f33e1b8dbd | 2 years ago |
Benjamin Bädorf | c3d0522b97 | 2 years ago |
Benjamin Bädorf | 3d9080085b | 2 years ago |
Benjamin Bädorf | 510d389618 | 2 years ago |
Benjamin Bädorf | 7eb7fcaf61 | 2 years ago |
Benjamin Bädorf | d045a8383c | 2 years ago |
Benjamin Bädorf | 290d79aae4 | 2 years ago |
Benjamin Bädorf | e434ef5b0a | 2 years ago |
Benjamin Bädorf | 3c853958d2 | 2 years ago |
Benjamin Bädorf | 9f251db19e | 2 years ago |
Benjamin Bädorf | 0d4e3a1e2c | 2 years ago |
Benjamin Bädorf | 47c6ea330c | 2 years ago |
Benjamin Bädorf | 47eac9bfd6 | 2 years ago |
Benjamin Bädorf | a066f83350 | 2 years ago |
Benjamin Bädorf | 7c346b2e82 | 2 years ago |
Benjamin Bädorf | f0d86c1cc2 | 2 years ago |
Benjamin Bädorf | 6e41289573 | 2 years ago |
Benjamin Bädorf | d2ca6969d2 | 2 years ago |
Benjamin Bädorf | 3cc9c05748 | 2 years ago |
Benjamin Bädorf | 050aa94c4d | 2 years ago |
Benjamin Bädorf | 9a923edc36 | 2 years ago |
Benjamin Bädorf | 3fc36bda26 | 2 years ago |
Benjamin Bädorf | eb165e37b7 | 2 years ago |
Benjamin Bädorf | 53aac04610 | 2 years ago |
Benjamin Bädorf | 8d5ec86b1c | 2 years ago |
Benjamin Bädorf | 9b63226795 | 2 years ago |
Benjamin Bädorf | c8bfaf98c1 | 2 years ago |
Benjamin Bädorf | 0cadc6cdad | 2 years ago |
Benjamin Bädorf | 764ffe28b5 | 2 years ago |
Benjamin Bädorf | 2ae17f10fb | 2 years ago |
Benjamin Bädorf | 6ef33e6151 | 2 years ago |
Benjamin Bädorf | 0b4bcad061 | 2 years ago |
@ -1,6 +1,18 @@ |
||||
<div class="between-dates"> |
||||
<span><%= t(:label_date_from) %>:</span> |
||||
<%= text_field_tag :from_value, from_value, id: "between-dates-from-value-#{filter.name}", class: 'advanced-filters--text-field -augmented-datepicker -slim', size: '10' %> |
||||
<%= angular_component_tag 'op-single-date-picker', |
||||
inputs: { |
||||
value: from_value, |
||||
id: "between-dates-from-value-#{filter.name}", |
||||
name: "from_value" |
||||
} |
||||
%> |
||||
<span><%= t(:label_date_to) %>:</span> |
||||
<%= text_field_tag :to_value, to_value, id: "between-dates-to-value-#{filter.name}", class: 'advanced-filters--text-field -augmented-datepicker -slim', size: '10' %> |
||||
<%= angular_component_tag 'op-single-date-picker', |
||||
inputs: { |
||||
value: to_value, |
||||
id: "between-dates-to-value-#{filter.name}", |
||||
name: "to_value" |
||||
} |
||||
%> |
||||
</div> |
||||
|
@ -1,7 +1,11 @@ |
||||
<div class="on-date"> |
||||
<div class="form--field-container"> |
||||
<div class="form--text-field-container -slim"> |
||||
<%= text_field_tag :value, value, id: "on-date-value-#{filter.name}", class: 'advanced-filters--text-field -slim -augmented-datepicker', size: '10' %> |
||||
</div> |
||||
<div class="form--field-container -visible-overflow"> |
||||
<%= angular_component_tag 'op-single-date-picker', |
||||
inputs: { |
||||
value: value, |
||||
id: "on-date-value-#{filter.name}", |
||||
name: "value" |
||||
} |
||||
%> |
||||
</div> |
||||
</div> |
||||
|
@ -1,31 +0,0 @@ |
||||
import { DatePicker } from 'core-app/shared/components/op-date-picker/datepicker'; |
||||
|
||||
/** |
||||
* Our application is still a hybrid one, meaning most routes are still |
||||
* handled by Rails. As such, we disable the default link-hijacking that |
||||
* Angular's HTML5-mode with <base href="/"> results in |
||||
* @param evt |
||||
* @param target |
||||
*/ |
||||
export function augmentedDatePicker(evt:JQuery.TriggeredEvent, target:JQuery) { |
||||
if (target.hasClass('-augmented-datepicker')) { |
||||
target |
||||
.attr('autocomplete', 'off'); // Disable autocomplete for those fields
|
||||
|
||||
window.OpenProject.getPluginContext() |
||||
.then((context) => { |
||||
const datePicker = new DatePicker( |
||||
context.injector, |
||||
'.-augmented-datepicker', |
||||
target.val() as string, |
||||
{ |
||||
weekNumbers: true, |
||||
allowInput: true, |
||||
}, |
||||
target[0], |
||||
); |
||||
datePicker.show(); |
||||
}) |
||||
.catch(() => {}); |
||||
} |
||||
} |
@ -1,16 +1,20 @@ |
||||
<div class="inline-label" id="div-values-{{filter.id}}"> |
||||
<op-single-date-picker (changed)="value = isoDateParser($event)" |
||||
[initialDate]="isoDateFormatter(value)" |
||||
[opAutofocus]="shouldFocus" |
||||
required="true" |
||||
[id]="'values-' + filter.id" |
||||
[name]="'v[' + filter.id + ']'" |
||||
classes="advanced-filters--date-field" |
||||
size="10"> |
||||
</op-single-date-picker> |
||||
<span class="advanced-filters--tooltip-trigger -multiline" |
||||
[attr.data-tooltip]="timeZoneText" |
||||
*ngIf="isTimeZoneDifferent"> |
||||
<op-icon icon-classes="icon icon-warning"></op-icon> |
||||
</span> |
||||
</div> |
||||
<op-single-date-picker |
||||
[id]="'values-' + filter.id" |
||||
[name]="'v[' + filter.id + ']'" |
||||
required="true" |
||||
|
||||
(valueChange)="value = isoDateParser($event)" |
||||
[value]="isoDateFormatter(value)" |
||||
|
||||
[opAutofocus]="shouldFocus" |
||||
|
||||
classes="advanced-filters--date-field" |
||||
></op-single-date-picker> |
||||
|
||||
<span |
||||
class="advanced-filters--tooltip-trigger -multiline" |
||||
[attr.data-tooltip]="timeZoneText" |
||||
*ngIf="isTimeZoneDifferent" |
||||
> |
||||
<span class="spot-icon spot-icon_warning"></span> |
||||
</span> |
||||
|
@ -1,27 +1,17 @@ |
||||
<div id="div-values-{{filter.id}}" class="inline-label"> |
||||
<op-single-date-picker (changed)="begin = isoDateParser($event)" |
||||
[initialDate]="isoDateFormatter(begin)" |
||||
[opAutofocus]="shouldFocus" |
||||
required="true" |
||||
[id]="'values-' + filter.id + '-begin'" |
||||
[name]="'v[' + filter.id + ']-begin'" |
||||
classes="advanced-filters--date-field" |
||||
size="10"> |
||||
</op-single-date-picker> |
||||
<op-multi-date-picker |
||||
[id]="'values-' + filter.id" |
||||
[name]="'v[' + filter.id + ']'" |
||||
|
||||
<span class="advanced-filters--affix" [textContent]="text.spacer"> |
||||
</span> |
||||
[(ngModel)]="value" |
||||
[opAutofocus]="shouldFocus" |
||||
|
||||
<op-single-date-picker (changed)="end = isoDateParser($event)" |
||||
[initialDate]="isoDateFormatter(end)" |
||||
[id]="'values-' + filter.id + '-end'" |
||||
[name]="'v[' + filter.id + ']-end'" |
||||
classes="advanced-filters--date-field" |
||||
size="10"> |
||||
</op-single-date-picker> |
||||
<span class="advanced-filters--tooltip-trigger -multiline" |
||||
*ngIf="isTimeZoneDifferent" |
||||
[attr.data-tooltip]="timeZoneText"> |
||||
<op-icon icon-classes="icon icon-warning"></op-icon> |
||||
</span> |
||||
</div> |
||||
classes="advanced-filters--date-field" |
||||
></op-multi-date-picker> |
||||
|
||||
<span |
||||
class="advanced-filters--tooltip-trigger -multiline" |
||||
*ngIf="isTimeZoneDifferent" |
||||
[attr.data-tooltip]="timeZoneText" |
||||
> |
||||
<span class="spot-icon spot-icon_warning"></span> |
||||
</span> |
||||
|
@ -1,11 +1,9 @@ |
||||
<div id="div-values-{{filter.id}}"> |
||||
<op-single-date-picker (changed)="value = parser($event)" |
||||
[initialDate]="formatter(value)" |
||||
[opAutofocus]="shouldFocus" |
||||
required="true" |
||||
[id]="'values-' + filter.id" |
||||
[name]="'v[' + filter.id + ']'" |
||||
classes="advanced-filters--date-field" |
||||
size="10"> |
||||
</op-single-date-picker> |
||||
</div> |
||||
<op-single-date-picker |
||||
classes="advanced-filters--date-field" |
||||
(changed)="value = parser($event)" |
||||
[value]="formatter(value)" |
||||
[opAutofocus]="shouldFocus" |
||||
[required]="true" |
||||
[id]="'values-' + filter.id" |
||||
[name]="'v[' + filter.id + ']'" |
||||
></op-single-date-picker> |
||||
|
@ -1,24 +1,9 @@ |
||||
<div id="div-values-{{filter.id}}" |
||||
class="inline-label"> |
||||
<op-single-date-picker (changed)="begin = parser($event)" |
||||
[initialDate]="formatter(begin)" |
||||
[opAutofocus]="shouldFocus" |
||||
required="true" |
||||
[id]="'values-' + filter.id + '-begin'" |
||||
[name]="'v[' + filter.id + ']-begin'" |
||||
classes="advanced-filters--date-field" |
||||
size="10"> |
||||
</op-single-date-picker> |
||||
<op-multi-date-picker |
||||
[id]="'values-' + filter.id" |
||||
[name]="'v[' + filter.id + ']'" |
||||
|
||||
<span class="advanced-filters--affix" [textContent]="text.spacer"> |
||||
</span> |
||||
[(ngModel)]="value" |
||||
[opAutofocus]="shouldFocus" |
||||
|
||||
<op-single-date-picker (changed)="end = parser($event)" |
||||
[initialDate]="formatter(end)" |
||||
required="true" |
||||
[id]="'values-' + filter.id + '-end'" |
||||
[name]="'v[' + filter.id + ']-end'" |
||||
classes="advanced-filters--date-field" |
||||
size="10"> |
||||
</op-single-date-picker> |
||||
</div> |
||||
classes="advanced-filters--date-field" |
||||
></op-multi-date-picker> |
||||
|
@ -1,22 +1,21 @@ |
||||
<div |
||||
id="activity-{{ activityNo }}" |
||||
[attr.data-qa-activity-number]="activityNo" |
||||
[ngSwitch]="activityType" |
||||
> |
||||
<div [ngSwitch]="activityType"> |
||||
<revision-activity |
||||
*ngSwitchCase="'Revision'" |
||||
[workPackage]="workPackage" |
||||
[activity]="activity" |
||||
[activityNo]="activityNo" |
||||
[hasUnreadNotification]="hasUnreadNotification" |
||||
></revision-activity> |
||||
<user-activity |
||||
*ngSwitchDefault |
||||
[workPackage]="workPackage" |
||||
[activity]="activity" |
||||
[activityNo]="activityNo" |
||||
[isInitial]="isInitial" |
||||
[hasUnreadNotification]="hasUnreadNotification" |
||||
></user-activity> |
||||
</div> |
||||
<revision-activity |
||||
*ngSwitchCase="'Revision'" |
||||
[workPackage]="workPackage" |
||||
[activity]="activity" |
||||
[activityNo]="activityNo" |
||||
[hasUnreadNotification]="hasUnreadNotification" |
||||
></revision-activity> |
||||
<user-activity |
||||
*ngSwitchDefault |
||||
[workPackage]="workPackage" |
||||
[activity]="activity" |
||||
[activityNo]="activityNo" |
||||
[isInitial]="isInitial" |
||||
[hasUnreadNotification]="hasUnreadNotification" |
||||
></user-activity> |
||||
</div> |
||||
|
@ -0,0 +1,199 @@ |
||||
<div |
||||
*ngIf="workPackage" |
||||
class="work-package--single-view" |
||||
[ngClass]="{'work-package--single-view_with-columns': showTwoColumnLayout()}" |
||||
data-selector="wp-single-view" |
||||
> |
||||
<div |
||||
class="wp-new--subject-wrapper" |
||||
*ngIf="isNewResource" |
||||
> |
||||
<op-editable-attribute-field |
||||
[resource]="workPackage" |
||||
[wrapperClasses]="'-no-label'" |
||||
[fieldName]="'subject'" |
||||
></op-editable-attribute-field> |
||||
</div> |
||||
|
||||
<div class="wp-info-wrapper"> |
||||
<wp-status-button |
||||
*ngIf="!isNewResource" |
||||
[workPackage]="workPackage" |
||||
></wp-status-button> |
||||
<attribute-help-text |
||||
[attribute]="'status'" |
||||
[attributeScope]="'WorkPackage'" |
||||
*ngIf="!isNewResource" |
||||
></attribute-help-text> |
||||
|
||||
<div |
||||
class="work-packages--info-row" |
||||
*ngIf="!isNewResource" |
||||
> |
||||
<span [textContent]="idLabel"></span>: |
||||
<span [textContent]="text.infoRow.createdBy"></span> |
||||
<!-- The space has to be in an extra span |
||||
because otherwise the browser would add a second space after it --> |
||||
<span> </span> |
||||
<op-user-link |
||||
class="user-link" |
||||
[user]="workPackage.author" |
||||
></op-user-link> |
||||
<span>. </span> |
||||
<span [textContent]="text.infoRow.lastUpdatedOn"></span> |
||||
<span> </span> |
||||
<op-date-time [dateTimeValue]="workPackage.updatedAt"></op-date-time> |
||||
<span>.</span> |
||||
</div> |
||||
|
||||
<wp-custom-actions [workPackage]="workPackage" class="custom-actions"></wp-custom-actions> |
||||
</div> |
||||
|
||||
<div |
||||
class="attributes-group -project-context __overflowing_element_container __overflowing_project_context" |
||||
*ngIf="projectContext && projectContext.field" |
||||
data-overflowing-identifier=".__overflowing_project_context" |
||||
> |
||||
<div> |
||||
<p class="wp-project-context--warning" [textContent]="text.project.required"></p> |
||||
<div |
||||
class="attributes-key-value" |
||||
[ngClass]="{'-span-all-columns': descriptor.spanAll }" |
||||
*ngFor="let descriptor of projectContext.field; trackBy:trackByName" |
||||
> |
||||
<div class="attributes-key-value--key"> |
||||
<wp-replacement-label [fieldName]="descriptor.name"> |
||||
{{ descriptor.label }} |
||||
<span |
||||
class="required" |
||||
*ngIf="descriptor.field.required && descriptor.field.writable" |
||||
>*</span> |
||||
</wp-replacement-label> |
||||
<attribute-help-text |
||||
[attribute]="descriptor.name" |
||||
[attributeScope]="'WorkPackage'" |
||||
></attribute-help-text> |
||||
</div> |
||||
<div class="attributes-key-value--value-container"> |
||||
<op-editable-attribute-field |
||||
[resource]="workPackage" |
||||
[fieldName]="descriptor.name" |
||||
></op-editable-attribute-field> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<div |
||||
class="attributes-group -project-context hide-when-print" |
||||
*ngIf="!isNewResource && projectContext && !projectContext.matches" |
||||
> |
||||
<div> |
||||
<p> |
||||
<span [innerHTML]="projectContextText"></span> |
||||
<br/> |
||||
<a |
||||
[attr.href]="projectContext.href" |
||||
class="project-context--switch-link" |
||||
[textContent]="text.project.switchTo" |
||||
></a> |
||||
</p> |
||||
</div> |
||||
</div> |
||||
|
||||
<ng-container *ngFor="let component of prependedAttributeGroupComponents()"> |
||||
<ndc-dynamic |
||||
[ndcDynamicComponent]="component" |
||||
[ndcDynamicInputs]="{ workPackage: workPackage }" |
||||
></ndc-dynamic> |
||||
</ng-container> |
||||
|
||||
<div class="attributes-group description-group"> |
||||
<div class="single-attribute work-packages--details--description"> |
||||
<op-editable-attribute-field |
||||
[fieldName]="'description'" |
||||
[resource]="workPackage" |
||||
[isDropTarget]="true" |
||||
[wrapperClasses]="'-no-label'" |
||||
></op-editable-attribute-field> |
||||
</div> |
||||
</div> |
||||
|
||||
<div |
||||
*ngFor="let group of groupedFields; trackBy:trackByName" |
||||
[hidden]="shouldHideGroup(group)" |
||||
[attr.data-group-name]="group.name" |
||||
[ngClass]="'__overflowing_' + group.id" |
||||
[attr.data-overflowing-identifier]="'.__overflowing_' + group.id" |
||||
class="attributes-group __overflowing_element_container" |
||||
> |
||||
<ng-container wp-isolated-query-space *ngIf="group.isolated"> |
||||
<ndc-dynamic |
||||
[ndcDynamicComponent]="attributeGroupComponent(group)" |
||||
[ndcDynamicInputs]="{ |
||||
workPackage: workPackage, |
||||
group: group, |
||||
query: group.query |
||||
}" |
||||
></ndc-dynamic> |
||||
</ng-container> |
||||
|
||||
<ng-container *ngIf="!group.isolated"> |
||||
<div class="attributes-group--header"> |
||||
<div class="attributes-group--header-container"> |
||||
<h3 |
||||
class="attributes-group--header-text" |
||||
[textContent]="group.name" |
||||
></h3> |
||||
</div> |
||||
</div> |
||||
|
||||
<ndc-dynamic |
||||
[ndcDynamicComponent]="attributeGroupComponent(group)" |
||||
[ndcDynamicInjector]="injector" |
||||
[ndcDynamicInputs]="{ workPackage: workPackage, group: group }" |
||||
></ndc-dynamic> |
||||
</ng-container> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="work-packages--attachments attributes-group" *ngIf="isNewResource"> |
||||
<div class="work-packages--attachments-container"> |
||||
<div class="attributes-group--header"> |
||||
<div class="attributes-group--header-container"> |
||||
<h3 class="attributes-group--header-text" [textContent]="text.attachments.label"></h3> |
||||
</div> |
||||
</div> |
||||
|
||||
<ndc-dynamic |
||||
[ndcDynamicComponent]="attachmentListComponent()" |
||||
[ndcDynamicInputs]="{ resource: workPackage }" |
||||
></ndc-dynamic> |
||||
|
||||
<ndc-dynamic |
||||
[ndcDynamicComponent]="attachmentUploadComponent()" |
||||
[ndcDynamicInputs]="{ resource: workPackage }" |
||||
*ngIf="workPackage.canAddAttachments" |
||||
></ndc-dynamic> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="work-packages--files attributes-group" *ngIf="!isNewResource"> |
||||
<div class="work-packages--files-container"> |
||||
<div class="attributes-group--header"> |
||||
<div class="attributes-group--header-container"> |
||||
<h3 class="attributes-group--header-text" [textContent]="text.files.label"></h3> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="attributes-group--icon-indented-text"> |
||||
<op-icon icon-classes="icon-info1"></op-icon> |
||||
<span [textContent]="text.files.migration_help"></span> |
||||
<a |
||||
[textContent]="text.files.label" |
||||
[uiSref]="uiSelfRef" |
||||
[uiParams]="{ workPackageId: workPackage.id, tabIdentifier: 'files' }" |
||||
></a> |
||||
</div> |
||||
</div> |
||||
</div> |
@ -1,165 +0,0 @@ |
||||
<div *ngIf="workPackage" |
||||
class="work-package--single-view" |
||||
[ngClass]="{'work-package--single-view_with-columns': showTwoColumnLayout()}" |
||||
data-selector="wp-single-view"> |
||||
<div class="wp-new--subject-wrapper" |
||||
*ngIf="isNewResource"> |
||||
<op-editable-attribute-field [resource]="workPackage" |
||||
[wrapperClasses]="'-no-label'" |
||||
[fieldName]="'subject'"></op-editable-attribute-field> |
||||
</div> |
||||
|
||||
<div class="wp-info-wrapper"> |
||||
<wp-status-button *ngIf="!isNewResource" |
||||
[workPackage]="workPackage"> |
||||
</wp-status-button> |
||||
<attribute-help-text [attribute]="'status'" |
||||
[attributeScope]="'WorkPackage'" |
||||
*ngIf="!isNewResource"></attribute-help-text> |
||||
|
||||
<div class="work-packages--info-row" |
||||
*ngIf="!isNewResource"> |
||||
<span [textContent]="idLabel"></span>: |
||||
<span [textContent]="text.infoRow.createdBy"></span> |
||||
<!-- The space has to be in an extra span |
||||
because otherwise the browser would add a second space after it --> |
||||
<span> </span> |
||||
<op-user-link class="user-link" |
||||
[user]="workPackage.author"></op-user-link> |
||||
<span>. </span> |
||||
<span [textContent]="text.infoRow.lastUpdatedOn"></span> |
||||
<span> </span> |
||||
<op-date-time [dateTimeValue]="workPackage.updatedAt"></op-date-time> |
||||
<span>.</span> |
||||
</div> |
||||
|
||||
<wp-custom-actions [workPackage]="workPackage" class="custom-actions"></wp-custom-actions> |
||||
</div> |
||||
|
||||
<div class="attributes-group -project-context __overflowing_element_container __overflowing_project_context" |
||||
*ngIf="projectContext && projectContext.field" |
||||
data-overflowing-identifier=".__overflowing_project_context"> |
||||
<div> |
||||
<p class="wp-project-context--warning" [textContent]="text.project.required"></p> |
||||
<div class="attributes-key-value" |
||||
[ngClass]="{'-span-all-columns': descriptor.spanAll }" |
||||
*ngFor="let descriptor of projectContext.field; trackBy:trackByName"> |
||||
<div class="attributes-key-value--key"> |
||||
<wp-replacement-label [fieldName]="descriptor.name"> |
||||
{{ descriptor.label }} |
||||
<span class="required" |
||||
*ngIf="descriptor.field.required && descriptor.field.writable">*</span> |
||||
</wp-replacement-label> |
||||
<attribute-help-text [attribute]="descriptor.name" |
||||
[attributeScope]="'WorkPackage'"></attribute-help-text> |
||||
</div> |
||||
<div class="attributes-key-value--value-container"> |
||||
<op-editable-attribute-field [resource]="workPackage" |
||||
[fieldName]="descriptor.name"></op-editable-attribute-field> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<div |
||||
class="attributes-group -project-context hide-when-print" |
||||
*ngIf="!isNewResource && projectContext && !projectContext.matches" |
||||
> |
||||
<div> |
||||
<p> |
||||
<span [innerHTML]="projectContextText"></span> |
||||
<br/> |
||||
<a [attr.href]="projectContext.href" |
||||
class="project-context--switch-link" |
||||
[textContent]="text.project.switchTo"> |
||||
</a> |
||||
</p> |
||||
</div> |
||||
</div> |
||||
|
||||
<ng-container *ngFor="let component of prependedAttributeGroupComponents()"> |
||||
<ndc-dynamic [ndcDynamicComponent]="component" |
||||
[ndcDynamicInputs]="{ workPackage: workPackage }"> |
||||
</ndc-dynamic> |
||||
</ng-container> |
||||
|
||||
<div class="attributes-group description-group"> |
||||
<div class="single-attribute work-packages--details--description"> |
||||
<op-editable-attribute-field [fieldName]="'description'" |
||||
[resource]="workPackage" |
||||
[isDropTarget]="true" |
||||
[wrapperClasses]="'-no-label'"> |
||||
</op-editable-attribute-field> |
||||
</div> |
||||
</div> |
||||
|
||||
<div |
||||
*ngFor="let group of groupedFields; trackBy:trackByName" |
||||
[hidden]="shouldHideGroup(group)" |
||||
[attr.data-group-name]="group.name" |
||||
[ngClass]="'__overflowing_' + group.id" |
||||
[attr.data-overflowing-identifier]="'.__overflowing_' + group.id" |
||||
class="attributes-group __overflowing_element_container" |
||||
> |
||||
<ng-container wp-isolated-query-space *ngIf="group.isolated"> |
||||
<ndc-dynamic [ndcDynamicComponent]="attributeGroupComponent(group)" |
||||
[ndcDynamicInputs]="{ workPackage: workPackage, |
||||
group: group, |
||||
query: group.query }"> |
||||
</ndc-dynamic> |
||||
</ng-container> |
||||
|
||||
<ng-container *ngIf="!group.isolated"> |
||||
<div class="attributes-group--header"> |
||||
<div class="attributes-group--header-container"> |
||||
<h3 class="attributes-group--header-text" |
||||
[textContent]="group.name"></h3> |
||||
</div> |
||||
</div> |
||||
|
||||
<ndc-dynamic [ndcDynamicComponent]="attributeGroupComponent(group)" |
||||
[ndcDynamicInjector]="injector" |
||||
[ndcDynamicInputs]="{ workPackage: workPackage, group: group }"> |
||||
</ndc-dynamic> |
||||
</ng-container> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="work-packages--attachments attributes-group" *ngIf="isNewResource"> |
||||
<div class="work-packages--attachments-container"> |
||||
<div class="attributes-group--header"> |
||||
<div class="attributes-group--header-container"> |
||||
<h3 class="attributes-group--header-text" [textContent]="text.attachments.label"></h3> |
||||
</div> |
||||
</div> |
||||
|
||||
<ndc-dynamic [ndcDynamicComponent]="attachmentListComponent()" |
||||
[ndcDynamicInputs]="{ resource: workPackage }"> |
||||
</ndc-dynamic> |
||||
|
||||
<ndc-dynamic [ndcDynamicComponent]="attachmentUploadComponent()" |
||||
[ndcDynamicInputs]="{ resource: workPackage }" |
||||
*ngIf="workPackage.canAddAttachments"> |
||||
</ndc-dynamic> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="work-packages--files attributes-group" *ngIf="!isNewResource"> |
||||
<div class="work-packages--files-container"> |
||||
<div class="attributes-group--header"> |
||||
<div class="attributes-group--header-container"> |
||||
<h3 class="attributes-group--header-text" [textContent]="text.files.label"></h3> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="attributes-group--icon-indented-text"> |
||||
<op-icon icon-classes="icon-info1"></op-icon> |
||||
<span [textContent]="text.files.migration_help"></span> |
||||
<a |
||||
[textContent]="text.files.label" |
||||
[uiSref]="uiSelfRef" |
||||
[uiParams]="{ workPackageId: workPackage.id, tabIdentifier: 'files' }" |
||||
></a> |
||||
</div> |
||||
</div> |
||||
</div> |
@ -0,0 +1 @@ |
||||
export const rangeSeparator = '-'; |
@ -0,0 +1,113 @@ |
||||
<spot-drop-modal |
||||
[opened]="isOpened" |
||||
(closed)="close()" |
||||
> |
||||
<button |
||||
slot="trigger" |
||||
type="button" |
||||
class="button" |
||||
(click)="open()" |
||||
>{{ datesString }}</button> |
||||
|
||||
<form |
||||
slot="body" |
||||
class="spot-container op-datepicker-modal op-datepicker-modal_wide" |
||||
data-qa-selector="op-datepicker-modal" |
||||
tabindex="0" |
||||
cdkFocusInitial |
||||
cdkTrapFocus |
||||
[cdkTrapFocusAutoCapture]="true" |
||||
(submit)="save($event)" |
||||
> |
||||
<spot-selector-field |
||||
[reverseLabel]="true" |
||||
[label]="text.ignoreNonWorkingDays.title" |
||||
> |
||||
<spot-switch |
||||
slot="input" |
||||
name="ignoreNonWorkingDays" |
||||
[(ngModel)]="ignoreNonWorkingDays" |
||||
(ngModelChange)="changeNonWorkingDays()" |
||||
data-qa-selector="op-datepicker-modal--include-non-working-days" |
||||
></spot-switch> |
||||
</spot-selector-field> |
||||
|
||||
<div class="op-datepicker-modal--dates-container"> |
||||
<spot-form-field |
||||
[label]="text.startDate" |
||||
> |
||||
<spot-text-field |
||||
slot="input" |
||||
name="startDate" |
||||
class="op-datepicker-modal--date-field" |
||||
[attr.data-qa-highlighted]="showFieldAsActive('start') || undefined" |
||||
[ngClass]="{'op-datepicker-modal--date-field_current' : showFieldAsActive('start')}" |
||||
[ngModel]="dates.start" |
||||
(ngModelChange)="startDateChanged$.next($event)" |
||||
[showClearButton]="currentlyActivatedDateField === 'start'" |
||||
(focusin)="setCurrentActivatedField('start')" |
||||
></spot-text-field> |
||||
<button |
||||
slot="action" |
||||
type="button" |
||||
class="spot-link" |
||||
(click)="setToday('start')" |
||||
[textContent]="text.today"> |
||||
</button> |
||||
</spot-form-field> |
||||
|
||||
<spot-form-field |
||||
[label]="text.endDate" |
||||
> |
||||
<spot-text-field |
||||
slot="input" |
||||
name="endDate" |
||||
class="op-datepicker-modal--date-field" |
||||
[attr.data-qa-highlighted]="showFieldAsActive('end') || undefined" |
||||
[ngClass]="{'op-datepicker-modal--date-field_current' : showFieldAsActive('end')}" |
||||
[ngModel]="dates.end" |
||||
(ngModelChange)="endDateChanged$.next($event)" |
||||
[showClearButton]="currentlyActivatedDateField === 'end'" |
||||
(focusin)="setCurrentActivatedField('end')" |
||||
></spot-text-field> |
||||
<button |
||||
slot="action" |
||||
type="button" |
||||
class="spot-link" |
||||
(click)="setToday('end')" |
||||
[textContent]="text.today"> |
||||
</button> |
||||
</spot-form-field> |
||||
</div> |
||||
|
||||
<input |
||||
id="flatpickr-input" |
||||
#flatpickrTarget |
||||
hidden> |
||||
|
||||
<div class="spot-action-bar"> |
||||
<div class="spot-action-bar--right"> |
||||
<button |
||||
type="button" |
||||
(click)="close()" |
||||
class="button spot-action-bar--action" |
||||
data-qa-selector="op-datepicker-modal--action" |
||||
[textContent]="text.cancel" |
||||
></button> |
||||
<button |
||||
type="submit" |
||||
class="button -highlight spot-action-bar--action" |
||||
data-qa-selector="op-datepicker-modal--action" |
||||
[textContent]="text.save" |
||||
></button> |
||||
</div> |
||||
</div> |
||||
</form> |
||||
</spot-drop-modal> |
||||
|
||||
<input |
||||
[id]="id" |
||||
[name]="name" |
||||
[value]="value" |
||||
hidden |
||||
> |
@ -0,0 +1,447 @@ |
||||
// -- copyright
|
||||
// OpenProject is an open source project management software.
|
||||
// Copyright (C) 2012-2022 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 COPYRIGHT and LICENSE files for more details.
|
||||
//++
|
||||
|
||||
import { |
||||
ChangeDetectionStrategy, |
||||
ChangeDetectorRef, |
||||
Component, |
||||
ElementRef, |
||||
EventEmitter, |
||||
forwardRef, |
||||
HostBinding, |
||||
Injector, |
||||
Input, |
||||
OnInit, |
||||
Output, |
||||
ViewChild, |
||||
ViewEncapsulation, |
||||
} from '@angular/core'; |
||||
import { I18nService } from 'core-app/core/i18n/i18n.service'; |
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; |
||||
import { |
||||
areDatesEqual, |
||||
mappedDate, |
||||
onDayCreate, |
||||
parseDate, |
||||
setDates, |
||||
validDate, |
||||
} from 'core-app/shared/components/datepicker/helpers/date-modal.helpers'; |
||||
import { TimezoneService } from 'core-app/core/datetime/timezone.service'; |
||||
import { DatePicker } from '../datepicker'; |
||||
import flatpickr from 'flatpickr'; |
||||
import { DayElement } from 'flatpickr/dist/types/instance'; |
||||
import { ActiveDateChange, DateFields, DateKeys } from '../wp-multi-date-form/wp-multi-date-form.component'; |
||||
import { fromEvent, merge, Observable, Subject } from 'rxjs'; |
||||
import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; |
||||
import { debounceTime, filter, map } from 'rxjs/operators'; |
||||
import { DeviceService } from 'core-app/core/browser/device.service'; |
||||
import { DateOption } from 'flatpickr/dist/types/options'; |
||||
import { WeekdayService } from 'core-app/core/days/weekday.service'; |
||||
import { FocusHelperService } from 'core-app/shared/directives/focus/focus-helper'; |
||||
|
||||
@Component({ |
||||
selector: 'op-multi-date-picker', |
||||
templateUrl: './multi-date-picker.component.html', |
||||
styleUrls: ['../styles/datepicker.modal.sass', '../styles/datepicker_mobile.modal.sass'], |
||||
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
encapsulation: ViewEncapsulation.None, |
||||
providers: [ |
||||
{ |
||||
provide: NG_VALUE_ACCESSOR, |
||||
useExisting: forwardRef(() => OpMultiDatePickerComponent), |
||||
multi: true, |
||||
}, |
||||
], |
||||
}) |
||||
export class OpMultiDatePickerComponent extends UntilDestroyedMixin implements OnInit, ControlValueAccessor { |
||||
@ViewChild('modalContainer') modalContainer:ElementRef<HTMLElement>; |
||||
|
||||
@ViewChild('flatpickrTarget') flatpickrTarget:ElementRef; |
||||
|
||||
@Input() id = `flatpickr-input-${+(new Date())}`; |
||||
|
||||
@Input() name = ''; |
||||
|
||||
@Input() fieldName:string = ''; |
||||
|
||||
@Input() value:string[] = []; |
||||
|
||||
@Output() valueChange = new EventEmitter(); |
||||
|
||||
text = { |
||||
save: this.I18n.t('js.button_save'), |
||||
cancel: this.I18n.t('js.button_cancel'), |
||||
startDate: this.I18n.t('js.work_packages.properties.startDate'), |
||||
endDate: this.I18n.t('js.work_packages.properties.dueDate'), |
||||
placeholder: this.I18n.t('js.placeholders.default'), |
||||
today: this.I18n.t('js.label_today'), |
||||
days: (count:number):string => this.I18n.t('js.units.day', { count }), |
||||
ignoreNonWorkingDays: { |
||||
title: this.I18n.t('js.work_packages.datepicker_modal.ignore_non_working_days.title'), |
||||
}, |
||||
}; |
||||
|
||||
get datesString():string { |
||||
if (this.value?.[0] && this.value?.[1]) { |
||||
return `${this.value[0]} - ${this.value[1]}`; |
||||
} |
||||
|
||||
return this.text.placeholder; |
||||
} |
||||
|
||||
ignoreNonWorkingDays = false; |
||||
|
||||
isOpened = false; |
||||
|
||||
currentlyActivatedDateField:DateFields; |
||||
|
||||
htmlId = ''; |
||||
|
||||
dates:{ [key in DateKeys]:string|null } = { |
||||
start: null, |
||||
end: null, |
||||
}; |
||||
|
||||
// Manual changes from the inputs to start and end dates
|
||||
startDateChanged$ = new Subject<string>(); |
||||
|
||||
startDateDebounced$:Observable<ActiveDateChange> = this.debouncedInput(this.startDateChanged$, 'start'); |
||||
|
||||
endDateChanged$ = new Subject<string>(); |
||||
|
||||
endDateDebounced$:Observable<ActiveDateChange> = this.debouncedInput(this.endDateChanged$, 'end'); |
||||
|
||||
// Manual changes to the datepicker, with information which field was active
|
||||
datepickerChanged$ = new Subject<ActiveDateChange>(); |
||||
|
||||
ignoreNonWorkingDaysWritable = true; |
||||
|
||||
private datePickerInstance:DatePicker; |
||||
|
||||
constructor( |
||||
readonly injector:Injector, |
||||
readonly cdRef:ChangeDetectorRef, |
||||
readonly I18n:I18nService, |
||||
readonly timezoneService:TimezoneService, |
||||
readonly deviceService:DeviceService, |
||||
readonly weekdayService:WeekdayService, |
||||
readonly focusHelper:FocusHelperService, |
||||
) { |
||||
super(); |
||||
|
||||
merge( |
||||
this.startDateDebounced$, |
||||
this.endDateDebounced$, |
||||
this.datepickerChanged$, |
||||
) |
||||
.pipe( |
||||
this.untilDestroyed(), |
||||
filter(() => !!this.datePickerInstance), |
||||
) |
||||
.subscribe(([field, update]) => { |
||||
// When clearing the one date, clear the others as well
|
||||
if (update !== null) { |
||||
this.handleSingleDateUpdate(field, update); |
||||
} |
||||
|
||||
this.cdRef.detectChanges(); |
||||
}); |
||||
} |
||||
|
||||
ngOnInit(): void { |
||||
this.htmlId = `wp-datepicker-${this.fieldName as string}`; |
||||
|
||||
this.setCurrentActivatedField(this.initialActivatedField); |
||||
} |
||||
|
||||
open():void { |
||||
this.isOpened = true; |
||||
this.initializeDatepicker(); |
||||
} |
||||
|
||||
close():void { |
||||
this.isOpened = false; |
||||
this.datePickerInstance?.destroy(); |
||||
} |
||||
|
||||
changeNonWorkingDays():void { |
||||
this.initializeDatepicker(); |
||||
this.cdRef.detectChanges(); |
||||
} |
||||
|
||||
save($event:Event):void { |
||||
$event.preventDefault(); |
||||
const value = [ |
||||
this.dates.start || '', |
||||
this.dates.end || '', |
||||
]; |
||||
this.valueChange.emit(value); |
||||
this.onChange(value); |
||||
this.close(); |
||||
} |
||||
|
||||
updateDate(key:DateKeys, val:string|null):void { |
||||
if ((val === null || validDate(val)) && this.datePickerInstance) { |
||||
this.dates[key] = mappedDate(val); |
||||
const dateValue = parseDate(val || '') || undefined; |
||||
this.enforceManualChangesToDatepicker(dateValue); |
||||
this.cdRef.detectChanges(); |
||||
} |
||||
} |
||||
|
||||
setCurrentActivatedField(val:DateFields):void { |
||||
this.currentlyActivatedDateField = val; |
||||
} |
||||
|
||||
toggleCurrentActivatedField():void { |
||||
this.currentlyActivatedDateField = this.currentlyActivatedDateField === 'start' ? 'end' : 'start'; |
||||
} |
||||
|
||||
isStateOfCurrentActivatedField(val:DateFields):boolean { |
||||
return this.currentlyActivatedDateField === val; |
||||
} |
||||
|
||||
setToday(key:DateKeys):void { |
||||
this.datepickerChanged$.next([key, new Date()]); |
||||
|
||||
const nextActive = key === 'start' ? 'end' : 'start'; |
||||
this.setCurrentActivatedField(nextActive); |
||||
} |
||||
|
||||
showFieldAsActive(field:DateFields):boolean { |
||||
return this.isStateOfCurrentActivatedField(field); |
||||
} |
||||
|
||||
private initializeDatepicker(minimalDate?:Date|null) { |
||||
this.datePickerInstance?.destroy(); |
||||
this.datePickerInstance = new DatePicker( |
||||
this.injector, |
||||
this.id, |
||||
[this.dates.start || '', this.dates.end || ''], |
||||
{ |
||||
mode: 'range', |
||||
showMonths: this.deviceService.isMobile ? 1 : 2, |
||||
inline: true, |
||||
onReady: (_date, _datestr, instance) => { |
||||
instance.calendarContainer.classList.add('op-datepicker-modal--flatpickr-instance'); |
||||
|
||||
this.ensureHoveredSelection(instance.calendarContainer); |
||||
}, |
||||
onChange: (dates:Date[], _datestr, instance) => { |
||||
this.onTouched(); |
||||
|
||||
if (dates.length === 2) { |
||||
this.setDates(dates[0], dates[1]); |
||||
this.toggleCurrentActivatedField(); |
||||
this.cdRef.detectChanges(); |
||||
return; |
||||
} |
||||
|
||||
// Update with the same flow as entering a value
|
||||
const { latestSelectedDateObj } = instance as { latestSelectedDateObj:Date }; |
||||
const activeField = this.currentlyActivatedDateField; |
||||
this.handleSingleDateUpdate(activeField, latestSelectedDateObj); |
||||
this.cdRef.detectChanges(); |
||||
}, |
||||
onDayCreate: (dObj:Date[], dStr:string, fp:flatpickr.Instance, dayElem:DayElement) => { |
||||
onDayCreate( |
||||
dayElem, |
||||
this.ignoreNonWorkingDays, |
||||
this.weekdayService.isNonWorkingDay(dayElem.dateObj), |
||||
this.isDayDisabled(dayElem, minimalDate), |
||||
); |
||||
}, |
||||
}, |
||||
this.flatpickrTarget.nativeElement, |
||||
); |
||||
} |
||||
|
||||
private enforceManualChangesToDatepicker(enforceDate?:Date) { |
||||
let startDate = parseDate(this.dates.start || ''); |
||||
let endDate = parseDate(this.dates.end || ''); |
||||
|
||||
if (startDate && endDate) { |
||||
// If the start date is manually changed to be after the end date,
|
||||
// we adjust the end date to be at least the same as the newly entered start date.
|
||||
// Same applies if the end date is set manually before the current start date
|
||||
if (startDate > endDate && this.isStateOfCurrentActivatedField('start')) { |
||||
endDate = startDate; |
||||
this.dates.end = this.timezoneService.formattedISODate(endDate); |
||||
} else if (endDate < startDate && this.isStateOfCurrentActivatedField('end')) { |
||||
startDate = endDate; |
||||
this.dates.start = this.timezoneService.formattedISODate(startDate); |
||||
} |
||||
} |
||||
|
||||
const dates = [startDate, endDate]; |
||||
setDates(dates, this.datePickerInstance, enforceDate); |
||||
} |
||||
|
||||
private setDates(newStart:Date, newEnd:Date) { |
||||
this.dates.start = this.timezoneService.formattedISODate(newStart); |
||||
this.dates.end = this.timezoneService.formattedISODate(newEnd); |
||||
} |
||||
|
||||
private handleSingleDateUpdate(activeField:DateFields, selectedDate:Date) { |
||||
if (activeField === 'duration') { |
||||
return; |
||||
} |
||||
|
||||
this.replaceDatesWithNewSelection(activeField, selectedDate); |
||||
|
||||
// Set the selected date on the datepicker
|
||||
this.enforceManualChangesToDatepicker(selectedDate); |
||||
} |
||||
|
||||
private replaceDatesWithNewSelection(activeField:DateFields, selectedDate:Date) { |
||||
/** |
||||
Overwrite flatpickr default behavior by not starting a new date range everytime but preserving either start or end date. |
||||
There are three cases to cover. |
||||
1. Everything before the current start date will become the new start date (independent of the active field) |
||||
2. Everything after the current end date will become the new end date if that is the currently active field. |
||||
If the active field is the start date, the selected date becomes the new start date and the end date is cleared. |
||||
3. Everything in between the current start and end date is dependent on the currently activated field. |
||||
* */ |
||||
|
||||
const parsedStartDate = parseDate(this.dates.start || '') as Date; |
||||
const parsedEndDate = parseDate(this.dates.end || '') as Date; |
||||
|
||||
if (selectedDate < parsedStartDate) { |
||||
if (activeField === 'start') { |
||||
// Set start, derive end from
|
||||
this.applyNewDates([selectedDate]); |
||||
} else { |
||||
// Reset and end date
|
||||
this.applyNewDates(['', selectedDate]); |
||||
} |
||||
} else if (selectedDate > parsedEndDate) { |
||||
if (activeField === 'end') { |
||||
this.applyNewDates([parsedStartDate, selectedDate]); |
||||
} else { |
||||
// Reset and end date
|
||||
this.applyNewDates([selectedDate]); |
||||
} |
||||
} else if (areDatesEqual(selectedDate, parsedStartDate) || areDatesEqual(selectedDate, parsedEndDate)) { |
||||
this.applyNewDates([selectedDate, selectedDate]); |
||||
} else { |
||||
const newDates = activeField === 'start' ? [selectedDate, parsedEndDate] : [parsedStartDate, selectedDate]; |
||||
this.applyNewDates(newDates); |
||||
} |
||||
} |
||||
|
||||
private applyNewDates([start, end]:DateOption[]) { |
||||
this.dates.start = start ? this.timezoneService.formattedISODate(start) : null; |
||||
this.dates.end = end ? this.timezoneService.formattedISODate(end) : null; |
||||
|
||||
// Apply the dates to the datepicker
|
||||
setDates([start, end], this.datePickerInstance); |
||||
} |
||||
|
||||
private get initialActivatedField():DateFields { |
||||
switch (this.fieldName) { |
||||
case 'startDate': |
||||
return 'start'; |
||||
case 'dueDate': |
||||
return 'end'; |
||||
case 'duration': |
||||
return 'duration'; |
||||
default: |
||||
return (this.dates.start && !this.dates.end) ? 'end' : 'start'; |
||||
} |
||||
} |
||||
|
||||
private isDayDisabled(dayElement:DayElement, minimalDate?:Date|null):boolean { |
||||
return !!minimalDate && dayElement.dateObj <= minimalDate; |
||||
} |
||||
|
||||
private debouncedInput(input$:Subject<string>, key:DateKeys):Observable<ActiveDateChange> { |
||||
return input$ |
||||
.pipe( |
||||
this.untilDestroyed(), |
||||
// Skip values that are already set as the current model
|
||||
filter((value) => value !== this.dates[key]), |
||||
// Avoid that the manual changes are moved to the datepicker too early.
|
||||
// The debounce is chosen quite large on purpose to catch the following case:
|
||||
// 1. Start date is for example 2022-07-15. The user wants to set the end date to the 19th.
|
||||
// 2. So he/she starts entering the finish date 2022-07-1 .
|
||||
// 3. This is already a valid date. Since it is before the start date,the start date would be changed automatically to the first without the debounce.
|
||||
// 4. The debounce gives the user enough time to type the last number "9" before the changes are converted to the datepicker and the start date would be affected.
|
||||
debounceTime(500), |
||||
filter((date) => validDate(date)), |
||||
map((date) => { |
||||
if (date === '') { |
||||
return null; |
||||
} |
||||
|
||||
return parseDate(date) as Date; |
||||
}), |
||||
map((date) => [key, date]), |
||||
); |
||||
} |
||||
|
||||
/** |
||||
* When hovering selections in the range datepicker, the range usually |
||||
* stays active no matter where the cursor is. |
||||
* |
||||
* We want to hide any hovered selection preview when we leave the datepicker. |
||||
* @param calendarContainer |
||||
* @private |
||||
*/ |
||||
private ensureHoveredSelection(calendarContainer:HTMLDivElement) { |
||||
fromEvent(calendarContainer, 'mouseenter') |
||||
.pipe( |
||||
this.untilDestroyed(), |
||||
) |
||||
.subscribe(() => calendarContainer.classList.remove('flatpickr-container-suppress-hover')); |
||||
|
||||
fromEvent(calendarContainer, 'mouseleave') |
||||
.pipe( |
||||
this.untilDestroyed(), |
||||
filter(() => !(!!this.dates.start && !!this.dates.end)), |
||||
) |
||||
.subscribe(() => calendarContainer.classList.add('flatpickr-container-suppress-hover')); |
||||
} |
||||
|
||||
writeValue(value:string[]|null):void { |
||||
this.value = value || []; |
||||
this.dates.start = this.value[0]; |
||||
this.dates.end = this.value[1]; |
||||
} |
||||
|
||||
onChange = (_:string[]):void => {}; |
||||
|
||||
onTouched = ():void => {}; |
||||
|
||||
registerOnChange(fn:(_:string[]) => void):void { |
||||
this.onChange = fn; |
||||
} |
||||
|
||||
registerOnTouched(fn:() => void):void { |
||||
this.onTouched = fn; |
||||
} |
||||
} |
@ -1,74 +0,0 @@ |
||||
<form |
||||
class="spot-modal op-datepicker-modal loading-indicator--location" |
||||
data-qa-selector="op-datepicker-modal" |
||||
[attr.id]="htmlId" |
||||
#modalContainer |
||||
data-indicator-name="modal" |
||||
(submit)="save($event)" |
||||
tabindex="0" |
||||
cdkFocusInitial |
||||
cdkTrapFocus |
||||
[cdkTrapFocusAutoCapture]="true" |
||||
> |
||||
<op-datepicker-banner [scheduleManually]="scheduleManually"></op-datepicker-banner> |
||||
|
||||
<div class="spot-modal--body spot-container"> |
||||
<div class="op-datepicker-modal--toggle-actions-container"> |
||||
<op-datepicker-scheduling-toggle |
||||
name="scheduleManually" |
||||
[(ngModel)]="scheduleManually" |
||||
(ngModelChange)="changeSchedulingMode()" |
||||
></op-datepicker-scheduling-toggle> |
||||
<op-datepicker-working-days-toggle |
||||
name="ignoreNonWorkingDays" |
||||
[(ngModel)]="ignoreNonWorkingDays" |
||||
(ngModelChange)="changeNonWorkingDays()" |
||||
></op-datepicker-working-days-toggle> |
||||
</div> |
||||
|
||||
<div class="op-datepicker-modal--dates-container"> |
||||
<spot-form-field |
||||
[label]="text.date" |
||||
> |
||||
<spot-text-field |
||||
slot="input" |
||||
name="date" |
||||
class="op-datepicker-modal--date-field" |
||||
[ngClass]="{ 'op-datepicker-modal--date-field_current': this.dateModalScheduling.isSchedulable }" |
||||
[(ngModel)]="date" |
||||
(ngModelChange)="dateChangedManually$.next()" |
||||
[showClearButton]="true" |
||||
></spot-text-field> |
||||
<button |
||||
slot="action" |
||||
type="button" |
||||
class="spot-link" |
||||
[ngClass]="{ 'op-datepicker-modal--hidden-link': !dateModalScheduling.isSchedulable }" |
||||
(click)="setToday()" |
||||
[textContent]="text.today"> |
||||
</button> |
||||
</spot-form-field> |
||||
</div> |
||||
|
||||
<input id="flatpickr-input" |
||||
hidden> |
||||
</div> |
||||
|
||||
<div class="spot-action-bar"> |
||||
<div class="spot-action-bar--right"> |
||||
<button |
||||
type="button" |
||||
(click)="cancel()" |
||||
class="op-datepicker-modal--action spot-modal--cancel-button button button_no-margin spot-action-bar--action" |
||||
data-qa-selector="op-datepicker-modal--action" |
||||
[textContent]="text.cancel" |
||||
></button> |
||||
<button |
||||
type="submit" |
||||
class="op-datepicker-modal--action button button_no-margin -highlight spot-action-bar--action" |
||||
data-qa-selector="op-datepicker-modal--action" |
||||
[textContent]="text.save" |
||||
></button> |
||||
</div> |
||||
</div> |
||||
</form> |
@ -0,0 +1,93 @@ |
||||
<spot-drop-modal |
||||
[opened]="opened" |
||||
(closed)="opened = false" |
||||
> |
||||
<ng-content |
||||
slot="trigger" |
||||
select="[slot=trigger]" |
||||
></ng-content> |
||||
<ng-template [ngIf]="useDefaultTrigger" slot="trigger"> |
||||
<input |
||||
type="text" |
||||
class="spot-input" |
||||
(click)="onInputClick($event)" |
||||
[value]="value" |
||||
(focus)="opened = true" |
||||
/> |
||||
</ng-template> |
||||
|
||||
<ng-container slot="body"> |
||||
<form |
||||
class="spot-container op-datepicker-modal" |
||||
data-qa-selector="op-datepicker-modal" |
||||
tabindex="0" |
||||
cdkFocusInitial |
||||
cdkTrapFocus |
||||
[cdkTrapFocusAutoCapture]="true" |
||||
(submit)="save($event)" |
||||
> |
||||
<spot-selector-field |
||||
*ngIf="showIgnoreNonWorkingDays" |
||||
[reverseLabel]="true" |
||||
[label]="text.ignoreNonWorkingDays.title" |
||||
> |
||||
<spot-switch |
||||
slot="input" |
||||
name="ignoreNonWorkingDays" |
||||
[(ngModel)]="ignoreNonWorkingDays" |
||||
(ngModelChange)="changeNonWorkingDays()" |
||||
data-qa-selector="op-datepicker-modal--include-non-working-days" |
||||
></spot-switch> |
||||
</spot-selector-field> |
||||
|
||||
<ng-content select="[slot=extra-fields]"></ng-content> |
||||
|
||||
<spot-form-field |
||||
[label]="text.date" |
||||
> |
||||
<spot-text-field |
||||
slot="input" |
||||
name="date" |
||||
class="op-datepicker-modal--date-field" |
||||
[ngModel]="workingValue" |
||||
(ngModelChange)="writeWorkingValue($event)" |
||||
[showClearButton]="true" |
||||
></spot-text-field> |
||||
<button |
||||
slot="action" |
||||
type="button" |
||||
class="spot-link" |
||||
(click)="setToday()" |
||||
[textContent]="text.today"> |
||||
</button> |
||||
</spot-form-field> |
||||
|
||||
<div #flatpickrTarget></div> |
||||
|
||||
<div class="spot-action-bar"> |
||||
<div class="spot-action-bar--right"> |
||||
<button |
||||
type="button" |
||||
(click)="opened = false" |
||||
class="button spot-action-bar--action" |
||||
data-qa-selector="op-datepicker-modal--action" |
||||
[textContent]="text.cancel" |
||||
></button> |
||||
<button |
||||
type="submit" |
||||
class="button -highlight spot-action-bar--action" |
||||
data-qa-selector="op-datepicker-modal--action" |
||||
[textContent]="text.save" |
||||
></button> |
||||
</div> |
||||
</div> |
||||
</form> |
||||
</ng-container> |
||||
</spot-drop-modal> |
||||
|
||||
<input |
||||
[id]="id" |
||||
[name]="name" |
||||
[value]="value" |
||||
hidden |
||||
> |
@ -0,0 +1,262 @@ |
||||
// -- copyright
|
||||
// OpenProject is an open source project management software.
|
||||
// Copyright (C) 2012-2022 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 COPYRIGHT and LICENSE files for more details.
|
||||
//++
|
||||
|
||||
import { |
||||
AfterContentInit, |
||||
ChangeDetectionStrategy, |
||||
ChangeDetectorRef, |
||||
Component, |
||||
ElementRef, |
||||
EventEmitter, |
||||
forwardRef, |
||||
Injector, |
||||
Input, |
||||
OnInit, |
||||
Output, |
||||
ViewChild, |
||||
ViewEncapsulation, |
||||
} from '@angular/core'; |
||||
import { I18nService } from 'core-app/core/i18n/i18n.service'; |
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; |
||||
import { |
||||
onDayCreate, |
||||
parseDate, |
||||
setDates, |
||||
} from 'core-app/shared/components/datepicker/helpers/date-modal.helpers'; |
||||
import { TimezoneService } from 'core-app/core/datetime/timezone.service'; |
||||
import { DatePicker } from '../datepicker'; |
||||
import flatpickr from 'flatpickr'; |
||||
import { DayElement } from 'flatpickr/dist/types/instance'; |
||||
import { populateInputsFromDataset } from '../../dataset-inputs'; |
||||
import { debounce } from 'lodash'; |
||||
|
||||
export const opSingleDatePickerSelector = 'op-single-date-picker'; |
||||
|
||||
@Component({ |
||||
selector: opSingleDatePickerSelector, |
||||
templateUrl: './single-date-picker.component.html', |
||||
styleUrls: ['../styles/datepicker.modal.sass', '../styles/datepicker_mobile.modal.sass'], |
||||
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
encapsulation: ViewEncapsulation.None, |
||||
providers: [ |
||||
{ |
||||
provide: NG_VALUE_ACCESSOR, |
||||
useExisting: forwardRef(() => OpSingleDatePickerComponent), |
||||
multi: true, |
||||
}, |
||||
], |
||||
}) |
||||
export class OpSingleDatePickerComponent implements ControlValueAccessor, OnInit, AfterContentInit { |
||||
@Output('closed') closed = new EventEmitter(); |
||||
|
||||
@Output('valueChange') valueChange = new EventEmitter(); |
||||
|
||||
private _value = ''; |
||||
|
||||
@Input() set value(newValue:string) { |
||||
this._value = newValue; |
||||
this.writeWorkingValue(newValue); |
||||
} |
||||
|
||||
get value() { |
||||
return this._value; |
||||
} |
||||
|
||||
@Input() id = `flatpickr-input-${+(new Date())}`; |
||||
|
||||
@Input() name = ''; |
||||
|
||||
@Input() required = false; |
||||
|
||||
@Input() minimalDate:Date|null = null; |
||||
|
||||
private _opened = false; |
||||
|
||||
@Input() set opened(opened:boolean) { |
||||
if (this._opened === !!opened) { |
||||
return; |
||||
} |
||||
|
||||
this._opened = !!opened; |
||||
|
||||
if (this._opened) { |
||||
this.initializeDatepickerDebounced(); |
||||
} else { |
||||
this.closed.emit(); |
||||
} |
||||
} |
||||
|
||||
get opened() { |
||||
return this._opened; |
||||
} |
||||
|
||||
@Input() showIgnoreNonWorkingDays = true; |
||||
|
||||
@Input() ignoreNonWorkingDays = false; |
||||
|
||||
@ViewChild('flatpickrTarget') flatpickrTarget:ElementRef; |
||||
|
||||
public workingValue = ''; |
||||
|
||||
public workingDate:Date = new Date(); |
||||
|
||||
public datePickerInstance:DatePicker; |
||||
|
||||
public useDefaultTrigger = false; |
||||
|
||||
text = { |
||||
save: this.I18n.t('js.button_save'), |
||||
cancel: this.I18n.t('js.button_cancel'), |
||||
date: this.I18n.t('js.work_packages.properties.date'), |
||||
placeholder: this.I18n.t('js.placeholders.default'), |
||||
today: this.I18n.t('js.label_today'), |
||||
ignoreNonWorkingDays: { |
||||
title: this.I18n.t('js.work_packages.datepicker_modal.ignore_non_working_days.title'), |
||||
}, |
||||
}; |
||||
|
||||
constructor( |
||||
readonly I18n:I18nService, |
||||
readonly timezoneService:TimezoneService, |
||||
readonly injector:Injector, |
||||
readonly cdRef:ChangeDetectorRef, |
||||
readonly elementRef:ElementRef, |
||||
) { |
||||
populateInputsFromDataset(this); |
||||
} |
||||
|
||||
ngOnInit(): void { |
||||
if (!this.value) { |
||||
const today = parseDate(new Date()) as Date; |
||||
this.writeValue(this.timezoneService.formattedISODate(today)); |
||||
} |
||||
} |
||||
|
||||
ngAfterContentInit() { |
||||
const trigger = this.elementRef.nativeElement.querySelector("[slot='trigger']"); |
||||
this.useDefaultTrigger = trigger === null; |
||||
} |
||||
|
||||
onInputClick(event:MouseEvent) { |
||||
event.stopPropagation(); |
||||
} |
||||
|
||||
save($event:Event) { |
||||
$event.preventDefault(); |
||||
this.valueChange.emit(this.workingValue); |
||||
this.onChange(this.workingValue); |
||||
this.writeValue(this.workingValue); |
||||
this.opened = false; |
||||
} |
||||
|
||||
setToday():void { |
||||
const today = parseDate(new Date()) as Date; |
||||
this.writeWorkingValue(this.timezoneService.formattedISODate(today)); |
||||
this.enforceManualChangesToDatepicker(today); |
||||
} |
||||
|
||||
changeNonWorkingDays():void { |
||||
this.initializeDatepickerDebounced(); |
||||
this.cdRef.detectChanges(); |
||||
} |
||||
|
||||
private enforceManualChangesToDatepicker(enforceDate?:Date) { |
||||
const date = parseDate(this.workingDate || ''); |
||||
setDates(date, this.datePickerInstance, enforceDate); |
||||
} |
||||
|
||||
private initializeDatepickerDebounced = debounce(this.initializeDatepicker.bind(this), 16); |
||||
|
||||
private initializeDatepicker(numberOfTries = 0) { |
||||
this.datePickerInstance?.destroy(); |
||||
|
||||
// If we're too early somehow, try again in a bit
|
||||
if (!this.flatpickrTarget?.nativeElement) { |
||||
if (numberOfTries >= 3) { |
||||
console.warn('Tried initializing flatpickr 3 times in a row with no success. Bailing out'); |
||||
return; |
||||
} |
||||
this.initializeDatepickerDebounced(numberOfTries + 1); |
||||
} |
||||
|
||||
this.datePickerInstance = new DatePicker( |
||||
this.injector, |
||||
this.id, |
||||
this.workingDate || '', |
||||
{ |
||||
mode: 'single', |
||||
showMonths: 1, |
||||
inline: true, |
||||
onReady: (_date:Date[], _datestr:string, instance:flatpickr.Instance) => { |
||||
instance.calendarContainer.classList.add('op-datepicker-modal--flatpickr-instance'); |
||||
}, |
||||
onChange: (dates:Date[]) => { |
||||
if (dates.length > 0) { |
||||
const dateString = this.timezoneService.formattedISODate(dates[0]); |
||||
this.writeWorkingValue(dateString); |
||||
this.enforceManualChangesToDatepicker(dates[0]); |
||||
this.onTouched(dateString); |
||||
} |
||||
|
||||
this.cdRef.detectChanges(); |
||||
}, |
||||
onDayCreate: (dObj:Date[], dStr:string, fp:flatpickr.Instance, dayElem:DayElement) => { |
||||
onDayCreate( |
||||
dayElem, |
||||
!this.ignoreNonWorkingDays, |
||||
this.datePickerInstance?.weekdaysService.isNonWorkingDay(dayElem.dateObj), |
||||
!!this.minimalDate && dayElem.dateObj <= this.minimalDate, |
||||
); |
||||
}, |
||||
}, |
||||
this.flatpickrTarget.nativeElement, |
||||
); |
||||
} |
||||
|
||||
writeWorkingValue(value:string):void { |
||||
this.workingValue = value; |
||||
this.workingDate = new Date(value); |
||||
} |
||||
|
||||
writeValue(value:string):void { |
||||
this.writeWorkingValue(value); |
||||
this.value = value; |
||||
} |
||||
|
||||
onChange = (_:string):void => {}; |
||||
|
||||
onTouched = (_:string):void => {}; |
||||
|
||||
registerOnChange(fn:(_:string) => void):void { |
||||
this.onChange = fn; |
||||
} |
||||
|
||||
registerOnTouched(fn:(_:string) => void):void { |
||||
this.onTouched = fn; |
||||
} |
||||
} |
@ -0,0 +1,70 @@ |
||||
<form |
||||
class="spot-container" |
||||
data-qa-selector="op-datepicker-modal" |
||||
[attr.id]="htmlId" |
||||
#modalContainer |
||||
data-indicator-name="modal" |
||||
(submit)="doSave($event)" |
||||
tabindex="0" |
||||
cdkFocusInitial |
||||
cdkTrapFocus |
||||
[cdkTrapFocusAutoCapture]="true" |
||||
> |
||||
<op-datepicker-banner></op-datepicker-banner> |
||||
|
||||
<div class="spot-container"> |
||||
<div class="op-datepicker-modal--toggle-actions-container"> |
||||
<op-datepicker-working-days-toggle |
||||
name="ignoreNonWorkingDays" |
||||
[(ngModel)]="ignoreNonWorkingDays" |
||||
(ngModelChange)="changeNonWorkingDays()" |
||||
></op-datepicker-working-days-toggle> |
||||
</div> |
||||
|
||||
<spot-form-field |
||||
[label]="text.date" |
||||
> |
||||
<spot-text-field |
||||
slot="input" |
||||
name="date" |
||||
class="op-datepicker-modal--date-field" |
||||
[ngClass]="{ 'op-datepicker-modal--date-field_current': this.dateModalScheduling.isSchedulable }" |
||||
[(ngModel)]="date" |
||||
(ngModelChange)="dateChangedManually$.next()" |
||||
[showClearButton]="true" |
||||
></spot-text-field> |
||||
<button |
||||
slot="action" |
||||
type="button" |
||||
class="spot-link" |
||||
[ngClass]="{ 'op-datepicker-modal--hidden-link': !dateModalScheduling.isSchedulable }" |
||||
(click)="setToday()" |
||||
[textContent]="text.today"> |
||||
</button> |
||||
</spot-form-field> |
||||
|
||||
<input |
||||
#flatpickrTarget |
||||
id="flatpickr-input" |
||||
hidden |
||||
> |
||||
</div> |
||||
|
||||
<div class="spot-action-bar"> |
||||
<div class="spot-action-bar--right"> |
||||
<button |
||||
type="button" |
||||
(click)="doCancel()" |
||||
class="op-datepicker-modal--action spot-modal--cancel-button button button_no-margin spot-action-bar--action" |
||||
data-qa-selector="op-datepicker-modal--action" |
||||
[textContent]="text.cancel" |
||||
></button> |
||||
<button |
||||
type="submit" |
||||
class="op-datepicker-modal--action button button_no-margin -highlight spot-action-bar--action" |
||||
data-qa-selector="op-datepicker-modal--action" |
||||
[textContent]="text.save" |
||||
></button> |
||||
</div> |
||||
</div> |
||||
</form> |
@ -1,81 +0,0 @@ |
||||
import { |
||||
AfterViewInit, ChangeDetectorRef, Component, forwardRef, NgZone, |
||||
} from '@angular/core'; |
||||
import { OpSingleDatePickerComponent } from 'core-app/shared/components/op-date-picker/op-single-date-picker/op-single-date-picker.component'; |
||||
import * as moment from 'moment'; |
||||
import { NG_VALUE_ACCESSOR } from '@angular/forms'; |
||||
import { TimezoneService } from 'core-app/core/datetime/timezone.service'; |
||||
import { ConfigurationService } from 'core-app/core/config/configuration.service'; |
||||
|
||||
@Component({ |
||||
selector: 'op-date-picker-adapter', |
||||
templateUrl: '../../../../../../op-date-picker/op-single-date-picker/op-single-date-picker.component.html', |
||||
providers: [ |
||||
{ |
||||
provide: NG_VALUE_ACCESSOR, |
||||
useExisting: forwardRef(() => DatePickerAdapterComponent), |
||||
multi: true, |
||||
}, |
||||
], |
||||
}) |
||||
export class DatePickerAdapterComponent extends OpSingleDatePickerComponent implements AfterViewInit { |
||||
onControlChange = (_:any) => { }; |
||||
|
||||
onControlTouch = () => { }; |
||||
|
||||
writeValue(date:string):void { |
||||
this.initialDate = this.formatter(date); |
||||
} |
||||
|
||||
registerOnChange(fn:(_:any) => void):void { |
||||
this.onControlChange = fn; |
||||
} |
||||
|
||||
registerOnTouched(fn:any):void { |
||||
this.onControlTouch = fn; |
||||
} |
||||
|
||||
setDisabledState(disabled:boolean):void { |
||||
this.disabled = disabled; |
||||
} |
||||
|
||||
ngAfterViewInit():void { |
||||
this.ngZone.runOutsideAngular(() => { |
||||
setTimeout(() => { |
||||
this.initializeDatepicker(); |
||||
this.changeDetectorRef.detectChanges(); |
||||
}); |
||||
}); |
||||
} |
||||
|
||||
onInputChange():void { |
||||
if (this.isEmpty()) { |
||||
this.datePickerInstance.clear(); |
||||
} else if (this.inputIsValidDate()) { |
||||
const valueToEmit = this.parser(this.currentValue); |
||||
this.onControlTouch(); |
||||
this.onControlChange(valueToEmit); |
||||
} |
||||
} |
||||
|
||||
closeOnOutsideClick(event:any) { |
||||
super.closeOnOutsideClick(event); |
||||
this.onControlTouch(); |
||||
} |
||||
|
||||
public parser(data:any) { |
||||
if (moment(data, 'YYYY-MM-DD', true).isValid()) { |
||||
return data; |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
public formatter(data:any):string { |
||||
if (moment(data, 'YYYY-MM-DD', true).isValid()) { |
||||
const d = this.timezoneService.parseDate(data); |
||||
|
||||
return this.timezoneService.formattedISODate(d); |
||||
} |
||||
return ''; |
||||
} |
||||
} |
@ -1,91 +0,0 @@ |
||||
import { |
||||
AfterViewInit, |
||||
Component, |
||||
forwardRef, |
||||
Input, |
||||
} from '@angular/core'; |
||||
import * as moment from 'moment'; |
||||
import { |
||||
ControlValueAccessor, |
||||
NG_VALUE_ACCESSOR, |
||||
} from '@angular/forms'; |
||||
import { OpSingleDatePickerComponent } from 'core-app/shared/components/op-date-picker/op-single-date-picker/op-single-date-picker.component'; |
||||
|
||||
/* eslint-disable-next-line change-detection-strategy/on-push */ |
||||
@Component({ |
||||
selector: 'op-date-picker-control', |
||||
templateUrl: '../../../../../../op-date-picker/op-single-date-picker/op-single-date-picker.component.html', |
||||
providers: [ |
||||
{ |
||||
provide: NG_VALUE_ACCESSOR, |
||||
useExisting: forwardRef(() => DatePickerControlComponent), |
||||
multi: true, |
||||
}, |
||||
], |
||||
}) |
||||
export class DatePickerControlComponent extends OpSingleDatePickerComponent implements ControlValueAccessor, AfterViewInit { |
||||
// Avoid Angular warning (It looks like you're using the disabled attribute with a reactive form directive...)
|
||||
/* eslint-disable-next-line @angular-eslint/no-input-rename */ |
||||
@Input('disable') disabled:boolean; |
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
onControlChange:(_?:unknown) => void = () => { }; |
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
onControlTouch:(_?:unknown) => void = () => { }; |
||||
|
||||
writeValue(date:string):void { |
||||
this.initialDate = this.formatter(date); |
||||
} |
||||
|
||||
registerOnChange(fn:(_:unknown) => void):void { |
||||
this.onControlChange = fn; |
||||
} |
||||
|
||||
registerOnTouched(fn:(_:unknown) => void):void { |
||||
this.onControlTouch = fn; |
||||
} |
||||
|
||||
setDisabledState(disabled:boolean):void { |
||||
this.disabled = disabled; |
||||
} |
||||
|
||||
ngAfterViewInit():void { |
||||
this.ngZone.runOutsideAngular(() => { |
||||
setTimeout(() => { |
||||
this.initializeDatepicker(); |
||||
this.changeDetectorRef.detectChanges(); |
||||
}); |
||||
}); |
||||
} |
||||
|
||||
onInputChange():void { |
||||
const valueToEmit = this.inputIsValidDate() |
||||
? this.parser(this.currentValue) |
||||
: ''; |
||||
|
||||
this.onControlChange(valueToEmit); |
||||
this.onControlTouch(); |
||||
} |
||||
|
||||
closeOnOutsideClick(event:MouseEvent):void { |
||||
super.closeOnOutsideClick(event); |
||||
this.onControlTouch(); |
||||
} |
||||
|
||||
public parser(data:string):string|null { |
||||
if (moment(data, 'YYYY-MM-DD', true).isValid()) { |
||||
return data; |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
public formatter(data:string):string { |
||||
if (moment(data, 'YYYY-MM-DD', true).isValid()) { |
||||
const d = this.timezoneService.parseDate(data); |
||||
|
||||
return this.timezoneService.formattedISODate(d); |
||||
} |
||||
return ''; |
||||
} |
||||
} |
@ -1,18 +0,0 @@ |
||||
import { NgModule } from '@angular/core'; |
||||
import { CommonModule } from '@angular/common'; |
||||
import { DatePickerModule } from 'core-app/shared/components/op-date-picker/date-picker.module'; |
||||
import { DatePickerControlComponent } from 'core-app/shared/components/dynamic-forms/components/dynamic-inputs/date-input/components/date-picker-control/date-picker-control.component'; |
||||
|
||||
@NgModule({ |
||||
declarations: [ |
||||
DatePickerControlComponent, |
||||
], |
||||
imports: [ |
||||
CommonModule, |
||||
DatePickerModule, |
||||
], |
||||
exports: [ |
||||
DatePickerControlComponent, |
||||
], |
||||
}) |
||||
export class DatePickerControlModule { } |
@ -1,7 +1,6 @@ |
||||
<op-date-picker-control |
||||
<op-single-date-picker |
||||
[required]="to.required" |
||||
[disable]="to.disabled" |
||||
[disabled]="to.disabled" |
||||
[formControl]="formControl" |
||||
[formlyAttributes]="field" |
||||
> |
||||
</op-date-picker-control> |
||||
></op-single-date-picker> |
||||
|
@ -0,0 +1,32 @@ |
||||
<spot-drop-modal |
||||
[opened]="opened" |
||||
(closed)="cancel()" |
||||
alignment="bottom-center" |
||||
> |
||||
<input |
||||
slot="trigger" |
||||
type="text" |
||||
class="spot-input" |
||||
(click)="onInputClick($event)" |
||||
[value]="dates" |
||||
(focus)="showDatePickerModal()" |
||||
/> |
||||
|
||||
<ng-container slot="body"> |
||||
<op-wp-single-date-form |
||||
*ngIf="opened && !isMultiDate" |
||||
[value]="dates" |
||||
[changeset]="change" |
||||
(save)="save()" |
||||
(cancel)="cancel()" |
||||
></op-wp-single-date-form> |
||||
<op-wp-multi-date-form |
||||
*ngIf="opened && isMultiDate" |
||||
[value]="dates" |
||||
[changeset]="change" |
||||
[fieldName]="name" |
||||
(save)="save()" |
||||
(cancel)="cancel()" |
||||
></op-wp-multi-date-form> |
||||
</ng-container> |
||||
</spot-drop-modal> |
@ -0,0 +1,24 @@ |
||||
<spot-drop-modal |
||||
[opened]="opened" |
||||
(closed)="cancel()" |
||||
alignment="bottom-center" |
||||
> |
||||
<input |
||||
type="number" |
||||
slot="trigger" |
||||
class="inline-edit--field op-input" |
||||
[ngModel]="formattedValue" |
||||
(click)="onInputClick($event)" |
||||
(focus)="opened = true" |
||||
disabled="disabled" |
||||
[id]="handler.htmlId" |
||||
/> |
||||
|
||||
<op-wp-multi-date-form |
||||
[changeset]="change" |
||||
[fieldName]="name" |
||||
(save)="save()" |
||||
(cancel)="cancel()" |
||||
slot="body" |
||||
></op-wp-multi-date-form> |
||||
</spot-drop-modal> |
@ -1,10 +1,12 @@ |
||||
<input type="text" |
||||
class="inline-edit--field op-input" |
||||
[opAutofocus]="shouldFocus" |
||||
[attr.aria-required]="required" |
||||
[attr.required]="required" |
||||
[disabled]="inFlight" |
||||
[(ngModel)]="value" |
||||
(keydown)="handler.handleUserKeydown($event)" |
||||
(focusout)="handler.onFocusOut()" |
||||
[id]="handler.htmlId" /> |
||||
<input |
||||
type="text" |
||||
class="inline-edit--field op-input some-class" |
||||
[opAutofocus]="shouldFocus" |
||||
[attr.aria-required]="required" |
||||
[attr.required]="required" |
||||
[disabled]="inFlight" |
||||
[(ngModel)]="value" |
||||
(keydown)="handler.handleUserKeydown($event)" |
||||
(focusout)="handler.onFocusOut()" |
||||
[id]="handler.htmlId" |
||||
/> |
||||
|
@ -1,18 +1,22 @@ |
||||
<div [ngClass]="[ |
||||
editFieldContainerClass, |
||||
fieldName, |
||||
active && '-active' || '', |
||||
wrapperClasses || '-small' |
||||
]" |
||||
(dragover)="startDragActivation($event)"> |
||||
<div |
||||
[ngClass]="[ |
||||
editFieldContainerClass, |
||||
fieldName, |
||||
active && '-active' || '', |
||||
wrapperClasses || '-small' |
||||
]" |
||||
(dragover)="startDragActivation($event)" |
||||
> |
||||
<div |
||||
#editContainer |
||||
[hidden]="!active" |
||||
></div> |
||||
|
||||
<div #editContainer |
||||
[hidden]="!active"> |
||||
</div> |
||||
|
||||
<div (click)="activateIfEditable($event)" |
||||
(keydown.enter)="activateIfEditable($event)" |
||||
[hidden]="active" |
||||
tabindex="-1" |
||||
#displayContainer></div> |
||||
<div |
||||
(click)="activateIfEditable($event)" |
||||
(keydown.enter)="activateIfEditable($event)" |
||||
[hidden]="active" |
||||
tabindex="-1" |
||||
#displayContainer |
||||
></div> |
||||
</div> |
||||
|
@ -1,127 +0,0 @@ |
||||
// -- copyright
|
||||
// OpenProject is an open source project management software.
|
||||
// Copyright (C) 2012-2023 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 COPYRIGHT and LICENSE files for more details.
|
||||
//++
|
||||
|
||||
import { |
||||
AfterViewInit, |
||||
ChangeDetectorRef, |
||||
Directive, |
||||
ElementRef, |
||||
EventEmitter, |
||||
Injector, |
||||
Input, |
||||
NgZone, |
||||
OnDestroy, |
||||
Output, |
||||
ViewChild, |
||||
} from '@angular/core'; |
||||
import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; |
||||
import { TimezoneService } from 'core-app/core/datetime/timezone.service'; |
||||
import { DatePicker } from 'core-app/shared/components/op-date-picker/datepicker'; |
||||
import { ConfigurationService } from 'core-app/core/config/configuration.service'; |
||||
|
||||
@Directive() |
||||
export abstract class AbstractDatePickerDirective extends UntilDestroyedMixin implements OnDestroy, AfterViewInit { |
||||
@Output() public canceled = new EventEmitter<string>(); |
||||
|
||||
@Input() public appendTo?:HTMLElement; |
||||
|
||||
@Input() public classes = ''; |
||||
|
||||
@Input() public id = ''; |
||||
|
||||
@Input() public name = ''; |
||||
|
||||
@Input() public required = false; |
||||
|
||||
@Input() public size = 20; |
||||
|
||||
@Input() public disabled = false; |
||||
|
||||
@ViewChild('dateInput') dateInput:ElementRef; |
||||
|
||||
protected datePickerInstance:DatePicker; |
||||
|
||||
public constructor( |
||||
readonly injector:Injector, |
||||
protected timezoneService:TimezoneService, |
||||
protected configurationService:ConfigurationService, |
||||
protected ngZone:NgZone, |
||||
protected changeDetectorRef:ChangeDetectorRef, |
||||
) { |
||||
super(); |
||||
|
||||
if (!this.id) { |
||||
this.id = `datepicker-input-${Math.floor(Math.random() * 1000).toString(3)}`; |
||||
} |
||||
} |
||||
|
||||
ngAfterViewInit():void { |
||||
this.initializeDatepicker(); |
||||
} |
||||
|
||||
ngOnDestroy():void { |
||||
if (this.datePickerInstance) { |
||||
this.datePickerInstance.destroy(); |
||||
} |
||||
} |
||||
|
||||
openOnClick():void { |
||||
if (!this.disabled) { |
||||
this.datePickerInstance.show(); |
||||
} |
||||
} |
||||
|
||||
closeOnOutsideClick(event:MouseEvent):void { |
||||
if (this.isOutsideClick(event)) { |
||||
this.close(); |
||||
} |
||||
} |
||||
|
||||
isOutsideClick(event:MouseEvent):boolean { |
||||
return (!(event.relatedTarget |
||||
&& this.datePickerInstance.datepickerInstance.calendarContainer.contains(event.relatedTarget as HTMLElement))); |
||||
} |
||||
|
||||
close():void { |
||||
this.datePickerInstance.hide(); |
||||
} |
||||
|
||||
protected isEmpty():boolean { |
||||
return this.currentValue.trim() === ''; |
||||
} |
||||
|
||||
protected get currentValue():string { |
||||
return this.inputElement?.value || ''; |
||||
} |
||||
|
||||
protected get inputElement():HTMLInputElement { |
||||
return this.dateInput.nativeElement as HTMLInputElement; |
||||
} |
||||
|
||||
protected abstract initializeDatepicker():void; |
||||
} |
@ -1,19 +0,0 @@ |
||||
import { NgModule } from '@angular/core'; |
||||
import { CommonModule } from '@angular/common'; |
||||
import { OpRangeDatePickerComponent } from 'core-app/shared/components/op-date-picker/op-range-date-picker/op-range-date-picker.component'; |
||||
import { OpSingleDatePickerComponent } from 'core-app/shared/components/op-date-picker/op-single-date-picker/op-single-date-picker.component'; |
||||
|
||||
@NgModule({ |
||||
declarations: [ |
||||
OpSingleDatePickerComponent, |
||||
OpRangeDatePickerComponent, |
||||
], |
||||
imports: [ |
||||
CommonModule, |
||||
], |
||||
exports: [ |
||||
OpSingleDatePickerComponent, |
||||
OpRangeDatePickerComponent, |
||||
], |
||||
}) |
||||
export class DatePickerModule { } |
@ -1,15 +0,0 @@ |
||||
<input |
||||
#dateInput |
||||
[id]="id" |
||||
[name]="name" |
||||
[value]="initialValue" |
||||
[ngClass]="classes + ' op-input'" |
||||
[size]="size" |
||||
[required]="required" |
||||
[disabled]="disabled" |
||||
(click)="openOnClick()" |
||||
(keydown.escape)="close()" |
||||
(keydown)="onKeyDown()" |
||||
(blur)="closeOnOutsideClick($event)" |
||||
type="text" |
||||
> |
@ -1,84 +0,0 @@ |
||||
import { |
||||
ChangeDetectionStrategy, |
||||
Component, |
||||
Input, |
||||
Output, |
||||
} from '@angular/core'; |
||||
import { Instance } from 'flatpickr/dist/types/instance'; |
||||
import { KeyCodes } from 'core-app/shared/helpers/keyCodes.enum'; |
||||
import { DatePicker } from 'core-app/shared/components/op-date-picker/datepicker'; |
||||
import { AbstractDatePickerDirective } from 'core-app/shared/components/op-date-picker/date-picker.directive'; |
||||
import { DebouncedEventEmitter } from 'core-app/shared/helpers/rxjs/debounced-event-emitter'; |
||||
import { componentDestroyed } from '@w11k/ngx-componentdestroyed'; |
||||
|
||||
export const rangeSeparator = '-'; |
||||
|
||||
@Component({ |
||||
selector: 'op-range-date-picker', |
||||
templateUrl: './op-range-date-picker.component.html', |
||||
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
}) |
||||
export class OpRangeDatePickerComponent extends AbstractDatePickerDirective { |
||||
@Output() public changed = new DebouncedEventEmitter<string[]>(componentDestroyed(this)); |
||||
|
||||
@Input() public initialDates:string[] = []; |
||||
|
||||
initialValue = ''; |
||||
|
||||
protected initializeDatepicker():void { |
||||
this.initialDates = this.initialDates || []; |
||||
this.initialValue = this.resolveDateArrayToString(this.initialDates); |
||||
|
||||
const options = { |
||||
allowInput: true, |
||||
appendTo: this.appendTo, |
||||
mode: 'range' as const, |
||||
onChange: (selectedDates:Date[], dateStr:string) => { |
||||
if (this.isEmpty()) { |
||||
return; |
||||
} |
||||
|
||||
this.inputElement.value = dateStr; |
||||
if (selectedDates.length === 2) { |
||||
this.changed.emit(this.resolveDateStringToArray(dateStr)); |
||||
} |
||||
}, |
||||
onKeyDown: (selectedDates:Date[], dateStr:string, instance:Instance, data:KeyboardEvent) => { |
||||
if (data.which === KeyCodes.ESCAPE) { |
||||
this.canceled.emit(); |
||||
} |
||||
}, |
||||
}; |
||||
|
||||
let initialValue; |
||||
if (this.isEmpty() && this.initialDates.length > 0) { |
||||
initialValue = this.initialDates.map((date) => this.timezoneService.parseISODate(date).toDate()); |
||||
} else { |
||||
initialValue = this.resolveDateStringToArray(this.currentValue); |
||||
} |
||||
|
||||
this.datePickerInstance = new DatePicker( |
||||
this.injector, |
||||
`#${this.id}`, |
||||
initialValue, |
||||
options, |
||||
null, |
||||
); |
||||
} |
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onKeyDown():boolean { |
||||
// Disable any manual user input as it most likely return in a wrong format
|
||||
return false; |
||||
} |
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
private resolveDateStringToArray(dates:string):string[] { |
||||
return dates.split(` ${rangeSeparator} `).map((date) => date.trim()); |
||||
} |
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
private resolveDateArrayToString(dates:string[]):string { |
||||
return dates.join(` ${rangeSeparator} `); |
||||
} |
||||
} |
@ -1,16 +0,0 @@ |
||||
<input |
||||
#dateInput |
||||
[id]="id" |
||||
[name]="name" |
||||
[value]="initialDate || ''" |
||||
[ngClass]="classes + ' op-input'" |
||||
[size]="size" |
||||
[required]="required" |
||||
[disabled]="disabled" |
||||
(click)="openOnClick()" |
||||
(keydown.enter)="enterPressed.emit(dateValue)" |
||||
(keydown.escape)="close()" |
||||
(input)="onInputChange()" |
||||
(blur)="onBlurred($event)" |
||||
type="text" |
||||
> |
@ -1,117 +0,0 @@ |
||||
// -- copyright
|
||||
// OpenProject is an open source project management software.
|
||||
// Copyright (C) 2012-2023 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 COPYRIGHT and LICENSE files for more details.
|
||||
//++
|
||||
|
||||
import { |
||||
Component, |
||||
EventEmitter, |
||||
Input, |
||||
Output, |
||||
} from '@angular/core'; |
||||
import { Instance } from 'flatpickr/dist/types/instance'; |
||||
import { KeyCodes } from 'core-app/shared/helpers/keyCodes.enum'; |
||||
import { DatePicker } from 'core-app/shared/components/op-date-picker/datepicker'; |
||||
import { AbstractDatePickerDirective } from 'core-app/shared/components/op-date-picker/date-picker.directive'; |
||||
import { DebouncedEventEmitter } from 'core-app/shared/helpers/rxjs/debounced-event-emitter'; |
||||
import { componentDestroyed } from '@w11k/ngx-componentdestroyed'; |
||||
|
||||
/* eslint-disable-next-line change-detection-strategy/on-push */ |
||||
@Component({ |
||||
selector: 'op-single-date-picker', |
||||
templateUrl: './op-single-date-picker.component.html', |
||||
}) |
||||
export class OpSingleDatePickerComponent extends AbstractDatePickerDirective { |
||||
@Output() public changed = new DebouncedEventEmitter<string>(componentDestroyed(this)); |
||||
|
||||
@Output() public blurred = new EventEmitter<string>(); |
||||
|
||||
@Output() public enterPressed = new EventEmitter<string>(); |
||||
|
||||
@Input() public initialDate = ''; |
||||
|
||||
onInputChange():void { |
||||
if (this.inputIsValidDate()) { |
||||
this.changed.emit(this.currentValue); |
||||
} |
||||
} |
||||
|
||||
onBlurred(event:MouseEvent):void { |
||||
if (this.isOutsideClick(event)) { |
||||
this.close(); |
||||
this.blurred.emit(this.currentValue); |
||||
} |
||||
} |
||||
|
||||
get dateValue():string { |
||||
if (this.inputIsValidDate()) { |
||||
return this.currentValue; |
||||
} |
||||
|
||||
return ''; |
||||
} |
||||
|
||||
protected inputIsValidDate():boolean { |
||||
return (/\d{4}-\d{2}-\d{2}/.exec(this.currentValue)) !== null; |
||||
} |
||||
|
||||
protected initializeDatepicker():void { |
||||
const options = { |
||||
allowInput: true, |
||||
appendTo: this.appendTo, |
||||
onChange: (selectedDates:Date[], dateStr:string) => { |
||||
const val:string = dateStr; |
||||
|
||||
if (this.isEmpty()) { |
||||
return; |
||||
} |
||||
|
||||
this.inputElement.value = val; |
||||
this.changed.emit(val); |
||||
}, |
||||
onKeyDown: (selectedDates:Date[], dateStr:string, instance:Instance, data:KeyboardEvent) => { |
||||
if (data.which === KeyCodes.ESCAPE) { |
||||
this.canceled.emit(); |
||||
} |
||||
}, |
||||
}; |
||||
|
||||
let initialValue; |
||||
if (this.isEmpty() && this.initialDate) { |
||||
initialValue = this.timezoneService.parseISODate(this.initialDate).toDate(); |
||||
} else { |
||||
initialValue = this.currentValue; |
||||
} |
||||
|
||||
this.datePickerInstance = new DatePicker( |
||||
this.injector, |
||||
`#${this.id}`, |
||||
initialValue, |
||||
options, |
||||
null, |
||||
); |
||||
} |
||||
} |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue