Skip to content

Commit

Permalink
Bulk Actions for Reading Lists (#3035)
Browse files Browse the repository at this point in the history
  • Loading branch information
majora2007 authored Jul 3, 2024
1 parent 1918c93 commit 6434ed7
Show file tree
Hide file tree
Showing 13 changed files with 965 additions and 16 deletions.
55 changes: 55 additions & 0 deletions API/Controllers/ReadingListController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -491,4 +491,59 @@ public async Task<ActionResult<bool>> DoesNameExists(string name)
if (string.IsNullOrEmpty(name)) return true;
return Ok(await _unitOfWork.ReadingListRepository.ReadingListExists(name));
}



/// <summary>
/// Promote/UnPromote multiple reading lists in one go. Will only update the authenticated user's reading lists and will only work if the user has promotion role
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("promote-multiple")]
public async Task<ActionResult> PromoteMultipleReadingLists(PromoteReadingListsDto dto)
{
// This needs to take into account owner as I can select other users cards
var userId = User.GetUserId();
if (!User.IsInRole(PolicyConstants.PromoteRole) && !User.IsInRole(PolicyConstants.AdminRole))
{
return BadRequest(await _localizationService.Translate(userId, "permission-denied"));
}

var readingLists = await _unitOfWork.ReadingListRepository.GetReadingListsByIds(dto.ReadingListIds);

foreach (var readingList in readingLists)
{
if (readingList.AppUserId != userId) continue;
readingList.Promoted = dto.Promoted;
_unitOfWork.ReadingListRepository.Update(readingList);
}

if (!_unitOfWork.HasChanges()) return Ok();
await _unitOfWork.CommitAsync();

return Ok();
}


/// <summary>
/// Delete multiple reading lists in one go
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("delete-multiple")]
public async Task<ActionResult> DeleteMultipleReadingLists(DeleteReadingListsDto dto)
{
// This needs to take into account owner as I can select other users cards
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.ReadingLists);
if (user == null) return Unauthorized();

user.ReadingLists = user.ReadingLists.Where(uc => !dto.ReadingListIds.Contains(uc.Id)).ToList();
_unitOfWork.UserRepository.Update(user);


if (!_unitOfWork.HasChanges()) return Ok();
await _unitOfWork.CommitAsync();

return Ok();
}
}
10 changes: 10 additions & 0 deletions API/DTOs/ReadingLists/DeleteReadingListsDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;

namespace API.DTOs.ReadingLists;

public class DeleteReadingListsDto
{
[Required]
public IList<int> ReadingListIds { get; set; }
}
9 changes: 9 additions & 0 deletions API/DTOs/ReadingLists/PromoteReadingListsDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System.Collections.Generic;

namespace API.DTOs.ReadingLists;

public class PromoteReadingListsDto
{
public IList<int> ReadingListIds { get; init; }
public bool Promoted { get; init; }
}
10 changes: 10 additions & 0 deletions API/Data/Repositories/ReadingListRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ Task<IEnumerable<ReadingListDto>> GetReadingListDtosForSeriesAndUserAsync(int us
Task<IList<ReadingList>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
Task<int> RemoveReadingListsWithoutSeries();
Task<ReadingList?> GetReadingListByTitleAsync(string name, int userId, ReadingListIncludes includes = ReadingListIncludes.Items);
Task<IEnumerable<ReadingList>> GetReadingListsByIds(IList<int> ids, ReadingListIncludes includes = ReadingListIncludes.Items);
}

public class ReadingListRepository : IReadingListRepository
Expand Down Expand Up @@ -156,6 +157,15 @@ public async Task<int> RemoveReadingListsWithoutSeries()
.FirstOrDefaultAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized) && x.AppUserId == userId);
}

public async Task<IEnumerable<ReadingList>> GetReadingListsByIds(IList<int> ids, ReadingListIncludes includes = ReadingListIncludes.Items)
{
return await _context.ReadingList
.Where(c => ids.Contains(c.Id))
.Includes(includes)
.AsSplitQuery()
.ToListAsync();
}

