[37509] Allow hal resources to define ckeditor context

This restores the functionality of defining which formattable fields
receive which capabilities of CKEditor, and especially which macros
should be shown.

https://community.openproject.org/wp/37509
fix/37509/modal-position-relative
Oliver Günther 4 years ago
parent cbe991a0ad
commit 0899a9efe3
No known key found for this signature in database
GPG Key ID: A3A8BDAD7C0C552C
  1. 15
      frontend/src/app/ckeditor/ckeditor-augmented-textarea.component.ts
  2. 1
      frontend/src/app/ckeditor/ckeditor-augmented-textarea.html
  3. 4
      frontend/src/app/components/modals/editor/macro-wp-button-modal/wp-button-macro.modal.html
  4. 18
      frontend/src/app/modules/common/ckeditor/ckeditor-setup.service.ts
  5. 6
      frontend/src/app/modules/common/ckeditor/op-ckeditor.component.ts
  6. 13
      frontend/src/app/modules/common/dynamic-forms/components/dynamic-inputs/formattable-textarea-input/components/formattable-control/formattable-control.component.html
  7. 53
      frontend/src/app/modules/common/dynamic-forms/components/dynamic-inputs/formattable-textarea-input/components/formattable-control/formattable-control.component.ts
  8. 2
      frontend/src/app/modules/fields/edit/field-types/formattable-edit-field/formattable-edit-field.component.html
  9. 34
      frontend/src/app/modules/fields/edit/field-types/formattable-edit-field/formattable-edit-field.component.ts
  10. 8
      frontend/src/app/modules/grids/widgets/custom-text/custom-text-edit-field.service.ts
  11. 5
      frontend/src/app/modules/hal/resources/hal-resource.ts
  12. 7
      frontend/src/app/modules/hal/resources/project-resource.ts
  13. 5
      frontend/src/app/modules/hal/resources/work-package-resource.ts
  14. 34
      modules/dashboards/spec/features/custom_text_spec.rb
  15. 34
      modules/dashboards/spec/features/project_description_spec.rb

@ -35,7 +35,11 @@ import { States } from 'core-components/states.service';
import { filter, takeUntil } from 'rxjs/operators';
import { NotificationsService } from "core-app/modules/common/notifications/notifications.service";
import { I18nService } from "core-app/modules/common/i18n/i18n.service";
import { ICKEditorContext, ICKEditorInstance } from "core-app/modules/common/ckeditor/ckeditor-setup.service";
import {
ICKEditorContext,
ICKEditorInstance,
ICKEditorType
} from "core-app/modules/common/ckeditor/ckeditor-setup.service";
import { OpCkeditorComponent } from "core-app/modules/common/ckeditor/op-ckeditor.component";
import { componentDestroyed } from "@w11k/ngx-componentdestroyed";
import { UntilDestroyedMixin } from "core-app/helpers/angular/until-destroyed.mixin";
@ -65,7 +69,6 @@ export class CkeditorAugmentedTextareaComponent extends UntilDestroyedMixin impl
public resource?:HalResource;
public context:ICKEditorContext;
public macros:boolean;
public editorType:string;
// Reference to the actual ckeditor instance component
@ViewChild(OpCkeditorComponent, { static: true }) private ckEditorInstance:OpCkeditorComponent;
@ -90,7 +93,7 @@ export class CkeditorAugmentedTextareaComponent extends UntilDestroyedMixin impl
this.textareaSelector = this.$element.attr('textarea-selector')!;
this.previewContext = this.$element.attr('preview-context')!;
this.macros = this.$element.attr('macros') !== 'false';
this.editorType = this.$element.attr('editor-type') || 'full';
const editorType = (this.$element.attr('editor-type') || 'full') as ICKEditorType;
// Parse the resource if any exists
const source = this.$element.data('resource');
@ -104,7 +107,11 @@ export class CkeditorAugmentedTextareaComponent extends UntilDestroyedMixin impl
this.initialContent = this.wrappedTextArea.val() as string;
this.$attachmentsElement = this.formElement.find('#attachments_fields');
this.context = { resource: this.resource, previewContext: this.previewContext };
this.context = {
type: editorType,
resource: this.resource,
previewContext: this.previewContext
};
if (!this.macros) {
this.context['macros'] = 'none';
}

