From 7939f769c231ee0c604cac2333b9cd39627c2c0f Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Wed, 30 Oct 2024 16:49:47 +0100 Subject: [PATCH 01/10] Add order_with_respect_to --- backend/core/models.py | 1 + ...029_alter_episode_order_with_respect_to.py | 17 +++++++++++++++ ...cription_order_with_respect_to_and_more.py | 21 +++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 backend/event/migrations/0029_alter_episode_order_with_respect_to.py create mode 100644 backend/letter/migrations/0022_alter_giftdescription_order_with_respect_to_and_more.py diff --git a/backend/core/models.py b/backend/core/models.py index 11093af8..fb56cd50 100644 --- a/backend/core/models.py +++ b/backend/core/models.py @@ -170,6 +170,7 @@ class EntityDescription(Named, models.Model): class Meta: abstract = True + order_with_respect_to = "source" def __str__(self): return f"{self.name} ({self.source})" diff --git a/backend/event/migrations/0029_alter_episode_order_with_respect_to.py b/backend/event/migrations/0029_alter_episode_order_with_respect_to.py new file mode 100644 index 00000000..6ba40ede --- /dev/null +++ b/backend/event/migrations/0029_alter_episode_order_with_respect_to.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.7 on 2024-10-30 13:39 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('event', '0028_alter_episode_source_mention_and_more'), + ] + + operations = [ + migrations.AlterOrderWithRespectTo( + name='episode', + order_with_respect_to='source', + ), + ] diff --git a/backend/letter/migrations/0022_alter_giftdescription_order_with_respect_to_and_more.py b/backend/letter/migrations/0022_alter_giftdescription_order_with_respect_to_and_more.py new file mode 100644 index 00000000..3ea836c7 --- /dev/null +++ b/backend/letter/migrations/0022_alter_giftdescription_order_with_respect_to_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.7 on 2024-10-30 13:39 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('letter', '0021_alter_giftdescription_source_mention_and_more'), + ] + + operations = [ + migrations.AlterOrderWithRespectTo( + name='giftdescription', + order_with_respect_to='source', + ), + migrations.AlterOrderWithRespectTo( + name='letterdescription', + order_with_respect_to='source', + ), + ] From a802b4cb66655d57b4d349e9956a55ef0ef22180 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Wed, 30 Oct 2024 16:51:12 +0100 Subject: [PATCH 02/10] Add UpdateEpisodeOrderMutation; update CreateEpisodeMutation --- .../event/mutations/CreateEpisodeMutation.py | 13 +++++++ .../mutations/UpdateEpisodeOrderMutation.py | 38 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 backend/event/mutations/UpdateEpisodeOrderMutation.py diff --git a/backend/event/mutations/CreateEpisodeMutation.py b/backend/event/mutations/CreateEpisodeMutation.py index 1b10fbe8..04622ec4 100644 --- a/backend/event/mutations/CreateEpisodeMutation.py +++ b/backend/event/mutations/CreateEpisodeMutation.py @@ -41,6 +41,7 @@ def mutate(cls, root: None, info: ResolveInfo, episode_data: CreateEpisodeInput) name=getattr(episode_data, "name"), source=source, ) + cls.append_to_source_episode_order(episode) cls.add_contribution(episode, episode_data, info) user = info.context.user @@ -48,6 +49,18 @@ def mutate(cls, root: None, info: ResolveInfo, episode_data: CreateEpisodeInput) return cls(episode=episode, errors=[]) # type: ignore + @staticmethod + def append_to_source_episode_order(episode: Episode) -> None: + """ + Append the episode ID to the source's episode order. + + This makes sure that the newly created episode is at the bottom of the episode list. + """ + ordered_episodes = episode.source.get_episode_order() + episode_ids = list(ordered_episodes.values_list("id", flat=True)) + episode_ids.append(episode.pk) + episode.source.set_episode_order(episode_ids) + @staticmethod def add_contribution(obj: Episode, data: CreateEpisodeInput, info: ResolveInfo): if info.context: diff --git a/backend/event/mutations/UpdateEpisodeOrderMutation.py b/backend/event/mutations/UpdateEpisodeOrderMutation.py new file mode 100644 index 00000000..d22f90f6 --- /dev/null +++ b/backend/event/mutations/UpdateEpisodeOrderMutation.py @@ -0,0 +1,38 @@ +from graphene import Mutation, Boolean, List, NonNull, ID, ResolveInfo + +from graphql_app.types.LettercraftErrorType import LettercraftErrorType +from source.models import Source + + +class UpdateEpisodeOrderMutation(Mutation): + ok = Boolean(required=True) + errors = List(NonNull(LettercraftErrorType), required=True) + + class Arguments: + episode_ids = List( + NonNull(ID), required=True, description="Ordered list of episode IDs" + ) + + @classmethod + def mutate(cls, root: None, info: ResolveInfo, episode_ids: list[str]): + source = Source.objects.filter(episode__in=episode_ids).distinct() + + if source.count() > 1: + error = LettercraftErrorType( + field="episode_ids", + messages=["Multiple sources found for given episode IDs."], + ) + return cls(ok=False, errors=[error]) # type: ignore + + source = source.first() + + if not source: + error = LettercraftErrorType( + field="episode_ids", + messages=["No source found for given episode IDs."], + ) + return cls(ok=False, errors=[error]) # type: ignore + + source.set_episode_order(episode_ids) # type: ignore + + return cls(ok=True, errors=[]) # type: ignore From 825f09b2475702163d0f1120f2292d6d579addf1 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Wed, 30 Oct 2024 16:51:34 +0100 Subject: [PATCH 03/10] Add backend test --- backend/conftest.py | 12 +++++++++ backend/event/tests/test_event_mutations.py | 29 +++++++++++++++++++++ backend/graphql_app/schema.py | 2 ++ 3 files changed, 43 insertions(+) create mode 100644 backend/event/tests/test_event_mutations.py diff --git a/backend/conftest.py b/backend/conftest.py index b9f3e746..5239a242 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -125,6 +125,18 @@ def episode(db, source, agent_description, agent_description_2, letter_descripti return event +@pytest.fixture() +def episode_2(db, source, agent_description, agent_description_2, letter_description): + event = Episode.objects.create( + name="Ernie eats a letter", + source=source, + ) + event.agents.add(agent_description) + event.agents.add(agent_description_2) + event.letters.add(letter_description) + return event + + @pytest.fixture() def case_study(db): case_study = CaseStudy.objects.create(name="Test Case Study") diff --git a/backend/event/tests/test_event_mutations.py b/backend/event/tests/test_event_mutations.py new file mode 100644 index 00000000..ae564ee8 --- /dev/null +++ b/backend/event/tests/test_event_mutations.py @@ -0,0 +1,29 @@ +from django.db.models.query import QuerySet + + +def test_episode_order_mutation(graphql_client, episode, episode_2): + result = graphql_client.execute( + f""" + mutation TestMutation {{ + updateEpisodeOrder( + episodeIds: [ + {episode_2.pk}, {episode.pk} + ] + ) {{ + ok + errors {{ + field + messages + }} + }} + }} + """ + ) + + assert result["data"]["updateEpisodeOrder"]["ok"] == True + assert result["data"]["updateEpisodeOrder"]["errors"] == [] + + order_queryset = episode.source.get_episode_order() # type: QuerySet + ids_list = list(order_queryset.values_list("id", flat=True)) + + assert ids_list == [episode_2.pk, episode.pk] diff --git a/backend/graphql_app/schema.py b/backend/graphql_app/schema.py index 470d6684..2da2bb5b 100644 --- a/backend/graphql_app/schema.py +++ b/backend/graphql_app/schema.py @@ -2,6 +2,7 @@ from event.mutations.CreateEpisodeMutation import CreateEpisodeMutation from event.mutations.DeleteEpisodeMutation import DeleteEpisodeMutation +from event.mutations.UpdateEpisodeOrderMutation import UpdateEpisodeOrderMutation from event.queries import EventQueries from letter.queries import LetterQueries from person.queries import PersonQueries @@ -57,6 +58,7 @@ class Mutation(ObjectType): create_episode_entity_link = CreateEpisodeEntityLinkMutation.Field() update_episode_entity_link = UpdateEpisodeEntityLinkMutation.Field() delete_episode_entity_link = DeleteEpisodeEntityLinkMutation.Field() + update_episode_order = UpdateEpisodeOrderMutation.Field() create_agent = CreateAgentMutation.Field() update_agent = UpdateAgentMutation.Field() delete_agent = DeleteAgentMutation.Field() From 0a127bed9e66841b2805aeeb88d6a32be7088c02 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Wed, 30 Oct 2024 17:07:55 +0100 Subject: [PATCH 04/10] 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; +} From db97afdf6c78c20d76141c2440ce5952aed6a194 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Wed, 30 Oct 2024 17:08:05 +0100 Subject: [PATCH 05/10] Update generated files --- frontend/generated/graphql.ts | 41 +++++++++++++++++++++++++++++++ frontend/generated/schema.graphql | 9 +++++++ 2 files changed, 50 insertions(+) diff --git a/frontend/generated/graphql.ts b/frontend/generated/graphql.ts index ed149043..48e83720 100644 --- a/frontend/generated/graphql.ts +++ b/frontend/generated/graphql.ts @@ -462,6 +462,7 @@ export type Mutation = { updateAgent?: Maybe; updateEpisode?: Maybe; updateEpisodeEntityLink?: Maybe; + updateEpisodeOrder?: Maybe; updateGift?: Maybe; updateLetter?: Maybe; updateOrCreateSource?: Maybe; @@ -557,6 +558,11 @@ export type MutationUpdateEpisodeEntityLinkArgs = { }; +export type MutationUpdateEpisodeOrderArgs = { + episodeIds: Array; +}; + + export type MutationUpdateGiftArgs = { giftData: UpdateGiftInput; }; @@ -987,6 +993,12 @@ export type UpdateEpisodeMutation = { ok: Scalars['Boolean']['output']; }; +export type UpdateEpisodeOrderMutation = { + __typename?: 'UpdateEpisodeOrderMutation'; + errors: Array; + ok: Scalars['Boolean']['output']; +}; + export type UpdateGiftInput = { book?: InputMaybe; categories?: InputMaybe>; @@ -1390,6 +1402,13 @@ export type DataEntrySourceDetailQueryVariables = Exact<{ export type DataEntrySourceDetailQuery = { __typename?: 'Query', source: { __typename?: 'SourceType', id: string, name: string, episodes: Array<{ __typename?: 'EpisodeType', id: string, name: string, description: string, summary: string, book: string, chapter: string, page: string, contributors: Array<{ __typename?: 'UserType', id: string, fullName: string }>, agents: Array<{ __typename?: 'AgentDescriptionType', id: string, name: string, isGroup: boolean, identified: boolean }>, gifts: Array<{ __typename?: 'GiftDescriptionType', id: string, name: string }>, letters: Array<{ __typename?: 'LetterDescriptionType', id: string, name: string }>, spaces: Array<{ __typename?: 'SpaceDescriptionType', id: string, name: string, hasIdentifiableFeatures: boolean }> }> } }; +export type DataEntryUpdateEpisodeOrderMutationVariables = Exact<{ + episodeIds: Array | Scalars['ID']['input']; +}>; + + +export type DataEntryUpdateEpisodeOrderMutation = { __typename?: 'Mutation', updateEpisodeOrder?: { __typename?: 'UpdateEpisodeOrderMutation', ok: boolean, errors: Array<{ __typename?: 'LettercraftErrorType', field: string, messages: Array }> } | null }; + export type DataEntrySourceListQueryVariables = Exact<{ [key: string]: never; }>; @@ -2589,6 +2608,28 @@ export const DataEntrySourceDetailDocument = gql` export class DataEntrySourceDetailGQL extends Apollo.Query { override document = DataEntrySourceDetailDocument; + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const DataEntryUpdateEpisodeOrderDocument = gql` + mutation DataEntryUpdateEpisodeOrder($episodeIds: [ID!]!) { + updateEpisodeOrder(episodeIds: $episodeIds) { + ok + errors { + field + messages + } + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class DataEntryUpdateEpisodeOrderGQL extends Apollo.Mutation { + override document = DataEntryUpdateEpisodeOrderDocument; + constructor(apollo: Apollo.Apollo) { super(apollo); } diff --git a/frontend/generated/schema.graphql b/frontend/generated/schema.graphql index ff9dea8d..60f80d6d 100644 --- a/frontend/generated/schema.graphql +++ b/frontend/generated/schema.graphql @@ -452,6 +452,10 @@ type Mutation { updateAgent(agentData: UpdateAgentInput!): UpdateAgentMutation updateEpisode(episodeData: UpdateEpisodeInput!): UpdateEpisodeMutation updateEpisodeEntityLink(data: UpdateEpisodeEntityLinkInput!): UpdateEpisodeEntityLinkMutation + updateEpisodeOrder( + """Ordered list of episode IDs""" + episodeIds: [ID!]! + ): UpdateEpisodeOrderMutation updateGift(giftData: UpdateGiftInput!): UpdateGiftMutation updateLetter(letterData: UpdateLetterInput!): UpdateLetterMutation updateOrCreateSource(sourceData: UpdateCreateSourceInput!): UpdateOrCreateSourceMutation @@ -839,6 +843,11 @@ type UpdateEpisodeMutation { ok: Boolean! } +type UpdateEpisodeOrderMutation { + errors: [LettercraftErrorType!]! + ok: Boolean! +} + input UpdateGiftInput { book: String categories: [ID!] From 8cf6d788b9bfdae5348583d8b61c93cfbfa3508d Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Mon, 4 Nov 2024 09:29:54 +0100 Subject: [PATCH 06/10] Restyle order buttons --- .../episode-preview.component.html | 2 ++ .../episode-preview.component.ts | 9 ++++--- .../data-entry/source/source.component.html | 19 +++++++++++---- frontend/src/app/shared/icons.ts | 4 ++-- .../order-button-group.component.html | 24 +++++++++++-------- .../order-button-group.component.ts | 3 ++- 6 files changed, 39 insertions(+), 22 deletions(-) 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 4d2e4a91..f7d57125 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 @@ -15,6 +15,8 @@
(); + @Input({ required: true }) public episode!: QueriedEpisode; + @Input() public isLast = false; + @Input() public isFirst = false; + @Output() public changeEpisodeOrder = new EventEmitter(); public dataIcons = dataIcons; diff --git a/frontend/src/app/data-entry/source/source.component.html b/frontend/src/app/data-entry/source/source.component.html index c4a22b1f..240b62d1 100644 --- a/frontend/src/app/data-entry/source/source.component.html +++ b/frontend/src/app/data-entry/source/source.component.html @@ -17,9 +17,18 @@

