Merge pull request #2030 from atmire/w2p-97732_feature-context-help-service

Context help tooltips
This commit is contained in:
Tim Donohue
2023-02-02 10:39:27 -06:00
committed by GitHub
37 changed files with 1143 additions and 27 deletions

View File

@@ -9,7 +9,18 @@
</ng-template>
<ng-template #editheader>
<h2 class="border-bottom pb-2">{{messagePrefix + '.head.edit' | translate}}</h2>
<h2 class="border-bottom pb-2">
<span
*dsContextHelp="{
content: 'admin.access-control.groups.form.tooltip.editGroupPage',
id: 'edit-group-page',
iconPlacement: 'right',
tooltipPlacement: ['right', 'bottom']
}"
>
{{messagePrefix + '.head.edit' | translate}}
</span>
</h2>
</ng-template>
<ds-alert *ngIf="groupBeingEdited?.permanent" [type]="AlertTypeEnum.Warning"

View File

@@ -1,9 +1,19 @@
<ng-container>
<h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3>
<h4 id="search" class="border-bottom pb-2">{{messagePrefix + '.search.head' | translate}}
<h4 id="search" class="border-bottom pb-2">
<span
*dsContextHelp="{
content: 'admin.access-control.groups.form.tooltip.editGroup.addEpeople',
id: 'edit-group-add-epeople',
iconPlacement: 'right',
tooltipPlacement: ['top', 'right', 'bottom']
}"
>
{{messagePrefix + '.search.head' | translate}}
</span>
</h4>
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
<div>
<select name="scope" id="scope" formControlName="scope" class="form-control" aria-label="Search scope">

View File

@@ -1,7 +1,16 @@
<ng-container>
<h3 class="border-bottom pb-2">{{messagePrefix + '.head' | translate}}</h3>
<h4 id="search" class="border-bottom pb-2">{{messagePrefix + '.search.head' | translate}}
<h4 id="search" class="border-bottom pb-2">
<span *dsContextHelp="{
content: 'admin.access-control.groups.form.tooltip.editGroup.addSubgroups',
id: 'edit-group-add-subgroups',
iconPlacement: 'right',
tooltipPlacement: ['top', 'right', 'bottom']
}"
>
{{messagePrefix + '.search.head' | translate}}
</span>
</h4>
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">

View File

