mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
[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:
@@ -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'
|
||||
|
||||
@@ -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";
|
||||
|
||||
+1
-1
@@ -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$()
|
||||
);
|
||||
|
||||
+3
-2
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -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');
|
||||
|
||||
|
||||
+17
-5
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user