Nested Menus (#1554)

* added initial submenu

* added submenu - needs a bit of more work

* removed admin and nonadmin action split

* the whole menu is build under the resetactions function

* removed download from seriesAction

* changed submenu layout
changed submenu toggle icon
fix for the hovering of submenu toggle

* moved the cdMarkForCheck in the subscribe block
This commit is contained in:
Korakot Santiudommongkol 2022-09-23 07:37:30 -07:00 committed by GitHub
parent dec6802f88
commit 3cdf8df1db
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 310 additions and 185 deletions

View File

@ -2637,9 +2637,9 @@
} }
}, },
"@angular/cdk": { "@angular/cdk": {
"version": "13.2.2", "version": "13.3.9",
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-13.2.2.tgz", "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-13.3.9.tgz",
"integrity": "sha512-cT5DIaz+NI9IGb3X61Wh26+L6zdRcOXT1BP37iRbK2Qa2qM8/0VNeK6hrBBIblyoHKR/WUmRlS8XYf6mmArpZw==", "integrity": "sha512-XCuCbeuxWFyo3EYrgEYx7eHzwl76vaWcxtWXl00ka8d+WAOtMQ6Tf1D98ybYT5uwF9889fFpXAPw98mVnlo3MA==",
"requires": { "requires": {
"parse5": "^5.0.0", "parse5": "^5.0.0",
"tslib": "^2.3.0" "tslib": "^2.3.0"

View File

@ -8,6 +8,8 @@ import { Volume } from '../_models/volume';
import { AccountService } from './account.service'; import { AccountService } from './account.service';
export enum Action { export enum Action {
AddTo = -2,
Others = -1,
/** /**
* Mark entity as read * Mark entity as read
*/ */
@ -83,13 +85,13 @@ export interface ActionItem<T> {
action: Action; action: Action;
callback: (action: Action, data: T) => void; callback: (action: Action, data: T) => void;
requiresAdmin: boolean; requiresAdmin: boolean;
children: Array<ActionItem<T>>;
} }
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
export class ActionFactoryService { export class ActionFactoryService {
libraryActions: Array<ActionItem<Library>> = []; libraryActions: Array<ActionItem<Library>> = [];
seriesActions: Array<ActionItem<Series>> = []; seriesActions: Array<ActionItem<Series>> = [];
@ -108,7 +110,7 @@ export class ActionFactoryService {
hasDownloadRole = false; hasDownloadRole = false;
constructor(private accountService: AccountService) { constructor(private accountService: AccountService) {
this.accountService.currentUser$.subscribe(user => { this.accountService.currentUser$.subscribe((user) => {
if (user) { if (user) {
this.isAdmin = this.accountService.hasAdminRole(user); this.isAdmin = this.accountService.hasAdminRole(user);
this.hasDownloadRole = this.accountService.hasDownloadRole(user); this.hasDownloadRole = this.accountService.hasDownloadRole(user);
@ -118,243 +120,282 @@ export class ActionFactoryService {
} }
this._resetActions(); this._resetActions();
if (this.isAdmin) {
this.collectionTagActions.push({
action: Action.Edit,
title: 'Edit',
callback: this.dummyCallback,
requiresAdmin: true
});
this.seriesActions.push({
action: Action.Scan,
title: 'Scan Series',
callback: this.dummyCallback,
requiresAdmin: true
});
this.seriesActions.push({
action: Action.RefreshMetadata,
title: 'Refresh Covers',
callback: this.dummyCallback,
requiresAdmin: true
});
this.seriesActions.push({
action: Action.AnalyzeFiles,
title: 'Analyze Files',
callback: this.dummyCallback,
requiresAdmin: true
});
this.seriesActions.push({
action: Action.Delete,
title: 'Delete',
callback: this.dummyCallback,
requiresAdmin: true
});
this.seriesActions.push({
action: Action.AddToCollection,
title: 'Add to Collection',
callback: this.dummyCallback,
requiresAdmin: true
});
this.seriesActions.push({
action: Action.Edit,
title: 'Edit',
callback: this.dummyCallback,
requiresAdmin: true
});
this.libraryActions.push({
action: Action.Scan,
title: 'Scan Library',
callback: this.dummyCallback,
requiresAdmin: true
});
this.libraryActions.push({
action: Action.RefreshMetadata,
title: 'Refresh Covers',
callback: this.dummyCallback,
requiresAdmin: true
});
this.libraryActions.push({
action: Action.AnalyzeFiles,
title: 'Analyze Files',
callback: this.dummyCallback,
requiresAdmin: true
});
this.chapterActions.push({
action: Action.Edit,
title: 'Details',
callback: this.dummyCallback,
requiresAdmin: false
});
}
if (this.hasDownloadRole || this.isAdmin) {
this.volumeActions.push({
action: Action.Download,
title: 'Download',
callback: this.dummyCallback,
requiresAdmin: false
});
this.chapterActions.push({
action: Action.Download,
title: 'Download',
callback: this.dummyCallback,
requiresAdmin: false
});
}
}); });
} }
getLibraryActions(callback: (action: Action, library: Library) => void) { getLibraryActions(callback: (action: Action, library: Library) => void) {
const actions = this.libraryActions.map(a => {return {...a}}); return this.applyCallbackToList(this.libraryActions, callback);
actions.forEach(action => action.callback = callback);
return actions;
} }
getSeriesActions(callback: (action: Action, series: Series) => void) { getSeriesActions(callback: (action: Action, series: Series) => void) {
const actions = this.seriesActions.map(a => {return {...a}}); return this.applyCallbackToList(this.seriesActions, callback);
actions.forEach(action => action.callback = callback);
return actions;
} }
getVolumeActions(callback: (action: Action, volume: Volume) => void) { getVolumeActions(callback: (action: Action, volume: Volume) => void) {
const actions = this.volumeActions.map(a => {return {...a}}); return this.applyCallbackToList(this.volumeActions, callback);
actions.forEach(action => action.callback = callback);
return actions;
} }
getChapterActions(callback: (action: Action, chapter: Chapter) => void) { getChapterActions(callback: (action: Action, chapter: Chapter) => void) {
const actions = this.chapterActions.map(a => {return {...a}}); return this.applyCallbackToList(this.chapterActions, callback);
actions.forEach(action => action.callback = callback);
return actions;
} }
getCollectionTagActions(callback: (action: Action, collectionTag: CollectionTag) => void) { getCollectionTagActions(callback: (action: Action, collectionTag: CollectionTag) => void) {
const actions = this.collectionTagActions.map(a => {return {...a}}); return this.applyCallbackToList(this.collectionTagActions, callback);
actions.forEach(action => action.callback = callback);
return actions;
} }
getReadingListActions(callback: (action: Action, readingList: ReadingList) => void) { getReadingListActions(callback: (action: Action, readingList: ReadingList) => void) {
const actions = this.readingListActions.map(a => {return {...a}}); return this.applyCallbackToList(this.readingListActions, callback);
actions.forEach(action => action.callback = callback);
return actions;
} }
getBookmarkActions(callback: (action: Action, series: Series) => void) { getBookmarkActions(callback: (action: Action, series: Series) => void) {
const actions = this.bookmarkActions.map(a => {return {...a}}); return this.applyCallbackToList(this.bookmarkActions, callback);
actions.forEach(action => action.callback = callback);
return actions;
} }
dummyCallback(action: Action, data: any) {} dummyCallback(action: Action, data: any) {}
_resetActions() { _resetActions() {
this.libraryActions = []; this.libraryActions = [
{
action: Action.Scan,
title: 'Scan Library',
callback: this.dummyCallback,
requiresAdmin: false,
children: [],
},
{
action: Action.Others,
title: 'Others',
callback: this.dummyCallback,
requiresAdmin: true,
children: [
{
action: Action.RefreshMetadata,
title: 'Refresh Covers',
callback: this.dummyCallback,
requiresAdmin: true,
children: [],
},
{
action: Action.AnalyzeFiles,
title: 'Analyze Files',
callback: this.dummyCallback,
requiresAdmin: true,
children: [],
},
],
},
];
this.collectionTagActions = [
{
action: Action.Edit,
title: 'Edit',
callback: this.dummyCallback,
requiresAdmin: true,
children: [],
},
];
this.collectionTagActions = [];
this.seriesActions = [ this.seriesActions = [
{ {
action: Action.MarkAsRead, action: Action.MarkAsRead,
title: 'Mark as Read', title: 'Mark as Read',
callback: this.dummyCallback, callback: this.dummyCallback,
requiresAdmin: false requiresAdmin: false,
children: [],
}, },
{ {
action: Action.MarkAsUnread, action: Action.MarkAsUnread,
title: 'Mark as Unread', title: 'Mark as Unread',
callback: this.dummyCallback, callback: this.dummyCallback,
requiresAdmin: false requiresAdmin: false,
}, children: [],
{
action: Action.AddToReadingList,
title: 'Add to Reading List',
callback: this.dummyCallback,
requiresAdmin: false
}, },
{ {
action: Action.AddToWantToReadList, action: Action.AddTo,
title: 'Add to Want To Read', title: 'Add to',
callback: this.dummyCallback, callback: this.dummyCallback,
requiresAdmin: false requiresAdmin: false,
children: [
{
action: Action.AddToWantToReadList,
title: 'Add to Want To Read',
callback: this.dummyCallback,
requiresAdmin: false,
children: [],
},
{
action: Action.RemoveFromWantToReadList,
title: 'Remove from Want To Read',
callback: this.dummyCallback,
requiresAdmin: false,
children: [],
},
{
action: Action.AddToReadingList,
title: 'Add to Reading List',
callback: this.dummyCallback,
requiresAdmin: false,
children: [],
},
{
action: Action.AddToCollection,
title: 'Add to Collection',
callback: this.dummyCallback,
requiresAdmin: true,
children: [],
},
],
}, },
{ {
action: Action.RemoveFromWantToReadList, action: Action.Scan,
title: 'Remove from Want To Read', title: 'Scan Series',
callback: this.dummyCallback, callback: this.dummyCallback,
requiresAdmin: false requiresAdmin: false,
} children: [],
},
{
action: Action.Edit,
title: 'Edit',
callback: this.dummyCallback,
requiresAdmin: true,
children: [],
},
{
action: Action.Others,
title: 'Others',
callback: this.dummyCallback,
requiresAdmin: false,
children: [
{
action: Action.RefreshMetadata,
title: 'Refresh Covers',
callback: this.dummyCallback,
requiresAdmin: true,
children: [],
},
{
action: Action.AnalyzeFiles,
title: 'Analyze Files',
callback: this.dummyCallback,
requiresAdmin: true,
children: [],
},
{
action: Action.Delete,
title: 'Delete',
callback: this.dummyCallback,
requiresAdmin: true,
children: [],
},
],
},
]; ];
this.volumeActions = [ this.volumeActions = [
{
action: Action.IncognitoRead,
title: 'Read Incognito',
callback: this.dummyCallback,
requiresAdmin: false,
children: [],
},
{ {
action: Action.MarkAsRead, action: Action.MarkAsRead,
title: 'Mark as Read', title: 'Mark as Read',
callback: this.dummyCallback, callback: this.dummyCallback,
requiresAdmin: false requiresAdmin: false,
children: [],
}, },
{ {
action: Action.MarkAsUnread, action: Action.MarkAsUnread,
title: 'Mark as Unread', title: 'Mark as Unread',
callback: this.dummyCallback, callback: this.dummyCallback,
requiresAdmin: false requiresAdmin: false,
}, children: [],
{
action: Action.AddToReadingList,
title: 'Add to Reading List',
callback: this.dummyCallback,
requiresAdmin: false
},
{
action: Action.IncognitoRead,
title: 'Read Incognito',
callback: this.dummyCallback,
requiresAdmin: false
}, },
{
action: Action.AddTo,
title: 'Add to',
callback: this.dummyCallback,
requiresAdmin: false,
children: [
{
action: Action.AddToReadingList,
title: 'Add to Reading List',
callback: this.dummyCallback,
requiresAdmin: false,
children: [],
}
]
},
{ {
action: Action.Edit, action: Action.Edit,
title: 'Details', title: 'Details',
callback: this.dummyCallback, callback: this.dummyCallback,
requiresAdmin: false requiresAdmin: false,
} children: [],
},
{
action: Action.Download,
title: 'Download',
callback: this.dummyCallback,
requiresAdmin: false,
children: [],
},
]; ];
this.chapterActions = [ this.chapterActions = [
{
action: Action.IncognitoRead,
title: 'Read Incognito',
callback: this.dummyCallback,
requiresAdmin: false,
children: [],
},
{ {
action: Action.MarkAsRead, action: Action.MarkAsRead,
title: 'Mark as Read', title: 'Mark as Read',
callback: this.dummyCallback, callback: this.dummyCallback,
requiresAdmin: false requiresAdmin: false,
children: [],
}, },
{ {
action: Action.MarkAsUnread, action: Action.MarkAsUnread,
title: 'Mark as Unread', title: 'Mark as Unread',
callback: this.dummyCallback, callback: this.dummyCallback,
requiresAdmin: false requiresAdmin: false,
children: [],
}, },
{
action: Action.AddTo,
title: 'Add to',
callback: this.dummyCallback,
requiresAdmin: false,
children: [
{
action: Action.AddToReadingList,
title: 'Add to Reading List',
callback: this.dummyCallback,
requiresAdmin: false,
children: [],
}
]
},
{ {
action: Action.IncognitoRead, action: Action.Edit,
title: 'Read Incognito', title: 'Details',
callback: this.dummyCallback, callback: this.dummyCallback,
requiresAdmin: false requiresAdmin: false,
children: [],
}, },
// RBS will handle rendering this, so non-admins with download are appicable
{ {
action: Action.AddToReadingList, action: Action.Download,
title: 'Add to Reading List', title: 'Download',
callback: this.dummyCallback, callback: this.dummyCallback,
requiresAdmin: false requiresAdmin: false,
children: [],
}, },
]; ];
@ -363,13 +404,15 @@ export class ActionFactoryService {
action: Action.Edit, action: Action.Edit,
title: 'Edit', title: 'Edit',
callback: this.dummyCallback, callback: this.dummyCallback,
requiresAdmin: false requiresAdmin: false,
children: [],
}, },
{ {
action: Action.Delete, action: Action.Delete,
title: 'Delete', title: 'Delete',
callback: this.dummyCallback, callback: this.dummyCallback,
requiresAdmin: false requiresAdmin: false,
children: [],
}, },
]; ];
@ -378,20 +421,41 @@ export class ActionFactoryService {
action: Action.ViewSeries, action: Action.ViewSeries,
title: 'View Series', title: 'View Series',
callback: this.dummyCallback, callback: this.dummyCallback,
requiresAdmin: false requiresAdmin: false,
children: [],
}, },
{ {
action: Action.DownloadBookmark, action: Action.DownloadBookmark,
title: 'Download', title: 'Download',
callback: this.dummyCallback, callback: this.dummyCallback,
requiresAdmin: false requiresAdmin: false,
children: [],
}, },
{ {
action: Action.Delete, action: Action.Delete,
title: 'Clear', title: 'Clear',
callback: this.dummyCallback, callback: this.dummyCallback,
requiresAdmin: false requiresAdmin: false,
children: [],
}, },
] ];
} }
private applyCallback(action: ActionItem<any>, callback: (action: Action, data: any) => void) {
action.callback = callback;
if (action.children === null || action.children?.length === 0) return;
action.children?.forEach((childAction) => {
this.applyCallback(childAction, callback);
});
}
private applyCallbackToList(list: Array<ActionItem<any>>, callback: (action: Action, data: any) => void): Array<ActionItem<any>> {
const actions = list.map((a) => {
return { ...a };
});
actions.forEach((action) => this.applyCallback(action, callback));
return actions;
}
} }

View File

@ -130,7 +130,7 @@ export class CardDetailDrawerComponent implements OnInit, OnDestroy {
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this)) this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this))
.filter(item => item.action !== Action.Edit); .filter(item => item.action !== Action.Edit);
this.chapterActions.push({title: 'Read', action: Action.Read, callback: this.handleChapterActionCallback.bind(this), requiresAdmin: false}); this.chapterActions.push({title: 'Read', action: Action.Read, callback: this.handleChapterActionCallback.bind(this), requiresAdmin: false, children: []});
this.libraryService.getLibraryType(this.libraryId).subscribe(type => { this.libraryService.getLibraryType(this.libraryId).subscribe(type => {
this.libraryType = type; this.libraryType = type;

View File

@ -2,10 +2,22 @@
<div ngbDropdown container="body" class="d-inline-block"> <div ngbDropdown container="body" class="d-inline-block">
<button [disabled]="disabled" class="btn {{btnClass}}" id="actions-{{labelBy}}" ngbDropdownToggle (click)="preventClick($event)"><i class="fa {{iconClass}}" aria-hidden="true"></i></button> <button [disabled]="disabled" class="btn {{btnClass}}" id="actions-{{labelBy}}" ngbDropdownToggle (click)="preventClick($event)"><i class="fa {{iconClass}}" aria-hidden="true"></i></button>
<div ngbDropdownMenu attr.aria-labelledby="actions-{{labelBy}}"> <div ngbDropdownMenu attr.aria-labelledby="actions-{{labelBy}}">
<button ngbDropdownItem *ngFor="let action of nonAdminActions" (click)="performAction($event, action)">{{action.title}}</button> <ng-container *ngTemplateOutlet="submenu; context: { list: actions }"></ng-container>
<div class="dropdown-divider" *ngIf="nonAdminActions.length > 1 && adminActions.length > 1"></div>
<button ngbDropdownItem *ngFor="let action of adminActions" (click)="performAction($event, action)">{{action.title}}</button>
</div> </div>
</div> </div>
<!-- IDEA: If we are not on desktop, then let's open a bottom drawer instead--> <ng-template #submenu let-list="list">
<ng-container *ngFor="let action of list">
<ng-container *ngIf="action.children === undefined || action?.children?.length === 0 else submenuDropdown">
<button ngbDropdownItem *ngIf="willRenderAction(action)" (click)="performAction($event, action)">{{action.title}}</button>
</ng-container>
<ng-template #submenuDropdown>
<div ngbDropdown #subMenuHover="ngbDropdown" placement="right" (mouseover)="preventClick($event); openSubmenu(action.title, subMenuHover)" (mouseleave)="preventClick($event)">
<button id="actions-{{action.title}}" class="submenu-toggle" ngbDropdownToggle>{{action.title}} <i class="fa-solid fa-angle-right submenu-icon"></i></button>
<div ngbDropdownMenu attr.aria-labelledby="actions-{{action.title}}">
<ng-container *ngTemplateOutlet="submenu; context: { list: action.children }"></ng-container>
</div>
</div>
</ng-template>
</ng-container>
</ng-template>
</ng-container> </ng-container>

View File

@ -1,3 +1,28 @@
.dropdown-toggle:after { .dropdown-toggle:after {
content: none !important; content: none !important;
} }
.submenu-toggle {
display: block;
width: 100%;
padding: var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);
font-weight: 400;
text-align: inherit;
border: 0;
color: var(--dropdown-item-text-color);
background-color: var(--dropdown-item-bg-color);
&:hover {
color: var(--dropdown-item-text-color);
background-color: var(--dropdown-item-hover-bg-color);
cursor: pointer;
}
&:focus-visible {
color: var(--dropdown-item-text-color);
background-color: var(--dropdown-item-hover-bg-color);
}
}
.submenu-icon {
float: right;
padding: var(--bs-dropdown-item-padding-y) 0;
}

View File

@ -1,5 +1,8 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { ActionItem } from 'src/app/_services/action-factory.service'; import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap';
import { take } from 'rxjs';
import { AccountService } from 'src/app/_services/account.service';
import { Action, ActionItem } from 'src/app/_services/action-factory.service';
@Component({ @Component({
selector: 'app-card-actionables', selector: 'app-card-actionables',
@ -16,16 +19,19 @@ export class CardActionablesComponent implements OnInit {
@Input() disabled: boolean = false; @Input() disabled: boolean = false;
@Output() actionHandler = new EventEmitter<ActionItem<any>>(); @Output() actionHandler = new EventEmitter<ActionItem<any>>();
adminActions: ActionItem<any>[] = []; isAdmin: boolean = false;
nonAdminActions: ActionItem<any>[] = []; canDownload: boolean = false;
submenu: {[key: string]: NgbDropdown} = {};
constructor(private readonly cdRef: ChangeDetectorRef, private accountService: AccountService) { }
constructor(private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void { ngOnInit(): void {
this.nonAdminActions = this.actions.filter(item => !item.requiresAdmin); this.accountService.currentUser$.pipe(take(1)).subscribe((user) => {
this.adminActions = this.actions.filter(item => item.requiresAdmin); if (!user) return;
this.cdRef.markForCheck(); this.isAdmin = this.accountService.hasAdminRole(user);
this.canDownload = this.accountService.hasDownloadRole(user);
this.cdRef.markForCheck();
});
} }
preventClick(event: any) { preventClick(event: any) {
@ -41,4 +47,23 @@ export class CardActionablesComponent implements OnInit {
} }
} }
willRenderAction(action: ActionItem<any>): boolean {
return (action.requiresAdmin && this.isAdmin)
|| (action.action === Action.Download && (this.canDownload || this.isAdmin))
|| (!action.requiresAdmin && action.action !== Action.Download)
}
openSubmenu(actionTitle: string, subMenu: NgbDropdown) {
// We keep track when we open and when we get a request to open, if we have other keys, we close them and clear their keys
if (Object.keys(this.submenu).length > 0) {
const keys = Object.keys(this.submenu).filter(k => k !== actionTitle);
keys.forEach(key => {
this.submenu[key].close();
delete this.submenu[key];
});
}
this.submenu[actionTitle] = subMenu;
subMenu.open();
}
} }

View File

@ -482,7 +482,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
this.seriesActions = this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this)) this.seriesActions = this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this))
.filter(action => action.action !== Action.Edit); .filter(action => action.action !== Action.Edit);
this.seriesActions.push({action: Action.Download, callback: this.seriesActions[0].callback, requiresAdmin: false, title: 'Download'});
this.volumeActions = this.actionFactoryService.getVolumeActions(this.handleVolumeActionCallback.bind(this)); this.volumeActions = this.actionFactoryService.getVolumeActions(this.handleVolumeActionCallback.bind(this));
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this)); this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this));