diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml
index 1c01f1134a8..f37cd7a9689 100644
--- a/config/locales/js-en.yml
+++ b/config/locales/js-en.yml
@@ -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'
diff --git a/docs/user-guide/wysiwyg/README.md b/docs/user-guide/wysiwyg/README.md
index 7075a2e66e0..c1712b959f5 100644
--- a/docs/user-guide/wysiwyg/README.md
+++ b/docs/user-guide/wysiwyg/README.md
@@ -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`
+
+
+
+
+
+**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.
+
+
+
+
+
+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"` |
+
diff --git a/frontend/src/app/components/wp-fast-table/builders/cell-builder.ts b/frontend/src/app/components/wp-fast-table/builders/cell-builder.ts
index 19a0c7cd543..e25bde2b7c6 100644
--- a/frontend/src/app/components/wp-fast-table/builders/cell-builder.ts
+++ b/frontend/src/app/components/wp-fast-table/builders/cell-builder.ts
@@ -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";
diff --git a/frontend/src/app/components/wp-fast-table/builders/modes/grouped/grouped-render-pass.ts b/frontend/src/app/components/wp-fast-table/builders/modes/grouped/grouped-render-pass.ts
index 234b257523b..1041b7370d8 100644
--- a/frontend/src/app/components/wp-fast-table/builders/modes/grouped/grouped-render-pass.ts
+++ b/frontend/src/app/components/wp-fast-table/builders/modes/grouped/grouped-render-pass.ts
@@ -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 {
diff --git a/frontend/src/app/components/wp-fast-table/handlers/cell/edit-cell-handler.ts b/frontend/src/app/components/wp-fast-table/handlers/cell/edit-cell-handler.ts
index 16f9133173e..b2a67ba9e9b 100644
--- a/frontend/src/app/components/wp-fast-table/handlers/cell/edit-cell-handler.ts
+++ b/frontend/src/app/components/wp-fast-table/handlers/cell/edit-cell-handler.ts
@@ -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';
diff --git a/frontend/src/app/components/wp-fast-table/handlers/row/click-handler.ts b/frontend/src/app/components/wp-fast-table/handlers/row/click-handler.ts
index b88e93a1551..ebf248bc470 100644
--- a/frontend/src/app/components/wp-fast-table/handlers/row/click-handler.ts
+++ b/frontend/src/app/components/wp-fast-table/handlers/row/click-handler.ts
@@ -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";
diff --git a/frontend/src/app/components/wp-fast-table/handlers/row/double-click-handler.ts b/frontend/src/app/components/wp-fast-table/handlers/row/double-click-handler.ts
index 83fc84137a2..0c43bb74c9f 100644
--- a/frontend/src/app/components/wp-fast-table/handlers/row/double-click-handler.ts
+++ b/frontend/src/app/components/wp-fast-table/handlers/row/double-click-handler.ts
@@ -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";
diff --git a/frontend/src/app/components/wp-table/timeline/cells/timeline-cell-renderer.ts b/frontend/src/app/components/wp-table/timeline/cells/timeline-cell-renderer.ts
index ee641a40466..e67b4f6416a 100644
--- a/frontend/src/app/components/wp-table/timeline/cells/timeline-cell-renderer.ts
+++ b/frontend/src/app/components/wp-table/timeline/cells/timeline-cell-renderer.ts
@@ -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";
diff --git a/frontend/src/app/global-dynamic-components.const.ts b/frontend/src/app/global-dynamic-components.const.ts
index 75bccee61ce..d2ac8e89fdd 100644
--- a/frontend/src/app/global-dynamic-components.const.ts
+++ b/frontend/src/app/global-dynamic-components.const.ts
@@ -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 },
];
diff --git a/frontend/src/app/helpers/string-helpers.ts b/frontend/src/app/helpers/string-helpers.ts
new file mode 100644
index 00000000000..7e89125d86c
--- /dev/null
+++ b/frontend/src/app/helpers/string-helpers.ts
@@ -0,0 +1,10 @@
+export namespace StringHelpers {
+
+ /**
+ * Capitalize
+ * @param value
+ */
+ export function capitalize(value:string):string {
+ return value.charAt(0).toUpperCase() + value.slice(1);
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/app/modules/apiv3/cache/cachable-apiv3-resource.ts b/frontend/src/app/modules/apiv3/cache/cachable-apiv3-resource.ts
index 082cb22d2fb..4926434b445 100644
--- a/frontend/src/app/modules/apiv3/cache/cachable-apiv3-resource.ts
+++ b/frontend/src/app/modules/apiv3/cache/cachable-apiv3-resource.ts
@@ -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
@@ -59,7 +59,7 @@ export abstract class CachableAPIV3Resource
.load()
.pipe(
take(1),
- share()
+ shareReplay(1)
);
this.cache.clearAndLoad(
@@ -70,7 +70,7 @@ export abstract class CachableAPIV3Resource
// Return concat of the loading observable
// for error handling and the like,
// but then continue with the streamed cache
- return merge(
+ return concat(
observable,
this.cache.state(id).values$()
);
diff --git a/frontend/src/app/modules/apiv3/endpoints/work_packages/api-v3-work-package-cached-subresource.ts b/frontend/src/app/modules/apiv3/endpoints/work_packages/api-v3-work-package-cached-subresource.ts
index f7ce5d809aa..b5e6f51627e 100644
--- a/frontend/src/app/modules/apiv3/endpoints/work_packages/api-v3-work-package-cached-subresource.ts
+++ b/frontend/src/app/modules/apiv3/endpoints/work_packages/api-v3-work-package-cached-subresource.ts
@@ -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 {
@@ -45,7 +45,8 @@ export class ApiV3WorkPackageCachedSubresource extends APIv3GettableResource(this.path)
.pipe(
- tap(collection => this.cache.updateWorkPackageList(collection.elements))
+ tap(collection => this.cache.updateWorkPackageList(collection.elements)),
+ take(1)
);
}
diff --git a/frontend/src/app/modules/apiv3/endpoints/work_packages/api-v3-work-packages-paths.ts b/frontend/src/app/modules/apiv3/endpoints/work_packages/api-v3-work-packages-paths.ts
index 2aaba0f4608..febe4caf1a1 100644
--- a/frontend/src/app/modules/apiv3/endpoints/work_packages/api-v3-work-packages-paths.ts
+++ b/frontend/src/app/modules/apiv3/endpoints/work_packages/api-v3-work-packages-paths.ts
@@ -107,8 +107,9 @@ export class APIV3WorkPackagesPaths extends CachableAPIV3Collection',
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class DisplayFieldComponent implements OnInit {
+ @Input() resource:HalResource;
+ @Input() fieldName:string;
+ @Input() displayClass?:Constructor;
+
+ @Input() containerType:'table'|'single-view'|'timeline' = 'table';
+ @Input() displayFieldOptions:{[key:string]:unknown} = {};
+
+ @ViewChild('displayFieldContainer') container:ElementRef;
+
+ 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 }
+ }
+}
diff --git a/frontend/src/app/modules/fields/display/display-field.service.ts b/frontend/src/app/modules/fields/display/display-field.service.ts
index ddbea6925cb..26a760a07a3 100644
--- a/frontend/src/app/modules/fields/display/display-field.service.ts
+++ b/frontend/src/app/modules/fields/display/display-field.service.ts
@@ -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 };
diff --git a/frontend/src/app/modules/fields/display/field-types/combined-date-display.field.ts b/frontend/src/app/modules/fields/display/field-types/combined-date-display.field.ts
index cdfef6108ad..d337d2bc1bc 100644
--- a/frontend/src/app/modules/fields/display/field-types/combined-date-display.field.ts
+++ b/frontend/src/app/modules/fields/display/field-types/combined-date-display.field.ts
@@ -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');
diff --git a/frontend/src/app/modules/fields/display/field-types/project-status-display-field.module.ts b/frontend/src/app/modules/fields/display/field-types/project-status-display-field.module.ts
index b03ac84a004..b61c14d402b 100644
--- a/frontend/src/app/modules/fields/display/field-types/project-status-display-field.module.ts
+++ b/frontend/src/app/modules/fields/display/field-types/project-status-display-field.module.ts
@@ -34,10 +34,22 @@ export class ProjectStatusDisplayField extends DisplayField {
public render(element:HTMLElement, displayText:string):void {
const code = this.value;
- element.innerHTML = `
-
- ${projectStatusI18n(code, this.I18n)}
-
- `;
+ 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);
+ }
}
}
diff --git a/frontend/src/app/modules/fields/display/field-types/wp-spent-time-display-field.module.ts b/frontend/src/app/modules/fields/display/field-types/wp-spent-time-display-field.module.ts
index 1e80cdf1dcc..6fb91589ff5 100644
--- a/frontend/src/app/modules/fields/display/field-types/wp-spent-time-display-field.module.ts
+++ b/frontend/src/app/modules/fields/display/field-types/wp-spent-time-display-field.module.ts
@@ -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', '');
diff --git a/frontend/src/app/modules/fields/edit/field/editable-attribute-field.component.ts b/frontend/src/app/modules/fields/edit/field/editable-attribute-field.component.ts
index 949426dc997..f00cf864665 100644
--- a/frontend/src/app/modules/fields/edit/field/editable-attribute-field.component.ts
+++ b/frontend/src/app/modules/fields/edit/field/editable-attribute-field.component.ts
@@ -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) {
diff --git a/frontend/src/app/modules/fields/field.base.ts b/frontend/src/app/modules/fields/field.base.ts
index c5393e0a41d..a243c63d6a7 100644
--- a/frontend/src/app/modules/fields/field.base.ts
+++ b/frontend/src/app/modules/fields/field.base.ts
@@ -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 {
diff --git a/frontend/src/app/modules/common/help-texts/attribute-help-text.component.html b/frontend/src/app/modules/fields/help-texts/attribute-help-text.component.html
similarity index 100%
rename from frontend/src/app/modules/common/help-texts/attribute-help-text.component.html
rename to frontend/src/app/modules/fields/help-texts/attribute-help-text.component.html
diff --git a/frontend/src/app/modules/common/help-texts/attribute-help-text.component.ts b/frontend/src/app/modules/fields/help-texts/attribute-help-text.component.ts
similarity index 97%
rename from frontend/src/app/modules/common/help-texts/attribute-help-text.component.ts
rename to frontend/src/app/modules/fields/help-texts/attribute-help-text.component.ts
index 493e6712d42..12b4df87f59 100644
--- a/frontend/src/app/modules/common/help-texts/attribute-help-text.component.ts
+++ b/frontend/src/app/modules/fields/help-texts/attribute-help-text.component.ts
@@ -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';
diff --git a/frontend/src/app/modules/common/help-texts/attribute-help-text.modal.ts b/frontend/src/app/modules/fields/help-texts/attribute-help-text.modal.ts
similarity index 100%
rename from frontend/src/app/modules/common/help-texts/attribute-help-text.modal.ts
rename to frontend/src/app/modules/fields/help-texts/attribute-help-text.modal.ts
diff --git a/frontend/src/app/modules/common/help-texts/attribute-help-text.service.ts b/frontend/src/app/modules/fields/help-texts/attribute-help-text.service.ts
similarity index 100%
rename from frontend/src/app/modules/common/help-texts/attribute-help-text.service.ts
rename to frontend/src/app/modules/fields/help-texts/attribute-help-text.service.ts
diff --git a/frontend/src/app/modules/common/help-texts/help-text.modal.html b/frontend/src/app/modules/fields/help-texts/help-text.modal.html
similarity index 100%
rename from frontend/src/app/modules/common/help-texts/help-text.modal.html
rename to frontend/src/app/modules/fields/help-texts/help-text.modal.html
diff --git a/frontend/src/app/modules/fields/macros/attribute-label-macro.component.ts b/frontend/src/app/modules/fields/macros/attribute-label-macro.component.ts
new file mode 100644
index 00000000000..808f1f3de0e
--- /dev/null
+++ b/frontend/src/app/modules/fields/macros/attribute-label-macro.component.ts
@@ -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();
+ }
+}
diff --git a/frontend/src/app/modules/fields/macros/attribute-label-macro.html b/frontend/src/app/modules/fields/macros/attribute-label-macro.html
new file mode 100644
index 00000000000..90d5e555cf6
--- /dev/null
+++ b/frontend/src/app/modules/fields/macros/attribute-label-macro.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/app/modules/fields/macros/attribute-macro.sass b/frontend/src/app/modules/fields/macros/attribute-macro.sass
new file mode 100644
index 00000000000..6523932a35d
--- /dev/null
+++ b/frontend/src/app/modules/fields/macros/attribute-macro.sass
@@ -0,0 +1,4 @@
+@import 'helpers'
+
+\:host
+ @include macro--text-style
\ No newline at end of file
diff --git a/frontend/src/app/modules/fields/macros/attribute-model-loader.service.ts b/frontend/src/app/modules/fields/macros/attribute-model-loader.service.ts
new file mode 100644
index 00000000000..209ecec3065
--- /dev/null
+++ b/frontend/src/app/modules/fields/macros/attribute-model-loader.service.ts
@@ -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();
+
+ 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 {
+ 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 {
+ 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)
+ );
+ }
+}
diff --git a/frontend/src/app/modules/fields/macros/attribute-value-macro.component.ts b/frontend/src/app/modules/fields/macros/attribute-value-macro.component.ts
new file mode 100644
index 00000000000..8e077580623
--- /dev/null
+++ b/frontend/src/app/modules/fields/macros/attribute-value-macro.component.ts
@@ -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;
+
+ // 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();
+ }
+}
diff --git a/frontend/src/app/modules/fields/macros/attribute-value-macro.html b/frontend/src/app/modules/fields/macros/attribute-value-macro.html
new file mode 100644
index 00000000000..f8b8a771f7e
--- /dev/null
+++ b/frontend/src/app/modules/fields/macros/attribute-value-macro.html
@@ -0,0 +1,9 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/app/modules/fields/macros/work-package-quickinfo-macro.component.ts b/frontend/src/app/modules/fields/macros/work-package-quickinfo-macro.component.ts
new file mode 100644
index 00000000000..767d1636ef9
--- /dev/null
+++ b/frontend/src/app/modules/fields/macros/work-package-quickinfo-macro.component.ts
@@ -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;
+ 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();
+ }
+}
diff --git a/frontend/src/app/modules/fields/macros/work-package-quickinfo-macro.html b/frontend/src/app/modules/fields/macros/work-package-quickinfo-macro.html
new file mode 100644
index 00000000000..f53abdc58f7
--- /dev/null
+++ b/frontend/src/app/modules/fields/macros/work-package-quickinfo-macro.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+ #{{workPackage.id}}:
+
+
+
+
+ (
+
+
+ )
+
+
+
+
+
+
+
diff --git a/frontend/src/app/modules/fields/macros/work-package-quickinfo-macro.sass b/frontend/src/app/modules/fields/macros/work-package-quickinfo-macro.sass
new file mode 100644
index 00000000000..7612314f5de
--- /dev/null
+++ b/frontend/src/app/modules/fields/macros/work-package-quickinfo-macro.sass
@@ -0,0 +1,7 @@
+@import 'helpers'
+
+\:host
+ @include macro--text-style
+
+display-field
+ padding-right: 2px
\ No newline at end of file
diff --git a/frontend/src/app/modules/fields/openproject-fields.module.ts b/frontend/src/app/modules/fields/openproject-fields.module.ts
index 8d23e285849..411b4d189ec 100644
--- a/frontend/src/app/modules/fields/openproject-fields.module.ts
+++ b/frontend/src/app/modules/fields/openproject-fields.module.ts
@@ -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 {
diff --git a/frontend/src/app/modules/grids/widgets/custom-text/custom-text.component.ts b/frontend/src/app/modules/grids/widgets/custom-text/custom-text.component.ts
index ed43ddfeae2..31306da43f6 100644
--- a/frontend/src/app/modules/grids/widgets/custom-text/custom-text.component.ts
+++ b/frontend/src/app/modules/grids/widgets/custom-text/custom-text.component.ts
@@ -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) {
diff --git a/frontend/src/app/modules/hal/resources/schema-resource.ts b/frontend/src/app/modules/hal/resources/schema-resource.ts
index ef5b049ad90..17e0b6a2370 100644
--- a/frontend/src/app/modules/hal/resources/schema-resource.ts
+++ b/frontend/src/app/modules/hal/resources/schema-resource.ts
@@ -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 {
diff --git a/frontend/src/app/modules/plugins/plugin-context.ts b/frontend/src/app/modules/plugins/plugin-context.ts
index 9260a2026b0..654212b9979 100644
--- a/frontend/src/app/modules/plugins/plugin-context.ts
+++ b/frontend/src/app/modules/plugins/plugin-context.ts
@@ -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),
opModalService: this.injector.get(OpModalService),
opFileUpload: this.injector.get(OpenProjectFileUploadService),
- attributeHelpTexts: this.injector.get(AttributeHelpTextsService),
displayField: this.injector.get(DisplayFieldService),
editField: this.injector.get(EditFieldService),
macros: this.injector.get(EditorMacrosService),
@@ -63,7 +60,6 @@ export class OpenProjectPluginContext {
public readonly classes = {
modals: {
passwordConfirmation: PasswordConfirmationModal,
- attributeHelpTexts: AttributeHelpTextModal,
dynamicContent: DynamicContentModal,
},
HalResource: HalResource,
diff --git a/frontend/src/app/modules/work_packages/openproject-work-packages.module.ts b/frontend/src/app/modules/work_packages/openproject-work-packages.module.ts
index 4651b5f24bf..d838799f7ca 100644
--- a/frontend/src/app/modules/work_packages/openproject-work-packages.module.ts
+++ b/frontend/src/app/modules/work_packages/openproject-work-packages.module.ts
@@ -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 {
diff --git a/frontend/src/global_styles/openproject/_mixins.sass b/frontend/src/global_styles/openproject/_mixins.sass
index f7c70172463..f9d34e6fa3f 100644
--- a/frontend/src/global_styles/openproject/_mixins.sass
+++ b/frontend/src/global_styles/openproject/_mixins.sass
@@ -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)
\ No newline at end of file
+ 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)
diff --git a/lib/open_project/text_formatting/filters/pattern_matcher_filter.rb b/lib/open_project/text_formatting/filters/pattern_matcher_filter.rb
index df7feefbacc..af071670b68 100644
--- a/lib/open_project/text_formatting/filters/pattern_matcher_filter.rb
+++ b/lib/open_project/text_formatting/filters/pattern_matcher_filter.rb
@@ -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
diff --git a/lib/open_project/text_formatting/matchers/attribute_macros.rb b/lib/open_project/text_formatting/matchers/attribute_macros.rb
new file mode 100644
index 00000000000..8b0f6142c33
--- /dev/null
+++ b/lib/open_project/text_formatting/matchers/attribute_macros.rb
@@ -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
diff --git a/lib/open_project/text_formatting/matchers/link_handlers/work_packages.rb b/lib/open_project/text_formatting/matchers/link_handlers/work_packages.rb
index 8759f3df072..737d8a9880c 100644
--- a/lib/open_project/text_formatting/matchers/link_handlers/work_packages.rb
+++ b/lib/open_project/text_formatting/matchers/link_handlers/work_packages.rb
@@ -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]),
diff --git a/spec/features/wysiwyg/macros/attribute_macros_spec.rb b/spec/features/wysiwyg/macros/attribute_macros_spec.rb
new file mode 100644
index 00000000000..6008149e6e8
--- /dev/null
+++ b/spec/features/wysiwyg/macros/attribute_macros_spec.rb
@@ -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
+
+
+
+
+ | Label |
+ Value |
+
+
+
+
+ | workPackageLabel:"Foo Bar":subject |
+ workPackageValue:"Foo Bar":subject |
+
+
+ | projectLabel:identifier |
+ projectValue:identifier |
+
+
+ | invalid subject workPackageValue:"Invalid":subject |
+ invalid project projectValue:"does not exist":identifier |
+
+
+
+ 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
diff --git a/spec/features/wysiwyg/macros/quicklink_macros_spec.rb b/spec/features/wysiwyg/macros/quicklink_macros_spec.rb
new file mode 100644
index 00000000000..7d02743a584
--- /dev/null
+++ b/spec/features/wysiwyg/macros/quicklink_macros_spec.rb
@@ -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
diff --git a/spec/lib/open_project/text_formatting/markdown/attribute_macros_spec.rb b/spec/lib/open_project/text_formatting/markdown/attribute_macros_spec.rb
new file mode 100644
index 00000000000..56cffbb068f
--- /dev/null
+++ b/spec/lib/open_project/text_formatting/markdown/attribute_macros_spec.rb
@@ -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
+
+ My headline
+
+
+ Inline reference to WP by ID:
+
+
+ Inline reference to WP by subject:
+
+
+ Inline reference to project:
+
+
+ Inline reference to project with id:
+
+ 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
+
+ My headline
+
+
+ Inline reference to WP by ID:
+
+
+ Inline reference to WP by subject:
+
+
+ Inline reference to project:
+
+
+ Inline reference to project with id:
+
+ EXPECTED
+ end
+
+ it 'should match' do
+ expect(subject).to be_html_eql(expected)
+ end
+ end
+end
diff --git a/spec/lib/open_project/text_formatting/markdown/markdown_spec.rb b/spec/lib/open_project/text_formatting/markdown/markdown_spec.rb
index b7fe9e28c4c..ccb03fe6b56 100644
--- a/spec/lib/open_project/text_formatting/markdown/markdown_spec.rb
+++ b/spec/lib/open_project/text_formatting/markdown/markdown_spec.rb
@@ -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("foo (bar #{issue_link})
") }
+ 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("foo (bar #{issue_link})
") }
+ end
context 'Escaping issue link' do
subject { format_text("Some leading text. !##{issue.id}. Some following") }
diff --git a/spec/requests/api/v3/work_package_resource_spec.rb b/spec/requests/api/v3/work_package_resource_spec.rb
index a7a46de02ec..32e886443c7 100644
--- a/spec/requests/api/v3/work_package_resource_spec.rb
+++ b/spec/requests/api/v3/work_package_resource_spec.rb
@@ -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