@ -2,7 +2,6 @@
<div class="op-ckeditor--wrapper">
<op-ckeditor [context]="context"
[content]="initialContent"
[ckEditorType]="editorType"
(onInitialized)="setup($event)"
(onContentChange)="markEdited()">
</op-ckeditor>

@ -1,11 +1,11 @@
<form
class="op-modal loading-indicator--location"
class="op-modal op-modal_autoheight loading-indicator--location"
(submit)="applyAndClose($event)"
data-indicator-name="modal"
>
<op-modal-header (close)="closeMe($event)">{{text.title}}</op-modal-header>
<div class="op-modal--modal-body form">
<div class="op-modal--body form">
<fieldset class="form--fieldset">
<legend [textContent]="text.selected_type"></legend>
<div class="form--field">

@ -3,11 +3,11 @@ import { HalResource } from "core-app/modules/hal/resources/hal-resource";
import { Injectable } from "@angular/core";
export interface ICKEditorInstance {
getData(obtions:{ trim:boolean }):string;
getData(options:{ trim:boolean }):string;
setData(content:string):void;
on(event:string, callback:Function):void;
on(event:string, callback:() => unknown):void;
model:any;
editing:any;
@ -23,12 +23,18 @@ export interface ICKEditorStatic {
createCustomized(el:string|HTMLElement, config?:any):Promise<ICKEditorInstance>;
}
export type ICKEditorType = 'full'|'constrained';
export type ICKEditorMacroType = 'none'|'resource'|'full'|boolean|string[];
export interface ICKEditorContext {
// Editor type to setup
type:ICKEditorType;
// Hal Resource to pass into ckeditor
resource?:HalResource;
// Specific removing of plugins
removePlugins?:string[];
// Set of enabled macro plugins or false to disable all
macros?:'none'|'wp'|'full'|boolean|string[];
macros?:ICKEditorMacroType;
// Additional options like the text orientation of the editors content
options?:{
rtl?:boolean;
@ -54,15 +60,15 @@ export class CKEditorSetupService {
* Pass a ICKEditorContext object that will be used to decide active plugins.
*
*
* @param {"full" | "constrained"} type
* @param {HTMLElement} wrapper
* @param {ICKEditorContext} context
* @returns {Promise<ICKEditorInstance>}
*/
public async create(type:'full'|'constrained', wrapper:HTMLElement, context:ICKEditorContext, initialData:string|null = null) {
public async create(wrapper:HTMLElement, context:ICKEditorContext, initialData:string|null = null) {
// Load the bundle
await this.load();
const type = context.type;
const editorClass = type === 'constrained' ? window.OPConstrainedEditor : window.OPClassicEditor;
wrapper.classList.add(`ckeditor-type-${type}`);
@ -104,7 +110,7 @@ export class CKEditorSetupService {
private createConfig(context:ICKEditorContext):any {
if (context.macros === 'none') {
context.macros = false;
} else if (context.macros === 'wp') {
} else if (context.macros === 'resource') {
context.macros = [
'OPMacroToc',
'OPMacroEmbeddedTable',

@ -30,7 +30,7 @@ import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild }
import {
CKEditorSetupService,
ICKEditorContext,
ICKEditorInstance
ICKEditorInstance, ICKEditorType
} from "core-app/modules/common/ckeditor/ckeditor-setup.service";
import { NotificationsService } from "core-app/modules/common/notifications/notifications.service";
import { I18nService } from "core-app/modules/common/i18n/i18n.service";
@ -46,7 +46,6 @@ const manualModeLocalStorageKey = 'op-ckeditor-uses-manual-mode';
styleUrls: ['./op-ckeditor.sass']
})
export class OpCkeditorComponent implements OnInit {
@Input() ckEditorType:'full'|'constrained' = 'full';
@Input() context:ICKEditorContext;
@Input()
public set content(newVal:string) {
@ -110,7 +109,7 @@ export class OpCkeditorComponent implements OnInit {
* Get the current live data from CKEditor. This may raise in cases
* the data cannot be loaded (MS Edge!)
*/
public getRawData() {
public getRawData():string {
if (this.manualMode) {
return this._content = this.codeMirrorInstance!.getValue();
} else {
@ -175,7 +174,6 @@ export class OpCkeditorComponent implements OnInit {
const editorPromise = this.ckEditorSetup
.create(
this.ckEditorType,
this.opCkeditorReplacementContainer.nativeElement,
this.context,
this.content

@ -1,9 +1,10 @@
<div class="op-ckeditor--wrapper op-ckeditor-element">
<op-ckeditor [context]="ckEditorContext"
[content]="value?.raw"
(onContentChange)="onContentChange($event)"
(onInitializationFailed)="initializationError = true"
(onInitialized)="onCkeditorSetup($event)"
[ckEditorType]="templateOptions.editorType">
<op-ckeditor
[context]="ckEditorContext"
[content]="value?.raw"
(onContentChange)="onContentChange($event)"
(onInitializationFailed)="initializationError = true"
(onInitialized)="onCkeditorSetup($event)"
>
</op-ckeditor>
</div>

@ -18,36 +18,33 @@ import { OpCkeditorComponent } from "core-app/modules/common/ckeditor/op-ckedito
]
})
export class FormattableControlComponent implements OnInit {
@Input()
templateOptions:FormlyTemplateOptions;
@Input() templateOptions:FormlyTemplateOptions;
@ViewChild(OpCkeditorComponent, { static: true }) editor:OpCkeditorComponent;
text:{[key:string]: string};
value:{raw:string};
text:{ [key:string]:string };
value:{ raw:string };
disabled = false;
touched:boolean;
// Detect when inner component could not be initialized
initializationError = false;
onChange = (_:any) => { }
onTouch = () => { }
onChange:(_any:unknown) => void = () => undefined;
onTouch:() => void = () => undefined;
public get ckEditorContext():ICKEditorContext {
return {
// TODO: Can the current editor work without resource??
// resource: this.change.pristineResource,
macros: 'none' as const,
// TODO: Do we need a previewContext
// previewContext: this.previewContext,
type: this.templateOptions.editorType,
macros: 'none',
options: { rtl: this.templateOptions?.rtl }
};
}
constructor(
readonly I18n:I18nService,
) { }
) {
}
ngOnInit(): void {
ngOnInit():void {
this.text = {
attachmentLabel: this.I18n.t('js.label_formattable_attachment_hint'),
save: this.I18n.t('js.inplace.button_save', { attribute: this.templateOptions?.name }),
@ -55,40 +52,38 @@ export class FormattableControlComponent implements OnInit {
};
}
writeValue(value:{raw:string}):void {
writeValue(value:{ raw:string }):void {
this.value = value;
}
registerOnChange(fn: (_: any) => void): void {
registerOnChange(fn:(_:unknown) => void):void {
this.onChange = fn;
}
registerOnTouched(fn: any): void {
registerOnTouched(fn:() => void):void {
this.onTouch = fn;
}
setDisabledState(disabled: boolean): void {
setDisabledState(disabled:boolean):void {
this.disabled = disabled;
this.editor.ckEditorInstance.isReadOnly = disabled;
}
onContentChange(value:string) {
const valueToEmit = {raw: value};
const valueToEmit = { raw: value };
this.onTouch();
this.onChange(valueToEmit);
}
onCkeditorSetup(editor:ICKEditorInstance) {
this.editor.ckEditorInstance.ui.focusTracker.on( 'change:isFocused', ( evt:any, name:any, isFocused:any ) => {
if (!isFocused && !this.touched) {
this.touched = true;
this.onTouch();
}
} );
// TODO: Check if it is new without resource
/*if (!this.resource.isNew) {
setTimeout(() => editor.editing.view.focus());
}*/
onCkeditorSetup(_editor:ICKEditorInstance) {
this.editor.ckEditorInstance.ui.focusTracker.on(
'change:isFocused',
(evt:unknown, name:unknown, isFocused:unknown) => {
if (!isFocused && !this.touched) {
this.touched = true;
this.onTouch();
}
});
}
}

@ -5,7 +5,7 @@
(onContentChange)="onContentChange($event)"
(onInitializationFailed)="initializationError = true"
(onInitialized)="onCkeditorSetup($event)"
[ckEditorType]="editorType">
>
</op-ckeditor>
</div>
<edit-field-controls *ngIf="!(handler.inEditMode || initializationError)"

@ -45,12 +45,19 @@ export class FormattableEditFieldComponent extends EditFieldComponent implements
// Values used in template
public isPreview = false;
public previewHtml = '';
public text:any = {};
public text:Record<string, string> = {};
public initialContent:string;
public editorType = this.resource.getEditorTypeFor(this.field.name);
public ckEditorContext:ICKEditorContext = {
resource: this.change.pristineResource,
macros: 'none' as const,
previewContext: this.previewContext,
options: { rtl: this.schema.options && this.schema.options.rtl },
type: 'constrained',
...this.resource.getEditorContext(this.field.name)
};
ngOnInit() {
ngOnInit():void {
super.ngOnInit();
this.handler.registerOnSubmit(() => this.getCurrentValue());
@ -61,7 +68,7 @@ export class FormattableEditFieldComponent extends EditFieldComponent implements
};
}
public onCkeditorSetup(editor:ICKEditorInstance) {
public onCkeditorSetup(editor:ICKEditorInstance):void {
if (!this.resource.isNew) {
setTimeout(() => editor.editing.view.focus());
}
@ -75,7 +82,7 @@ export class FormattableEditFieldComponent extends EditFieldComponent implements
});
}
public onContentChange(value:string) {
public onContentChange(value:string):void {
// Have the guard clause to avoid the text being set
// in the changeset when no actual change has taken place.
if (this.rawValue !== value) {
@ -83,7 +90,7 @@ export class FormattableEditFieldComponent extends EditFieldComponent implements
}
}
public handleUserSubmit() {
public handleUserSubmit():boolean {
this.getCurrentValue()
.then(() => {
this.handler.handleUserSubmit();
@ -92,20 +99,11 @@ export class FormattableEditFieldComponent extends EditFieldComponent implements
return false;
}
public get ckEditorContext():ICKEditorContext {
return {
resource: this.change.pristineResource,
macros: 'none' as const,
previewContext: this.previewContext,
options: { rtl: this.schema.options && this.schema.options.rtl }
};
}
private get previewContext() {
return this.handler.previewContext(this.resource);
}
public reset() {
public reset():void {
if (this.editor && this.editor.initialized) {
this.editor.content = this.rawValue;
@ -113,7 +111,7 @@ export class FormattableEditFieldComponent extends EditFieldComponent implements
}
}
public get rawValue() {
public get rawValue():string {
if (this.value && this.value.raw) {
return this.value.raw;
} else {
@ -129,7 +127,7 @@ export class FormattableEditFieldComponent extends EditFieldComponent implements
return !(this.value && this.value.raw);
}
protected initialize() {
protected initialize():void {
this.initialContent = this.rawValue;
if (this.resource.isNew && this.editor) {

@ -8,6 +8,7 @@ import { HalResourceService } from "core-app/modules/hal/services/hal-resource.s
import { ResourceChangeset } from "core-app/modules/fields/changeset/resource-changeset";
import { SchemaCacheService } from "core-components/schemas/schema-cache.service";
import { SchemaResource } from "core-app/modules/hal/resources/schema-resource";
import { ICKEditorContext } from "core-app/modules/common/ckeditor/ckeditor-setup.service";
@Injectable()
export class CustomTextEditFieldService extends EditFieldHandler {
@ -134,7 +135,12 @@ export class CustomTextEditFieldService extends EditFieldHandler {
const schemaHref = 'customtext-schema';
const resourceSource = {
text: value.options.text,
getEditorTypeFor: () => 'full',
getEditorContext: () => {
return {
type: 'full',
macros: 'resource',
} as ICKEditorContext;
},
canAddAttachments: value.grid.canAddAttachments,
uploadAttachments: (files:UploadFile[]) => value.grid.uploadAttachments(files),
_links: {

@ -32,6 +32,7 @@ import { Injector } from '@angular/core';
import { States } from 'core-components/states.service';
import { I18nService } from 'core-app/modules/common/i18n/i18n.service';
import { InjectField } from "core-app/helpers/angular/inject-field.decorator";
import { ICKEditorContext } from "core-app/modules/common/ckeditor/ckeditor-setup.service";
export interface HalResourceClass<T extends HalResource = HalResource> {
new(injector:Injector,
@ -242,8 +243,8 @@ export class HalResource {
return undefined;
}
public getEditorTypeFor(_fieldName:string):'full'|'constrained' {
return 'constrained';
public getEditorContext(fieldName:string):ICKEditorContext {
return { type: 'constrained' };
}
public $load(force = false):Promise<this> {

@ -27,18 +27,19 @@
//++
import { HalResource } from 'core-app/modules/hal/resources/hal-resource';
import { ICKEditorContext } from "core-app/modules/common/ckeditor/ckeditor-setup.service";
export class ProjectResource extends HalResource {
public get state() {
return this.states.projects.get(this.id!) as any;
}
public getEditorTypeFor(fieldName:string):"full"|"constrained" {
public getEditorContext(fieldName:string):ICKEditorContext {
if (['statusExplanation', 'description'].indexOf(fieldName) !== -1) {
return 'full';
return { type: 'full', macros: 'resource' };
}
return 'constrained';
return { type: 'constrained' };
}
/**

@ -43,6 +43,7 @@ import { WorkPackagesActivityService } from "core-components/wp-single-view-tabs
import { WorkPackageNotificationService } from "core-app/modules/work_packages/notifications/work-package-notification.service";
import { InjectField } from "core-app/helpers/angular/inject-field.decorator";
import { APIV3Service } from "core-app/modules/apiv3/api-v3.service";
import { ICKEditorContext } from "core-app/modules/common/ckeditor/ckeditor-setup.service";
export interface WorkPackageResourceEmbedded {
activities:CollectionResource;
@ -171,8 +172,8 @@ export class WorkPackageBaseResource extends HalResource {
}
}
public getEditorTypeFor(fieldName:string):"full"|"constrained" {
return fieldName === 'description' ? 'full' : 'constrained';
public getEditorContext(fieldName:string):ICKEditorContext {
return { type: fieldName === 'description' ? 'full' : 'constrained', macros: false };
}
public isParentOf(otherWorkPackage:WorkPackageResource) {

@ -31,13 +31,16 @@ require 'spec_helper'
require_relative '../support/pages/dashboard'
describe 'Project description widget on dashboard', type: :feature, js: true do
let!(:type) { FactoryBot.create :type_task, name: 'Task' }
let!(:project) do
FactoryBot.create :project
FactoryBot.create :project, types: [type]
end
let(:permissions) do
%i[view_dashboards
manage_dashboards]
manage_dashboards
add_work_packages
]
end
let(:role) do
@ -63,6 +66,33 @@ describe 'Project description widget on dashboard', type: :feature, js: true do
dashboard_page.visit!
end
it 'can use the wp create button macro within it' do
dashboard_page.add_widget(1, 1, :within, "Custom text")
sleep(0.1)
# As the user lacks the manage_public_queries and save_queries permission, no other widget is present
custom_text_widget = Components::Grids::GridArea.new('.grid--area.-widgeted:nth-of-type(1)')
within custom_text_widget.area do
find('.inplace-editing--container ').click
end
editor.insert_macro 'Insert create work package button'
expect(page).to have_selector('.op-modal')
select 'Task', from: 'selected-type'
find('.op-modal--submit-button').click
field.save!
dashboard_page.expect_and_dismiss_notification message: I18n.t('js.notice_successful_update')
within('#content') do
expect(page).to have_selector("a[href=\"/projects/#{project.identifier}/work_packages/new?type=#{type.id}\"]")
end
end
it 'can add the widget set custom text and upload attachments' do
dashboard_page.add_widget(1, 1, :within, "Custom text")

@ -116,4 +116,38 @@ describe 'Project description widget on dashboard', type: :feature, js: true do
end
end
end
context 'with editing and wp add permissions' do
let!(:type) { FactoryBot.create :type_task, name: 'Task' }
let!(:project) do
FactoryBot.create :project, types: [type]
end
let(:current_user) do
FactoryBot.create(:user, member_in_project: project, member_with_permissions: editing_permissions + %i[add_work_packages])
end
let(:editor) { ::Components::WysiwygEditor.new 'body' }
it 'can create a button macro for work packages' do
# As the user lacks the manage_public_queries and save_queries permission, no other widget is present
description_widget = Components::Grids::GridArea.new('.grid--area.-widgeted:nth-of-type(1)')
field = TextEditorField.new dashboard_page, 'description'
field.activate!
editor.insert_macro 'Insert create work package button'
expect(page).to have_selector('.op-modal')
select 'Task', from: 'selected-type'
find('.op-modal--submit-button').click
field.save!
dashboard_page.expect_and_dismiss_notification message: I18n.t('js.notice_successful_update')
within('#content') do
expect(page).to have_selector("a[href=\"/projects/#{project.identifier}/work_packages/new?type=#{type.id}\"]")
end
end
end
end

Loading…
Cancel
Save