@@ -47,6 +47,7 @@ import { truncatableReducer, TruncatablesState } from './shared/truncatable/trun
import { ThemeState, themeReducer } from './shared/theme-support/theme.reducer';
import { MenusState } from './shared/menu/menus-state.model';
import { correlationIdReducer } from './correlation-id/correlation-id.reducer';
import { contextHelpReducer, ContextHelpState } from './shared/context-help.reducer';
export interface AppState {
router: RouterReducerState;
@@ -67,6 +68,7 @@ export interface AppState {
epeopleRegistry: EPeopleRegistryState;
groupRegistry: GroupRegistryState;
correlationId: string;
contextHelp: ContextHelpState;
}
export const appReducers: ActionReducerMap<AppState> = {
@@ -87,7 +89,8 @@ export const appReducers: ActionReducerMap<AppState> = {
communityList: CommunityListReducer,
epeopleRegistry: ePeopleRegistryReducer,
groupRegistry: groupRegistryReducer,
correlationId: correlationIdReducer
correlationId: correlationIdReducer,
contextHelp: contextHelpReducer,
};
export const routerStateSelector = (state: AppState) => state.router;

View File

@@ -64,7 +64,7 @@
</p>
<ul class="footer-info list-unstyled small d-flex justify-content-center mb-0">
<li>
<a class="text-white" href="javascript:void(0);"
<a class="text-white" href="javascript:void(0);"
(click)="showCookieSettings()">{{ 'footer.link.cookies' | translate}}</a>
</li>
<li *ngIf="showPrivacyPolicy">

View File

@@ -0,0 +1,10 @@
<div *ngIf="buttonVisible$ | async">
<a href="javascript:void(0);"
role="button"
(click)="onClick()"
[attr.aria-label]="'nav.context-help-toggle' | translate"
[title]="'nav.context-help-toggle' | translate"
>
<i class="fas fa-lg fa-fw fa-question-circle ds-context-help-toggle"></i>
</a>
</div>

View File

@@ -0,0 +1,8 @@
.ds-context-help-toggle {
color: var(--ds-header-icon-color);
background-color: var(--ds-header-bg);
&:hover, &:focus {
color: var(--ds-header-icon-color-hover);
}
}

View File

@@ -0,0 +1,63 @@
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { ContextHelpToggleComponent } from './context-help-toggle.component';
import { TranslateModule } from '@ngx-translate/core';
import { ContextHelpService } from '../../shared/context-help.service';
import { of as observableOf } from 'rxjs';
import { By } from '@angular/platform-browser';
describe('ContextHelpToggleComponent', () => {
let component: ContextHelpToggleComponent;
let fixture: ComponentFixture<ContextHelpToggleComponent>;
let contextHelpService;
beforeEach(async () => {
contextHelpService = jasmine.createSpyObj('contextHelpService', [
'tooltipCount$', 'toggleIcons'
]);
contextHelpService.tooltipCount$.and.returnValue(observableOf(0));
await TestBed.configureTestingModule({
declarations: [ ContextHelpToggleComponent ],
providers: [
{ provide: ContextHelpService, useValue: contextHelpService },
],
imports: [ TranslateModule.forRoot() ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(ContextHelpToggleComponent);
component = fixture.componentInstance;
});
it('should create', () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
describe('if there are no elements on the page with a tooltip', () => {
it('the toggle should not be visible', fakeAsync(() => {
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(fixture.debugElement.query(By.css('div'))).toBeNull();
});
}));
});
describe('if there are elements on the page with a tooltip', () => {
beforeEach(() => {
contextHelpService.tooltipCount$.and.returnValue(observableOf(1));
fixture.detectChanges();
});
it('clicking the button should toggle context help icon visibility', fakeAsync(() => {
fixture.whenStable().then(() => {
fixture.debugElement.query(By.css('a')).nativeElement.click();
tick();
expect(contextHelpService.toggleIcons).toHaveBeenCalled();
});
}));
});
});

View File

@@ -0,0 +1,36 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { ContextHelpService } from '../../shared/context-help.service';
import { Observable, Subscription } from 'rxjs';
import { map } from 'rxjs/operators';
/**
* Renders a "context help toggle" button that toggles the visibility of tooltip buttons on the page.
* If there are no tooltip buttons available on the current page, the toggle is unclickable.
*/
@Component({
selector: 'ds-context-help-toggle',
templateUrl: './context-help-toggle.component.html',
styleUrls: ['./context-help-toggle.component.scss']
})
export class ContextHelpToggleComponent implements OnInit, OnDestroy {
buttonVisible$: Observable<boolean>;
constructor(
private contextHelpService: ContextHelpService,
) { }
private subs: Subscription[];
ngOnInit(): void {
this.buttonVisible$ = this.contextHelpService.tooltipCount$().pipe(map(x => x > 0));
this.subs = [this.buttonVisible$.subscribe()];
}
ngOnDestroy() {
this.subs.forEach(sub => sub.unsubscribe());
}
onClick() {
this.contextHelpService.toggleIcons();
}
}

View File

@@ -8,6 +8,7 @@
<nav role="navigation" [attr.aria-label]="'nav.user.description' | translate" class="navbar navbar-light navbar-expand-md flex-shrink-0 px-0">
<ds-themed-search-navbar></ds-themed-search-navbar>
<ds-lang-switch></ds-lang-switch>
<ds-context-help-toggle></ds-context-help-toggle>
<ds-themed-auth-nav-menu></ds-themed-auth-nav-menu>
<ds-impersonate-navbar></ds-impersonate-navbar>
<div class="pl-2">

View File

@@ -15,7 +15,7 @@
a {
color: var(--ds-header-icon-color);
&:hover, &focus {
&:hover, &:focus {
color: var(--ds-header-icon-color-hover);
}
}

View File

@@ -1,6 +1,8 @@
<ng-container *ngVar="(communitiesRD$ | async) as communitiesRD">
<div *ngIf="communitiesRD?.hasSucceeded ">
<h2>{{'home.top-level-communities.head' | translate}}</h2>
<h2>
{{'home.top-level-communities.head' | translate}}
</h2>
<p class="lead">{{'home.top-level-communities.help' | translate}}</p>
<ds-viewable-collection
[config]="config"

View File

@@ -1,6 +1,8 @@
<div class="page-internal-server-error container">
<h1>500</h1>
<h2><small>{{"500.page-internal-server-error" | translate}}</small></h2>
<h2><small>
{{"500.page-internal-server-error" | translate}}
</small></h2>
<br/>
<p>{{"500.help" | translate}}</p>
<br/>

View File

@@ -42,6 +42,7 @@ import {
} from './page-internal-server-error/page-internal-server-error.component';
import { ThemedPageErrorComponent } from './page-error/themed-page-error.component';
import { PageErrorComponent } from './page-error/page-error.component';
import { ContextHelpToggleComponent } from './header/context-help-toggle/context-help-toggle.component';
const IMPORTS = [
CommonModule,
@@ -78,7 +79,8 @@ const DECLARATIONS = [
ThemedPageInternalServerErrorComponent,
PageInternalServerErrorComponent,
ThemedPageErrorComponent,
PageErrorComponent
PageErrorComponent,
ContextHelpToggleComponent,
];
const EXPORTS = [

View File

@@ -14,6 +14,11 @@ a.submit-icon {
cursor: pointer;
position: sticky;
top: 0;
color: var(--ds-header-icon-color);
&:hover, &:focus {
color: var(--ds-header-icon-color-hover);
}
}
@media screen and (max-width: map-get($grid-breakpoints, md)) {
@@ -22,8 +27,5 @@ a.submit-icon {
width: 40vw !important;
}
a.submit-icon {
color: var(--bs-link-color);
}
}

View File

@@ -13,9 +13,9 @@
}
.dropdown-toggle {
color: var(--ds-header-icon-color) !important;
color: var(--ds-header-icon-color);
&:hover, &focus {
&:hover, &:focus {
color: var(--ds-header-icon-color-hover);
}
}

View File

@@ -0,0 +1,25 @@
<ng-template #help>
<div class="preserve-line-breaks ds-context-help-content">
<ng-container *ngFor="let elem of (parsedContent$ | async)">
<ng-container *ngIf="elem.href">
<a href="{{elem.href}}" target="_blank">{{elem.text}}</a>
</ng-container>
<ng-container *ngIf="elem.href === undefined">
{{ elem }}
</ng-container>
</ng-container>
</div>
</ng-template>
<i *ngIf="shouldShowIcon$ | async"
[ngClass]="{'ds-context-help-icon fas fa-question-circle shadow-sm': true,
'ds-context-help-icon-right': iconPlacement !== 'left',
'ds-context-help-icon-left': iconPlacement === 'left'}"
[ngbTooltip]="help"
[placement]="tooltipPlacement"
autoClose="outside"
triggers="manual"
container="body"
#tooltip="ngbTooltip"
(click)="onClick()">
</i>
<ng-container *ngTemplateOutlet="templateRef"></ng-container>

View File

@@ -0,0 +1,31 @@
:host {
position: relative;
}
.ds-context-help-icon {
position: absolute;
top: 0;
cursor: pointer;
color: var(--bs-info);
background-color: var(--bs-white);
font-size: 16px; // not relative, because we don't want the icon to resize based on the container
line-height: 1;
border-radius: 50%;
}
.ds-context-help-icon-left {
left: var(--ds-context-x-offset);
}
.ds-context-help-icon-right {
right: calc(-1 * var(--ds-context-help-icon-size));
}
::ng-deep .tooltip-inner {
width: var(--ds-context-help-tooltip-width);
max-width: var(--ds-context-help-tooltip-width);
a {
color: var(--ds-context-help-tooltip-link-color);
text-decoration: underline;
}
}

View File

@@ -0,0 +1,219 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { of as observableOf, BehaviorSubject } from 'rxjs';
import { ContextHelpWrapperComponent } from './context-help-wrapper.component';
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateService } from '@ngx-translate/core';
import { ContextHelpService } from '../context-help.service';
import { ContextHelp } from '../context-help.model';
import { Component, Input, DebugElement } from '@angular/core';
import { PlacementArray } from '@ng-bootstrap/ng-bootstrap/util/positioning';
import { PlacementDir } from './placement-dir.model';
import { By } from '@angular/platform-browser';
@Component({
template: `
<ng-template #div>template</ng-template>
<ds-context-help-wrapper
#chwrapper
[templateRef]="div"
[content]="content"
[id]="id"
[tooltipPlacement]="tooltipPlacement"
[iconPlacement]="iconPlacement"
[dontParseLinks]="dontParseLinks"
>
</ds-context-help-wrapper>
`
})
class TemplateComponent {
@Input() content: string;
@Input() id: string;
@Input() tooltipPlacement?: PlacementArray;
@Input() iconPlacement?: PlacementDir;
@Input() dontParseLinks?: boolean;
}
const messages = {
lorem: 'lorem ipsum dolor sit amet',
linkTest: 'This is text, [this](https://dspace.lyrasis.org/) is a link, and [so is this](https://google.com/)'
};
const exampleContextHelp: ContextHelp = {
id: 'test-tooltip',
isTooltipVisible: false
};
describe('ContextHelpWrapperComponent', () => {
let templateComponent: TemplateComponent;
let wrapperComponent: ContextHelpWrapperComponent;
let fixture: ComponentFixture<TemplateComponent>;
let el: DebugElement;
let translateService: any;
let contextHelpService: any;
let getContextHelp$: BehaviorSubject<ContextHelp>;
let shouldShowIcons$: BehaviorSubject<boolean>;
function makeWrappedElement(): HTMLElement {
const wrapped: HTMLElement = document.createElement('div');
wrapped.innerHTML = 'example element';
return wrapped;
}
beforeEach(waitForAsync( () => {
translateService = jasmine.createSpyObj('translateService', ['get']);
contextHelpService = jasmine.createSpyObj('contextHelpService', [
'shouldShowIcons$',
'getContextHelp$',
'add',
'remove',
'toggleIcons',
'toggleTooltip',
'showTooltip',
'hideTooltip'
]);
TestBed.configureTestingModule({
imports: [ NgbTooltipModule ],
providers: [
{ provide: TranslateService, useValue: translateService },
{ provide: ContextHelpService, useValue: contextHelpService },
],
declarations: [ TemplateComponent, ContextHelpWrapperComponent ]
}).compileComponents();
}));
beforeEach(() => {
// Initializing services.
getContextHelp$ = new BehaviorSubject<ContextHelp>(exampleContextHelp);
shouldShowIcons$ = new BehaviorSubject<boolean>(false);
contextHelpService.getContextHelp$.and.returnValue(getContextHelp$);
contextHelpService.shouldShowIcons$.and.returnValue(shouldShowIcons$);
translateService.get.and.callFake((content) => observableOf(messages[content]));
getContextHelp$.next(exampleContextHelp);
shouldShowIcons$.next(false);
// Initializing components.
fixture = TestBed.createComponent(TemplateComponent);
el = fixture.debugElement;
templateComponent = fixture.componentInstance;
templateComponent.content = 'lorem';
templateComponent.id = 'test-tooltip';
templateComponent.tooltipPlacement = ['bottom'];
templateComponent.iconPlacement = 'left';
wrapperComponent = el.query(By.css('ds-context-help-wrapper')).componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(templateComponent).toBeDefined();
expect(wrapperComponent).toBeDefined();
});
it('should not show the context help icon while icon visibility is not turned on', (done) => {
fixture.whenStable().then(() => {
const wrapper = el.query(By.css('ds-context-help-wrapper')).nativeElement;
expect(wrapper.children.length).toBe(0);
done();
});
});
describe('when icon visibility is turned on', () => {
beforeEach(() => {
shouldShowIcons$.next(true);
fixture.detectChanges();
spyOn(wrapperComponent.tooltip, 'open').and.callThrough();
spyOn(wrapperComponent.tooltip, 'close').and.callThrough();
});
it('should show the context help button', (done) => {
fixture.whenStable().then(() => {
const wrapper = el.query(By.css('ds-context-help-wrapper')).nativeElement;
expect(wrapper.children.length).toBe(1);
const [i] = wrapper.children;
expect(i.tagName).toBe('I');
done();
});
});
describe('after the icon is clicked', () => {
let i;
beforeEach(() => {
i = el.query(By.css('.ds-context-help-icon')).nativeElement;
i.click();
fixture.detectChanges();
});
it('should display the tooltip', () => {
expect(contextHelpService.toggleTooltip).toHaveBeenCalledWith('test-tooltip');
getContextHelp$.next({...exampleContextHelp, isTooltipVisible: true});
fixture.detectChanges();
expect(wrapperComponent.tooltip.open).toHaveBeenCalled();
expect(wrapperComponent.tooltip.close).toHaveBeenCalledTimes(0);
expect(fixture.debugElement.query(By.css('.ds-context-help-content')).nativeElement.textContent)
.toMatch(/\s*lorem ipsum dolor sit amet\s*/);
});
it('should correctly display links', () => {
templateComponent.content = 'linkTest';
getContextHelp$.next({...exampleContextHelp, isTooltipVisible: true});
fixture.detectChanges();
const nodeList: NodeList = fixture.debugElement.query(By.css('.ds-context-help-content'))
.nativeElement
.childNodes;
const relevantNodes = Array.from(nodeList).filter(node => node.nodeType !== Node.COMMENT_NODE);
expect(relevantNodes.length).toBe(4);
const [text1, link1, text2, link2] = relevantNodes;
expect(text1.nodeType).toBe(Node.TEXT_NODE);
expect(text1.nodeValue).toMatch(/\s* This is text, \s*/);
expect(link1.nodeName).toBe('A');
expect((link1 as any).href).toBe('https://dspace.lyrasis.org/');
expect(link1.textContent).toBe('this');
expect(text2.nodeType).toBe(Node.TEXT_NODE);
expect(text2.nodeValue).toMatch(/\s* is a link, and \s*/);
expect(link2.nodeName).toBe('A');
expect((link2 as any).href).toBe('https://google.com/');
expect(link2.textContent).toBe('so is this');
});
it('should not display links if specified not to', () => {
templateComponent.dontParseLinks = true;
templateComponent.content = 'linkTest';
getContextHelp$.next({...exampleContextHelp, isTooltipVisible: true});
fixture.detectChanges();
const nodeList: NodeList = fixture.debugElement.query(By.css('.ds-context-help-content'))
.nativeElement
.childNodes;
const relevantNodes = Array.from(nodeList).filter(node => node.nodeType !== Node.COMMENT_NODE);
expect(relevantNodes.length).toBe(1);
const [text] = relevantNodes;
expect(text.nodeType).toBe(Node.TEXT_NODE);
expect(text.nodeValue).toMatch(
/\s* This is text, \[this\]\(https:\/\/dspace.lyrasis.org\/\) is a link, and \[so is this\]\(https:\/\/google.com\/\) \s*/);
});
describe('after the icon is clicked again', () => {
beforeEach(() => {
i.click();
fixture.detectChanges();
spyOn(wrapperComponent.tooltip, 'isOpen').and.returnValue(true);
});
it('should close the tooltip', () => {
expect(contextHelpService.toggleTooltip).toHaveBeenCalledWith('test-tooltip');
getContextHelp$.next({...exampleContextHelp, isTooltipVisible: false});
fixture.detectChanges();
expect(wrapperComponent.tooltip.close).toHaveBeenCalled();
});
});
});
});
});

View File

@@ -0,0 +1,171 @@
import { Component, Input, OnInit, TemplateRef, OnDestroy, ViewChild } from '@angular/core';
import { PlacementArray } from '@ng-bootstrap/ng-bootstrap/util/positioning';
import { TranslateService } from '@ngx-translate/core';
import { Observable, Subscription, BehaviorSubject, combineLatest } from 'rxjs';
import { map, distinctUntilChanged, mergeMap } from 'rxjs/operators';
import { PlacementDir } from './placement-dir.model';
import { ContextHelpService } from '../context-help.service';
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap';
import { hasValueOperator } from '../empty.util';
import { ContextHelp } from '../context-help.model';
type ParsedContent = (string | {href: string, text: string})[];
/**
* This component renders an info icon next to the wrapped element which
* produces a tooltip when clicked.
*/
@Component({
selector: 'ds-context-help-wrapper',
templateUrl: './context-help-wrapper.component.html',
styleUrls: ['./context-help-wrapper.component.scss'],
})
export class ContextHelpWrapperComponent implements OnInit, OnDestroy {
/**
* Template reference for the wrapped element.
*/
@Input() templateRef: TemplateRef<any>;
/**
* Identifier for the context help tooltip.
*/
@Input() id: string;
/**
* Indicate where the tooltip should show up, relative to the info icon.
*/
@Input() tooltipPlacement?: PlacementArray = [];
/**
* Indicate whether the info icon should appear to the left or to
* the right of the wrapped element.
*/
@Input() iconPlacement?: PlacementDir = 'left';
/**
* If true, don't process text to render links.
*/
@Input() set dontParseLinks(dont: boolean) {
this.dontParseLinks$.next(dont);
}
private dontParseLinks$: BehaviorSubject<boolean> = new BehaviorSubject(false);
shouldShowIcon$: Observable<boolean>;
tooltip: NgbTooltip;
@Input() set content(translateKey: string) {
this.content$.next(translateKey);
}
private content$: BehaviorSubject<string | undefined> = new BehaviorSubject(undefined);
parsedContent$: Observable<ParsedContent>;
private subs: {always: Subscription[], tooltipBound: Subscription[]}
= {always: [], tooltipBound: []};
constructor(
private translateService: TranslateService,
private contextHelpService: ContextHelpService
) { }
ngOnInit() {
this.parsedContent$ = combineLatest([
this.content$.pipe(distinctUntilChanged(), mergeMap(translateKey => this.translateService.get(translateKey))),
this.dontParseLinks$.pipe(distinctUntilChanged())
]).pipe(
map(([text, dontParseLinks]) =>
dontParseLinks ? [text] : this.parseLinks(text))
);
this.shouldShowIcon$ = this.contextHelpService.shouldShowIcons$();
this.subs.always = [this.parsedContent$.subscribe(), this.shouldShowIcon$.subscribe()];
}
@ViewChild('tooltip', { static: false }) set setTooltip(tooltip: NgbTooltip) {
this.tooltip = tooltip;
this.clearSubs('tooltipBound');
if (this.tooltip !== undefined) {
this.subs.tooltipBound = [
this.contextHelpService.getContextHelp$(this.id)
.pipe(hasValueOperator())
.subscribe((ch: ContextHelp) => {
if (ch.isTooltipVisible && !this.tooltip.isOpen()) {
this.tooltip.open();
} else if (!ch.isTooltipVisible && this.tooltip.isOpen()) {
this.tooltip.close();
}
}),
this.tooltip.shown.subscribe(() => {
this.contextHelpService.showTooltip(this.id);
}),
this.tooltip.hidden.subscribe(() => {
this.contextHelpService.hideTooltip(this.id);
})
];
}
}
ngOnDestroy() {
this.clearSubs();
}
onClick() {
this.contextHelpService.toggleTooltip(this.id);
}
/**
* Parses Markdown-style links, splitting up a given text
* into link-free pieces of text and objects of the form
* {href: string, text: string} (which represent links).
* This function makes no effort to check whether the href is a
* correct URL. Currently, this function does not support escape
* characters: its behavior when given a string containing square
* brackets that do not deliminate a link is undefined.
* Regular parentheses outside of links do work, however.
*
* For example:
* parseLinks("This is text, [this](https://google.com) is a link, and [so is this](https://youtube.com)")
* =
* [ "This is text, ",
* {href: "https://google.com", text: "this"},
* " is a link, and ",
* {href: "https://youtube.com", text: "so is this"}
* ]
*/
private parseLinks(text: string): ParsedContent {
// Implementation note: due to `matchAll` method on strings not being available for all versions,
// separate "split" and "parse" steps are needed.
// We use splitRegexp (the outer `match` call) to split the text
// into link-free pieces of text (matched by /[^\[]+/) and pieces
// of text of the form "[some link text](some.link.here)" (matched
// by /\[([^\]]*)\]\(([^\)]*)\)/)
const splitRegexp = /[^\[]+|\[([^\]]*)\]\(([^\)]*)\)/g;
// Once the array is split up in link-representing strings and
// non-link-representing strings, we use parseRegexp (the inner
// `match` call) to transform the link-representing strings into
// {href: string, text: string} objects.
const parseRegexp = /^\[([^\]]*)\]\(([^\)]*)\)$/;
return text.match(splitRegexp).map((substring: string) => {
const match = substring.match(parseRegexp);
return match === null
? substring
: ({href: match[2], text: match[1]});
});
}
private clearSubs(filter: null | 'tooltipBound' = null) {
if (filter === null) {
[].concat(...Object.values(this.subs)).forEach(sub => sub.unsubscribe());
this.subs = {always: [], tooltipBound: []};
} else {
this.subs[filter].forEach(sub => sub.unsubscribe());
this.subs[filter] = [];
}
}
}

View File

@@ -0,0 +1 @@
export type PlacementDir = 'left' | 'right';

View File

@@ -0,0 +1,83 @@
/* eslint-disable max-classes-per-file */
import { Action } from '@ngrx/store';
import { type } from './ngrx/type';
import { ContextHelp } from './context-help.model';
export const ContextHelpActionTypes = {
'CONTEXT_HELP_TOGGLE_ICONS': type('dspace/context-help/CONTEXT_HELP_TOGGLE_ICONS'),
'CONTEXT_HELP_ADD': type('dspace/context-help/CONTEXT_HELP_ADD'),
'CONTEXT_HELP_REMOVE': type('dspace/context-help/CONTEXT_HELP_REMOVE'),
'CONTEXT_HELP_TOGGLE_TOOLTIP': type('dspace/context-help/CONTEXT_HELP_TOGGLE_TOOLTIP'),
'CONTEXT_HELP_SHOW_TOOLTIP': type('dspace/context-help/CONTEXT_HELP_SHOW_TOOLTIP'),
'CONTEXT_HELP_HIDE_TOOLTIP' : type('dspace/context-help/CONTEXT_HELP_HIDE_TOOLTIP'),
};
/**
* Toggles the visibility of all context help icons.
*/
export class ContextHelpToggleIconsAction implements Action {
type = ContextHelpActionTypes.CONTEXT_HELP_TOGGLE_ICONS;
}
/**
* Registers a new context help icon to the store.
*/
export class ContextHelpAddAction implements Action {
type = ContextHelpActionTypes.CONTEXT_HELP_ADD;
model: ContextHelp;
constructor (model: ContextHelp) {
this.model = model;
}
}
/**
* Removes a context help icon from the store.
*/
export class ContextHelpRemoveAction implements Action {
type = ContextHelpActionTypes.CONTEXT_HELP_REMOVE;
id: string;
constructor(id: string) {
this.id = id;
}
}
export abstract class ContextHelpTooltipAction implements Action {
type;
id: string;
constructor(id: string) {
this.id = id;
}
}
/**
* Toggles the tooltip of a single context help icon.
*/
export class ContextHelpToggleTooltipAction extends ContextHelpTooltipAction {
type = ContextHelpActionTypes.CONTEXT_HELP_TOGGLE_TOOLTIP;
}
/**
* Shows the tooltip of a single context help icon.
*/
export class ContextHelpShowTooltipAction extends ContextHelpTooltipAction {
type = ContextHelpActionTypes.CONTEXT_HELP_SHOW_TOOLTIP;
}
/**
* Hides the tooltip of a single context help icon.
*/
export class ContextHelpHideTooltipAction extends ContextHelpTooltipAction {
type = ContextHelpActionTypes.CONTEXT_HELP_HIDE_TOOLTIP;
}
export type ContextHelpAction
= ContextHelpToggleIconsAction
| ContextHelpAddAction
| ContextHelpRemoveAction
| ContextHelpToggleTooltipAction
| ContextHelpShowTooltipAction
| ContextHelpHideTooltipAction;

View File

@@ -0,0 +1,93 @@
import { Component, Input } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { of as observableOf, BehaviorSubject } from 'rxjs';
import { ContextHelpDirective, ContextHelpDirectiveInput } from './context-help.directive';
import { TranslateService } from '@ngx-translate/core';
import { ContextHelpWrapperComponent } from './context-help-wrapper/context-help-wrapper.component';
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { ContextHelpService } from './context-help.service';
import { ContextHelp } from './context-help.model';
@Component({
template: `<div *dsContextHelp="contextHelpParams()">some text</div>`
})
class TestComponent {
@Input() content = '';
@Input() id = '';
contextHelpParams(): ContextHelpDirectiveInput {
return {
content: this.content,
id: this.id,
iconPlacement: 'left',
tooltipPlacement: ['bottom']
};
}
}
const messages = {
lorem: 'lorem ipsum dolor sit amet',
linkTest: 'This is text, [this](https://dspace.lyrasis.org) is a link, and [so is this](https://google.com)'
};
const exampleContextHelp: ContextHelp = {
id: 'test-tooltip',
isTooltipVisible: false
};
describe('ContextHelpDirective', () => {
let component: TestComponent;
let fixture: ComponentFixture<TestComponent>;
let translateService: any;
let contextHelpService: any;
let getContextHelp$: BehaviorSubject<ContextHelp>;
let shouldShowIcons$: BehaviorSubject<boolean>;
beforeEach(waitForAsync(() => {
translateService = jasmine.createSpyObj('translateService', ['get']);
contextHelpService = jasmine.createSpyObj('contextHelpService', [
'shouldShowIcons$',
'getContextHelp$',
'add',
'remove',
'toggleIcons',
'toggleTooltip',
'showTooltip',
'hideTooltip'
]);
TestBed.configureTestingModule({
imports: [NgbTooltipModule],
providers: [
{ provide: TranslateService, useValue: translateService },
{ provide: ContextHelpService, useValue: contextHelpService }
],
declarations: [TestComponent, ContextHelpWrapperComponent, ContextHelpDirective]
}).compileComponents();
}));
beforeEach(() => {
// Set up service behavior.
getContextHelp$ = new BehaviorSubject<ContextHelp>(exampleContextHelp);
shouldShowIcons$ = new BehaviorSubject<boolean>(false);
contextHelpService.getContextHelp$.and.returnValue(getContextHelp$);
contextHelpService.shouldShowIcons$.and.returnValue(shouldShowIcons$);
translateService.get.and.callFake((content) => observableOf(messages[content]));
// Set up fixture and component.
fixture = TestBed.createComponent(TestComponent);
component = fixture.componentInstance;
component.id = 'test-tooltip';
component.content = 'lorem';
fixture.detectChanges();
});
it('should generate the context help wrapper component', (done) => {
fixture.whenStable().then(() => {
expect(fixture.nativeElement.children.length).toBe(1);
const [wrapper] = fixture.nativeElement.children;
expect(component).toBeDefined();
expect(wrapper.tagName).toBe('DS-CONTEXT-HELP-WRAPPER');
expect(contextHelpService.add).toHaveBeenCalledWith(exampleContextHelp);
done();
});
});
});

View File

@@ -0,0 +1,76 @@
import {
ComponentFactoryResolver,
ComponentRef,
Directive,
Input,
OnChanges,
TemplateRef,
ViewContainerRef,
OnDestroy
} from '@angular/core';
import { PlacementArray } from '@ng-bootstrap/ng-bootstrap/util/positioning';
import { ContextHelpWrapperComponent } from './context-help-wrapper/context-help-wrapper.component';
import { PlacementDir } from './context-help-wrapper/placement-dir.model';
import { ContextHelpService } from './context-help.service';
export interface ContextHelpDirectiveInput {
content: string;
id: string;
tooltipPlacement?: PlacementArray;
iconPlacement?: PlacementDir;
}
/**
* Directive to add a clickable tooltip icon to an element.
* The tooltip icon's position is configurable ('left' or 'right')
* and so is the position of the tooltip itself (PlacementArray).
*/
@Directive({
selector: '[dsContextHelp]',
})
export class ContextHelpDirective implements OnChanges, OnDestroy {
/**
* Expects an object with the following fields:
* - content: a string referring to an entry in the i18n files
* - tooltipPlacement: a PlacementArray describing where the tooltip should expand, relative to the tooltip icon
* - iconPlacement: a string 'left' or 'right', describing where the tooltip icon should be placed, relative to the element
*/
@Input() dsContextHelp: ContextHelpDirectiveInput;
mostRecentId: string | undefined = undefined;
protected wrapper: ComponentRef<ContextHelpWrapperComponent>;
constructor(
private templateRef: TemplateRef<any>,
private viewContainerRef: ViewContainerRef,
private componentFactoryResolver: ComponentFactoryResolver,
private contextHelpService: ContextHelpService
) {}
ngOnChanges() {
this.clearMostRecentId();
this.mostRecentId = this.dsContextHelp.id;
this.contextHelpService.add({id: this.dsContextHelp.id, isTooltipVisible: false});
if (this.wrapper === undefined) {
const factory
= this.componentFactoryResolver.resolveComponentFactory(ContextHelpWrapperComponent);
this.wrapper = this.viewContainerRef.createComponent(factory);
}
this.wrapper.instance.templateRef = this.templateRef;
this.wrapper.instance.content = this.dsContextHelp.content;
this.wrapper.instance.id = this.dsContextHelp.id;
this.wrapper.instance.tooltipPlacement = this.dsContextHelp.tooltipPlacement;
this.wrapper.instance.iconPlacement = this.dsContextHelp.iconPlacement;
}
ngOnDestroy() {
this.clearMostRecentId();
}
private clearMostRecentId(): void {
if (this.mostRecentId !== undefined) {
this.contextHelpService.remove(this.mostRecentId);
}
}
}

View File

@@ -0,0 +1,4 @@
export class ContextHelp {
id: string;
isTooltipVisible = false;
}

View File

@@ -0,0 +1,48 @@
import { ContextHelp } from './context-help.model';
import { ContextHelpAction, ContextHelpActionTypes } from './context-help.actions';
export interface ContextHelpModels {
[id: string]: ContextHelp;
}
export interface ContextHelpState {
allIconsVisible: boolean;
models: ContextHelpModels;
}
const initialState: ContextHelpState = {allIconsVisible: false, models: {}};
export function contextHelpReducer(state: ContextHelpState = initialState, action: ContextHelpAction): ContextHelpState {
switch (action.type) {
case ContextHelpActionTypes.CONTEXT_HELP_TOGGLE_ICONS: {
return {...state, allIconsVisible: !state.allIconsVisible};
}
case ContextHelpActionTypes.CONTEXT_HELP_ADD: {
const newModels = {...state.models, [action.model.id]: action.model};
return {...state, models: newModels};
}
case ContextHelpActionTypes.CONTEXT_HELP_REMOVE: {
const {[action.id]: _, ...remainingModels} = state.models;
return {...state, models: remainingModels};
}
case ContextHelpActionTypes.CONTEXT_HELP_TOGGLE_TOOLTIP: {
return modifyTooltipVisibility(state, action.id, v => !v);
}
case ContextHelpActionTypes.CONTEXT_HELP_SHOW_TOOLTIP: {
return modifyTooltipVisibility(state, action.id, _ => true);
}
case ContextHelpActionTypes.CONTEXT_HELP_HIDE_TOOLTIP: {
return modifyTooltipVisibility(state, action.id, _ => false);
}
default: {
return state;
}
}
}
function modifyTooltipVisibility(state: ContextHelpState, id: string, modify: (vis: boolean) => boolean): ContextHelpState {
const {[id]: matchingModel, ...otherModels} = state.models;
const modifiedModel = {...matchingModel, isTooltipVisible: modify(matchingModel.isTooltipVisible)};
const newModels = {...otherModels, [id]: modifiedModel};
return {...state, models: newModels};
}

View File

@@ -0,0 +1,78 @@
import { TestBed } from '@angular/core/testing';
import { ContextHelpService } from './context-help.service';
import { StoreModule, Store } from '@ngrx/store';
import { appReducers, storeModuleConfig } from '../app.reducer';
import { TestScheduler } from 'rxjs/testing';
describe('ContextHelpService', () => {
let service: ContextHelpService;
let store;
let testScheduler;
const booleans = { f: false, t: true };
const mkContextHelp = (id: string) => ({ 0: {id, isTooltipVisible: false}, 1: {id, isTooltipVisible: true} });
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [
StoreModule.forRoot(appReducers, storeModuleConfig)
]
});
});
beforeEach(() => {
store = TestBed.inject(Store);
service = new ContextHelpService(store);
testScheduler = new TestScheduler((actual, expected) => expect(actual).toEqual(expected));
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('toggleIcons calls should be observable in shouldShowIcons$', () => {
testScheduler.run(({cold, expectObservable}) => {
const toggles = cold('-xxxxx');
toggles.subscribe((_) => service.toggleIcons());
expectObservable(service.shouldShowIcons$()).toBe('ftftft', booleans);
});
});
it('add and remove calls should be observable in getContextHelp$', () => {
testScheduler.run(({cold, expectObservable}) => {
const modifications = cold('-abAcCB', {
a: () => service.add({id: 'a', isTooltipVisible: false}),
b: () => service.add({id: 'b', isTooltipVisible: false}),
c: () => service.add({id: 'c', isTooltipVisible: false}),
A: () => service.remove('a'), B: () => service.remove('b'), C: () => service.remove('c'),
});
modifications.subscribe(mod => mod());
const match = (id) => ({ 0: undefined, 1: {id, isTooltipVisible: false} });
expectObservable(service.getContextHelp$('a')).toBe('01-0---', match('a'));
expectObservable(service.getContextHelp$('b')).toBe('0-1---0', match('b'));
expectObservable(service.getContextHelp$('c')).toBe('0---10-', match('c'));
});
});
it('toggleTooltip calls should be observable in getContextHelp$', () => {
service.add({id: 'a', isTooltipVisible: false});
service.add({id: 'b', isTooltipVisible: false});
testScheduler.run(({cold, expectObservable}) => {
const toggles = cold('-aaababbabba');
toggles.subscribe(id => service.toggleTooltip(id));
expectObservable(service.getContextHelp$('a')).toBe('0101-0--1--0', mkContextHelp('a'));
expectObservable(service.getContextHelp$('b')).toBe('0---1-01-01-', mkContextHelp('b'));
});
});
it('hideTooltip and showTooltip calls should be observable in getContextHelp$', () => {
service.add({id: 'a', isTooltipVisible: false});
testScheduler.run(({cold, expectObservable}) => {
const hideShowCalls = cold('-shssshhs', {
s: () => service.showTooltip('a'), h: () => service.hideTooltip('a')
});
hideShowCalls.subscribe(fn => fn());
expectObservable(service.getContextHelp$('a')).toBe('010111001', mkContextHelp('a'));
});
});
});