public void Remove(ReadingListItem item)
{
_context.ReadingListItem.Remove(item);
Expand Down
16 changes: 15 additions & 1 deletion UI/Web/src/app/_services/action-factory.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,7 @@ export class ActionFactoryService {
}
],
},
// RBS will handle rendering this, so non-admins with download are appicable
// RBS will handle rendering this, so non-admins with download are applicable
{
action: Action.Download,
title: 'download',
Expand Down Expand Up @@ -583,6 +583,20 @@ export class ActionFactoryService {
class: 'danger',
children: [],
},
{
action: Action.Promote,
title: 'promote',
callback: this.dummyCallback,
requiresAdmin: false,
children: [],
},
{
action: Action.UnPromote,
title: 'unpromote',
callback: this.dummyCallback,
requiresAdmin: false,
children: [],
},
];

this.bookmarkActions = [
Expand Down
43 changes: 41 additions & 2 deletions UI/Web/src/app/_services/action.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {UserCollection} from "../_models/collection-tag";
import {CollectionTagService} from "./collection-tag.service";
import {SmartFilter} from "../_models/metadata/v2/smart-filter";
import {FilterService} from "./filter.service";
import {ReadingListService} from "./reading-list.service";

export type LibraryActionCallback = (library: Partial<Library>) => void;
export type SeriesActionCallback = (series: Series) => void;
Expand All @@ -48,7 +49,8 @@ export class ActionService implements OnDestroy {
constructor(private libraryService: LibraryService, private seriesService: SeriesService,
private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal,
private confirmService: ConfirmService, private memberService: MemberService, private deviceService: DeviceService,
private readonly collectionTagService: CollectionTagService, private filterService: FilterService) { }
private readonly collectionTagService: CollectionTagService, private filterService: FilterService,
private readonly readingListService: ReadingListService) { }

ngOnDestroy() {
this.onDestroy.next();
Expand Down Expand Up @@ -386,7 +388,7 @@ export class ActionService implements OnDestroy {
}

/**
* Mark all series as Unread.
* Mark all collections as promoted/unpromoted.
* @param collections UserCollection, should have id, pagesRead populated
* @param promoted boolean, promoted state
* @param callback Optional callback to perform actions after API completes
Expand Down Expand Up @@ -422,6 +424,43 @@ export class ActionService implements OnDestroy {
});
}

/**
* Mark all reading lists as promoted/unpromoted.
* @param readingLists UserCollection, should have id, pagesRead populated
* @param promoted boolean, promoted state
* @param callback Optional callback to perform actions after API completes
*/
promoteMultipleReadingLists(readingLists: Array<ReadingList>, promoted: boolean, callback?: BooleanActionCallback) {
this.readingListService.promoteMultipleReadingLists(readingLists.map(v => v.id), promoted).pipe(take(1)).subscribe(() => {
if (promoted) {
this.toastr.success(translate('toasts.reading-list-promoted'));
} else {
this.toastr.success(translate('toasts.reading-list-unpromoted'));
}

if (callback) {
callback(true);
}
});
}

/**
* Deletes multiple collections
* @param readingLists ReadingList, should have id
* @param callback Optional callback to perform actions after API completes
*/
async deleteMultipleReadingLists(readingLists: Array<ReadingList>, callback?: BooleanActionCallback) {
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-reading-list'))) return;

this.readingListService.deleteMultipleReadingLists(readingLists.map(v => v.id)).pipe(take(1)).subscribe(() => {
this.toastr.success(translate('toasts.reading-lists-deleted'));

if (callback) {
callback(true);
}
});
}

addMultipleToReadingList(seriesId: number, volumes: Array<Volume>, chapters?: Array<Chapter>, callback?: BooleanActionCallback) {
if (this.readingListModalRef != null) { return; }
this.readingListModalRef = this.modalService.open(AddToListModalComponent, { scrollable: true, size: 'md', fullscreen: 'md' });
Expand Down
22 changes: 19 additions & 3 deletions UI/Web/src/app/_services/reading-list.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { PaginatedResult } from '../_models/pagination';
import { ReadingList, ReadingListItem } from '../_models/reading-list';
import { CblImportSummary } from '../_models/reading-list/cbl/cbl-import-summary';
import { TextResonse } from '../_types/text-response';
import { ActionItem } from './action-factory.service';
import {Action, ActionItem} from './action-factory.service';

@Injectable({
providedIn: 'root'
Expand Down Expand Up @@ -87,9 +87,15 @@ export class ReadingListService {
return this.httpClient.post<string>(this.baseUrl + 'readinglist/remove-read?readingListId=' + readingListId, {}, TextResonse);
}

actionListFilter(action: ActionItem<ReadingList>, readingList: ReadingList, isAdmin: boolean) {
if (readingList?.promoted && !isAdmin) return false;
actionListFilter(action: ActionItem<ReadingList>, readingList: ReadingList, canPromote: boolean) {

const isPromotionAction = action.action == Action.Promote || action.action == Action.UnPromote;

if (isPromotionAction) return canPromote;
return true;

// if (readingList?.promoted && !isAdmin) return false;
// return true;
}

nameExists(name: string) {
Expand All @@ -107,4 +113,14 @@ export class ReadingListService {
getCharacters(readingListId: number) {
return this.httpClient.get<Array<Person>>(this.baseUrl + 'readinglist/characters?readingListId=' + readingListId);
}

promoteMultipleReadingLists(listIds: Array<number>, promoted: boolean) {
return this.httpClient.post(this.baseUrl + 'readinglist/promote-multiple', {readingListIds: listIds, promoted}, TextResonse);
}

deleteMultipleReadingLists(listIds: Array<number>) {
return this.httpClient.post(this.baseUrl + 'readinglist/delete-multiple', {readingListIds: listIds}, TextResonse);
}


}
6 changes: 5 additions & 1 deletion UI/Web/src/app/cards/bulk-selection.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {ReplaySubject} from 'rxjs';
import {filter} from 'rxjs/operators';
import {Action, ActionFactoryService, ActionItem} from '../_services/action-factory.service';

type DataSource = 'volume' | 'chapter' | 'special' | 'series' | 'bookmark' | 'sideNavStream' | 'collection';
type DataSource = 'volume' | 'chapter' | 'special' | 'series' | 'bookmark' | 'sideNavStream' | 'collection' | 'readingList';

/**
* Responsible for handling selections on cards. Can handle multiple card sources next to each other in different loops.
Expand Down Expand Up @@ -159,6 +159,10 @@ export class BulkSelectionService {
return this.applyFilterToList(this.actionFactory.getCollectionTagActions(callback), [Action.Promote, Action.UnPromote, Action.Delete]);
}

if (Object.keys(this.selectedCards).filter(item => item === 'readingList').length > 0) {
return this.applyFilterToList(this.actionFactory.getReadingListActions(callback), [Action.Promote, Action.UnPromote, Action.Delete]);
}

return this.applyFilterToList(this.actionFactory.getVolumeActions(callback), allowedActions);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ <h2 title>{{t('title')}}</h2>
<h6 subtitle>{{t('item-count', {num: collections.length | number})}}</h6>
</app-side-nav-companion-bar>
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>

<app-card-detail-layout
[isLoading]="isLoading"
[items]="collections"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ <h2 title>
<app-card-actionables [actions]="globalActions" (actionHandler)="performGlobalAction($event)"></app-card-actionables>
<span>{{t('title')}}</span>
</h2>
<h6 subtitle class="subtitle-with-actionables" *ngIf="pagination">{{t('item-count', {num: pagination.totalItems | number})}}</h6>
@if (pagination) {
<h6 subtitle class="subtitle-with-actionables">{{t('item-count', {num: pagination.totalItems | number})}}</h6>
}

</app-side-nav-companion-bar>
<app-bulk-operations [actionCallback]="bulkActionCallback"></app-bulk-operations>

<app-card-detail-layout
[isLoading]="loadingLists"
Expand All @@ -18,7 +22,9 @@ <h6 subtitle class="subtitle-with-actionables" *ngIf="pagination">{{t('item-coun
<ng-template #cardItem let-item let-position="idx" >
<app-card-item [title]="item.title" [entity]="item" [actions]="actions[item.id]"
[suppressLibraryLink]="true" [imageUrl]="imageService.getReadingListCoverImage(item.id)"
(clicked)="handleClick(item)"></app-card-item>
(clicked)="handleClick(item)"
(selection)="bulkSelectionService.handleCardSelection('readingList', position, lists.length, $event)"
[selected]="bulkSelectionService.isCardSelected('readingList', position)" [allowSelection]="true"></app-card-item>
</ng-template>

<ng-template #noData>
Expand Down
Loading

0 comments on commit 6434ed7

Please sign in to comment.