Skip to content

Commit 7ba82f1

Browse files
committed
[ITB-2164] Treat navigation controls (e.g., menu, buttons, table rows) as links allowing opening in new tabs
1 parent 47e383e commit 7ba82f1

90 files changed

Lines changed: 1437 additions & 701 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

gitb-ui/ui/src/app/app.module.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,7 @@ import {StartupWizardModalComponent} from './modals/startup-wizard-modal/startup
295295
import {RadioCardComponent} from './components/radio-card/radio-card.component';
296296
import {UsageTipModalComponent} from './modals/usage-tip-modal/usage-tip-modal.component';
297297
import {SubmitOnControlEnterDirective} from './directives/submit-on-control-enter.directive';
298+
import {NavTargetDirective} from './directives/nav-target.directive';
298299
import {
299300
NgbAlertModule,
300301
NgbCollapseModule,
@@ -541,7 +542,8 @@ import { TestResultCommentsModalComponent } from './modals/test-result-comments-
541542
NgbNavModule,
542543
CodemirrorModule,
543544
MarkdownModule.forRoot(),
544-
SubmitOnControlEnterDirective
545+
SubmitOnControlEnterDirective,
546+
NavTargetDirective
545547
], providers: [
546548
CookieService,
547549
{ provide: HUGERTE_SCRIPT_SRC, useValue: 'hugerte/hugerte.min.js' },

gitb-ui/ui/src/app/common/utils.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,16 @@ export class Utils {
8989
return removed;
9090
}
9191

92+
/**
93+
* Determines whether a click on a navigation link (`<a [navTarget]>`) is a plain, same-tab
94+
* navigation as opposed to one that opens the link elsewhere (a modifier click, or a
95+
* non-primary mouse button). Used to guard side effects that should only apply to the current
96+
* tab (e.g. recording a "return to source" location, or clearing display state) - since a
97+
* modified click still fires the DOM "click" event even though the browser opens the link in
98+
* a new tab/window rather than navigating away from the current page.
99+
*/
100+
public static isPlainNavigationClick(event: MouseEvent): boolean {
101+
return event.button === 0 && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey
102+
}
103+
92104
}

gitb-ui/ui/src/app/components/base-table/base-table.component.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {TableRowApi} from '../table-row/table-row-api';
2222
import {Constants} from '../../common/constants';
2323
import {Observable} from 'rxjs';
2424
import {CheckboxOption} from '../checkbox-option-panel/checkbox-option';
25+
import {NavigationTarget} from '../../types/navigation-target';
2526