View File

@@ -0,0 +1,113 @@
import { Injectable } from '@angular/core';
import { ContextHelp } from './context-help.model';
import { Store, createFeatureSelector, createSelector, select, MemoizedSelector } from '@ngrx/store';
import { ContextHelpState, ContextHelpModels } from './context-help.reducer';
import {
ContextHelpToggleIconsAction,
ContextHelpAddAction,
ContextHelpRemoveAction,
ContextHelpShowTooltipAction,
ContextHelpHideTooltipAction,
ContextHelpToggleTooltipAction
} from './context-help.actions';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
const contextHelpStateSelector =
createFeatureSelector<ContextHelpState>('contextHelp');
const allIconsVisibleSelector = createSelector(
contextHelpStateSelector,
(state: ContextHelpState): boolean => state.allIconsVisible
);
const contextHelpSelector =
(id: string): MemoizedSelector<ContextHelpState, ContextHelp> => createSelector(
contextHelpStateSelector,
(state: ContextHelpState) => state.models[id]
);
const allContextHelpSelector = createSelector(
contextHelpStateSelector,
((state: ContextHelpState) => state.models)
);
@Injectable({
providedIn: 'root'
})
export class ContextHelpService {
constructor(private store: Store<ContextHelpState>) { }
/**
* Observable keeping track of whether context help icons should be visible globally.
*/
shouldShowIcons$(): Observable<boolean> {
return this.store.pipe(select(allIconsVisibleSelector));
}
/**
* Observable that tracks the state for a specific context help icon.
*
* @param id: id of the context help icon.
*/
getContextHelp$(id: string): Observable<ContextHelp> {
return this.store.pipe(select(contextHelpSelector(id)));
}
/**
* Observable that yields true iff there are currently no context help entries in the store.
*/
tooltipCount$(): Observable<number> {
return this.store.pipe(select(allContextHelpSelector))
.pipe(map((models: ContextHelpModels) => Object.keys(models).length));
}
/**
* Toggles the visibility of all context help icons.
*/
toggleIcons() {
this.store.dispatch(new ContextHelpToggleIconsAction());
}
/**
* Registers a new context help icon to the store.
*
* @param contextHelp: the initial state of the new help icon.
*/
add(contextHelp: ContextHelp) {
this.store.dispatch(new ContextHelpAddAction(contextHelp));
}
/**
* Removes a context help icon from the store.
*
* @id: the id of the help icon to be removed.
*/
remove(id: string) {
this.store.dispatch(new ContextHelpRemoveAction(id));
}
/**
* Toggles the tooltip of a single context help icon.
*
* @id: the id of the help icon for which the visibility will be toggled.
*/
toggleTooltip(id: string) {
this.store.dispatch(new ContextHelpToggleTooltipAction(id));
}
/**
* Shows the tooltip of a single context help icon.
*
* @id: the id of the help icon that will be made visible.
*/
showTooltip(id: string) {
this.store.dispatch(new ContextHelpShowTooltipAction(id));
}
/**
* Hides the tooltip of a single context help icon.
*
* @id: the id of the help icon that will be made invisible.
*/
hideTooltip(id: string) {
this.store.dispatch(new ContextHelpHideTooltipAction(id));
}
}

