From 3cdf8df1db77dfe3baad6f51db44460c5f9bdf45 Mon Sep 17 00:00:00 2001 From: Korakot Santiudommongkol <47130579+KorakotSanti@users.noreply.github.com> Date: Fri, 23 Sep 2022 07:37:30 -0700 Subject: [PATCH] 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 --- UI/Web/package-lock.json | 6 +- .../app/_services/action-factory.service.ts | 398 ++++++++++-------- .../card-detail-drawer.component.ts | 2 +- .../card-actionables.component.html | 20 +- .../card-actionables.component.scss | 27 +- .../card-actionables.component.ts | 41 +- .../series-detail/series-detail.component.ts | 1 - 7 files changed, 310 insertions(+), 185 deletions(-) diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 66b2b2840..87bfaaf59 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -2637,9 +2637,9 @@ } }, "@angular/cdk": { - "version": "13.2.2", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-13.2.2.tgz", - "integrity": "sha512-cT5DIaz+NI9IGb3X61Wh26+L6zdRcOXT1BP37iRbK2Qa2qM8/0VNeK6hrBBIblyoHKR/WUmRlS8XYf6mmArpZw==", + "version": "13.3.9", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-13.3.9.tgz", + "integrity": "sha512-XCuCbeuxWFyo3EYrgEYx7eHzwl76vaWcxtWXl00ka8d+WAOtMQ6Tf1D98ybYT5uwF9889fFpXAPw98mVnlo3MA==", "requires": { "parse5": "^5.0.0", "tslib": "^2.3.0" diff --git a/UI/Web/src/app/_services/action-factory.service.ts b/UI/Web/src/app/_services/action-factory.service.ts index 8ed905324..2ed6ec6c1 100644 --- a/UI/Web/src/app/_services/action-factory.service.ts +++ b/UI/Web/src/app/_services/action-factory.service.ts @@ -8,6 +8,8 @@ import { Volume } from '../_models/volume'; import { AccountService } from './account.service'; export enum Action { + AddTo = -2, + Others = -1, /** * Mark entity as read */ @@ -83,13 +85,13 @@ export interface ActionItem { action: Action; callback: (action: Action, data: T) => void; requiresAdmin: boolean; + children: Array>; } @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class ActionFactoryService { - libraryActions: Array> = []; seriesActions: Array> = []; @@ -108,7 +110,7 @@ export class ActionFactoryService { hasDownloadRole = false; constructor(private accountService: AccountService) { - this.accountService.currentUser$.subscribe(user => { + this.accountService.currentUser$.subscribe((user) => { if (user) { this.isAdmin = this.accountService.hasAdminRole(user); this.hasDownloadRole = this.accountService.hasDownloadRole(user); @@ -118,243 +120,282 @@ export class ActionFactoryService { } 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) { - const actions = this.libraryActions.map(a => {return {...a}}); - actions.forEach(action => action.callback = callback); - return actions; + return this.applyCallbackToList(this.libraryActions, callback); } getSeriesActions(callback: (action: Action, series: Series) => void) { - const actions = this.seriesActions.map(a => {return {...a}}); - actions.forEach(action => action.callback = callback); - return actions; + return this.applyCallbackToList(this.seriesActions, callback); } getVolumeActions(callback: (action: Action, volume: Volume) => void) { - const actions = this.volumeActions.map(a => {return {...a}}); - actions.forEach(action => action.callback = callback); - return actions; + return this.applyCallbackToList(this.volumeActions, callback); } getChapterActions(callback: (action: Action, chapter: Chapter) => void) { - const actions = this.chapterActions.map(a => {return {...a}}); - actions.forEach(action => action.callback = callback); - return actions; + return this.applyCallbackToList(this.chapterActions, callback); } getCollectionTagActions(callback: (action: Action, collectionTag: CollectionTag) => void) { - const actions = this.collectionTagActions.map(a => {return {...a}}); - actions.forEach(action => action.callback = callback); - return actions; + return this.applyCallbackToList(this.collectionTagActions, callback); } getReadingListActions(callback: (action: Action, readingList: ReadingList) => void) { - const actions = this.readingListActions.map(a => {return {...a}}); - actions.forEach(action => action.callback = callback); - return actions; + return this.applyCallbackToList(this.readingListActions, callback); } getBookmarkActions(callback: (action: Action, series: Series) => void) { - const actions = this.bookmarkActions.map(a => {return {...a}}); - actions.forEach(action => action.callback = callback); - return actions; + return this.applyCallbackToList(this.bookmarkActions, callback); } dummyCallback(action: Action, data: any) {} _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 = [ { action: Action.MarkAsRead, title: 'Mark as Read', callback: this.dummyCallback, - requiresAdmin: false + requiresAdmin: false, + children: [], }, { action: Action.MarkAsUnread, title: 'Mark as Unread', callback: this.dummyCallback, - requiresAdmin: false - }, - { - action: Action.AddToReadingList, - title: 'Add to Reading List', - callback: this.dummyCallback, - requiresAdmin: false + requiresAdmin: false, + children: [], }, { - action: Action.AddToWantToReadList, - title: 'Add to Want To Read', + action: Action.AddTo, + title: 'Add to', 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, - title: 'Remove from Want To Read', + action: Action.Scan, + title: 'Scan Series', 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 = [ + { + action: Action.IncognitoRead, + title: 'Read Incognito', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + }, { action: Action.MarkAsRead, title: 'Mark as Read', callback: this.dummyCallback, - requiresAdmin: false + requiresAdmin: false, + children: [], }, { action: Action.MarkAsUnread, title: 'Mark as Unread', callback: this.dummyCallback, - requiresAdmin: false - }, - { - action: Action.AddToReadingList, - title: 'Add to Reading List', - callback: this.dummyCallback, - requiresAdmin: false - }, - { - action: Action.IncognitoRead, - title: 'Read Incognito', - 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.Edit, title: 'Details', callback: this.dummyCallback, - requiresAdmin: false - } + requiresAdmin: false, + children: [], + }, + { + action: Action.Download, + title: 'Download', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + }, ]; this.chapterActions = [ + { + action: Action.IncognitoRead, + title: 'Read Incognito', + callback: this.dummyCallback, + requiresAdmin: false, + children: [], + }, { action: Action.MarkAsRead, title: 'Mark as Read', callback: this.dummyCallback, - requiresAdmin: false + requiresAdmin: false, + children: [], }, { action: Action.MarkAsUnread, title: 'Mark as Unread', 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, - title: 'Read Incognito', + action: Action.Edit, + title: 'Details', callback: this.dummyCallback, - requiresAdmin: false + requiresAdmin: false, + children: [], }, + // RBS will handle rendering this, so non-admins with download are appicable { - action: Action.AddToReadingList, - title: 'Add to Reading List', + action: Action.Download, + title: 'Download', callback: this.dummyCallback, - requiresAdmin: false + requiresAdmin: false, + children: [], }, ]; @@ -363,13 +404,15 @@ export class ActionFactoryService { action: Action.Edit, title: 'Edit', callback: this.dummyCallback, - requiresAdmin: false + requiresAdmin: false, + children: [], }, { action: Action.Delete, title: 'Delete', callback: this.dummyCallback, - requiresAdmin: false + requiresAdmin: false, + children: [], }, ]; @@ -378,20 +421,41 @@ export class ActionFactoryService { action: Action.ViewSeries, title: 'View Series', callback: this.dummyCallback, - requiresAdmin: false + requiresAdmin: false, + children: [], }, { action: Action.DownloadBookmark, title: 'Download', callback: this.dummyCallback, - requiresAdmin: false + requiresAdmin: false, + children: [], }, { action: Action.Delete, title: 'Clear', callback: this.dummyCallback, - requiresAdmin: false + requiresAdmin: false, + children: [], }, - ] + ]; } + + private applyCallback(action: ActionItem, 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>, callback: (action: Action, data: any) => void): Array> { + const actions = list.map((a) => { + return { ...a }; + }); + actions.forEach((action) => this.applyCallback(action, callback)); + return actions; + } } diff --git a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts index edbe95aba..571c852fa 100644 --- a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts +++ b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts @@ -130,7 +130,7 @@ export class CardDetailDrawerComponent implements OnInit, OnDestroy { this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this)) .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.libraryType = type; diff --git a/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.html b/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.html index cd07213e9..8f11b1027 100644 --- a/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.html +++ b/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.html @@ -2,10 +2,22 @@
- - - +
- + + + + + + +
+ +
+ +
+
+
+
+
\ No newline at end of file diff --git a/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.scss b/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.scss index 62a635f5c..5768c28f8 100644 --- a/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.scss +++ b/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.scss @@ -1,3 +1,28 @@ .dropdown-toggle:after { content: none !important; -} \ No newline at end of file +} + +.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; +} diff --git a/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.ts b/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.ts index fd6ad39ac..fa5cd3c4d 100644 --- a/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.ts +++ b/UI/Web/src/app/cards/card-item/card-actionables/card-actionables.component.ts @@ -1,5 +1,8 @@ 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({ selector: 'app-card-actionables', @@ -16,16 +19,19 @@ export class CardActionablesComponent implements OnInit { @Input() disabled: boolean = false; @Output() actionHandler = new EventEmitter>(); - adminActions: ActionItem[] = []; - nonAdminActions: ActionItem[] = []; + isAdmin: boolean = false; + canDownload: boolean = false; + submenu: {[key: string]: NgbDropdown} = {}; - - constructor(private readonly cdRef: ChangeDetectorRef) { } + constructor(private readonly cdRef: ChangeDetectorRef, private accountService: AccountService) { } ngOnInit(): void { - this.nonAdminActions = this.actions.filter(item => !item.requiresAdmin); - this.adminActions = this.actions.filter(item => item.requiresAdmin); - this.cdRef.markForCheck(); + this.accountService.currentUser$.pipe(take(1)).subscribe((user) => { + if (!user) return; + this.isAdmin = this.accountService.hasAdminRole(user); + this.canDownload = this.accountService.hasDownloadRole(user); + this.cdRef.markForCheck(); + }); } preventClick(event: any) { @@ -41,4 +47,23 @@ export class CardActionablesComponent implements OnInit { } } + willRenderAction(action: ActionItem): 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(); + } + } diff --git a/UI/Web/src/app/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/series-detail.component.ts index 9b42022bc..1e74c104f 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/series-detail.component.ts @@ -482,7 +482,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe this.seriesActions = this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this)) .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.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this));