Episodes

@@ -50,10 +59,12 @@

Create new episode

type="button" class="btn btn-primary" (click)="newEpisodeForm.submit()" - > Create -
+
Creating new episode...
diff --git a/frontend/src/app/shared/icons.ts b/frontend/src/app/shared/icons.ts index c1c405cf..c8089bd5 100644 --- a/frontend/src/app/shared/icons.ts +++ b/frontend/src/app/shared/icons.ts @@ -18,8 +18,8 @@ export const actionIcons = { expand: 'caret-down', collapse: 'caret-up', edit: 'pencil', - up: 'chevron-up', - down: 'chevron-down', + up: 'arrow-up', + down: 'arrow-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 index 04abac65..d979f93f 100644 --- 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 @@ -1,14 +1,18 @@
- -
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 index e6455cc8..5bf4ca74 100644 --- 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 @@ -9,8 +9,9 @@ export type OrderChange = "up" | "down"; styleUrls: ["./order-button-group.component.scss"], }) export class OrderButtonGroupComponent { - @Input() entityName: string | null = null; @Output() changeOrder = new EventEmitter(); + @Input() upDisabled = false; + @Input() downDisabled = false; public actionIcons = actionIcons; From 1fa7d177f2fdf5d4e59bbc8a087318521e706c5f Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Mon, 4 Nov 2024 10:23:37 +0100 Subject: [PATCH 07/10] Throw error on out of bounds item move --- frontend/src/app/shared/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/shared/utils.ts b/frontend/src/app/shared/utils.ts index a36e748f..812e6b28 100644 --- a/frontend/src/app/shared/utils.ts +++ b/frontend/src/app/shared/utils.ts @@ -54,7 +54,7 @@ export function moveItemInArray(array: T[], fromIndex: number, toIndex: numbe const toIndexOutOfBounds = toIndex < 0 || toIndex >= array.length; if (fromIndexOutOfBounds || toIndexOutOfBounds) { - return array; + throw new Error(`Index out of bounds: from ${fromIndex} to ${toIndex}.`); } const item = array.splice(fromIndex, 1)[0]; From 6aca9adc2d91307f482b2eb03ac73d3d2fe0bc6a Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Mon, 4 Nov 2024 10:24:11 +0100 Subject: [PATCH 08/10] Improved validation in UpdateEpisodeOrderMutation --- .../mutations/UpdateEpisodeOrderMutation.py | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/backend/event/mutations/UpdateEpisodeOrderMutation.py b/backend/event/mutations/UpdateEpisodeOrderMutation.py index d22f90f6..c4c02e15 100644 --- a/backend/event/mutations/UpdateEpisodeOrderMutation.py +++ b/backend/event/mutations/UpdateEpisodeOrderMutation.py @@ -15,17 +15,30 @@ class Arguments: @classmethod def mutate(cls, root: None, info: ResolveInfo, episode_ids: list[str]): - source = Source.objects.filter(episode__in=episode_ids).distinct() + if len(episode_ids) == 0: + error=LettercraftErrorType( + field="episode_ids", + messages=["No episode IDs provided."] + ) + return cls(ok=False, errors=[error]) # type: ignore - if source.count() > 1: + corresponding_sources = Source.objects.filter(episode__in=episode_ids) + if corresponding_sources.count() != len(episode_ids): error = LettercraftErrorType( field="episode_ids", - messages=["Multiple sources found for given episode IDs."], + messages=["Not every episode has a corresponding source."] ) - return cls(ok=False, errors=[error]) # type: ignore + return cls(ok=False, errors=[error]) # type: ignore - source = source.first() + distinct_sources = corresponding_sources.distinct() + if distinct_sources.count() > 1: + error = LettercraftErrorType( + field="episode_ids", + messages=["The provided episode IDs belong to more than one source."], + ) + return cls(ok=False, errors=[error]) # type: ignore + source = distinct_sources.first() if not source: error = LettercraftErrorType( field="episode_ids", From 2af7c60989eb36b6f4ff679cd8f294ce697957c6 Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Mon, 4 Nov 2024 10:30:59 +0100 Subject: [PATCH 09/10] Fix frontend tests --- frontend/src/app/shared/utils.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/shared/utils.spec.ts b/frontend/src/app/shared/utils.spec.ts index 9be837ab..94af3790 100644 --- a/frontend/src/app/shared/utils.spec.ts +++ b/frontend/src/app/shared/utils.spec.ts @@ -39,8 +39,8 @@ describe("moveItemInArray", () => { expect(moveItemInArray(array, 4, 4)).toBe(array); }); - it("should return the same array if the indices are out of bounds", () => { + it("should throw an error if the indices are out of bounds", () => { const array = ["Alice", "Bernard", "Claire", "David", "Eve"]; - expect(moveItemInArray(array, -99, 99)).toBe(array); + expect(() => moveItemInArray(array, -99, 99)).toThrowError(); }); }); From eb7745447cee07615597f89d3dd7fcdd21a8593d Mon Sep 17 00:00:00 2001 From: Xander Vertegaal Date: Fri, 8 Nov 2024 13:49:33 +0100 Subject: [PATCH 10/10] Improved validation checks on UpdateEpisodeOrderMutation --- .../mutations/UpdateEpisodeOrderMutation.py | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/backend/event/mutations/UpdateEpisodeOrderMutation.py b/backend/event/mutations/UpdateEpisodeOrderMutation.py index c4c02e15..787dcc6f 100644 --- a/backend/event/mutations/UpdateEpisodeOrderMutation.py +++ b/backend/event/mutations/UpdateEpisodeOrderMutation.py @@ -1,5 +1,6 @@ from graphene import Mutation, Boolean, List, NonNull, ID, ResolveInfo +from event.models import Episode from graphql_app.types.LettercraftErrorType import LettercraftErrorType from source.models import Source @@ -16,36 +17,40 @@ class Arguments: @classmethod def mutate(cls, root: None, info: ResolveInfo, episode_ids: list[str]): if len(episode_ids) == 0: - error=LettercraftErrorType( - field="episode_ids", - messages=["No episode IDs provided."] + error = LettercraftErrorType( + field="episode_ids", messages=["No episode IDs provided."] ) - return cls(ok=False, errors=[error]) # type: ignore + return cls(ok=False, errors=[error]) # type: ignore - corresponding_sources = Source.objects.filter(episode__in=episode_ids) - if corresponding_sources.count() != len(episode_ids): + # Check if all episode IDs are valid. + episodes = Episode.objects.filter(id__in=episode_ids).prefetch_related("source") + if episodes.count() != len(episode_ids): error = LettercraftErrorType( field="episode_ids", - messages=["Not every episode has a corresponding source."] + messages=["Not every provided episode ID is valid."], ) - return cls(ok=False, errors=[error]) # type: ignore + return cls(ok=False, errors=[error]) # type: ignore + + corresponding_sources: set[Source] = {episode.source for episode in episodes} - distinct_sources = corresponding_sources.distinct() - if distinct_sources.count() > 1: + # Check if there is at least one source for the provided episode IDs. + # (There should always be.) + if len(corresponding_sources) == 0: error = LettercraftErrorType( - field="episode_ids", - messages=["The provided episode IDs belong to more than one source."], + field="episode_ids", messages=["No source found for given episode IDs."] ) return cls(ok=False, errors=[error]) # type: ignore - source = distinct_sources.first() - if not source: + # Check if all provided episode IDs belong to the same source. + if len(corresponding_sources) > 1: error = LettercraftErrorType( field="episode_ids", - messages=["No source found for given episode IDs."], + messages=["The provided episode IDs belong to more than one source."], ) return cls(ok=False, errors=[error]) # type: ignore + source = corresponding_sources.pop() + source.set_episode_order(episode_ids) # type: ignore return cls(ok=True, errors=[]) # type: ignore