diff --git a/frontend/src/app/core/path-helper/path-helper.service.ts b/frontend/src/app/core/path-helper/path-helper.service.ts
index f4f2006fcdc..5e80a59998c 100644
--- a/frontend/src/app/core/path-helper/path-helper.service.ts
+++ b/frontend/src/app/core/path-helper/path-helper.service.ts
@@ -54,6 +54,10 @@ export class PathHelperService {
return `${this.staticBase}/attachments/${attachmentIdentifier}/content`;
}
+ public attributeHelpTextsShowDialogPath(id:string|number) {
+ return `${this.staticBase}/attribute_help_texts/${id}/show_dialog`;
+ }
+
public fileLinksPath():string {
return `${this.api.v3.apiV3Base}/file_links`;
}
diff --git a/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text-modal.service.ts b/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text-modal.service.ts
new file mode 100644
index 00000000000..89751aaac8e
--- /dev/null
+++ b/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text-modal.service.ts
@@ -0,0 +1,49 @@
+//-- copyright
+// OpenProject is an open source project management software.
+// Copyright (C) the OpenProject GmbH
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License version 3.
+//
+// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+// Copyright (C) 2006-2013 Jean-Philippe Lang
+// Copyright (C) 2010-2013 the ChiliProject Team
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License
+// as published by the Free Software Foundation; either version 2
+// of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program; if not, write to the Free Software
+// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+//
+// See COPYRIGHT and LICENSE files for more details.
+//++
+
+import { Injectable } from '@angular/core';
+import { TurboRequestsService } from 'core-app/core/turbo/turbo-requests.service';
+import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
+
+@Injectable({ providedIn: 'root' })
+export class AttributeHelpTextModalService {
+ constructor(
+ protected pathHelper:PathHelperService,
+ protected turboRequests:TurboRequestsService,
+ ) {
+
+ }
+
+ public show(helpTextId:string):void {
+ void this.turboRequests.requestStream(this.helpTextModalUrl(helpTextId));
+ }
+
+ private helpTextModalUrl(helpTextId:string):string {
+ return this.pathHelper.attributeHelpTextsShowDialogPath(helpTextId);
+ }
+}
diff --git a/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.component.html b/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.component.html
index a1bedaacb73..818456a46af 100644
--- a/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.component.html
+++ b/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.component.html
@@ -1,14 +1,25 @@
-
+
-
-
-
+ >
+
+
+
+
+
diff --git a/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.component.spec.ts b/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.component.spec.ts
new file mode 100644
index 00000000000..27ee0cacbdc
--- /dev/null
+++ b/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.component.spec.ts
@@ -0,0 +1,108 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core';
+import { AttributeHelpTextComponent } from 'core-app/shared/components/attribute-help-texts/attribute-help-text.component';
+import { By } from '@angular/platform-browser';
+import { I18nService } from 'core-app/core/i18n/i18n.service';
+import { AttributeHelpTextsService } from './attribute-help-text.service';
+import { AttributeHelpTextModalService } from './attribute-help-text-modal.service';
+import { QuestionIconComponent } from '@openproject/octicons-angular';
+import { OpIconComponent } from '../icon/icon.component';
+
+describe('AttributeHelpTextComponent', () => {
+ let component:AttributeHelpTextComponent;
+ let fixture:ComponentFixture;
+ let element:DebugElement;
+
+ const serviceStub = {};
+ let modalServiceStub:jasmine.SpyObj;
+ const i18nStub = { t: (_scope:string|string[], _options?:{ [key:string]:any }) => 'Show help text' };
+
+ beforeEach(() => {
+ modalServiceStub = jasmine.createSpyObj('AttributeHelpTextModalService', ['show']);
+
+ void TestBed
+ .configureTestingModule({
+ declarations: [
+ AttributeHelpTextComponent,
+ OpIconComponent,
+ ],
+ providers: [
+ { provide: AttributeHelpTextsService, useValue: serviceStub },
+ { provide: AttributeHelpTextModalService, useValue: modalServiceStub },
+ { provide: I18nService, useValue: i18nStub },
+ ],
+ imports: [
+ QuestionIconComponent,
+ ],
+ schemas: [CUSTOM_ELEMENTS_SCHEMA],
+ })
+ .compileComponents();
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(AttributeHelpTextComponent);
+ component = fixture.debugElement.componentInstance;
+ component.helpTextId = 1;
+ component.attribute = 'subject';
+ component.attributeScope = 'Project';
+ element = fixture.debugElement;
+
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('renders a button', () => {
+ const button = element.query(By.css("[role='button']"));
+
+ expect(button).toBeTruthy();
+ expect(button.nativeElement).toHaveClass('spot-link');
+ });
+
+ it('renders a tooltip', () => {
+ const tooltip = element.query(By.css('tool-tip'));
+
+ expect(tooltip).toBeTruthy();
+ expect(tooltip.nativeElement.textContent).toEqual('Show help text');
+ expect(tooltip.nativeElement.getAttribute('for')).toMatch(/attribute-help-text-component-\d+/);
+ expect(tooltip.nativeElement.popover).toEqual('manual');
+ expect(tooltip.nativeElement.dataset.direction).toEqual('sw');
+ expect(tooltip.nativeElement.dataset.type).toEqual('label');
+ });
+
+ it('renders an icon', () => {
+ const icon = element.query(By.directive(QuestionIconComponent));
+
+ expect(icon.nativeElement.getAttribute('size')).toEqual('xsmall');
+ });
+
+ it('applies .help-text--entry class', () => {
+ const button = element.query(By.css("[role='button']"));
+
+ expect(button.nativeElement).toHaveClass('help-text--entry');
+ });
+
+ it('applies an ID', () => {
+ const button = element.query(By.css("[role='button']"));
+
+ expect(button.nativeElement.id).toMatch(/attribute-help-text-component-\d+/);
+ });
+
+ it('defines a data-qa-help-text-for attribute', () => {
+ const button = element.query(By.css("[role='button']"));
+
+ expect(button.nativeElement.dataset.qaHelpTextFor).toEqual('subject');
+ });
+
+ it('should call modalService on click', () => {
+ const button = element.query(By.css("[role='button']"));
+ button.nativeElement.click();
+
+ fixture.detectChanges();
+ void fixture.whenStable().then(() => {
+ expect(modalServiceStub.show).toHaveBeenCalledOnceWith('1');
+ });
+ });
+});
diff --git a/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.component.ts b/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.component.ts
index 189ed962f2e..d9186ed1c5a 100644
--- a/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.component.ts
+++ b/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.component.ts
@@ -36,10 +36,10 @@ import {
OnInit,
} from '@angular/core';
import { I18nService } from 'core-app/core/i18n/i18n.service';
-import { OpModalService } from 'core-app/shared/components/modal/modal.service';
import { populateInputsFromDataset } from 'core-app/shared/components/dataset-inputs';
import { AttributeHelpTextsService } from './attribute-help-text.service';
-import { AttributeHelpTextModalComponent } from './attribute-help-text.modal';
+import { AttributeHelpTextModalService } from './attribute-help-text-modal.service';
+import { uniqueId } from 'lodash';
export const attributeHelpTextSelector = 'attribute-help-text';
@@ -57,21 +57,19 @@ export class AttributeHelpTextComponent implements OnInit {
// Scope to search for
@Input() public attributeScope:string;
- // Load single id entry if given
+ // Use single id entry if given
@Input() public helpTextId?:string|number;
- public exists = false;
+ readonly tooltipId = uniqueId('tooltip-');
readonly text = {
open_dialog: this.I18n.t('js.help_texts.show_modal'),
- edit: this.I18n.t('js.button_edit'),
- close: this.I18n.t('js.button_close'),
};
constructor(
readonly elementRef:ElementRef,
protected attributeHelpTexts:AttributeHelpTextsService,
- protected opModalService:OpModalService,
+ protected attributeHelpTextModalService:AttributeHelpTextModalService,
protected cdRef:ChangeDetectorRef,
protected injector:Injector,
protected I18n:I18nService,
@@ -80,30 +78,39 @@ export class AttributeHelpTextComponent implements OnInit {
}
ngOnInit() {
- if (this.helpTextId) {
- this.exists = true;
- } else {
- // Need to load the promise to find out if the attribute exists
- this.load().then((resource) => {
- this.exists = !!resource;
+ // Need to load the promise to find out if the attribute exists
+ this.getId()
+ .then((id) => {
+ this.helpTextId = id;
this.cdRef.detectChanges();
- return resource;
- });
- }
+ })
+ .catch(() => {});
+ }
+
+ public get exists() {
+ return this.helpTextId != null;
+ }
+
+ public get buttonId():string {
+ return `attribute-help-text-component-${this.helpTextId}`;
}
public handleClick(event:Event):void {
- this.load().then((resource) => {
- this.opModalService.show(AttributeHelpTextModalComponent, this.injector, { helpText: resource });
- });
+ void this.getId().then((id) => this.attributeHelpTextModalService.show(id));
event.preventDefault();
}
+ private async getId() {
+ if (this.exists) return this.helpTextId!.toString();
+
+ const resource = await this.load();
+ const id = resource?.id;
+ if (!id) return Promise.reject();
+ return id;
+ }
+
private load() {
- if (this.helpTextId) {
- return this.attributeHelpTexts.requireById(this.helpTextId);
- }
return this.attributeHelpTexts.require(this.attribute, this.attributeScope);
}
}
diff --git a/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.modal.ts b/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.modal.ts
deleted file mode 100644
index 0efd83ea25a..00000000000
--- a/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.modal.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-//-- copyright
-// OpenProject is an open source project management software.
-// Copyright (C) the OpenProject GmbH
-//
-// This program is free software; you can redistribute it and/or
-// modify it under the terms of the GNU General Public License version 3.
-//
-// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
-// Copyright (C) 2006-2013 Jean-Philippe Lang
-// Copyright (C) 2010-2013 the ChiliProject Team
-//
-// This program is free software; you can redistribute it and/or
-// modify it under the terms of the GNU General Public License
-// as published by the Free Software Foundation; either version 2
-// of the License, or (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with this program; if not, write to the Free Software
-// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
-//
-// See COPYRIGHT and LICENSE files for more details.
-//++
-
-import {
- ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Inject, OnInit,
-} from '@angular/core';
-import { OpModalComponent } from 'core-app/shared/components/modal/modal.component';
-import { OpModalLocalsMap } from 'core-app/shared/components/modal/modal.types';
-import { OpModalLocalsToken } from 'core-app/shared/components/modal/modal.service';
-import { I18nService } from 'core-app/core/i18n/i18n.service';
-import { HelpTextResource } from 'core-app/features/hal/resources/help-text-resource';
-
-@Component({
- templateUrl: './help-text.modal.html',
- changeDetection: ChangeDetectionStrategy.OnPush,
-})
-export class AttributeHelpTextModalComponent extends OpModalComponent implements OnInit {
- readonly text = {
- attachments: this.I18n.t('js.label_attachments'),
- edit: this.I18n.t('js.button_edit'),
- close: this.I18n.t('js.button_close'),
- };
-
- public helpText:HelpTextResource = this.locals.helpText!;
-
- constructor(
- @Inject(OpModalLocalsToken) public locals:OpModalLocalsMap,
- readonly I18n:I18nService,
- readonly cdRef:ChangeDetectorRef,
- readonly elementRef:ElementRef,
- ) {
- super(locals, cdRef, elementRef);
- }
-
- ngOnInit() {
- super.ngOnInit();
-
- // Load the attachments
- this
- .helpText
- .attachments
- .$load()
- .then(() => this.cdRef.detectChanges());
- }
-
- public get helpTextLink() {
- if (this.helpText.editText) {
- return this.helpText.editText.$link.href;
- }
-
- return '';
- }
-}
diff --git a/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.module.ts b/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.module.ts
index 8867385b62b..f6b0afc9461 100644
--- a/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.module.ts
+++ b/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.module.ts
@@ -1,24 +1,20 @@
import { CommonModule } from '@angular/common';
-import { NgModule } from '@angular/core';
+import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { OpenprojectAttachmentsModule } from 'core-app/shared/components/attachments/openproject-attachments.module';
import { IconModule } from 'core-app/shared/components/icon/icon.module';
-import { OpenprojectModalModule } from 'core-app/shared/components/modal/modal.module';
import { AttributeHelpTextComponent } from './attribute-help-text.component';
-import { AttributeHelpTextModalComponent } from './attribute-help-text.modal';
import { StaticAttributeHelpTextComponent } from './static-attribute-help-text.component';
import { StaticAttributeHelpTextModalComponent } from './static-attribute-help-text.modal';
@NgModule({
imports: [
CommonModule,
- OpenprojectModalModule,
OpenprojectAttachmentsModule,
IconModule,
],
declarations: [
AttributeHelpTextComponent,
- AttributeHelpTextModalComponent,
StaticAttributeHelpTextComponent,
StaticAttributeHelpTextModalComponent,
],
@@ -28,5 +24,6 @@ import { StaticAttributeHelpTextModalComponent } from './static-attribute-help-t
AttributeHelpTextComponent,
StaticAttributeHelpTextComponent,
],
+ schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AttributeHelpTextModule {}
diff --git a/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.service.ts b/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.service.ts
index b451f6ce914..ea15bf05fc7 100644
--- a/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.service.ts
+++ b/frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.service.ts
@@ -29,9 +29,8 @@
import { input } from '@openproject/reactivestates';
import { Injectable } from '@angular/core';
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
-import { take } from 'rxjs/operators';
-import { CollectionResource } from 'core-app/features/hal/resources/collection-resource';
import { HelpTextResource } from 'core-app/features/hal/resources/help-text-resource';
+import { firstValueFrom, map } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class AttributeHelpTextsService {
@@ -49,43 +48,30 @@ export class AttributeHelpTextsService {
public require(attribute:string, scope:string):Promise {
this.load();
- return new Promise((resolve, reject) => {
- this.helpTexts
+ return new Promise((resolve) => {
+ void this.helpTexts
.valuesPromise()
.then(() => resolve(this.find(attribute, scope)));
});
}
- /**
- * Search for a given attribute help text
- *
- */
- public requireById(id:string|number):Promise {
- this.load();
-
- return this
- .helpTexts
- .values$()
- .pipe(
- take(1),
- )
- .toPromise()
- .then(() => {
- const value = this.helpTexts.getValueOr([]);
- return _.find(value, (element) => element.id?.toString() === id.toString());
- });
+ private load():void {
+ this.helpTexts
+ .putFromPromiseIfPristine(() => firstValueFrom(this.loadUncached()));
}
- private load():void {
- this.helpTexts.putFromPromiseIfPristine(() => this.apiV3Service
+ private loadUncached() {
+ return this
+ .apiV3Service
.help_texts
.get()
- .toPromise()
- .then((resources:CollectionResource) => resources.elements));
+ .pipe(
+ map((collection) => collection.elements),
+ );
}
- private find(attribute:string, scope:string):HelpTextResource|undefined {
- const value = this.helpTexts.getValueOr([]);
- return _.find(value, (element) => element.scope === scope && element.attribute === attribute);
+ private find(attribute:string, scope:string) {
+ const value = this.helpTexts.getValueOr([]);
+ return value.find((element) => element.scope === scope && element.attribute === attribute);
}
}
diff --git a/frontend/src/app/shared/components/attribute-help-texts/help-text.modal.html b/frontend/src/app/shared/components/attribute-help-texts/help-text.modal.html
deleted file mode 100644
index cbbaf4267ce..00000000000
--- a/frontend/src/app/shared/components/attribute-help-texts/help-text.modal.html
+++ /dev/null
@@ -1,51 +0,0 @@
-
diff --git a/spec/features/projects/attribute_help_texts_spec.rb b/spec/features/projects/attribute_help_texts_spec.rb
index d180322d940..79ebfa7061d 100644
--- a/spec/features/projects/attribute_help_texts_spec.rb
+++ b/spec/features/projects/attribute_help_texts_spec.rb
@@ -31,18 +31,30 @@
require "spec_helper"
RSpec.describe "Project attribute help texts", :js do
- let(:project) { create(:project) }
+ let!(:project) { create(:project) }
- let(:instance) do
- create(:project_help_text,
- attribute_name: :name,
- help_text: "Some **help text** for name.")
- create(:project_help_text,
- attribute_name: :description,
- help_text: "Some **help text** for description.")
- create(:project_help_text,
- attribute_name: :status,
- help_text: "Some **help text** for status.")
+ let!(:name_help_text) do
+ create(
+ :project_help_text,
+ attribute_name: :name,
+ help_text: "Some **help text** for name."
+ )
+ end
+
+ let!(:description_help_text) do
+ create(
+ :project_help_text,
+ attribute_name: :description,
+ help_text: "Some **help text** for description."
+ )
+ end
+
+ let!(:status_help_text) do
+ create(
+ :project_help_text,
+ attribute_name: :status,
+ help_text: "Some **help text** for status."
+ )
end
let(:grid) do
@@ -56,17 +68,23 @@ RSpec.describe "Project attribute help texts", :js do
end_column: 1)
end
- let(:modal) { Components::AttributeHelpTextModal.new(instance) }
- let(:wp_page) { Pages::FullWorkPackage.new work_package }
-
before do
login_as user
- project
- instance
end
- shared_examples "allows to view help texts" do
- it "shows an indicator for whatever help text exists" do
+ shared_examples "allows to view help texts" do |show_edit:|
+ it "shows help text links" do
+ visit project_path(project)
+
+ within "#menu-sidebar" do
+ click_link_or_button "Overview"
+ end
+
+ wait_for_network_idle
+ expect(page).to have_css("#{test_selector('op-widget-box--header')} .help-text--entry", wait: 10, count: 2)
+ end
+
+ it "shows help text modal on clicking help text link" do
visit project_path(project)
within "#menu-sidebar" do
@@ -77,18 +95,28 @@ RSpec.describe "Project attribute help texts", :js do
expect(page).to have_css("#{test_selector('op-widget-box--header')} .help-text--entry", wait: 10)
# Open help text modal
- modal.open!
- expect(modal.modal_container).to have_css("strong", text: "help text")
- modal.expect_edit(editable: user.allowed_globally?(:edit_attribute_help_texts))
+ page.find("[data-qa-help-text-for='description").click
- modal.close!
+ expect(page).to have_modal "Description"
+ within_modal "Description" do
+ expect(page).to have_css("strong", text: "help text")
+
+ expect(page).to have_button "Close"
+ if show_edit
+ expect(page).to have_link "Edit"
+ end
+
+ click_on "Close"
+ end
+
+ expect(page).to have_no_modal "Description"
end
end
describe "as admin" do
let(:user) { create(:admin) }
- it_behaves_like "allows to view help texts"
+ it_behaves_like "allows to view help texts", show_edit: true
it "shows the help text on the project create form", :selenium do
visit new_project_path
@@ -119,6 +147,6 @@ RSpec.describe "Project attribute help texts", :js do
create(:user, member_with_permissions: { project => [:view_project] })
end
- it_behaves_like "allows to view help texts"
+ it_behaves_like "allows to view help texts", show_edit: false
end
end
diff --git a/spec/features/work_packages/attribute_help_texts_spec.rb b/spec/features/work_packages/attribute_help_texts_spec.rb
index 06f1ebde8e1..38545669405 100644
--- a/spec/features/work_packages/attribute_help_texts_spec.rb
+++ b/spec/features/work_packages/attribute_help_texts_spec.rb
@@ -40,7 +40,6 @@ RSpec.describe "Work package attribute help texts", :js do
help_text: "Some **help text** for status.")
end
- let(:modal) { Components::AttributeHelpTextModal.new(instance) }
let(:wp_page) { Pages::FullWorkPackage.new work_package }
before do
@@ -52,23 +51,33 @@ RSpec.describe "Work package attribute help texts", :js do
wp_page.ensure_page_loaded
end
- shared_examples "allows to view help texts" do
+ shared_examples "allows to view help texts" do |show_edit:|
it "shows an indicator for whatever help text exists" do
expect(page).to have_css('.work-package--single-view [data-qa-help-text-for="status"]')
# Open help text modal
- modal.open!
- expect(modal.modal_container).to have_css("strong", text: "help text")
- modal.expect_edit(editable: user.allowed_globally?(:edit_attribute_help_texts))
+ page.find("[data-qa-help-text-for='status']").click
- modal.close!
+ expect(page).to have_modal "Status"
+ within_modal "Status" do
+ expect(page).to have_css("strong", text: "help text")
+
+ expect(page).to have_button "Close"
+ if show_edit
+ expect(page).to have_link "Edit"
+ end
+
+ click_on "Close"
+ end
+
+ expect(page).to have_no_modal "Status"
end
end
describe "as admin" do
let(:user) { create(:admin) }
- it_behaves_like "allows to view help texts"
+ it_behaves_like "allows to view help texts", show_edit: false
end
describe "as regular user" do
@@ -76,6 +85,6 @@ RSpec.describe "Work package attribute help texts", :js do
create(:user, member_with_permissions: { project => [:view_work_packages] })
end
- it_behaves_like "allows to view help texts"
+ it_behaves_like "allows to view help texts", show_edit: false
end
end