From 0a127bed9e66841b2805aeeb88d6a32be7088c02 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Wed, 30 Oct 2024 17:07:55 +0100 Subject: [PATCH] Add order buttons in frontend --- .../episode-preview.component.html | 16 ++-- .../episode-preview.component.scss | 5 ++ .../episode-preview.component.ts | 17 +++- .../data-entry/source/source.component.html | 1 + .../app/data-entry/source/source.component.ts | 88 +++++++++++++++++-- .../src/app/data-entry/source/source.graphql | 10 +++ frontend/src/app/shared/icons.ts | 2 + .../order-button-group.component.html | 14 +++ .../order-button-group.component.scss | 0 .../order-button-group.component.spec.ts | 23 +++++ .../order-button-group.component.ts | 24 +++++ frontend/src/app/shared/shared.module.ts | 3 + frontend/src/app/shared/utils.spec.ts | 25 +++++- frontend/src/app/shared/utils.ts | 26 ++++++ 14 files changed, 239 insertions(+), 15 deletions(-) create mode 100644 frontend/src/app/shared/order-button-group/order-button-group.component.html create mode 100644 frontend/src/app/shared/order-button-group/order-button-group.component.scss create mode 100644 frontend/src/app/shared/order-button-group/order-button-group.component.spec.ts create mode 100644 frontend/src/app/shared/order-button-group/order-button-group.component.ts diff --git a/frontend/src/app/data-entry/source/episode-preview/episode-preview.component.html b/frontend/src/app/data-entry/source/episode-preview/episode-preview.component.html index ec1a4eea..4d2e4a91 100644 --- a/frontend/src/app/data-entry/source/episode-preview/episode-preview.component.html +++ b/frontend/src/app/data-entry/source/episode-preview/episode-preview.component.html @@ -12,11 +12,17 @@ page {{ episode.page }} - +
+ + +
diff --git a/frontend/src/app/data-entry/source/episode-preview/episode-preview.component.scss b/frontend/src/app/data-entry/source/episode-preview/episode-preview.component.scss index e69de29b..bbf3c07c 100644 --- a/frontend/src/app/data-entry/source/episode-preview/episode-preview.component.scss +++ b/frontend/src/app/data-entry/source/episode-preview/episode-preview.component.scss @@ -0,0 +1,5 @@ +.button-group-wrapper { + display: flex; + align-items: center; + gap: 1rem; +} diff --git a/frontend/src/app/data-entry/source/episode-preview/episode-preview.component.ts b/frontend/src/app/data-entry/source/episode-preview/episode-preview.component.ts index faf22d19..fc0b6fda 100644 --- a/frontend/src/app/data-entry/source/episode-preview/episode-preview.component.ts +++ b/frontend/src/app/data-entry/source/episode-preview/episode-preview.component.ts @@ -1,9 +1,16 @@ -import { Component, DestroyRef, Input } from "@angular/core"; +import { + Component, + DestroyRef, + EventEmitter, + Input, + Output, +} from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { ModalService } from "@services/modal.service"; import { ToastService } from "@services/toast.service"; import { dataIcons } from "@shared/icons"; import { agentIcon, locationIcon } from "@shared/icons-utils"; +import { OrderChange } from "@shared/order-button-group/order-button-group.component"; import { DataEntryDeleteEpisodeGQL, DataEntrySourceDetailQuery, @@ -21,7 +28,12 @@ type QueriedEpisode = NonNullable< export class EpisodePreviewComponent { @Input({ required: true }) public episode!: QueriedEpisode; + + @Output() + public changeEpisodeOrder = new EventEmitter(); + public dataIcons = dataIcons; + agentIcon = agentIcon; locationIcon = locationIcon; @@ -40,7 +52,8 @@ export class EpisodePreviewComponent { }) .then(() => { this.performDelete(episodeId); - }).catch(() => { + }) + .catch(() => { // Do nothing on cancel / dismissal. }); } diff --git a/frontend/src/app/data-entry/source/source.component.html b/frontend/src/app/data-entry/source/source.component.html index 3d0cc876..c4a22b1f 100644 --- a/frontend/src/app/data-entry/source/source.component.html +++ b/frontend/src/app/data-entry/source/source.component.html @@ -19,6 +19,7 @@

Episodes

diff --git a/frontend/src/app/data-entry/source/source.component.ts b/frontend/src/app/data-entry/source/source.component.ts index 8f0016d5..cc2406cc 100644 --- a/frontend/src/app/data-entry/source/source.component.ts +++ b/frontend/src/app/data-entry/source/source.component.ts @@ -1,10 +1,22 @@ -import { Component, computed, TemplateRef } from "@angular/core"; -import { toSignal } from "@angular/core/rxjs-interop"; +import { Component, computed, DestroyRef, TemplateRef } from "@angular/core"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; import { ActivatedRoute, Router } from "@angular/router"; import { NgbModal, NgbModalRef } from "@ng-bootstrap/ng-bootstrap"; +import { ToastService } from "@services/toast.service"; import { actionIcons, dataIcons } from "@shared/icons"; -import { DataEntrySourceDetailGQL, EpisodeType } from "generated/graphql"; +import { + DataEntrySourceDetailGQL, + DataEntrySourceDetailQuery, + DataEntryUpdateEpisodeOrderGQL, + DataEntryUpdateEpisodeOrderMutation, + EpisodeType, +} from "generated/graphql"; import { map, shareReplay, switchMap } from "rxjs"; +import { MutationResult } from "apollo-angular"; +import { moveItemInArray } from "@shared/utils"; +import { OrderChange } from "@shared/order-button-group/order-button-group.component"; + +type QueriedEpisode = DataEntrySourceDetailQuery["source"]["episodes"][number]; @Component({ selector: "lc-source", @@ -35,9 +47,7 @@ export class SourceComponent { ); public sourceTitle = toSignal( - this.source$.pipe( - map((source) => source.name) - ), + this.source$.pipe(map((source) => source.name)), { initialValue: "" } ); @@ -48,10 +58,13 @@ export class SourceComponent { public mutationInProgress = false; constructor( + private destroyRef: DestroyRef, private route: ActivatedRoute, private router: Router, private modalService: NgbModal, - private sourceDetailQuery: DataEntrySourceDetailGQL + private toastService: ToastService, + private sourceDetailQuery: DataEntrySourceDetailGQL, + private updateEpisodeOrder: DataEntryUpdateEpisodeOrderGQL ) {} public openNewEpisodeModal(newEpisodeModal: TemplateRef): void { @@ -75,4 +88,65 @@ export class SourceComponent { public identify(_index: number, item: Pick): string { return item.id; } + + public reorderEpisodes( + episodes: QueriedEpisode[], + episodeId: string, + change: OrderChange + ): void { + const episodeIds = episodes.map((episode) => episode.id); + const currentIndex = episodeIds.indexOf(episodeId); + + const indexNotFound = currentIndex === -1; + const indexAtBoundary = + (change === "up" && currentIndex <= 0) || + (change === "down" && currentIndex >= episodeIds.length - 1); + + // Don't mutate if the order change is invalid. + if (indexNotFound || indexAtBoundary) { + return; + } + + const newIndex = change === "up" ? currentIndex - 1 : currentIndex + 1; + const newOrder = moveItemInArray(episodeIds, currentIndex, newIndex); + + this.updateEpisodeOrder + .mutate( + { + episodeIds: newOrder, + }, + { + update: (cache) => cache.evict({ fieldName: "source" }), + } + ) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((result) => { + this.onOrderMutationResult(result); + }); + } + + private onOrderMutationResult( + result: MutationResult + ): void { + const graphQLErrors = result.errors; + const mutationErrors = result.data?.updateEpisodeOrder?.errors; + + if (graphQLErrors?.length) { + const messages = graphQLErrors.map((error) => error.message); + this.toastService.show({ + type: "danger", + header: "Failed to save episode order", + body: messages.join("\n\n"), + }); + } else if (mutationErrors?.length) { + const messages = mutationErrors.map( + (error) => `${error.field}: ${error.messages.join("\n")}` + ); + this.toastService.show({ + type: "danger", + header: "Failed to save episode order", + body: messages.join("\n\n"), + }); + } + } } diff --git a/frontend/src/app/data-entry/source/source.graphql b/frontend/src/app/data-entry/source/source.graphql index cffa8ce5..6da7ee3b 100644 --- a/frontend/src/app/data-entry/source/source.graphql +++ b/frontend/src/app/data-entry/source/source.graphql @@ -36,3 +36,13 @@ query DataEntrySourceDetail($id: ID!) { } } } + +mutation DataEntryUpdateEpisodeOrder($episodeIds: [ID!]!) { + updateEpisodeOrder(episodeIds: $episodeIds) { + ok + errors { + field + messages + } + } +} diff --git a/frontend/src/app/shared/icons.ts b/frontend/src/app/shared/icons.ts index 0ada0cb4..c1c405cf 100644 --- a/frontend/src/app/shared/icons.ts +++ b/frontend/src/app/shared/icons.ts @@ -18,6 +18,8 @@ export const actionIcons = { expand: 'caret-down', collapse: 'caret-up', edit: 'pencil', + up: 'chevron-up', + down: 'chevron-down', }; export const authIcons = { diff --git a/frontend/src/app/shared/order-button-group/order-button-group.component.html b/frontend/src/app/shared/order-button-group/order-button-group.component.html new file mode 100644 index 00000000..04abac65 --- /dev/null +++ b/frontend/src/app/shared/order-button-group/order-button-group.component.html @@ -0,0 +1,14 @@ +
+ + +
diff --git a/frontend/src/app/shared/order-button-group/order-button-group.component.scss b/frontend/src/app/shared/order-button-group/order-button-group.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/app/shared/order-button-group/order-button-group.component.spec.ts b/frontend/src/app/shared/order-button-group/order-button-group.component.spec.ts new file mode 100644 index 00000000..3bbf5bff --- /dev/null +++ b/frontend/src/app/shared/order-button-group/order-button-group.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { OrderButtonGroupComponent } from "./order-button-group.component"; +import { SharedTestingModule } from "@shared/shared-testing.module"; + +describe("OrderButtonGroupComponent", () => { + let component: OrderButtonGroupComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [OrderButtonGroupComponent], + imports: [SharedTestingModule], + }); + fixture = TestBed.createComponent(OrderButtonGroupComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/shared/order-button-group/order-button-group.component.ts b/frontend/src/app/shared/order-button-group/order-button-group.component.ts new file mode 100644 index 00000000..e6455cc8 --- /dev/null +++ b/frontend/src/app/shared/order-button-group/order-button-group.component.ts @@ -0,0 +1,24 @@ +import { Component, EventEmitter, Input, Output } from "@angular/core"; +import { actionIcons } from "@shared/icons"; + +export type OrderChange = "up" | "down"; + +@Component({ + selector: "lc-order-button-group", + templateUrl: "./order-button-group.component.html", + styleUrls: ["./order-button-group.component.scss"], +}) +export class OrderButtonGroupComponent { + @Input() entityName: string | null = null; + @Output() changeOrder = new EventEmitter(); + + public actionIcons = actionIcons; + + public onUp(): void { + this.changeOrder.emit("up"); + } + + public onDown(): void { + this.changeOrder.emit("down"); + } +} diff --git a/frontend/src/app/shared/shared.module.ts b/frontend/src/app/shared/shared.module.ts index 03a3079d..02b1bebd 100644 --- a/frontend/src/app/shared/shared.module.ts +++ b/frontend/src/app/shared/shared.module.ts @@ -13,6 +13,7 @@ import { BaseModalComponent } from "./base-modal/base-modal.component"; import { ConfirmationModalComponent } from './confirmation-modal/confirmation-modal.component'; import { ContributorsComponent } from './contributors/contributors.component'; import { CollapsibleCardComponent } from './collapsible-card/collapsible-card.component'; +import { OrderButtonGroupComponent } from './order-button-group/order-button-group.component'; @@ -25,6 +26,7 @@ import { CollapsibleCardComponent } from './collapsible-card/collapsible-card.co ConfirmationModalComponent, ContributorsComponent, CollapsibleCardComponent, + OrderButtonGroupComponent, ], imports: [ CommonModule, @@ -47,6 +49,7 @@ import { CollapsibleCardComponent } from './collapsible-card/collapsible-card.co ActionButtonGroupComponent, ContributorsComponent, CollapsibleCardComponent, + OrderButtonGroupComponent, CommonModule, BrowserModule, BrowserAnimationsModule, diff --git a/frontend/src/app/shared/utils.spec.ts b/frontend/src/app/shared/utils.spec.ts index 22f2404b..9be837ab 100644 --- a/frontend/src/app/shared/utils.spec.ts +++ b/frontend/src/app/shared/utils.spec.ts @@ -1,4 +1,4 @@ -import { differenceBy, splat } from "./utils"; +import { differenceBy, moveItemInArray, splat } from "./utils"; describe('differenceBy', () => { it('should compare lists', () => { @@ -21,3 +21,26 @@ describe('splat', () => { expect(splat(Math.min)([3, 5, 2])).toBe(2) }); }); + +describe("moveItemInArray", () => { + it("should move an item in an array", () => { + const array = ["Alice", "Bernard", "Claire", "David", "Eve"]; + expect(moveItemInArray(array, 1, 3)).toEqual([ + "Alice", + "Claire", + "David", + "Bernard", + "Eve", + ]); + }); + + it("should return the same array if the indices are the same", () => { + const array = ["Alice", "Bernard", "Claire", "David", "Eve"]; + expect(moveItemInArray(array, 4, 4)).toBe(array); + }); + + it("should return the same array if the indices are out of bounds", () => { + const array = ["Alice", "Bernard", "Claire", "David", "Eve"]; + expect(moveItemInArray(array, -99, 99)).toBe(array); + }); +}); diff --git a/frontend/src/app/shared/utils.ts b/frontend/src/app/shared/utils.ts index 7b91a0f6..a36e748f 100644 --- a/frontend/src/app/shared/utils.ts +++ b/frontend/src/app/shared/utils.ts @@ -35,3 +35,29 @@ export const differenceBy = ( */ export const splat = (func: (...a: I[]) => O) => (args: I[]) => func(...args); + + +/** + * Moves an item within an array from one index to another. Returns the same array if the from and to indices are identical or out of bounds. + * + * @param {T[]} array - The array containing the item to move. + * @param {number} fromIndex - The index of the item to move. + * @param {number} toIndex - The index to move the item to. + * @returns {T[]} The array with the item moved to the new index. + */ +export function moveItemInArray(array: T[], fromIndex: number, toIndex: number): T[] { + if (fromIndex === toIndex) { + return array; + } + + const fromIndexOutOfBounds = fromIndex < 0 || fromIndex >= array.length; + const toIndexOutOfBounds = toIndex < 0 || toIndex >= array.length; + + if (fromIndexOutOfBounds || toIndexOutOfBounds) { + return array; + } + + const item = array.splice(fromIndex, 1)[0]; + array.splice(toIndex, 0, item); + return array; +}