[33648] Add value macros for referencing label and values of project and work packages (#8578)

* Add macros for attribute values and labels of HAL resources

* Implement label macro with help text

For that, we need to move help texts out of the work package module.
The fields module appears to be best suited for it.

* Load the current project for project attributes

* Don't render things that only matter in writable situations

Such as the project dropdown indicator or the spent time modal

* Throw if ID was not correctly provided

* Bootstrap the inserted html of a custom text

* Expand errors if user cannot see/load expected macro resource

* Add text formatting spec

* Add feature spec to test both loadable and failing macros

* Reference only subjects in the current project if not referenced by id

* Extend documentation

* Refactor loading switch

* Add macro for work package quickinfo (previously ## and ###)

* Extend spec to double and triple hash macros

* Ensure we allow numeric strings as IDs

Was testing for isNumber, which only checks for actual number types

* Fix showing the placeholder when not empty

* Add error span to the quickinfo macro

* Better styling and title help for macros
This commit is contained in:
Oliver Günther
2020-08-24 14:48:39 +02:00
committed by GitHub
parent 31d9e1989d
commit 8d2da3f6d8
50 changed files with 1318 additions and 72 deletions
+5
View File
@@ -133,6 +133,11 @@ en:
manual: 'Switch to Markdown source'
wysiwyg: 'Switch to WYSIWYG editor'
macro:
error: 'Cannot expand macro: %{message}'
attribute_reference:
macro_help_tooltip: 'This text segment is being dynamically rendered by a macro.'
not_found: 'Requested resource could not be found'
invalid_attribute: "The selected attribute '%{name}' does not exist."
child_pages:
button: 'Links to child pages'
include_parent: 'Include parent'
+72 -3
View File
@@ -117,8 +117,8 @@ As with the textile formatting syntax, you can link to other resources within Op
- **wiki page with separate link name**: `[[Wiki page|The text of the link]]`
- **wiki page in the Sandbox project**: `[[Sandbox:Wiki page]]`
- **work package with ID12**: `#12`
- **work package with ID 12 with subject and dates**: `##12`
- **work package with ID 12 with subject, assignee, description, and dates**: `###12`
- **work package with ID 12 with subject and type**: `##12`
- **work package with ID 12 with subject, type, status, and dates**: `###12`
- **version by ID or name**: `version#3`, `version:"Release 1.0.0"`
- **project by ID/name**: `project#12` , `project:"My project name"`
- **attachment by filename**: `attachment:filename.zip`
@@ -129,10 +129,79 @@ As with the textile formatting syntax, you can link to other resources within Op
- **commit by hash:** `commit:f30e13e4`
- **To a source file in the repository**: `source:"some/file"`
To avoid processing these items, preceed them with a bang `!` character such as `!#12` will prevent linking to a work package with ID 12.
To avoid processing these items, precede them with a bang `!` character such as `!#12` will prevent linking to a work package with ID 12.
### Autocompletion for work packages and users
For work packages and users, typing `#` or `@` will open an autocompleter for visible work packages and users, respectively.
## Embedding of work package and project attributes
You can embed specific attributes of work packages or projects using the following syntax:
- **Linking to the subject of work package with id #1234**: `workPackageValue:1234:subject`
- **Linking to the current project's status**: `projectValue:status`
- **Linking to the subject of work package with subject "Project start"**: `workPackageValue:"Project start":subject`
<div class="alert alert-info" role="alert">
**Note**: Referencing a work package by subject results in only looking for work packages with that given subject in the current project (if any). If you need to cross-reference work packages, use their ID to pinpoint the work package you want to reference.
</div>
You can also embed attribute values and [their help texts](https://docs.openproject.org/system-admin-guide/manage-work-packages/attribute-help-texts/#manage-attribute-help-texts-premium-feature) by using `workPackageLabel` instead: `workPackageLabel:1234:status` would output the translated label for "Status" and (if exists), the corresponding help text for it.
Note that these macros will only be expanded in the frontend. For each individual user, the correct permissions will be checked and the macro will result in an error if the user is not allowed to view the respective resource.
**Available attributes for work packages**
The following list contains all suppported attribute names for the `workPackageValue` and `workPackageLabel` macros.
| **Attribute** | Usage example |
| ------------------- | ------------------------------------------------------------ |
| Assigned user | `workPackageValue:1234:assignee` |
| Author | `workPackageValue:1234:author` |
| Category | `workPackageValue:1234:category` |
| Creation date | `workPackageValue:1234:createdAt` |
| Finish date | `workPackageValue:1234:dueDate` |
| Estimated time | `workPackageValue:1234:estimatedTime` |
| Parent work package | `workPackageValue:1234:parent` |
| Priority | `workPackageValue:1234:priority` |
| Project | `workPackageValue:1234:project` |
| Remaining Time | `workPackageValue:1234:remainingTime` |
| Responsible user | `workPackageValue:1234:responsible` |
| Spent time | `workPackageValue:1234:spentTime` |
| Start date | `workPackageValue:1234:startDate` |
| Status | `workPackageValue:1234:status` |
| Subject / Title | `workPackageValue:1234:subject` |
| Work package type | `workPackageValue:1234:type` |
| Date of last update | `workPackageValue:1234:updatedAt` |
| Version | `workPackageValue:1234:version` |
| *Custom Fields* | `workPackageValue:1234:"Name of the work package custom field"` |
**Available attributes for projects**
The following list contains all suppported attribute names for the `projectValue` and `projectLabel` macros. The examples all show references to the _current_ project the document is rendered in. They can also reference another project with `projectValue:"Identifier of the project":attribute`.
| **Attribute** | Usage example |
| ------------------------- | ------------------------------------------------- |
| Project active? (boolean) | `projectValue:active` |
| Description | `projectValue:description` |
| Identifier of the project | `projectValue:identifier` |
| Name of the project | `projectValue:name` |
| Status | `projectValue:status` |
| Status description | `projectValue:statusExplanation` |
| Parent project | `projectValue:parent` |
| Project public? (boolean) | `projectValue:public` |
| *Custom Fields* | `projectValue:"Name of the project custom field"` |
@@ -1,8 +1,9 @@
import {WorkPackageResource} from 'core-app/modules/hal/resources/work-package-resource';
import {
displayClassName,
DisplayFieldRenderer,
editFieldContainerClass
} from '../../wp-edit-form/display-field-renderer';
} from "core-app/modules/fields/display/display-field-renderer";
import {Injector} from '@angular/core';
import {QueryColumn} from "core-components/wp-query/query-column";
import {SchemaCacheService} from "core-components/schemas/schema-cache.service";
@@ -10,7 +10,7 @@ import {groupByProperty, groupedRowClassName} from './grouped-rows-helpers';
import {GroupObject} from 'core-app/modules/hal/resources/wp-collection-resource';
import {collapsedRowClass} from "core-components/wp-fast-table/builders/modes/grouped/grouped-classes.constants";
import {GroupSumsBuilder} from "core-components/wp-fast-table/builders/modes/grouped/group-sums-builder";
import {DisplayFieldRenderer} from "core-components/wp-edit-form/display-field-renderer";
import {DisplayFieldRenderer} from "core-app/modules/fields/display/display-field-renderer";
export class GroupedRenderPass extends PlainRenderPass {
@@ -1,7 +1,7 @@
import {Injector} from '@angular/core';
import {debugLog} from '../../../../helpers/debug_output';
import {States} from '../../../states.service';
import {displayClassName, editableClassName, readOnlyClassName} from '../../../wp-edit-form/display-field-renderer';
import {displayClassName, editableClassName, readOnlyClassName} from 'core-app/modules/fields/display/display-field-renderer';
import {HalResourceEditingService} from "core-app/modules/fields/edit/services/hal-resource-editing.service";
import {tableRowClassName} from '../../builders/rows/single-row-builder';
@@ -8,7 +8,7 @@ import {tableRowClassName} from '../../builders/rows/single-row-builder';
import {WorkPackageTable} from '../../wp-fast-table';
import {TableEventComponent, TableEventHandler} from '../table-handler-registry';
import {WorkPackageViewSelectionService} from "core-app/modules/work_packages/routing/wp-view-base/view-services/wp-view-selection.service";
import {displayClassName} from "core-components/wp-edit-form/display-field-renderer";
import {displayClassName} from "core-app/modules/fields/display/display-field-renderer";
import {activeFieldClassName} from "core-app/modules/fields/edit/edit-form/edit-form";
import {InjectField} from "core-app/helpers/angular/inject-field.decorator";
@@ -9,7 +9,7 @@ import {WorkPackageTable} from '../../wp-fast-table';
import {TableEventComponent, TableEventHandler} from '../table-handler-registry';
import {LinkHandling} from "core-app/modules/common/link-handling/link-handling";
import {WorkPackageViewSelectionService} from "core-app/modules/work_packages/routing/wp-view-base/view-services/wp-view-selection.service";
import {displayClassName} from "core-components/wp-edit-form/display-field-renderer";
import {displayClassName} from "core-app/modules/fields/display/display-field-renderer";
import {activeFieldClassName} from "core-app/modules/fields/edit/edit-form/edit-form";
import {InjectField} from "core-app/helpers/angular/inject-field.decorator";
@@ -22,7 +22,7 @@ import {
} from './wp-timeline-cell';
import {classNameBarLabel, classNameLeftHandle, classNameRightHandle} from './wp-timeline-cell-mouse-handler';
import {WorkPackageTimelineTableController} from '../container/wp-timeline-container.directive';
import {DisplayFieldRenderer} from '../../../wp-edit-form/display-field-renderer';
import {DisplayFieldRenderer} from 'core-app/modules/fields/display/display-field-renderer';
import {Injector} from '@angular/core';
import {TimezoneService} from 'core-components/datetime/timezone.service';
import {Highlighting} from "core-components/wp-fast-table/builders/highlighting/highlighting.functions";
@@ -116,10 +116,6 @@ import {
GlobalSearchTabsComponent,
globalSearchTabsSelector
} from "core-app/modules/global_search/tabs/global-search-tabs.component";
import {
AttributeHelpTextComponent,
attributeHelpTextSelector
} from "core-app/modules/common/help-texts/attribute-help-text.component";
import {MainMenuToggleComponent, mainMenuToggleSelector} from "core-components/main-menu/main-menu-toggle.component";
import {
MembersAutocompleterComponent,
@@ -138,6 +134,22 @@ import {
BacklogsPageComponent,
backlogsPageComponentSelector
} from "core-app/modules/backlogs/backlogs-page/backlogs-page.component";
import {
attributeValueMacro,
AttributeValueMacroComponent
} from "core-app/modules/fields/macros/attribute-value-macro.component";
import {
attributeLabelMacro,
AttributeLabelMacroComponent
} from "core-app/modules/fields/macros/attribute-label-macro.component";
import {
AttributeHelpTextComponent,
attributeHelpTextSelector
} from "core-app/modules/fields/help-texts/attribute-help-text.component";
import {
quickInfoMacroSelector,
WorkPackageQuickinfoMacroComponent
} from "core-app/modules/fields/macros/work-package-quickinfo-macro.component";
export const globalDynamicComponents:OptionalBootstrapDefinition[] = [
{ selector: appBaseSelector, cls: ApplicationBaseComponent },
@@ -180,6 +192,9 @@ export const globalDynamicComponents:OptionalBootstrapDefinition[] = [
{ selector: wpQuerySelectSelector, cls: WorkPackageQuerySelectDropdownComponent },
{ selector: triggerActionsEntryComponentSelector, cls: TriggerActionsEntryComponent, embeddable: true },
{ selector: backlogsPageComponentSelector, cls: BacklogsPageComponent },
{ selector: attributeValueMacro, cls: AttributeValueMacroComponent, embeddable: true },
{ selector: attributeLabelMacro, cls: AttributeLabelMacroComponent, embeddable: true },
{ selector: quickInfoMacroSelector, cls: WorkPackageQuickinfoMacroComponent, embeddable: true },
];
@@ -0,0 +1,10 @@
export namespace StringHelpers {
/**
* Capitalize
* @param value
*/
export function capitalize(value:string):string {
return value.charAt(0).toUpperCase() + value.slice(1);
}
}
@@ -32,7 +32,7 @@ import {States} from "core-components/states.service";
import {HasId, StateCacheService} from "core-app/modules/apiv3/cache/state-cache.service";
import {concat, from, merge, Observable, of} from "rxjs";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {mapTo, publish, share, switchMap, take, tap} from "rxjs/operators";
import {mapTo, publish, share, shareReplay, switchMap, take, tap} from "rxjs/operators";
import {SchemaCacheService} from "core-components/schemas/schema-cache.service";
export abstract class CachableAPIV3Resource<T extends HasId = HalResource>
@@ -59,7 +59,7 @@ export abstract class CachableAPIV3Resource<T extends HasId = HalResource>
.load()
.pipe(
take(1),
share()
shareReplay(1)
);
this.cache.clearAndLoad(
@@ -70,7 +70,7 @@ export abstract class CachableAPIV3Resource<T extends HasId = HalResource>
// Return concat of the loading observable
// for error handling and the like,
// but then continue with the streamed cache
return merge<T>(
return concat<T>(
observable,
this.cache.state(id).values$()
);
@@ -35,7 +35,7 @@ import {ApiV3FilterBuilder} from "core-components/api/api-v3/api-v3-filter-build
import {CachableAPIV3Resource} from "core-app/modules/apiv3/cache/cachable-apiv3-resource";
import {APIV3WorkPackagesPaths} from "core-app/modules/apiv3/endpoints/work_packages/api-v3-work-packages-paths";
import {StateCacheService} from "core-app/modules/apiv3/cache/state-cache.service";
import {tap} from "rxjs/operators";
import {take, tap} from "rxjs/operators";
import {WorkPackageCache} from "core-app/modules/apiv3/endpoints/work_packages/work-package.cache";
export class ApiV3WorkPackageCachedSubresource extends APIv3GettableResource<WorkPackageCollectionResource> {
@@ -45,7 +45,8 @@ export class ApiV3WorkPackageCachedSubresource extends APIv3GettableResource<Wor
.halResourceService
.get<WorkPackageCollectionResource>(this.path)
.pipe(
tap(collection => this.cache.updateWorkPackageList(collection.elements))
tap(collection => this.cache.updateWorkPackageList(collection.elements)),
take(1)
);
}
@@ -107,8 +107,9 @@ export class APIV3WorkPackagesPaths extends CachableAPIV3Collection<WorkPackageR
* Shortcut to filter work packages by subject or ID
* @param term
* @param idOnly
* @param additionalParams Additional set of params to the API
*/
public filterBySubjectOrId(term:string, idOnly:boolean = false):ApiV3WorkPackageCachedSubresource {
public filterBySubjectOrId(term:string, idOnly:boolean = false, additionalParams:{ [key:string]:string } = {}):ApiV3WorkPackageCachedSubresource {
let filters:ApiV3FilterBuilder = new ApiV3FilterBuilder();
if (idOnly) {
@@ -120,7 +121,8 @@ export class APIV3WorkPackagesPaths extends CachableAPIV3Collection<WorkPackageR
let params = {
sortBy: '[["updatedAt","desc"]]',
offset: '1',
pageSize: '10'
pageSize: '10',
...additionalParams
};
return this.filtered(filters, params);
@@ -261,7 +261,7 @@ export function bootstrapModule(injector:Injector) {
DraggableAutocompleteComponent,
HomescreenNewFeaturesBlockComponent,
BoardVideoTeaserModalComponent
BoardVideoTeaserModalComponent,
]
})
export class OpenprojectCommonModule {
@@ -0,0 +1,69 @@
import {ChangeDetectionStrategy, Component, ElementRef, Injector, Input, OnInit, ViewChild} from '@angular/core';
import {IFieldSchema} from "core-app/modules/fields/field.base";
import {DisplayFieldService} from "core-app/modules/fields/display/display-field.service";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {SchemaCacheService} from "core-components/schemas/schema-cache.service";
import {Constructor} from "@angular/cdk/table";
import {DisplayField} from "core-app/modules/fields/display/display-field.module";
@Component({
selector: 'display-field',
template: '<span #displayFieldContainer></span>',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DisplayFieldComponent implements OnInit {
@Input() resource:HalResource;
@Input() fieldName:string;
@Input() displayClass?:Constructor<DisplayField>;
@Input() containerType:'table'|'single-view'|'timeline' = 'table';
@Input() displayFieldOptions:{[key:string]:unknown} = {};
@ViewChild('displayFieldContainer') container:ElementRef<HTMLSpanElement>;
constructor(private injector:Injector,
private displayFieldService:DisplayFieldService,
private schemaCache:SchemaCacheService) {
}
ngOnInit() {
this.schemaCache
.ensureLoaded(this.resource)
.then(schema => {
this.render(schema[this.fieldName]);
});
}
render(fieldSchema:IFieldSchema) {
const field = this.getDisplayFieldInstance(fieldSchema);
const container = this.container.nativeElement;
container.hidden = false;
// Default the field to a placeholder when rendering
if (field.isEmpty()) {
container.textContent = '-';
} else {
field.render(container, field.valueString);
}
}
private getDisplayFieldInstance(fieldSchema:IFieldSchema) {
if (this.displayClass) {
let instance = new this.displayClass(this.fieldName, this.displayFieldContext);
instance.apply(this.resource, fieldSchema);
return instance;
}
return this.displayFieldService.getField(
this.resource,
this.fieldName,
fieldSchema,
this.displayFieldContext
);
}
private get displayFieldContext() {
return { injector: this.injector, container: this.containerType, options: this.displayFieldOptions }
}
}
@@ -41,7 +41,7 @@ export interface DisplayFieldContext {
injector:Injector;
/** Where will the field be rendered? This may result in different styles (Multi select field, e.g.,) */
container: 'table'|'single-view'|'timeline';
container:'table'|'single-view'|'timeline';
/** Options passed to the display field */
options:{ [key:string]:any };
@@ -37,6 +37,8 @@ export class CombinedDateDisplayField extends DateDisplayField {
};
public render(element:HTMLElement, displayText:string):void {
element.innerHTML = '';
let startDateElement = this.createDateDisplayField('startDate');
let dueDateElement = this.createDateDisplayField('dueDate');
@@ -34,10 +34,22 @@ export class ProjectStatusDisplayField extends DisplayField {
public render(element:HTMLElement, displayText:string):void {
const code = this.value;
element.innerHTML = `
<span class="project-status--bulb ${projectStatusCodeCssClass(code)}"></span>
<span class="project-status--name ${projectStatusCodeCssClass(code)}">${projectStatusI18n(code, this.I18n)}</span>
<span class="project-status--pulldown-icon icon icon-pulldown"></span>
`;
const bulb = document.createElement('span');
bulb.classList.add('project-status--bulb', projectStatusCodeCssClass(code));
const name = document.createElement('span');
name.classList.add('project-status--name', projectStatusCodeCssClass(code));
name.textContent = projectStatusI18n(code, this.I18n);
element.innerHTML = '';
element.appendChild(bulb);
element.appendChild(name);
if (this.writable) {
const pulldown = document.createElement('span');
pulldown.classList.add('project-status--pulldown-icon', 'icon', 'icon-pulldown');
element.appendChild(pulldown);
}
}
}
@@ -42,7 +42,7 @@ export class WorkPackageSpentTimeDisplayField extends DurationDisplayField {
};
@InjectField() PathHelper:PathHelperService;
@InjectField() timeEntryCreateService:TimeEntryCreateService;
@InjectField(TimeEntryCreateService, null) timeEntryCreateService:TimeEntryCreateService;
@InjectField() apiV3Service:APIV3Service;
public render(element:HTMLElement, displayText:string):void {
@@ -79,7 +79,7 @@ export class WorkPackageSpentTimeDisplayField extends DurationDisplayField {
}
private appendTimelogLink(element:HTMLElement) {
if (this.resource.logTime) {
if (this.timeEntryCreateService && this.resource.logTime) {
const timelogElement = document.createElement('a');
timelogElement.setAttribute('class', 'icon icon-time');
timelogElement.setAttribute('href', '');
@@ -27,12 +27,6 @@
// ++
import {States} from 'core-components/states.service';
import {
displayClassName,
DisplayFieldRenderer,
editFieldContainerClass
} from 'core-components/wp-edit-form/display-field-renderer';
import {HalResourceEditingService} from "core-app/modules/fields/edit/services/hal-resource-editing.service";
import {SelectionHelpers} from '../../../../helpers/selection-helpers';
import {debugLog} from '../../../../helpers/debug_output';
@@ -44,7 +38,7 @@ import {
Injector,
Input,
OnDestroy,
OnInit,
OnInit, Optional,
ViewChild
} from '@angular/core';
import {ConfigurationService} from 'core-app/modules/common/config/configuration.service';
@@ -57,6 +51,11 @@ import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {UntilDestroyedMixin} from "core-app/helpers/angular/until-destroyed.mixin";
import {SchemaCacheService} from "core-components/schemas/schema-cache.service";
import {ISchemaProxy} from "core-app/modules/hal/schemas/schema-proxy";
import {
displayClassName,
DisplayFieldRenderer,
editFieldContainerClass
} from "core-app/modules/fields/display/display-field-renderer";
@Component({
selector: 'editable-attribute-field',
@@ -64,12 +63,12 @@ import {ISchemaProxy} from "core-app/modules/hal/schemas/schema-proxy";
templateUrl: './editable-attribute-field.component.html'
})
export class EditableAttributeFieldComponent extends UntilDestroyedMixin implements OnInit, OnDestroy {
@Input('fieldName') public fieldName:string;
@Input('resource') public resource:HalResource;
@Input('wrapperClasses') public wrapperClasses?:string;
@Input('displayFieldOptions') public displayFieldOptions:any = {};
@Input('displayPlaceholder') public displayPlaceholder?:string;
@Input('isDropTarget') public isDropTarget?:boolean = false;
@Input() public fieldName:string;
@Input() public resource:HalResource;
@Input() public wrapperClasses?:string;
@Input() public displayFieldOptions:any = {};
@Input() public displayPlaceholder?:string;
@Input() public isDropTarget?:boolean = false;
@ViewChild('displayContainer', { static: true }) readonly displayContainer:ElementRef;
@ViewChild('editContainer', { static: true }) readonly editContainer:ElementRef;
@@ -88,8 +87,8 @@ export class EditableAttributeFieldComponent extends UntilDestroyedMixin impleme
protected opContextMenu:OPContextMenuService,
protected halEditing:HalResourceEditingService,
protected schemaCache:SchemaCacheService,
// Get parent field group from injector
protected editForm:EditFormComponent,
// Get parent field group from injector if we're in a form
@Optional() protected editForm:EditFormComponent,
protected NotificationsService:NotificationsService,
protected cdRef:ChangeDetectorRef,
protected I18n:I18nService) {
@@ -106,7 +105,9 @@ export class EditableAttributeFieldComponent extends UntilDestroyedMixin impleme
public ngOnInit() {
this.fieldRenderer = new DisplayFieldRenderer(this.injector, 'single-view', this.displayFieldOptions);
this.$element = jQuery(this.elementRef.nativeElement);
this.editForm.register(this);
// Register on the form if we're in an editable context
this.editForm?.register(this);
this.halEditing
.temporaryEditResource(this.resource)
@@ -147,8 +148,8 @@ export class EditableAttributeFieldComponent extends UntilDestroyedMixin impleme
}
}
public get isEditable() {
return this.schema.isAttributeEditable(this.fieldName);
public get isEditable():boolean {
return this.editForm && this.schema.isAttributeEditable(this.fieldName);
}
public activateIfEditable(event:JQuery.TriggeredEvent) {
@@ -63,7 +63,7 @@ export class Field extends UntilDestroyedMixin {
}
public get writable():boolean {
return this.schema.writable;
return this.schema.writable && this.context.options.writable !== false;
}
public get hasDefault():boolean {
@@ -38,7 +38,7 @@ import {
} from '@angular/core';
import {I18nService} from 'core-app/modules/common/i18n/i18n.service';
import {OpModalService} from 'core-components/op-modals/op-modal.service';
import {AttributeHelpTextModal} from 'core-app/modules/common/help-texts/attribute-help-text.modal';
import {AttributeHelpTextModal} from "core-app/modules/fields/help-texts/attribute-help-text.modal";
export const attributeHelpTextSelector = 'attribute-help-text';
@@ -0,0 +1,137 @@
// -- 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.
// ++ Ng1FieldControlsWrapper,
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
HostBinding,
Injector,
ViewChild
} from "@angular/core";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {APIV3Service} from "core-app/modules/apiv3/api-v3.service";
import {NEVER, Observable} from "rxjs";
import {filter, map, take, tap} from "rxjs/operators";
import {SchemaCacheService} from "core-components/schemas/schema-cache.service";
import {HalResourceEditingService} from "core-app/modules/fields/edit/services/hal-resource-editing.service";
import {DisplayFieldService} from "core-app/modules/fields/display/display-field.service";
import {IFieldSchema} from "core-app/modules/fields/field.base";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {
AttributeModelLoaderService,
SupportedAttributeModels
} from "core-app/modules/fields/macros/attribute-model-loader.service";
import {StringHelpers} from "core-app/helpers/string-helpers";
export const attributeLabelMacro = 'macro.macro--attribute-label';
@Component({
selector: attributeLabelMacro,
templateUrl: './attribute-label-macro.html',
styleUrls: ['./attribute-macro.sass'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
HalResourceEditingService
]
})
export class AttributeLabelMacroComponent {
// Whether the value could not be loaded
error:string|null = null;
text = {
help: this.I18n.t('js.editor.macro.attribute_reference.macro_help_tooltip'),
not_found: this.I18n.t('js.editor.macro.attribute_reference.not_found'),
invalid_attribute: (attr:string) =>
this.I18n.t('js.editor.macro.attribute_reference.invalid_attribute', { name: attr }),
};
@HostBinding('title') hostTitle = this.text.help;
// The loaded resource, required for help text
resource:HalResource|null = null;
// The scope to load for attribute help text
attributeScope:string;
// The attribute name, normalized from schema
attribute:string;
// The label to render
label:string;
constructor(readonly elementRef:ElementRef,
readonly injector:Injector,
readonly resourceLoader:AttributeModelLoaderService,
readonly schemaCache:SchemaCacheService,
readonly displayField:DisplayFieldService,
readonly I18n:I18nService,
readonly cdRef:ChangeDetectorRef) {
}
ngOnInit() {
const element = this.elementRef.nativeElement as HTMLElement;
const model:SupportedAttributeModels = element.dataset.model as any;
const id:string = element.dataset.id!;
const attributeName:string = element.dataset.attribute!;
this.attributeScope = StringHelpers.capitalize(model);
this.loadResourceAttribute(model, id, attributeName);
}
private async loadResourceAttribute(model:SupportedAttributeModels, id:string, attributeName:string) {
let resource:HalResource|null;
try {
this.resource = resource = await this.resourceLoader.require(model, id);
} catch (e) {
console.error("Failed to render macro " + e);
return this.markError(this.text.not_found);
}
if (!resource) {
this.markError(this.text.not_found);
return;
}
const schema = await this.schemaCache.ensureLoaded(resource);
this.attribute = schema.attributeFromLocalizedName(attributeName) || attributeName;
this.label = schema[this.attribute]?.name;
if (!this.label) {
this.markError(this.text.invalid_attribute(attributeName));
}
this.cdRef.detectChanges();
}
markError(message:string) {
this.error = this.I18n.t('js.editor.macro.error', { message: message });
this.cdRef.detectChanges();
}
}
@@ -0,0 +1,11 @@
<ng-container *ngIf="resource">
<span [textContent]="label"></span>
<attribute-help-text [attribute]="attribute"
[attributeScope]="attributeScope">
</attribute-help-text>
</ng-container>
<span *ngIf="error">
<i class="icon icon-error"></i>
<em [textContent]="error"></em>
</span>
@@ -0,0 +1,4 @@
@import 'helpers'
\:host
@include macro--text-style
@@ -0,0 +1,150 @@
// -- 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.
// ++ Ng1FieldControlsWrapper,
import {Injectable} from "@angular/core";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {APIV3Service} from "core-app/modules/apiv3/api-v3.service";
import {NEVER, Observable, throwError} from "rxjs";
import {map, take, tap} from "rxjs/operators";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {multiInput} from "reactivestates";
import {TransitionService} from "@uirouter/core";
import {SchemaResource} from "core-app/modules/hal/resources/schema-resource";
import {CurrentProjectService} from "core-components/projects/current-project.service";
export type SupportedAttributeModels = 'project'|'workPackage';
@Injectable({ providedIn: "root" })
export class AttributeModelLoaderService {
text = {
not_found: this.I18n.t('js.editor.macro.attribute_reference.not_found')
};
// Cache the required model/id values because
// we may need to expensively filter for them
private cache$ = multiInput<HalResource>();
constructor(readonly apiV3Service:APIV3Service,
readonly transitions:TransitionService,
readonly currentProject:CurrentProjectService,
readonly I18n:I18nService) {
// Clear cached values whenever leaving the page
transitions.onStart({}, () => {
this.cache$.clear();
return true;
});
}
/**
* Require a given model with an id reference to be loaded.
* This might be a singular resource identified by an actual integer ID or
* another (e.g., work package subject) reference.
*
* @param model
* @param id
*/
require(model:SupportedAttributeModels, id:string):Promise<HalResource|null> {
const identifier = `${model}-${id}`;
const state = this.cache$.get(identifier);
if (state.isPristine()) {
const promise = this.load(model, id).toPromise();
state.clearAndPutFromPromise(promise);
return promise;
}
return state
.values$()
.pipe(
take(1),
tap(val => console.log("VAL " + val), err => console.error('ERR ' + err))
)
.toPromise();
}
private load(model:SupportedAttributeModels, id?:string|undefined|null):Observable<HalResource|null> {
switch (model) {
case 'workPackage':
return this.loadWorkPackage(id);
case 'project':
return this.loadProject(id);
default:
return NEVER;
}
}
private loadProject(id:string|undefined|null) {
id = id || this.currentProject.id;
if (!id) {
return throwError(this.text.not_found);
}
return this
.apiV3Service
.projects
.id(id)
.get()
.pipe(
take(1)
);
}
private loadWorkPackage(id?:string|undefined|null) {
if (!id) {
return throwError(this.text.not_found);
}
// Return global reference to the subject
if (/^[1-9]\d*$/.test(id)) {
return this
.apiV3Service
.work_packages
.id(id)
.get()
.pipe(
take(1)
);
}
// Otherwise, look for subject IN the current project (if we're in project context)
return this
.apiV3Service
.withOptionalProject(this.currentProject.id)
.work_packages
.filterBySubjectOrId(id, false, { pageSize: '1' })
.get()
.pipe(
take(1),
map(collection => collection.elements[0] || null)
);
}
}
@@ -0,0 +1,134 @@
// -- 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.
// ++ Ng1FieldControlsWrapper,
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
HostBinding,
Injector,
ViewChild
} from "@angular/core";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {APIV3Service} from "core-app/modules/apiv3/api-v3.service";
import {NEVER, Observable} from "rxjs";
import {filter, map, take, tap} from "rxjs/operators";
import {SchemaCacheService} from "core-components/schemas/schema-cache.service";
import {HalResourceEditingService} from "core-app/modules/fields/edit/services/hal-resource-editing.service";
import {DisplayFieldService} from "core-app/modules/fields/display/display-field.service";
import {IFieldSchema} from "core-app/modules/fields/field.base";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {
AttributeModelLoaderService,
SupportedAttributeModels
} from "core-app/modules/fields/macros/attribute-model-loader.service";
export const attributeValueMacro = 'macro.macro--attribute-value';
@Component({
selector: attributeValueMacro,
templateUrl: './attribute-value-macro.html',
styleUrls: ['./attribute-macro.sass'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
HalResourceEditingService
]
})
export class AttributeValueMacroComponent {
@ViewChild('displayContainer') private displayContainer:ElementRef<HTMLSpanElement>;
// Whether the value could not be loaded
error:string|null = null;
text = {
help: this.I18n.t('js.editor.macro.attribute_reference.macro_help_tooltip'),
placeholder: this.I18n.t('js.placeholders.default'),
not_found: this.I18n.t('js.editor.macro.attribute_reference.not_found'),
invalid_attribute: (attr:string) =>
this.I18n.t('js.editor.macro.attribute_reference.invalid_attribute', { name: attr }),
};
@HostBinding('title') hostTitle = this.text.help;
resource:HalResource;
fieldName:string;
constructor(readonly elementRef:ElementRef,
readonly injector:Injector,
readonly resourceLoader:AttributeModelLoaderService,
readonly schemaCache:SchemaCacheService,
readonly displayField:DisplayFieldService,
readonly I18n:I18nService,
readonly cdRef:ChangeDetectorRef) {
}
ngOnInit() {
const element = this.elementRef.nativeElement as HTMLElement;
const model:SupportedAttributeModels = element.dataset.model as any;
const id:string = element.dataset.id!;
const attributeName:string = element.dataset.attribute!;
this.loadAndRender(model, id, attributeName);
}
private async loadAndRender(model:SupportedAttributeModels, id:string, attributeName:string) {
let resource:HalResource|null;
try {
resource = await this.resourceLoader.require(model, id);
} catch (e) {
console.error("Failed to render macro " + e);
return this.markError(this.text.not_found);
}
if (!resource) {
this.markError(this.text.not_found);
return;
}
const schema = await this.schemaCache.ensureLoaded(resource);
const attribute = schema.attributeFromLocalizedName(attributeName) || attributeName;
const fieldSchema = schema[attribute] as IFieldSchema|undefined;
if (fieldSchema) {
this.resource = resource;
this.fieldName = attribute;
} else {
this.markError(this.text.invalid_attribute(attributeName));
}
this.cdRef.detectChanges();
}
markError(message:string) {
this.error = this.I18n.t('js.editor.macro.error', { message: message });
this.cdRef.detectChanges();
}
}
@@ -0,0 +1,9 @@
<span *ngIf="error">
<i class="icon icon-error"></i>
<em [textContent]="error"></em>
</span>
<display-field *ngIf="!!resource"
[resource]="resource"
[displayFieldOptions]="{ writable: false }"
[fieldName]="fieldName">
</display-field>
@@ -0,0 +1,102 @@
// -- 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.
// ++ Ng1FieldControlsWrapper,
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostBinding, Injector} from "@angular/core";
import {APIV3Service} from "core-app/modules/apiv3/api-v3.service";
import {Observable} from "rxjs";
import {tap} from "rxjs/operators";
import {SchemaCacheService} from "core-components/schemas/schema-cache.service";
import {HalResourceEditingService} from "core-app/modules/fields/edit/services/hal-resource-editing.service";
import {DisplayFieldService} from "core-app/modules/fields/display/display-field.service";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {WorkPackageResource} from "core-app/modules/hal/resources/work-package-resource";
import {DateDisplayField} from "core-app/modules/fields/display/field-types/date-display-field.module";
import {CombinedDateDisplayField} from "core-app/modules/fields/display/field-types/combined-date-display.field";
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service";
export const quickInfoMacroSelector = 'macro.macro--wp-quickinfo';
@Component({
selector: quickInfoMacroSelector,
templateUrl: './work-package-quickinfo-macro.html',
styleUrls: ['./work-package-quickinfo-macro.sass'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
HalResourceEditingService
]
})
export class WorkPackageQuickinfoMacroComponent {
// Whether the value could not be loaded
error:string|null = null;
text = {
not_found: this.I18n.t('js.editor.macro.attribute_reference.not_found'),
help: this.I18n.t('js.editor.macro.attribute_reference.macro_help_tooltip')
};
@HostBinding('title') hostTitle = this.text.help;
/** Work package to be shown */
workPackage$:Observable<WorkPackageResource>;
dateDisplayField = CombinedDateDisplayField;
workPackageLink:string;
detailed:boolean = false;
constructor(readonly elementRef:ElementRef,
readonly injector:Injector,
readonly apiV3Service:APIV3Service,
readonly schemaCache:SchemaCacheService,
readonly displayField:DisplayFieldService,
readonly pathHelper:PathHelperService,
readonly I18n:I18nService,
readonly cdRef:ChangeDetectorRef) {
}
ngOnInit() {
const element = this.elementRef.nativeElement as HTMLElement;
const id:string = element.dataset.id!;
this.detailed = element.dataset.detailed === 'true';
this.workPackageLink = this.pathHelper.workPackagePath(id);
this.workPackage$ = this
.apiV3Service
.work_packages
.id(id)
.get()
.pipe(
tap({ error: (e) => this.markError(this.text.not_found) })
);
}
markError(message:string) {
console.error("Failed to render macro " + message);
this.error = this.I18n.t('js.editor.macro.error', { message: message });
this.cdRef.detectChanges();
}
}
@@ -0,0 +1,38 @@
<ng-container *ngIf="(workPackage$ | async) as workPackage">
<display-field *ngIf="detailed"
[resource]="workPackage"
[displayFieldOptions]="{ writable: false }"
fieldName="status">
</display-field>
<display-field [resource]="workPackage"
[displayFieldOptions]="{ writable: false }"
fieldName="type">
</display-field>
<a class="work-package--quickinfo preview-trigger"
[href]="workPackageLink"
[attr.data-work-package-id]="workPackage.id">
#{{workPackage.id}}:
</a>
<display-field [resource]="workPackage"
[displayFieldOptions]="{ writable: false }"
fieldName="subject">
</display-field>
<ng-container *ngIf="detailed">
(<display-field *ngIf="!!workPackage.date"
[resource]="workPackage"
[displayFieldOptions]="{ writable: false }"
fieldName="date">
</display-field>
<display-field *ngIf="!workPackage.date"
[resource]="workPackage"
[displayFieldOptions]="{ writable: false }"
[displayClass]="dateDisplayField"
fieldName="startDate">
</display-field>)
</ng-container>
</ng-container>
<span *ngIf="error">
<i class="icon icon-error"></i>
<em [textContent]="error"></em>
</span>
@@ -0,0 +1,7 @@
@import 'helpers'
\:host
@include macro--text-style
display-field
padding-right: 2px
@@ -52,10 +52,18 @@ import {EditableAttributeFieldComponent} from "core-app/modules/fields/edit/fiel
import {ProjectStatusEditFieldComponent} from "core-app/modules/fields/edit/field-types/project-status-edit-field.component";
import {PlainFormattableEditFieldComponent} from "core-app/modules/fields/edit/field-types/plain-formattable-edit-field.component";
import {TimeEntryWorkPackageEditFieldComponent} from "core-app/modules/fields/edit/field-types/te-work-package-edit-field.component";
import {AttributeValueMacroComponent} from "core-app/modules/fields/macros/attribute-value-macro.component";
import {AttributeLabelMacroComponent} from "core-app/modules/fields/macros/attribute-label-macro.component";
import {AttributeHelpTextComponent} from "core-app/modules/fields/help-texts/attribute-help-text.component";
import {AttributeHelpTextModal} from "core-app/modules/fields/help-texts/attribute-help-text.modal";
import {OpenprojectAttachmentsModule} from "core-app/modules/attachments/openproject-attachments.module";
import {WorkPackageQuickinfoMacroComponent} from "core-app/modules/fields/macros/work-package-quickinfo-macro.component";
import {DisplayFieldComponent} from "core-app/modules/fields/display/display-field.component";
@NgModule({
imports: [
OpenprojectCommonModule,
OpenprojectAttachmentsModule,
OpenprojectAccessibilityModule,
OpenprojectEditorModule,
],
@@ -64,6 +72,7 @@ import {TimeEntryWorkPackageEditFieldComponent} from "core-app/modules/fields/ed
EditFormPortalComponent,
EditFormComponent,
EditableAttributeFieldComponent,
AttributeHelpTextComponent,
],
providers: [
{
@@ -95,8 +104,16 @@ import {TimeEntryWorkPackageEditFieldComponent} from "core-app/modules/fields/ed
WorkPackageEditFieldComponent,
TimeEntryWorkPackageEditFieldComponent,
EditFormComponent,
DisplayFieldComponent,
EditableAttributeFieldComponent,
ProjectStatusEditFieldComponent,
AttributeValueMacroComponent,
AttributeLabelMacroComponent,
// Help texts
AttributeHelpTextComponent,
AttributeHelpTextModal,
WorkPackageQuickinfoMacroComponent,
]
})
export class OpenprojectFieldsModule {
@@ -1,5 +1,6 @@
import {AbstractWidgetComponent} from "core-app/modules/grids/widgets/abstract-widget.component";
import {
ApplicationRef,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
@@ -17,6 +18,7 @@ import {HalResource} from "core-app/modules/hal/resources/hal-resource";
import {filter} from 'rxjs/operators';
import {GridAreaService} from "core-app/modules/grids/grid/area.service";
import {DomSanitizer, SafeHtml} from '@angular/platform-browser';
import {DynamicBootstrapper} from "core-app/globals/dynamic-bootstrapper";
@Component({
templateUrl: './custom-text.component.html',
@@ -35,7 +37,8 @@ export class WidgetCustomTextComponent extends AbstractWidgetComponent implement
protected injector:Injector,
public handler:CustomTextEditFieldService,
protected cdr:ChangeDetectorRef,
readonly sanitization:DomSanitizer,
protected sanitization:DomSanitizer,
protected appRef:ApplicationRef,
protected layout:GridAreaService) {
super(i18n, injector);
}
@@ -126,6 +129,11 @@ export class WidgetCustomTextComponent extends AbstractWidgetComponent implement
private memorizeCustomText() {
this.customText = this.sanitization.bypassSecurityTrustHtml(this.handler.htmlText);
// Allow embeddable rendered content
setTimeout(() => {
DynamicBootstrapper.bootstrapOptionalEmbeddable(this.appRef, this.displayContainer.nativeElement);
}, 100);
}
private clickedElementIsLinkWithinDisplayContainer(event:any) {
@@ -29,6 +29,7 @@
import {HalResource} from 'core-app/modules/hal/resources/hal-resource';
import {CollectionResource} from 'core-app/modules/hal/resources/collection-resource';
import {InputState} from 'reactivestates';
import {IFieldSchema} from "core-app/modules/fields/field.base";
export class SchemaResource extends HalResource {
@@ -39,6 +40,21 @@ export class SchemaResource extends HalResource {
public get availableAttributes() {
return _.keys(this.$source).filter(name => name.indexOf('_') !== 0);
}
// Find the attribute name with a matching (localized) name;
public attributeFromLocalizedName(name:string):string|null {
let match:string|null = null;
for (let attribute of this.availableAttributes) {
let fieldSchema = this[attribute];
if (fieldSchema?.name === name) {
match = attribute;
break;
}
}
return match;
}
}
export class SchemaAttributeObject<T = HalResource> {
@@ -7,8 +7,6 @@ import {ExternalQueryConfigurationService} from "core-components/wp-table/extern
import {HalResourceService} from "core-app/modules/hal/services/hal-resource.service";
import {PasswordConfirmationModal} from "../../components/modals/request-for-confirmation/password-confirmation.modal";
import {OpModalService} from "../../components/op-modals/op-modal.service";
import {AttributeHelpTextsService} from "../common/help-texts/attribute-help-text.service";
import {AttributeHelpTextModal} from "../common/help-texts/attribute-help-text.modal";
import {DynamicContentModal} from "../../components/modals/modal-wrapper/dynamic-content.modal";
import {DisplayField} from "core-app/modules/fields/display/display-field.module";
import {HalResource} from "core-app/modules/hal/resources/hal-resource";
@@ -48,7 +46,6 @@ export class OpenProjectPluginContext {
notifications: this.injector.get<NotificationsService>(NotificationsService),
opModalService: this.injector.get<OpModalService>(OpModalService),
opFileUpload: this.injector.get<OpenProjectFileUploadService>(OpenProjectFileUploadService),
attributeHelpTexts: this.injector.get<AttributeHelpTextsService>(AttributeHelpTextsService),
displayField: this.injector.get<DisplayFieldService>(DisplayFieldService),
editField: this.injector.get<EditFieldService>(EditFieldService),
macros: this.injector.get<EditorMacrosService>(EditorMacrosService),
@@ -63,7 +60,6 @@ export class OpenProjectPluginContext {
public readonly classes = {
modals: {
passwordConfirmation: PasswordConfirmationModal,
attributeHelpTexts: AttributeHelpTextModal,
dynamicContent: DynamicContentModal,
},
HalResource: HalResource,
@@ -165,8 +165,6 @@ import {WorkPackageSettingsButtonComponent} from "core-components/wp-buttons/wp-
import {BackButtonComponent} from "core-app/modules/common/back-routing/back-button.component";
import {DatePickerModal} from "core-components/datepicker/datepicker.modal";
import {WorkPackagesTableComponent} from "core-components/wp-table/wp-table.component";
import {AttributeHelpTextComponent} from "core-app/modules/common/help-texts/attribute-help-text.component";
import {AttributeHelpTextModal} from "core-app/modules/common/help-texts/attribute-help-text.modal";
@NgModule({
imports: [
@@ -372,9 +370,7 @@ import {AttributeHelpTextModal} from "core-app/modules/common/help-texts/attribu
WorkPackageSingleCardComponent,
WorkPackageViewToggleButton,
// Help texts
AttributeHelpTextComponent,
AttributeHelpTextModal,
],
exports: [
WorkPackagesTableComponent,
@@ -409,10 +405,6 @@ import {AttributeHelpTextModal} from "core-app/modules/common/help-texts/attribu
WorkPackageSingleViewComponent,
WorkPackageSplitViewComponent,
BackButtonComponent,
// Help texts
AttributeHelpTextComponent,
AttributeHelpTextModal,
]
})
export class OpenprojectWorkPackagesModule {
@@ -200,4 +200,17 @@ $scrollbar-size: 10px
visibility: hidden
@mixin d_n_d--preview
box-shadow: 0 0 10px 5px rgba(0, 0, 0, 0.1)
box-shadow: 0 0 10px 5px rgba(0, 0, 0, 0.1)
@mixin macro--text-style
@media screen
// Ensure width of contents is wrapped
display: inline-block
background: rgba(218,223,225,0.19)
border: 1px solid transparent
padding: 2px
&:hover
cursor: default
border-color: #c7c7c7
background: rgba(218,223,225,0.75)
@@ -37,7 +37,8 @@ module OpenProject::TextFormatting
def self.matchers
[
OpenProject::TextFormatting::Matchers::ResourceLinksMatcher,
OpenProject::TextFormatting::Matchers::WikiLinksMatcher
OpenProject::TextFormatting::Matchers::WikiLinksMatcher,
OpenProject::TextFormatting::Matchers::AttributeMacros
]
end
@@ -0,0 +1,71 @@
#-- encoding: UTF-8
#-- 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-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 OpenProject::TextFormatting
module Matchers
# OpenProject attribute macros syntax
# Examples:
# workPackageLabel:1234:subject # Outputs work package label attribute "Subject" + help text
# workPackageValue:1234:subject # Outputs the actual subject of #1234
#
# projectLabel:statusExplanation # Outputs current project label attribute "Status description" + help text
# projectValue:statusExplanation # Outputs current project value for "Status description"
class AttributeMacros < RegexMatcher
def self.regexp
%r{
(\w+)(Label|Value) # The model type we try to reference
(?::(?:([^"\s]+)|"([^"]+)"))? # Optional: An ID or subject reference
(?::([^"\s.]+|"([^".]+)")) # The attribute name we're trying to reference
}x
end
##
# Faster inclusion check before the regex is being applied
def self.applicable?(content)
content.include?('Label:') || content.include?('Value:')
end
def self.process_match(m, matched_string, context)
# Leading string before match
macro_attributes = {
model: m[1],
id: m[4] || m[3],
attribute: m[6] || m[5]
}
type = m[2].downcase
ApplicationController.helpers.content_tag :macro,
'',
class: "macro--attribute-#{type}",
data: macro_attributes
end
end
end
end
@@ -50,11 +50,22 @@ module OpenProject::TextFormatting::Matchers
# prohibits links to things like #0123
return if wp_id.to_s != matcher.identifier
render_work_package_link(wp_id)
if matcher.sep == '##' || matcher.sep == '###'
render_work_package_macro(wp_id, detailed: (matcher.sep === '###'))
else
render_work_package_link(wp_id)
end
end
private
def render_work_package_macro(wp_id, detailed: false)
ApplicationController.helpers.content_tag :macro,
'',
class: "macro--wp-quickinfo",
data: { id: wp_id, detailed: detailed }
end
def render_work_package_link(wp_id)
link_to("##{wp_id}",
work_package_path_or_url(id: wp_id, only_path: context[:only_path]),
@@ -0,0 +1,107 @@
#-- 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-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 'Wysiwyg attribute macros', type: :feature, js: true do
using_shared_fixtures :admin
let(:user) { admin }
let!(:project) { FactoryBot.create(:project, identifier: 'some-project', enabled_module_names: %w[wiki work_package_tracking]) }
let!(:work_package) { FactoryBot.create(:work_package, subject: "Foo Bar", project: project) }
let(:editor) { ::Components::WysiwygEditor.new }
let(:markdown) {
<<~MD
# My headline
<table>
<thead>
<tr>
<th>Label</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>workPackageLabel:"Foo Bar":subject</td>
<td>workPackageValue:"Foo Bar":subject</td>
</tr>
<tr>
<td>projectLabel:identifier</td>
<td>projectValue:identifier</td>
</tr>
<tr>
<td>invalid subject workPackageValue:"Invalid":subject</td>
<td>invalid project projectValue:"does not exist":identifier</td>
</tr>
</tbody>
</table>
MD
}
before do
login_as(user)
end
describe 'in wikis' do
describe 'creating a wiki page' do
before do
visit project_wiki_path(project, :wiki)
end
it 'can add and save multiple code blocks (Regression #28350)' do
editor.in_editor do |container,|
editor.set_markdown markdown
expect(container).to have_selector('table')
end
click_on 'Save'
expect(page).to have_selector('.flash.notice')
# Expect output widget
within('#content') do
expect(page).to have_selector('td', text: 'Subject')
expect(page).to have_selector('td', text: 'Foo Bar')
expect(page).to have_selector('td', text: 'Identifier')
expect(page).to have_selector('td', text: 'some-project')
expect(page).to have_selector('td', text: 'invalid subject Cannot expand macro: Requested resource could not be found')
expect(page).to have_selector('td', text: 'invalid project Cannot expand macro: Requested resource could not be found')
end
# Edit page again
click_on 'Edit'
editor.in_editor do |container,|
expect(container).to have_selector('tbody td', count: 6)
end
end
end
end
end
@@ -0,0 +1,93 @@
#-- 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-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 'Wysiwyg work package quicklink macros', type: :feature, js: true do
using_shared_fixtures :admin
let(:user) { admin }
let!(:project) { FactoryBot.create(:project, identifier: 'some-project', enabled_module_names: %w[wiki work_package_tracking]) }
let!(:work_package) {
FactoryBot.create(:work_package, subject: "Foo Bar", project: project, start_date: '2020-01-01', due_date: '2020-02-01')
}
let(:editor) { ::Components::WysiwygEditor.new }
let(:markdown) {
<<~MD
# My headline
###{work_package.id}
####{work_package.id}
MD
}
before do
login_as(user)
end
describe 'in wikis' do
describe 'creating a wiki page' do
before do
visit project_wiki_path(project, :wiki)
end
it 'can add and save multiple code blocks (Regression #28350)' do
editor.in_editor do |container,|
editor.set_markdown markdown
expect(container).to have_selector('p', text: "###{work_package.id}")
expect(container).to have_selector('p', text: "####{work_package.id}")
end
click_on 'Save'
expect(page).to have_selector('.flash.notice')
# Expect output widget
within('#content') do
expect(page).to have_selector('macro', count: 2)
expect(page).to have_selector('span', text: 'Foo Bar', count: 2)
expect(page).to have_selector('span', text: work_package.type.name.upcase, count: 2)
expect(page).to have_selector('span', text: work_package.status.name, count: 1)
# Dates are being rendered in two nested spans
expect(page).to have_selector('span', text: '01/01/2020', count: 2)
expect(page).to have_selector('span', text: '02/01/2020', count: 2)
expect(page).to have_selector('.work-package--quickinfo.preview-trigger', text: "##{work_package.id}", count: 2)
end
# Edit page again
click_on 'Edit'
editor.in_editor do |container,|
expect(container).to have_selector('p', text: "###{work_package.id}")
expect(container).to have_selector('p', text: "####{work_package.id}")
end
end
end
end
end
@@ -0,0 +1,119 @@
#-- 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-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 OpenProject::TextFormatting,
'Attribute macros',
# Speeds up the spec by avoiding event mailers to be processed
with_settings: { notified_events: [] } do
subject do
::OpenProject::TextFormatting::Renderer.format_text(raw)
end
describe 'attribute label macros' do
let(:raw) do
<<~RAW
# My headline
Inline reference to WP by ID: workPackageLabel:1234:subject
Inline reference to WP by subject: workPackageLabel:"Some subject":"Some custom field with spaces"
Inline reference to project: projectLabel:status
Inline reference to project with id: projectLabel:"some id":status
RAW
end
let(:expected) do
<<~EXPECTED
<h1 id="my-headline">
<a class="wiki-anchor icon-paragraph" aria-hidden="true" href="#my-headline"></a>My headline
</h1>
<p>
Inline reference to WP by ID: <macro class="macro--attribute-label" data-model="workPackage" data-id="1234" data-attribute="subject"></macro>
</p>
<p>
Inline reference to WP by subject: <macro class="macro--attribute-label" data-model="workPackage" data-id="Some subject" data-attribute="Some custom field with spaces"></macro>
</p>
<p>
Inline reference to project: <macro class="macro--attribute-label" data-model="project" data-attribute="status"></macro>
</p>
<p>
Inline reference to project with id: <macro class="macro--attribute-label" data-model="project" data-id="some id" data-attribute="status"></macro>
</p>
EXPECTED
end
it 'should match' do
expect(subject).to be_html_eql(expected)
end
end
describe 'attribute value macros' do
let(:raw) do
<<~RAW
# My headline
Inline reference to WP by ID: workPackageValue:1234:subject
Inline reference to WP by subject: workPackageValue:"Some subject":"Some custom field with spaces"
Inline reference to project: projectValue:status
Inline reference to project with id: projectValue:"some id":status
RAW
end
let(:expected) do
<<~EXPECTED
<h1 id="my-headline">
<a class="wiki-anchor icon-paragraph" aria-hidden="true" href="#my-headline"></a>My headline
</h1>
<p>
Inline reference to WP by ID: <macro class="macro--attribute-value" data-model="workPackage" data-id="1234" data-attribute="subject"></macro>
</p>
<p>
Inline reference to WP by subject: <macro class="macro--attribute-value" data-model="workPackage" data-id="Some subject" data-attribute="Some custom field with spaces"></macro>
</p>
<p>
Inline reference to project: <macro class="macro--attribute-value" data-model="project" data-attribute="status"></macro>
</p>
<p>
Inline reference to project with id: <macro class="macro--attribute-value" data-model="project" data-id="some id" data-attribute="status"></macro>
</p>
EXPECTED
end
it 'should match' do
expect(subject).to be_html_eql(expected)
end
end
end
@@ -43,27 +43,27 @@ describe OpenProject::TextFormatting,
end
describe '.format_text' do
let(:project) { FactoryBot.create :valid_project }
shared_let(:project) { FactoryBot.create :valid_project }
let(:identifier) { project.identifier }
let(:role) do
shared_let(:role) do
FactoryBot.create :role,
permissions: %i(view_work_packages edit_work_packages
browse_repository view_changesets view_wiki_pages)
end
let(:project_member) do
shared_let(:project_member) do
FactoryBot.create :user,
member_in_project: project,
member_through_role: role
end
let(:issue) do
shared_let(:issue) do
FactoryBot.create :work_package,
project: project,
author: project_member,
type: project.types.first
end
let!(:non_member) do
shared_let(:non_member) do
FactoryBot.create(:non_member)
end
@@ -237,6 +237,29 @@ describe OpenProject::TextFormatting,
}
end
describe 'double hash issue link' do
let(:issue_link) do
content_tag :macro,
'',
class: "macro--wp-quickinfo",
data: { id: '1234', detailed: 'false' }
end
subject { format_text("foo (bar ##1234)") }
it { is_expected.to be_html_eql("<p>foo (bar #{issue_link})</p>") }
end
describe 'triple hash issue link' do
let(:issue_link) do
content_tag :macro,
'',
class: "macro--wp-quickinfo",
data: { id: '1234', detailed: 'true' }
end
subject { format_text("foo (bar ###1234)") }
it { is_expected.to be_html_eql("<p>foo (bar #{issue_link})</p>") }
end
context 'Escaping issue link' do
subject { format_text("Some leading text. !##{issue.id}. Some following") }
@@ -286,7 +286,7 @@ describe 'API v3 Work package resource',
# resolves links
expect(subject['html'])
.to have_selector("a[href='/work_packages/#{other_wp.id}']")
.to have_selector("macro.macro--wp-quickinfo[data-id='#{other_wp.id}']")
# resolves macros
is_expected.to have_text('Table of contents')
end