2627
@Component({
2728
template: '',
@@ -64,6 +65,7 @@ export abstract class BaseTableComponent extends BaseComponent {
6465
@Input() optionProvider?: (row: any) => Observable<CheckboxOption[][]>
6566
@Input() optionsVisibleForRow?: (row: any) => boolean
6667
@Input() optionPendingProperty = 'optionPending'
68+
@Input() rowTarget?: (row: any) => NavigationTarget|undefined
6769

6870
@Output() onSelect: EventEmitter<any> = new EventEmitter()
6971
@Output() onDeselect: EventEmitter<any> = new EventEmitter()
@@ -74,6 +76,7 @@ export abstract class BaseTableComponent extends BaseComponent {
7476
@Output() onDelete: EventEmitter<any> = new EventEmitter()
7577
@Output() pageNavigation: EventEmitter<PagingEvent> = new EventEmitter()
7678
@Output() onSort: EventEmitter<TableColumnDefinition> = new EventEmitter()
79+
@Output() navigating: EventEmitter<MouseEvent> = new EventEmitter()
7780

7881
@ViewChildren("tableRowComponent") tableRowComponents?: QueryList<TableRowApi>
7982

@@ -136,6 +139,10 @@ export abstract class BaseTableComponent extends BaseComponent {
136139
this.pageNavigation.emit(event)
137140
}
138141

142+
propagateNavigating(event: MouseEvent) {
143+
this.navigating.emit(event)
144+
}
145+
139146
@HostListener('document:click', ['$event'])
140147
clickRegistered(event: Event) {
141148
this.tableRowComponents?.forEach((component) => component.documentClick(event))

gitb-ui/ui/src/app/components/breadcrumb/breadcrumb-item.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,14 @@
1414
*/
1515

1616
import { BreadcrumbType } from "src/app/types/breadcrumb-type"
17+
import { NavigationTarget } from "src/app/types/navigation-target"
1718

1819
export interface BreadcrumbItem {
1920

2021
type: BreadcrumbType
2122
action?: Function
23+
/** Populated alongside `action` whenever the crumb navigates, so it can be rendered as a real link. */
24+
target?: NavigationTarget
2225

2326
label?: string
2427
typeId?: number|string

gitb-ui/ui/src/app/components/breadcrumb/breadcrumb.component.html

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,30 @@
66
@if (!$first) {
77
<div class="breacrumbItemSeparator"><i class="fa-solid fa-angle-right"></i></div>
88
}
9-
<div class="breacrumbItem"
10-
[class.actionable]="crumb.action != undefined"
11-
#popup="ngbTooltip"
12-
(mouseover)="mouseOver($event)"
13-
[tooltipClass]="'shortTooltip'"
14-
placement="bottom"
15-
[ngbTooltip]="crumb.label"
16-
[openDelay]="Constants.TOOLTIP_DELAY"
17-
[disableTooltip]="!hasOverflow"
18-
(click)="popup.close();breadcrumbClicked(crumb)"
19-
>{{crumb.label}}</div>
9+
@if (crumb.target) {
10+
<a class="breacrumbItem actionable plain-link"
11+
#popup="ngbTooltip"
12+
(mouseover)="mouseOver($event)"
13+
[tooltipClass]="'shortTooltip'"
14+
placement="bottom"
15+
[ngbTooltip]="crumb.label"
16+
[openDelay]="Constants.TOOLTIP_DELAY"
17+
[disableTooltip]="!hasOverflow"
18+
[navTarget]="crumb.target"
19+
(click)="popup.close();breadcrumbClicked($event)"
20+
>{{crumb.label}}</a>
21+
} @else {
22+
<div class="breacrumbItem"
23+
#popup="ngbTooltip"
24+
(mouseover)="mouseOver($event)"
25+
[tooltipClass]="'shortTooltip'"
26+
placement="bottom"
27+
[ngbTooltip]="crumb.label"
28+
[openDelay]="Constants.TOOLTIP_DELAY"
29+
[disableTooltip]="!hasOverflow"
30+
(click)="popup.close()"
31+
>{{crumb.label}}</div>
32+
}
2033
}
2134
}
2235
}

gitb-ui/ui/src/app/components/breadcrumb/breadcrumb.component.less

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@
2626
margin: 0px 3px;
2727
}
2828
.breacrumbItem {
29+
/*
30+
* Explicit even though flex-item blockification already achieves this for the <a> crumbs
31+
* (actionable crumbs are now rendered as anchors so the browser exposes normal link
32+
* behaviour - open in new tab, copy link, etc.) - kept for clarity/defensiveness.
33+
*/
34+
display: block;
2935
padding: 1px 5px;
3036
margin: 0px 2px;
3137
border-radius: @border-radius;

gitb-ui/ui/src/app/components/breadcrumb/breadcrumb.component.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {BreadcrumbService} from 'src/app/services/breadcrumb.service';
2222
import {BreadcrumbLabelRequest} from 'src/app/types/breadcrumb-label-request';
2323
import {Subscription} from 'rxjs';
2424
import {Constants} from 'src/app/common/constants';
25+
import {Utils} from 'src/app/common/utils';
2526

2627
@Component({
2728
selector: 'app-breadcrumb',
@@ -51,7 +52,8 @@ export class BreadcrumbComponent implements OnInit, OnDestroy {
5152
this.homeCrumb = {
5253
label: 'Home',
5354
type: BreadcrumbType.home,
54-
action: () => this.routingService.toHome()
55+
action: () => this.routingService.toHome(),
56+
target: this.routingService.linkToHome()
5557
}
5658
this.breadcrumbSubscription = this.dataService.onBreadcrumbChange$.subscribe((info) => {
5759
setTimeout(() => {
@@ -168,10 +170,15 @@ export class BreadcrumbComponent implements OnInit, OnDestroy {
168170
}
169171
}
170172

171-
breadcrumbClicked(crumb: BreadcrumbItem) {
172-
if (crumb.action) {
173+
/**
174+
* Navigation itself is now handled by the crumb's own [navTarget] (a real router link) - this
175+
* only clears display state, and only for a plain (unmodified, primary-button) click: a
176+
* ctrl/cmd/shift/alt click or middle-click still fires this "click" handler even though the
177+
* crumb opens in a new tab/window rather than navigating away from the current one.
178+
*/
179+
breadcrumbClicked(event: MouseEvent) {
180+
if (Utils.isPlainNavigationClick(event)) {
173181
this.dataService.clearAllDisplayStates()
174-
crumb.action()
175182
}
176183
}
177184

gitb-ui/ui/src/app/components/checkbox-option-panel/checkbox-option-panel.component.html

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,26 @@
1010
@for (optionSet of options; track index1; let index1 = $index) {
1111
<div class="row">
1212
@for (option of optionSet; track index2; let index2 = $index) {
13-
<div class="col-12 itemContainer" (click)="handleClick(option.key); $event.stopPropagation()">
14-
<div class="d-flex">
15-
@if (singleSelection) {
13+
@if (option.target) {
14+
<a class="col-12 itemContainer plain-link" [navTarget]="option.target" (click)="handleClick(option, $event); $event.stopPropagation()">
15+
<div class="d-flex">
1616
<div class="optionLabel">@if (option.iconClass) { <div class="optionIcon"><i [ngClass]="option.iconClass!"></i></div> } <div>{{option.label}}</div></div>
17-
} @else {
18-
<input class="form-check-input" type="checkbox" [attr.id]="'option-'+index1+'-'+index2" [attr.name]="'option-'+index1+'-'+index2" [(ngModel)]="currentState[option.key]" (ngModelChange)="updated.emit(currentState)" [disabled]="option.disabled || false">
19-
<label class="form-check-label disable-select" [attr.for]="'option-'+index1+'-'+index2" [class.text-muted]="option.disabled">
20-
<span class="optionLabel">@if (option.iconClass) { <i class="optionIcon" [ngClass]="option.iconClass!"></i> } {{option.label}}</span>
21-
</label>
22-
}
17+
</div>
18+
</a>
19+
} @else {
20+
<div class="col-12 itemContainer" (click)="handleClick(option); $event.stopPropagation()">
21+
<div class="d-flex">
22+
@if (singleSelection) {
23+
<div class="optionLabel">@if (option.iconClass) { <div class="optionIcon"><i [ngClass]="option.iconClass!"></i></div> } <div>{{option.label}}</div></div>
24+
} @else {
25+
<input class="form-check-input" type="checkbox" [attr.id]="'option-'+index1+'-'+index2" [attr.name]="'option-'+index1+'-'+index2" [(ngModel)]="currentState[option.key]" (ngModelChange)="updated.emit(currentState)" [disabled]="option.disabled || false">
26+
<label class="form-check-label disable-select" [attr.for]="'option-'+index1+'-'+index2" [class.text-muted]="option.disabled">
27+
<span class="optionLabel">@if (option.iconClass) { <i class="optionIcon" [ngClass]="option.iconClass!"></i> } {{option.label}}</span>
28+
</label>
29+
}
30+
</div>
2331
</div>
24-
</div>
32+
}
2533
}
2634
@if (!$last) {
2735
<div class="separator"></div>

gitb-ui/ui/src/app/components/checkbox-option-panel/checkbox-option-panel.component.less

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,11 @@ label {
6262
white-space: nowrap;
6363
}
6464
.row {
65-
& > div {
65+
/*
66+
* Matches both <div> (action options) and <a> (options that navigate, rendered as a real link -
67+
* see CheckboxOption.target) item containers.
68+
*/
69+
& > div, & > a {
6670
padding: 8px 20px 8px 14px;
6771
}
6872
}

gitb-ui/ui/src/app/components/checkbox-option-panel/checkbox-option-panel.component.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ export class CheckboxOptionPanelComponent implements OnInit, OnDestroy, CheckBox
5959
@Output() opening = new EventEmitter<void>()
6060
@Output() opened = new EventEmitter<void>()
6161
@Output() closed = new EventEmitter<void>()
62+
/** Emitted for options with a `target` (navigation happens via the option's own [navTarget]) so a caller can still run pre-navigation side effects (e.g. recording a "return to source" location). */
63+
@Output() navigate = new EventEmitter<MouseEvent>()
6264

6365
@ViewChild("button") buttonElement?: ElementRef<HTMLButtonElement>
6466
@ViewChild('popupTemplate') popupTemplate?: TemplateRef<any>;
@@ -273,13 +275,23 @@ export class CheckboxOptionPanelComponent implements OnInit, OnDestroy, CheckBox
273275
}
274276
}
275277

276-
handleClick(key: string) {
278+
/**
279+
* For an option with a `target`, navigation happens via the option's own [navTarget] (a real
280+
* router link) rather than here - emitting `updated` for it too would cause a second, imperative
281+
* navigation on top of the link's own. Such options only need the popup closed, and give the
282+
* caller a chance (via `navigate`) to run any pre-navigation side effect.
283+
*/
284+
handleClick(option: CheckboxOption, event?: MouseEvent) {
277285
if (this.singleSelection) {
278-
this.currentState = {}
279-
this.currentState[key] = true
280-
const event:CheckboxOptionState = {}
281-
event[key] = true
282-
this.updated.emit(event)
286+
if (option.target == undefined) {
287+
this.currentState = {}
288+
this.currentState[option.key] = true
289+
const emitted: CheckboxOptionState = {}
290+
emitted[option.key] = true
291+
this.updated.emit(emitted)
292+
} else if (event != undefined) {
293+
this.navigate.emit(event)
294+
}
283295
this.close()
284296
}
285297
}

0 commit comments

Comments
 (0)