Skip to content

Commit

Permalink
Add order buttons in frontend
Browse files Browse the repository at this point in the history
  • Loading branch information
XanderVertegaal committed Oct 30, 2024
1 parent 825f09b commit 0a127be
Show file tree
Hide file tree
Showing 14 changed files with 239 additions and 15 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,17 @@
<span *ngIf="episode.page" class="inline-list-item">page {{ episode.page }}</span>
</small>
</div>
<lc-action-button-group
[editLink]="['/', 'data-entry', 'episodes', episode.id]"
(deleteAction)="onClickDelete(episode.id)"
[showButtonText]="false"
/>
<div class="button-group-wrapper">
<lc-order-button-group
entityName="episode"
(changeOrder)="changeEpisodeOrder.emit($event)"
/>
<lc-action-button-group
[editLink]="['/', 'data-entry', 'episodes', episode.id]"
(deleteAction)="onClickDelete(episode.id)"
[showButtonText]="false"
/>
</div>
</div>
</div>
<div class="card-body">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.button-group-wrapper {
display: flex;
align-items: center;
gap: 1rem;
}
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -21,7 +28,12 @@ type QueriedEpisode = NonNullable<
export class EpisodePreviewComponent {
@Input({ required: true })
public episode!: QueriedEpisode;

@Output()
public changeEpisodeOrder = new EventEmitter<OrderChange>();

public dataIcons = dataIcons;

agentIcon = agentIcon;
locationIcon = locationIcon;

Expand All @@ -40,7 +52,8 @@ export class EpisodePreviewComponent {
})
.then(() => {
this.performDelete(episodeId);
}).catch(() => {
})
.catch(() => {
// Do nothing on cancel / dismissal.
});
}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/app/data-entry/source/source.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ <h2 class="mb-4">Episodes</h2>
<lc-episode-preview
*ngFor="let episode of source.episodes; trackBy: identify"
[episode]="episode"
(changeEpisodeOrder)="reorderEpisodes(source.episodes, episode.id, $event)"
/>
</div>
<div class="btn-group mb-4" *ngIf="source$ | async as source">
Expand Down
88 changes: 81 additions & 7 deletions frontend/src/app/data-entry/source/source.component.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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: "" }
);

Expand All @@ -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<unknown>): void {
Expand All @@ -75,4 +88,65 @@ export class SourceComponent {
public identify(_index: number, item: Pick<EpisodeType, "id">): 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<DataEntryUpdateEpisodeOrderMutation>
): 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"),
});
}
}
}
10 changes: 10 additions & 0 deletions frontend/src/app/data-entry/source/source.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,13 @@ query DataEntrySourceDetail($id: ID!) {
}
}
}

mutation DataEntryUpdateEpisodeOrder($episodeIds: [ID!]!) {
updateEpisodeOrder(episodeIds: $episodeIds) {
ok
errors {
field
messages
}
}
}
2 changes: 2 additions & 0 deletions frontend/src/app/shared/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export const actionIcons = {
expand: 'caret-down',
collapse: 'caret-up',
edit: 'pencil',
up: 'chevron-up',
down: 'chevron-down',
};

export const authIcons = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<div class="btn-group">
<button type="button" class="btn btn-light" (click)="onUp()">
<lc-icon
[icon]="actionIcons.up"
[title]="entityName ? 'Move ' + entityName + ' up' : 'Move up'"
/>
</button>
<button type="button" class="btn btn-light" (click)="onDown()">
<lc-icon
[icon]="actionIcons.down"
[title]="entityName ? 'Move ' + entityName + ' down' : 'Move down'"
/>
</button>
</div>
Empty file.
Original file line number Diff line number Diff line change
@@ -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<OrderButtonGroupComponent>;

beforeEach(() => {
TestBed.configureTestingModule({
declarations: [OrderButtonGroupComponent],
imports: [SharedTestingModule],
});
fixture = TestBed.createComponent(OrderButtonGroupComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it("should create", () => {
expect(component).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -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<OrderChange>();

public actionIcons = actionIcons;

public onUp(): void {
this.changeOrder.emit("up");
}

public onDown(): void {
this.changeOrder.emit("down");
}
}
3 changes: 3 additions & 0 deletions frontend/src/app/shared/shared.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';



Expand All @@ -25,6 +26,7 @@ import { CollapsibleCardComponent } from './collapsible-card/collapsible-card.co
ConfirmationModalComponent,
ContributorsComponent,
CollapsibleCardComponent,
OrderButtonGroupComponent,
],
imports: [
CommonModule,
Expand All @@ -47,6 +49,7 @@ import { CollapsibleCardComponent } from './collapsible-card/collapsible-card.co
ActionButtonGroupComponent,
ContributorsComponent,
CollapsibleCardComponent,
OrderButtonGroupComponent,
CommonModule,
BrowserModule,
BrowserAnimationsModule,
Expand Down
25 changes: 24 additions & 1 deletion frontend/src/app/shared/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { differenceBy, splat } from "./utils";
import { differenceBy, moveItemInArray, splat } from "./utils";

describe('differenceBy', () => {
it('should compare lists', () => {
Expand All @@ -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);
});
});
26 changes: 26 additions & 0 deletions frontend/src/app/shared/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,29 @@ export const differenceBy = <T extends object>(
*/
export const splat = <I, O>(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<T>(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;
}

0 comments on commit 0a127be

Please sign in to comment.