[#64550] Port attribute-help-text to Primer dialog

Makes ng `attribute-help-text` interop with Primerized attribute help
text dialogs.
This commit is contained in:
Alexander Brandon Coles
2025-06-09 17:41:13 +01:00
parent 76350e719a
commit 5ef4c53673
11 changed files with 294 additions and 224 deletions
@@ -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`;
}
@@ -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);
}
}
@@ -1,14 +1,25 @@
<div
*ngIf="exists"
<ng-container *ngIf="exists">
<div
class="spot-link help-text--entry"
[attr.id]="buttonId"
[attr.aria-labelledby]="tooltipId"
[attr.data-qa-help-text-for]="attribute"
[title]="text.open_dialog"
(click)="handleClick($event)"
(keydown.enter)="handleClick($event)"
(keydown.space)="handleClick($event)"
role="button"
tabindex="0"
>
<span *ngIf="additionalLabel" [textContent]="additionalLabel"></span>
<svg question-icon size="xsmall"></svg>
</div>
>
<span *ngIf="additionalLabel" [textContent]="additionalLabel"></span>
<svg question-icon size="xsmall"></svg>
</div>
<tool-tip
class="sr-only position-absolute"
[attr.for]="buttonId"
[attr.id]="tooltipId"
popover="manual"
data-direction="sw"
data-type="label"
[textContent]="text.open_dialog"
/>
</ng-container>
@@ -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<AttributeHelpTextComponent>;
let element:DebugElement;
const serviceStub = {};
let modalServiceStub:jasmine.SpyObj<AttributeHelpTextModalService>;
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');
});
});
});
@@ -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);
}
}
@@ -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 '';
}
}
@@ -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 {}
@@ -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<HelpTextResource|undefined> {
this.load();
return new Promise<HelpTextResource|undefined>((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<HelpTextResource|undefined> {
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<HelpTextResource>) => 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<HelpTextResource[]>([]);
return value.find((element) => element.scope === scope && element.attribute === attribute);
}
}
@@ -1,51 +0,0 @@
<div
class="spot-modal attribute-help-text--modal loading-indicator--location"
data-indicator-name="modal"
>
<div
class="spot-modal--header"
data-test-selector="attribute-help-text--header"
>
<span class="spot-icon spot-icon_help1"></span>
<span class="attribute-help-text--header-text"
[textContent]="helpText.attributeCaption"></span>
</div>
<div class="spot-modal--body spot-container">
<div
class="op-uc-container op-uc-container__no-permalinks"
[innerHtml]="helpText.helpText.html"
></div>
<fieldset class="form--fieldset" *ngIf="helpText && helpText.attachments.elements.length > 0">
<legend class="form--fieldset-legend">
{{ text.attachments }}
</legend>
<op-attachments
[resource]="helpText"
[allowUploading]="false"
></op-attachments>
</fieldset>
</div>
<div class="spot-action-bar">
<div class="spot-action-bar--right">
<button
type="button"
class="button spot-action-bar--action spot-modal--cancel-button"
(click)="closeMe()"
>
{{ text.close }}
</button>
<a
class="help-text--edit-button button spot-action-bar--action"
*ngIf="helpText.editText"
[attr.href]="helpTextLink"
[attr.title]="text.edit"
>
<op-icon icon-classes="button--icon icon-edit"></op-icon>
<span class="button--text" [textContent]="text.edit"></span>
</a>
</div>
</div>
</div>
@@ -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
@@ -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