View File

@@ -2,12 +2,10 @@
display:none;
}
@media screen and (min-width: map-get($grid-breakpoints, md)) {
.dropdown-toggle {
color: var(--ds-header-icon-color) !important;
.dropdown-toggle {
color: var(--ds-header-icon-color);
&:hover, &focus {
color: var(--ds-header-icon-color-hover);
}
&:hover, &:focus {
color: var(--ds-header-icon-color-hover);
}
}

View File

@@ -1,7 +1,7 @@
.filters {
a {
color: var(--bs-body-color);
&:hover, &focus {
&:hover, &:focus {
text-decoration: none;
}
span.badge {

View File

@@ -1,6 +1,6 @@
a {
color: var(--bs-body-color);
&:hover, &focus {
&:hover, &:focus {
text-decoration: none;
}
span.badge {

View File

@@ -1,6 +1,6 @@
a {
color: var(--bs-body-color);
&:hover, &focus {
&:hover, &:focus {
text-decoration: none;
}
span.badge {

View File

@@ -227,6 +227,8 @@ import { SearchNavbarComponent } from '../search-navbar/search-navbar.component'
import { ThemedSearchNavbarComponent } from '../search-navbar/themed-search-navbar.component';
import { ScopeSelectorModalComponent } from './search-form/scope-selector-modal/scope-selector-modal.component';
import { DsSelectComponent } from './ds-select/ds-select.component';
import { ContextHelpDirective } from './context-help.directive';
import { ContextHelpWrapperComponent } from './context-help-wrapper/context-help-wrapper.component';
import { RSSComponent } from './rss-feed/rss.component';
import { BrowserOnlyPipe } from './utils/browser-only.pipe';
import { ThemedLoadingComponent } from './loading/themed-loading.component';
@@ -345,6 +347,7 @@ const COMPONENTS = [
ListableNotificationObjectComponent,
DsoPageEditButtonComponent,
MetadataFieldWrapperComponent,
ContextHelpWrapperComponent,
];
const ENTRY_COMPONENTS = [
@@ -423,7 +426,8 @@ const DIRECTIVES = [
ClaimedTaskActionsDirective,
NgForTrackByIdDirective,
MetadataFieldValidator,
HoverClassDirective
HoverClassDirective,
ContextHelpDirective,
];
@NgModule({

View File

@@ -501,7 +501,11 @@
"admin.access-control.groups.form.return": "Back",
"admin.access-control.groups.form.tooltip.editGroupPage": "On this page, you can modify the properties and members of a group. In the top section, you can edit the group name and description, unless this is an admin group for a collection or community, in which case the group name and description are auto-generated and cannot be edited. In the following sections, you can edit group membership. See [the wiki](https://wiki.lyrasis.org/display/DSDOC7x/Create+or+manage+a+user+group) for more details.",
"admin.access-control.groups.form.tooltip.editGroup.addEpeople": "To add or remove an EPerson to/from this group, either click the 'Browse All' button or use the search bar below to search for users (use the dropdown to the left of the search bar to choose whether to search by metadata or by email). Then click the plus icon for each user you wish to add in the list below, or the trash can icon for each user you wish to remove. The list below may have several pages: use the page controls below the list to navigate to the next pages. Once you are ready, save your changes by clicking the 'Save' button in the top section.",
"admin.access-control.groups.form.tooltip.editGroup.addSubgroups": "To add or remove a Subgroup to/from this group, either click the 'Browse All' button or use the search bar below to search for users. Then click the plus icon for each user you wish to add in the list below, or the trash can icon for each user you wish to remove. The list below may have several pages: use the page controls below the list to navigate to the next pages. Once you are ready, save your changes by clicking the 'Save' button in the top section.",
"admin.search.breadcrumbs": "Administrative Search",
@@ -2882,6 +2886,8 @@
"nav.community-browse.header": "By Community",
"nav.context-help-toggle": "Toggle context help",
"nav.language": "Language switch",
"nav.login": "Log In",

View File

@@ -22,8 +22,8 @@
--ds-header-bg: #{$white};
--ds-header-logo-height: 50px;
--ds-header-logo-height-xs: 50px;
--ds-header-icon-color: #{$cyan};
--ds-header-icon-color-hover: #{darken($white, 15%)};
--ds-header-icon-color: #{$link-color};
--ds-header-icon-color-hover: #{$link-hover-color};
--ds-header-navbar-border-top-color: #{$white};
--ds-header-navbar-border-bottom-color: #{$gray-400};
--ds-navbar-link-color: #{$cyan};
@@ -86,5 +86,10 @@
--ds-search-form-scope-max-width: 150px;
--ds-context-x-offset: -16px;
--ds-context-help-icon-size: 16px;
--ds-context-help-tooltip-width: 300px;
--ds-context-help-tooltip-link-color: $white;
--ds-gap: 0.25rem;
}

View File

@@ -8,6 +8,7 @@
<div class="d-flex flex-grow-1 ml-auto justify-content-end align-items-center">
<ds-search-navbar class="navbar-search"></ds-search-navbar>
<ds-lang-switch></ds-lang-switch>
<ds-context-help-toggle></ds-context-help-toggle>
<ds-themed-auth-nav-menu></ds-themed-auth-nav-menu>
<ds-impersonate-navbar></ds-impersonate-navbar>
<div class="pl-2">

View File

@@ -17,6 +17,7 @@
</div>
<ds-search-navbar class="navbar-collapsed"></ds-search-navbar>
<ds-lang-switch class="navbar-collapsed"></ds-lang-switch>
<ds-context-help-toggle class="navbar-collapsed"></ds-context-help-toggle>
<ds-themed-auth-nav-menu class="navbar-collapsed"></ds-themed-auth-nav-menu>
<ds-impersonate-navbar class="navbar-collapsed"></ds-impersonate-navbar>
</div>