From 5ef4c53673e533087fdbb0164982c2e2f3c8cc22 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Mon, 9 Jun 2025 17:41:13 +0100 Subject: [PATCH] [#64550] Port attribute-help-text to Primer dialog Makes ng `attribute-help-text` interop with Primerized attribute help text dialogs. --- .../core/path-helper/path-helper.service.ts | 4 + .../attribute-help-text-modal.service.ts | 49 ++++++++ .../attribute-help-text.component.html | 25 ++-- .../attribute-help-text.component.spec.ts | 108 ++++++++++++++++++ .../attribute-help-text.component.ts | 51 +++++---- .../attribute-help-text.modal.ts | 78 ------------- .../attribute-help-text.module.ts | 7 +- .../attribute-help-text.service.ts | 44 +++---- .../attribute-help-texts/help-text.modal.html | 51 --------- .../projects/attribute_help_texts_spec.rb | 76 ++++++++---- .../attribute_help_texts_spec.rb | 25 ++-- 11 files changed, 294 insertions(+), 224 deletions(-) create mode 100644 frontend/src/app/shared/components/attribute-help-texts/attribute-help-text-modal.service.ts create mode 100644 frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.component.spec.ts delete mode 100644 frontend/src/app/shared/components/attribute-help-texts/attribute-help-text.modal.ts delete mode 100644 frontend/src/app/shared/components/attribute-help-texts/help-text.modal.html 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 @@ -
-
- - -
- -
- - -
- - {{ text.attachments }} - - -
-
- -
-
- - - - - -
-
-
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