diff --git a/backend/event/mutations/UpdateEpisodeMutation.py b/backend/event/mutations/UpdateEpisodeMutation.py new file mode 100644 index 00000000..a3537f39 --- /dev/null +++ b/backend/event/mutations/UpdateEpisodeMutation.py @@ -0,0 +1,49 @@ +from graphene import ID, Boolean, InputObjectType, List, NonNull, ResolveInfo, String +from django.core.exceptions import ObjectDoesNotExist +from event.models import Episode +from graphql_app.LettercraftMutation import LettercraftMutation + +from graphql_app.types.LettercraftErrorType import LettercraftErrorType + + +class UpdateEpisodeMutationInput(InputObjectType): + id = ID(required=True) + name = String() + book = String() + chapter = String() + page = String() + designators = List(NonNull(String)) + summary = String() + categories = List(NonNull(ID)) + + +class UpdateEpisodeMutation(LettercraftMutation): + ok = Boolean(required=True) + errors = List(NonNull(LettercraftErrorType), required=True) + + django_model = Episode + + class Arguments: + input = UpdateEpisodeMutationInput(required=True) + + @classmethod + def mutate(cls, root: None, info: ResolveInfo, input: UpdateEpisodeMutationInput): + try: + retrieved_object = cls.get_or_create_object(info, input) + except ObjectDoesNotExist as e: + error = LettercraftErrorType(field="id", messages=[str(e)]) + return cls(ok=False, errors=[error]) # type: ignore + + episode = retrieved_object.object + + try: + cls.mutate_object(input, episode, info) + except ObjectDoesNotExist as field: + error = LettercraftErrorType( + field=str(field), messages=["Related object cannot be found."] + ) + return cls(ok=False, errors=[error]) # type: ignore + + episode.save() + + return cls(ok=True, errors=[]) # type: ignore diff --git a/backend/event/types/EpisodeType.py b/backend/event/types/EpisodeType.py index 152586f8..3383d6e4 100644 --- a/backend/event/types/EpisodeType.py +++ b/backend/event/types/EpisodeType.py @@ -1,12 +1,15 @@ -from graphene import ResolveInfo +from graphene import List, NonNull, ResolveInfo from graphene_django import DjangoObjectType from core.types.EntityDescriptionType import EntityDescriptionType from django.db.models import QuerySet -from event.models import Episode +from event.models import Episode, EpisodeCategory +from event.types.EpisodeCategoryType import EpisodeCategoryType class EpisodeType(EntityDescriptionType, DjangoObjectType): + categories = List(NonNull(EpisodeCategoryType), required=True) + class Meta: model = Episode fields = [ @@ -26,3 +29,9 @@ def get_queryset( info: ResolveInfo, ) -> QuerySet[Episode]: return queryset.all() + + @staticmethod + def resolve_categories( + parent: Episode, info: ResolveInfo + ) -> QuerySet[EpisodeCategory]: + return parent.categories.all() diff --git a/backend/graphql_app/LettercraftMutation.py b/backend/graphql_app/LettercraftMutation.py new file mode 100644 index 00000000..2949cd59 --- /dev/null +++ b/backend/graphql_app/LettercraftMutation.py @@ -0,0 +1,187 @@ +from typing import Type +from dataclasses import dataclass +from graphene import InputObjectType, Mutation, ResolveInfo +from django.db.models import Model +from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist +from graphene_django.types import DjangoObjectType +from graphene_django.registry import get_global_registry + + +@dataclass +class RetrievedObject: + object: Model + created: bool + + +class LettercraftMutation(Mutation): + """ + An extension of Graphene's base Mutation class. + """ + + django_model: Type[Model] | None = None + + @classmethod + def get_or_create_object( + cls, info: ResolveInfo, mutation_input: InputObjectType + ) -> RetrievedObject: + """ + Retrieves a Django model instance by id. If the instance does not exist, a new instance is created (but not saved to the database). The instance will be empty, i.e. will not have any (obligatory) fields set. + + Any mutation making use of this method should have a class field 'django_model' defined referring to the class of the object that is being mutated. + + Args: + - mutation_input (InputObjectType): The mutation input. + - info (ResolveInfo): An object containing the request and user information. + + Exceptions: + - ImproperlyConfigured is raised in two cases: + 1. when the mutation class implementing LettercraftMutation does not have the required class field 'django_model' defined; + 2. when the corresponding Graphene type cannot be found. This exception should not be caught. + - ObjectDoesNotExist is raised when an ID is provided but the corresponding object is not found or inaccessible to the user making the request. + """ + + if cls.django_model is None: + raise ImproperlyConfigured( + "Mutation class must have a class field 'django_model' defined." + ) + + model = cls.django_model + + id = getattr(mutation_input, "id", None) + + # The registry is a global object that holds all the models and types. + # It is used to get the corresponding Graphene type for a Django model. + registry = get_global_registry() + graphene_type = registry.get_type_for_model(model) + + if graphene_type is None: + raise ImproperlyConfigured("Graphene type not found for model.") + + if id is None: + return RetrievedObject(object=model(), created=True) + + try: + retrieved = graphene_type.get_queryset(model.objects, info).get(pk=id) + except model.DoesNotExist: + raise ObjectDoesNotExist("Object not found.") + + return RetrievedObject(object=retrieved, created=False) + + @staticmethod + def mutate_object( + input: InputObjectType, + object_instance: Model, + resolve_info: ResolveInfo, + excluded_fields: list[str] = [], + ) -> None: + """ + Updates both simple and related fields in a GraphQL mutation. + + Simple fields are simply set on the mutated object. + + For related fields, the corresponding Django models and Graphene types are retrieved, + and type.get_queryset() is called to ensure the user has access to those particular models. + + Args: + - input (dict): A dictionary containing the updated values for the related fields. + object_instance (Model): The Django model instance to be updated. + - resolve_info (ResolveInfo): An object containing the request and user information. + excluded_fields (List[str]): A list of fields to be excluded from the update. + + Exceptions: + - ObjectDoesNotExist is raised when an ID of a ForeignKey field is provided but the corresponding object is not found or inaccessible to the user making the request. This exception exposes the name of the field. + """ + + # The Graphene registry maps Django models to Graphene types. + registry = get_global_registry() + + simple_fields = [] + one_to_many_fields = [] + many_to_many_fields = [] + + for key, value in input.items(): + # Skip excluded fields + if key in excluded_fields: + continue + + field = object_instance._meta.get_field(key) + + # Triage + if field.many_to_many is True: + many_to_many_fields.append(key) + elif field.many_to_one is True: + one_to_many_fields.append(key) + else: + simple_fields.append(key) + + # Update simple fields + for key in simple_fields: + try: + value = getattr(input, key) + setattr(object_instance, key, value) + except AttributeError: + pass + + # Update one-to-many fields. + # Only selects existing objects on the 'many' side + # of the relationship. + for key in one_to_many_fields: + try: + value = getattr(input, key) + except AttributeError: + continue + + if value is None or value == "": + setattr(object_instance, key, None) + continue + + field = object_instance._meta.get_field(key) + django_model = field.related_model # type: Model | None + + if django_model is None: + continue + + graphene_type = registry.get_type_for_model(django_model) + + try: + accessible_object = graphene_type.get_queryset( + django_model.objects, resolve_info + ).get(id=value) + except django_model.DoesNotExist: + raise ObjectDoesNotExist(key) + + setattr(object_instance, key, accessible_object) + + # Saving the object instance so we can use it for many-to-many fields. + object_instance.save() + + # Update many-to-many fields + for key in many_to_many_fields: + try: + value = getattr(input, key) + except AttributeError: + continue + + if value is None: + getattr(object_instance, key).set([]) + continue + + field = object_instance._meta.get_field(key) + django_model = field.related_model + graphene_type = registry.get_type_for_model(django_model) + + accessible_objects = graphene_type.get_queryset( + django_model.objects, resolve_info + ) + + new_ids = [ + item["id"] + for item in accessible_objects.filter(id__in=value).values("id") + ] + + getattr(object_instance, key).set(new_ids) + + # All subclasses of Mutation must implement the mutate method. + @classmethod + def mutate(cls, root: None, info: ResolveInfo, *args, **kwargs): + cls.mutate(root, info, *args, **kwargs) diff --git a/backend/graphql_app/schema.py b/backend/graphql_app/schema.py index c8c949e9..d96ef993 100644 --- a/backend/graphql_app/schema.py +++ b/backend/graphql_app/schema.py @@ -7,6 +7,7 @@ from space.queries import SpaceQueries from user.queries import UserQueries from source.mutations.UpdateOrCreateSourceMutation import UpdateOrCreateSourceMutation +from event.mutations.UpdateEpisodeMutation import UpdateEpisodeMutation class Query( @@ -23,6 +24,7 @@ class Query( class Mutation(ObjectType): update_or_create_source = UpdateOrCreateSourceMutation.Field() + update_episode = UpdateEpisodeMutation.Field() schema = Schema(query=Query, mutation=Mutation) diff --git a/backend/graphql_app/types/LettercraftErrorType.py b/backend/graphql_app/types/LettercraftErrorType.py new file mode 100644 index 00000000..fba92eac --- /dev/null +++ b/backend/graphql_app/types/LettercraftErrorType.py @@ -0,0 +1,13 @@ +from typing import List +from graphene_django.types import ErrorType + + +class LettercraftErrorType(ErrorType): + """ + A simple wrapper around Graphene-Django's ErrorType with a constructor. + """ + + def __init__(self, field: str, messages: List[str]): + super().__init__() + self.field = field + self.messages = messages diff --git a/frontend/generated/graphql.ts b/frontend/generated/graphql.ts index cec25f5a..787e9123 100644 --- a/frontend/generated/graphql.ts +++ b/frontend/generated/graphql.ts @@ -73,12 +73,22 @@ export type AgentDescriptionType = { sourceMention?: Maybe; }; +export type EpisodeCategoryType = { + __typename?: 'EpisodeCategoryType'; + /** Longer description to help identify this object */ + description: Scalars['String']['output']; + id: Scalars['ID']['output']; + /** A name to help identify this object */ + name: Scalars['String']['output']; +}; + export type EpisodeType = { __typename?: 'EpisodeType'; /** agents involved in this episode */ agents: Array; /** The book in the source */ book: Scalars['String']['output']; + categories: Array; /** The chapter or chapters in the source */ chapter: Scalars['String']['output']; contributors: Array; @@ -269,12 +279,25 @@ export enum LetterLetterDescriptionSourceMentionChoices { Implied = 'IMPLIED' } +/** A simple wrapper around Graphene-Django's ErrorType with a constructor. */ +export type LettercraftErrorType = { + __typename?: 'LettercraftErrorType'; + field: Scalars['String']['output']; + messages: Array; +}; + export type Mutation = { __typename?: 'Mutation'; + updateEpisode?: Maybe; updateOrCreateSource?: Maybe; }; +export type MutationUpdateEpisodeArgs = { + input: UpdateEpisodeMutationInput; +}; + + export type MutationUpdateOrCreateSourceArgs = { input: UpdateCreateSourceInput; }; @@ -747,6 +770,23 @@ export type UpdateCreateSourceInput = { name: Scalars['String']['input']; }; +export type UpdateEpisodeMutation = { + __typename?: 'UpdateEpisodeMutation'; + errors: Array; + ok: Scalars['Boolean']['output']; +}; + +export type UpdateEpisodeMutationInput = { + book?: InputMaybe; + categories?: InputMaybe>; + chapter?: InputMaybe; + designators?: InputMaybe>; + id: Scalars['ID']['input']; + name?: InputMaybe; + page?: InputMaybe; + summary?: InputMaybe; +}; + export type UpdateOrCreateSourceMutation = { __typename?: 'UpdateOrCreateSourceMutation'; errors?: Maybe>>; @@ -767,6 +807,41 @@ export type DataEntryAgentQueryVariables = Exact<{ export type DataEntryAgentQuery = { __typename?: 'Query', agentDescription?: { __typename?: 'AgentDescriptionType', id: string, name: string, description: string, source: { __typename?: 'SourceType', id: string, name: string } } | null }; +export type DataEntryEpisodeContentsQueryVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type DataEntryEpisodeContentsQuery = { __typename?: 'Query', episode?: { __typename?: 'EpisodeType', id: string, summary: string, categories: Array<{ __typename?: 'EpisodeCategoryType', id: string }> } | null }; + +export type DataEntryEpisodeIdentificationQueryVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type DataEntryEpisodeIdentificationQuery = { __typename?: 'Query', episode?: { __typename?: 'EpisodeType', id: string, name: string } | null }; + +export type DataEntryEpisodeSourceTextMentionQueryVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type DataEntryEpisodeSourceTextMentionQuery = { __typename?: 'Query', episode?: { __typename?: 'EpisodeType', id: string, designators: Array, book: string, chapter: string, page: string } | null }; + +export type DataEntryEpisodeFormQueryVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type DataEntryEpisodeFormQuery = { __typename?: 'Query', episode?: { __typename?: 'EpisodeType', id: string, name: string, description: string, source: { __typename?: 'SourceType', id: string, name: string } } | null }; + +export type DataEntryUpdateEpisodeMutationVariables = Exact<{ + input: UpdateEpisodeMutationInput; +}>; + + +export type DataEntryUpdateEpisodeMutation = { __typename?: 'Mutation', updateEpisode?: { __typename?: 'UpdateEpisodeMutation', ok: boolean, errors: Array<{ __typename?: 'LettercraftErrorType', field: string, messages: Array }> } | null }; + export type DataEntryGiftQueryVariables = Exact<{ id: Scalars['ID']['input']; }>; @@ -820,6 +895,115 @@ export const DataEntryAgentDocument = gql` export class DataEntryAgentGQL extends Apollo.Query { override document = DataEntryAgentDocument; + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const DataEntryEpisodeContentsDocument = gql` + query DataEntryEpisodeContents($id: ID!) { + episode(id: $id) { + id + summary + categories { + id + } + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class DataEntryEpisodeContentsGQL extends Apollo.Query { + override document = DataEntryEpisodeContentsDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const DataEntryEpisodeIdentificationDocument = gql` + query DataEntryEpisodeIdentification($id: ID!) { + episode(id: $id) { + id + name + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class DataEntryEpisodeIdentificationGQL extends Apollo.Query { + override document = DataEntryEpisodeIdentificationDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const DataEntryEpisodeSourceTextMentionDocument = gql` + query DataEntryEpisodeSourceTextMention($id: ID!) { + episode(id: $id) { + id + designators + book + chapter + page + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class DataEntryEpisodeSourceTextMentionGQL extends Apollo.Query { + override document = DataEntryEpisodeSourceTextMentionDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const DataEntryEpisodeFormDocument = gql` + query DataEntryEpisodeForm($id: ID!) { + episode(id: $id) { + id + name + description + source { + id + name + } + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class DataEntryEpisodeFormGQL extends Apollo.Query { + override document = DataEntryEpisodeFormDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const DataEntryUpdateEpisodeDocument = gql` + mutation DataEntryUpdateEpisode($input: UpdateEpisodeMutationInput!) { + updateEpisode(input: $input) { + ok + errors { + field + messages + } + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class DataEntryUpdateEpisodeGQL extends Apollo.Mutation { + override document = DataEntryUpdateEpisodeDocument; + constructor(apollo: Apollo.Apollo) { super(apollo); } diff --git a/frontend/generated/schema.graphql b/frontend/generated/schema.graphql index c3fa41ca..68aa5f19 100644 --- a/frontend/generated/schema.graphql +++ b/frontend/generated/schema.graphql @@ -71,12 +71,22 @@ type AgentDescriptionType { sourceMention: PersonAgentDescriptionSourceMentionChoices } +type EpisodeCategoryType { + """Longer description to help identify this object""" + description: String! + id: ID! + + """A name to help identify this object""" + name: String! +} + type EpisodeType { """agents involved in this episode""" agents: [AgentDescriptionType!]! """The book in the source""" book: String! + categories: [EpisodeCategoryType!]! """The chapter or chapters in the source""" chapter: String! @@ -312,7 +322,16 @@ enum LetterLetterDescriptionSourceMentionChoices { IMPLIED } +""" +A simple wrapper around Graphene-Django's ErrorType with a constructor. +""" +type LettercraftErrorType { + field: String! + messages: [String!]! +} + type Mutation { + updateEpisode(input: UpdateEpisodeMutationInput!): UpdateEpisodeMutation updateOrCreateSource(input: UpdateCreateSourceInput!): UpdateOrCreateSourceMutation } @@ -805,6 +824,22 @@ input UpdateCreateSourceInput { name: String! } +type UpdateEpisodeMutation { + errors: [LettercraftErrorType!]! + ok: Boolean! +} + +input UpdateEpisodeMutationInput { + book: String + categories: [ID!] + chapter: String + designators: [String!] + id: ID! + name: String + page: String + summary: String +} + type UpdateOrCreateSourceMutation { errors: [String] source: SourceType diff --git a/frontend/src/app/data-entry/data-entry.module.ts b/frontend/src/app/data-entry/data-entry.module.ts index 9dd8f300..815915e9 100644 --- a/frontend/src/app/data-entry/data-entry.module.ts +++ b/frontend/src/app/data-entry/data-entry.module.ts @@ -5,28 +5,26 @@ import { GiftFormModule } from "./gift-form/gift-form.module"; import { LetterFormModule } from "./letter-form/letter-form.module"; import { LocationFormModule } from "./location-form/location-form.module"; import { AgentFormModule } from "./agent-form/agent-form.module"; -import { SourceComponent } from './source/source.component'; -import { EpisodePreviewComponent } from './source/episode-preview/episode-preview.component'; +import { SourceComponent } from "./source/source.component"; +import { EpisodePreviewComponent } from "./source/episode-preview/episode-preview.component"; +import { EpisodeFormModule } from "./episode-form/episode-form.module"; @NgModule({ - declarations: [ - SourcesComponent, - SourceComponent, - EpisodePreviewComponent, - ], + declarations: [SourcesComponent, SourceComponent, EpisodePreviewComponent], imports: [ SharedModule, AgentFormModule, GiftFormModule, LetterFormModule, LocationFormModule, + EpisodeFormModule, ], exports: [ - SourcesComponent, AgentFormModule, GiftFormModule, LetterFormModule, LocationFormModule, + EpisodeFormModule, ], }) export class DataEntryModule {} diff --git a/frontend/src/app/data-entry/episode-form/episode-agents-form/episode-agents-form.component.html b/frontend/src/app/data-entry/episode-form/episode-agents-form/episode-agents-form.component.html new file mode 100644 index 00000000..ce59c301 --- /dev/null +++ b/frontend/src/app/data-entry/episode-form/episode-agents-form/episode-agents-form.component.html @@ -0,0 +1 @@ +

Coming soon!

diff --git a/frontend/src/app/data-entry/episode-form/episode-agents-form/episode-agents-form.component.scss b/frontend/src/app/data-entry/episode-form/episode-agents-form/episode-agents-form.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/app/data-entry/episode-form/episode-agents-form/episode-agents-form.component.spec.ts b/frontend/src/app/data-entry/episode-form/episode-agents-form/episode-agents-form.component.spec.ts new file mode 100644 index 00000000..18ab6a8b --- /dev/null +++ b/frontend/src/app/data-entry/episode-form/episode-agents-form/episode-agents-form.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { EpisodeAgentsFormComponent } from "./episode-agents-form.component"; +import { SharedTestingModule } from "@shared/shared-testing.module"; + +describe("EpisodeAgentsFormComponent", () => { + let component: EpisodeAgentsFormComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [EpisodeAgentsFormComponent], + imports: [SharedTestingModule], + }); + fixture = TestBed.createComponent(EpisodeAgentsFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/data-entry/episode-form/episode-agents-form/episode-agents-form.component.ts b/frontend/src/app/data-entry/episode-form/episode-agents-form/episode-agents-form.component.ts new file mode 100644 index 00000000..4e157701 --- /dev/null +++ b/frontend/src/app/data-entry/episode-form/episode-agents-form/episode-agents-form.component.ts @@ -0,0 +1,8 @@ +import { Component } from "@angular/core"; + +@Component({ + selector: "lc-episode-agents-form", + templateUrl: "./episode-agents-form.component.html", + styleUrls: ["./episode-agents-form.component.scss"], +}) +export class EpisodeAgentsFormComponent {} diff --git a/frontend/src/app/data-entry/episode-form/episode-contents-form/episode-contents-form.component.html b/frontend/src/app/data-entry/episode-form/episode-contents-form/episode-contents-form.component.html new file mode 100644 index 00000000..b17e1de9 --- /dev/null +++ b/frontend/src/app/data-entry/episode-form/episode-contents-form/episode-contents-form.component.html @@ -0,0 +1,19 @@ +
+
+ +

Describe the events in this passage.

+ +
+ +
+ +

+ Pick one or more labels that describe this episode. +

+

Coming soon!

+
+
diff --git a/frontend/src/app/data-entry/episode-form/episode-contents-form/episode-contents-form.component.scss b/frontend/src/app/data-entry/episode-form/episode-contents-form/episode-contents-form.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/app/data-entry/episode-form/episode-contents-form/episode-contents-form.component.spec.ts b/frontend/src/app/data-entry/episode-form/episode-contents-form/episode-contents-form.component.spec.ts new file mode 100644 index 00000000..a37f3965 --- /dev/null +++ b/frontend/src/app/data-entry/episode-form/episode-contents-form/episode-contents-form.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { EpisodeContentsFormComponent } from "./episode-contents-form.component"; +import { SharedTestingModule } from "@shared/shared-testing.module"; + +describe("EpisodeContentsFormComponent", () => { + let component: EpisodeContentsFormComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [EpisodeContentsFormComponent], + imports: [SharedTestingModule], + }); + fixture = TestBed.createComponent(EpisodeContentsFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/data-entry/episode-form/episode-contents-form/episode-contents-form.component.ts b/frontend/src/app/data-entry/episode-form/episode-contents-form/episode-contents-form.component.ts new file mode 100644 index 00000000..38f5176a --- /dev/null +++ b/frontend/src/app/data-entry/episode-form/episode-contents-form/episode-contents-form.component.ts @@ -0,0 +1,90 @@ +import { Component, DestroyRef, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormControl, FormGroup } from "@angular/forms"; +import { ActivatedRoute } from "@angular/router"; +import { ToastService } from "@services/toast.service"; +import { + DataEntryEpisodeContentsGQL, + DataEntryUpdateEpisodeGQL, +} from "generated/graphql"; +import { + debounceTime, + filter, + map, + Observable, + switchMap, + withLatestFrom, +} from "rxjs"; + +@Component({ + selector: "lc-episode-contents-form", + templateUrl: "./episode-contents-form.component.html", + styleUrls: ["./episode-contents-form.component.scss"], +}) +export class EpisodeContentsFormComponent implements OnInit { + private id$: Observable = this.route.params.pipe( + map((params) => params["id"]) + ); + + private episode$ = this.id$.pipe( + switchMap((id) => this.episodeQuery.watch({ id }).valueChanges), + map((result) => result.data.episode) + ); + + public form = new FormGroup({ + summary: new FormControl("", { + nonNullable: true, + }), + categories: new FormControl([], { + nonNullable: true, + }), + }); + + constructor( + private destroyRef: DestroyRef, + private route: ActivatedRoute, + private toastService: ToastService, + private episodeQuery: DataEntryEpisodeContentsGQL, + private episodeMutation: DataEntryUpdateEpisodeGQL + ) {} + + ngOnInit(): void { + this.episode$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((episode) => { + if (!episode) { + return; + } + this.form.patchValue({ + summary: episode.summary, + categories: episode.categories.map((c) => c.id), + }); + }); + + this.form.valueChanges + .pipe( + map(() => this.form.getRawValue()), + filter(() => this.form.valid), + debounceTime(300), + withLatestFrom(this.id$), + switchMap(([episode, id]) => + this.episodeMutation.mutate({ + input: { + id, + ...episode, + }, + }) + ) + ) + .subscribe((result) => { + const errors = result.data?.updateEpisode?.errors; + if (errors && errors.length > 0) { + this.toastService.show({ + body: errors.map((error) => error.messages).join("\n"), + type: "danger", + header: "Update failed", + }); + } + }); + } +} diff --git a/frontend/src/app/data-entry/episode-form/episode-contents-form/episode-contents-form.graphql b/frontend/src/app/data-entry/episode-form/episode-contents-form/episode-contents-form.graphql new file mode 100644 index 00000000..0ca2e2a4 --- /dev/null +++ b/frontend/src/app/data-entry/episode-form/episode-contents-form/episode-contents-form.graphql @@ -0,0 +1,10 @@ +query DataEntryEpisodeContents($id: ID!) { + episode(id: $id) { + id + summary + categories { + id + + } + } +} diff --git a/frontend/src/app/data-entry/episode-form/episode-form.component.html b/frontend/src/app/data-entry/episode-form/episode-form.component.html new file mode 100644 index 00000000..2ff0e8c5 --- /dev/null +++ b/frontend/src/app/data-entry/episode-form/episode-form.component.html @@ -0,0 +1,36 @@ + + + +

+ + {{ episode.name }} + ({{ episode.source.name }}) +

+

+ {{ episode.description}} +

+
+ +

Identification

+ + +

Source text

+ + +

Contents

+ + +

Agents

+ + +

Locations

+ + +

Objects

+ + + +
+ Loading... +
+
diff --git a/frontend/src/app/data-entry/episode-form/episode-form.component.scss b/frontend/src/app/data-entry/episode-form/episode-form.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/app/data-entry/episode-form/episode-form.component.spec.ts b/frontend/src/app/data-entry/episode-form/episode-form.component.spec.ts new file mode 100644 index 00000000..cae14fa3 --- /dev/null +++ b/frontend/src/app/data-entry/episode-form/episode-form.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { EpisodeFormComponent } from "./episode-form.component"; +import { SharedTestingModule } from "@shared/shared-testing.module"; +import { EpisodeFormModule } from "./episode-form.module"; + +describe("EpisodeFormComponent", () => { + let component: EpisodeFormComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [EpisodeFormComponent], + imports: [SharedTestingModule, EpisodeFormModule], + }); + fixture = TestBed.createComponent(EpisodeFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/data-entry/episode-form/episode-form.component.ts b/frontend/src/app/data-entry/episode-form/episode-form.component.ts new file mode 100644 index 00000000..4103ee99 --- /dev/null +++ b/frontend/src/app/data-entry/episode-form/episode-form.component.ts @@ -0,0 +1,56 @@ +import { Component } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { actionIcons, dataIcons } from "@shared/icons"; +import { DataEntryEpisodeFormGQL } from "generated/graphql"; +import { filter, map, share, switchMap } from "rxjs"; + +@Component({ + selector: "lc-episode", + templateUrl: "./episode-form.component.html", + styleUrls: ["./episode-form.component.scss"], +}) +export class EpisodeFormComponent { + private id$ = this.route.params.pipe(map((params) => params["id"])); + + public episode$ = this.id$.pipe( + filter((id) => id !== "new"), + switchMap((id) => this.episodeQuery.watch({ id }).valueChanges), + map((result) => result.data.episode), + share() + ); + + public breadcrumbs$ = this.episode$.pipe( + filter((episode) => !!episode), + map((episode) => { + if (!episode) { + return []; + } + return [ + { + label: "Lettercraft", + link: "/", + }, + { + label: "Data entry", + link: "/data-entry", + }, + { + label: episode.source.name, + link: `/source/${episode.source.id}`, + }, + { + label: episode.name, + link: `/data-entry/episode/${episode.id}`, + }, + ]; + }) + ); + + public dataIcons = dataIcons; + public actionIcons = actionIcons; + + constructor( + private route: ActivatedRoute, + private episodeQuery: DataEntryEpisodeFormGQL + ) {} +} diff --git a/frontend/src/app/data-entry/episode-form/episode-form.module.ts b/frontend/src/app/data-entry/episode-form/episode-form.module.ts new file mode 100644 index 00000000..06743a84 --- /dev/null +++ b/frontend/src/app/data-entry/episode-form/episode-form.module.ts @@ -0,0 +1,25 @@ +import { NgModule } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { EpisodeIdentificationFormComponent } from "./episode-identification-form/episode-identification-form.component"; +import { EpisodeSourceTextFormComponent } from "./episode-source-text-form/episode-source-text-form.component"; +import { EpisodeContentsFormComponent } from "./episode-contents-form/episode-contents-form.component"; +import { EpisodeAgentsFormComponent } from "./episode-agents-form/episode-agents-form.component"; +import { EpisodeLocationsFormComponent } from "./episode-locations-form/episode-locations-form.component"; +import { EpisodeObjectsFormComponent } from "./episode-objects-form/episode-objects-form.component"; +import { SharedModule } from "@shared/shared.module"; +import { EpisodeFormComponent } from "./episode-form.component"; +import { DataEntrySharedModule } from "../shared/data-entry-shared.module"; + +@NgModule({ + declarations: [ + EpisodeFormComponent, + EpisodeIdentificationFormComponent, + EpisodeSourceTextFormComponent, + EpisodeContentsFormComponent, + EpisodeAgentsFormComponent, + EpisodeLocationsFormComponent, + EpisodeObjectsFormComponent, + ], + imports: [CommonModule, SharedModule, DataEntrySharedModule], +}) +export class EpisodeFormModule {} diff --git a/frontend/src/app/data-entry/episode-form/episode-identification-form/episode-identification-form.component.html b/frontend/src/app/data-entry/episode-form/episode-identification-form/episode-identification-form.component.html new file mode 100644 index 00000000..864a6b08 --- /dev/null +++ b/frontend/src/app/data-entry/episode-form/episode-identification-form/episode-identification-form.component.html @@ -0,0 +1,20 @@ +
+
+ +

+ This is a name to identify the episode in the overview of the + source. For example: "journey to Rome", "Clovis' response", + "Radegund asks Germanus for help". +

+ +

This field is required.

+
+
diff --git a/frontend/src/app/data-entry/episode-form/episode-identification-form/episode-identification-form.component.scss b/frontend/src/app/data-entry/episode-form/episode-identification-form/episode-identification-form.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/app/data-entry/episode-form/episode-identification-form/episode-identification-form.component.spec.ts b/frontend/src/app/data-entry/episode-form/episode-identification-form/episode-identification-form.component.spec.ts new file mode 100644 index 00000000..f522e5cd --- /dev/null +++ b/frontend/src/app/data-entry/episode-form/episode-identification-form/episode-identification-form.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { EpisodeIdentificationFormComponent } from "./episode-identification-form.component"; +import { SharedTestingModule } from "@shared/shared-testing.module"; + +describe("EpisodeIdentificationFormComponent", () => { + let component: EpisodeIdentificationFormComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [EpisodeIdentificationFormComponent], + imports: [SharedTestingModule], + }); + fixture = TestBed.createComponent(EpisodeIdentificationFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/data-entry/episode-form/episode-identification-form/episode-identification-form.component.ts b/frontend/src/app/data-entry/episode-form/episode-identification-form/episode-identification-form.component.ts new file mode 100644 index 00000000..2c8122b2 --- /dev/null +++ b/frontend/src/app/data-entry/episode-form/episode-identification-form/episode-identification-form.component.ts @@ -0,0 +1,87 @@ +import { Component, DestroyRef, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormControl, FormGroup, Validators } from "@angular/forms"; +import { ActivatedRoute } from "@angular/router"; +import { ToastService } from "@services/toast.service"; +import { + DataEntryEpisodeIdentificationGQL, + DataEntryUpdateEpisodeGQL, +} from "generated/graphql"; +import { + debounceTime, + filter, + map, + Observable, + switchMap, + withLatestFrom, +} from "rxjs"; + +@Component({ + selector: "lc-episode-identification-form", + templateUrl: "./episode-identification-form.component.html", + styleUrls: ["./episode-identification-form.component.scss"], +}) +export class EpisodeIdentificationFormComponent implements OnInit { + public id$: Observable = this.route.params.pipe( + map((params) => params["id"]) + ); + + public episode$ = this.id$.pipe( + switchMap((id) => this.episodeQuery.watch({ id }).valueChanges), + map((result) => result.data.episode) + ); + + public form = new FormGroup({ + name: new FormControl("", { + nonNullable: true, + validators: [Validators.required], + }), + }); + + constructor( + private destroyRef: DestroyRef, + private route: ActivatedRoute, + private toastService: ToastService, + private episodeQuery: DataEntryEpisodeIdentificationGQL, + private episodeMutation: DataEntryUpdateEpisodeGQL + ) {} + + public ngOnInit(): void { + this.episode$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((episode) => { + if (!episode) { + return; + } + this.form.patchValue(episode); + }); + + this.form.valueChanges + .pipe( + map(() => this.form.getRawValue()), + filter(() => this.form.valid), + debounceTime(300), + withLatestFrom(this.id$), + switchMap(([episode, id]) => + this.episodeMutation + .mutate({ + input: { + id, + name: episode.name, + }, + }) + .pipe(takeUntilDestroyed(this.destroyRef)) + ) + ) + .subscribe((result) => { + const errors = result.data?.updateEpisode?.errors; + if (errors && errors.length > 0) { + this.toastService.show({ + body: errors.map((error) => error.messages).join("\n"), + type: "danger", + header: "Update failed", + }); + } + }); + } +} diff --git a/frontend/src/app/data-entry/episode-form/episode-identification-form/episode-identification-form.graphql b/frontend/src/app/data-entry/episode-form/episode-identification-form/episode-identification-form.graphql new file mode 100644 index 00000000..4fc6780d --- /dev/null +++ b/frontend/src/app/data-entry/episode-form/episode-identification-form/episode-identification-form.graphql @@ -0,0 +1,6 @@ +query DataEntryEpisodeIdentification($id: ID!) { + episode(id: $id) { + id + name + } +} diff --git a/frontend/src/app/data-entry/episode-form/episode-locations-form/episode-locations-form.component.html b/frontend/src/app/data-entry/episode-form/episode-locations-form/episode-locations-form.component.html new file mode 100644 index 00000000..ce59c301 --- /dev/null +++ b/frontend/src/app/data-entry/episode-form/episode-locations-form/episode-locations-form.component.html @@ -0,0 +1 @@ +

Coming soon!

diff --git a/frontend/src/app/data-entry/episode-form/episode-locations-form/episode-locations-form.component.scss b/frontend/src/app/data-entry/episode-form/episode-locations-form/episode-locations-form.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/app/data-entry/episode-form/episode-locations-form/episode-locations-form.component.spec.ts b/frontend/src/app/data-entry/episode-form/episode-locations-form/episode-locations-form.component.spec.ts new file mode 100644 index 00000000..2b27207c --- /dev/null +++ b/frontend/src/app/data-entry/episode-form/episode-locations-form/episode-locations-form.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { EpisodeLocationsFormComponent } from "./episode-locations-form.component"; +import { SharedTestingModule } from "@shared/shared-testing.module"; + +describe("EpisodeLocationsFormComponent", () => { + let component: EpisodeLocationsFormComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [EpisodeLocationsFormComponent], + imports: [SharedTestingModule], + }); + fixture = TestBed.createComponent(EpisodeLocationsFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/data-entry/episode-form/episode-locations-form/episode-locations-form.component.ts b/frontend/src/app/data-entry/episode-form/episode-locations-form/episode-locations-form.component.ts new file mode 100644 index 00000000..78ce037b --- /dev/null +++ b/frontend/src/app/data-entry/episode-form/episode-locations-form/episode-locations-form.component.ts @@ -0,0 +1,8 @@ +import { Component } from "@angular/core"; + +@Component({ + selector: "lc-episode-locations-form", + templateUrl: "./episode-locations-form.component.html", + styleUrls: ["./episode-locations-form.component.scss"], +}) +export class EpisodeLocationsFormComponent {} diff --git a/frontend/src/app/data-entry/episode-form/episode-objects-form/episode-objects-form.component.html b/frontend/src/app/data-entry/episode-form/episode-objects-form/episode-objects-form.component.html new file mode 100644 index 00000000..ce59c301 --- /dev/null +++ b/frontend/src/app/data-entry/episode-form/episode-objects-form/episode-objects-form.component.html @@ -0,0 +1 @@ +

Coming soon!

diff --git a/frontend/src/app/data-entry/episode-form/episode-objects-form/episode-objects-form.component.scss b/frontend/src/app/data-entry/episode-form/episode-objects-form/episode-objects-form.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/app/data-entry/episode-form/episode-objects-form/episode-objects-form.component.spec.ts b/frontend/src/app/data-entry/episode-form/episode-objects-form/episode-objects-form.component.spec.ts new file mode 100644 index 00000000..78fef072 --- /dev/null +++ b/frontend/src/app/data-entry/episode-form/episode-objects-form/episode-objects-form.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { EpisodeObjectsFormComponent } from "./episode-objects-form.component"; +import { SharedTestingModule } from "@shared/shared-testing.module"; + +describe("EpisodeObjectsFormComponent", () => { + let component: EpisodeObjectsFormComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [EpisodeObjectsFormComponent], + imports: [SharedTestingModule], + }); + fixture = TestBed.createComponent(EpisodeObjectsFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/data-entry/episode-form/episode-objects-form/episode-objects-form.component.ts b/frontend/src/app/data-entry/episode-form/episode-objects-form/episode-objects-form.component.ts new file mode 100644 index 00000000..d525c0ff --- /dev/null +++ b/frontend/src/app/data-entry/episode-form/episode-objects-form/episode-objects-form.component.ts @@ -0,0 +1,8 @@ +import { Component } from "@angular/core"; + +@Component({ + selector: "lc-episode-objects-form", + templateUrl: "./episode-objects-form.component.html", + styleUrls: ["./episode-objects-form.component.scss"], +}) +export class EpisodeObjectsFormComponent {} diff --git a/frontend/src/app/data-entry/episode-form/episode-source-text-form/episode-source-text-form.component.html b/frontend/src/app/data-entry/episode-form/episode-source-text-form/episode-source-text-form.component.html new file mode 100644 index 00000000..0dbf5085 --- /dev/null +++ b/frontend/src/app/data-entry/episode-form/episode-source-text-form/episode-source-text-form.component.html @@ -0,0 +1,37 @@ +
+ + +

Location

+

+ Describe the location of this episode in the source text. +

+
+
+ + +
+
+ + +
+
+ + +
+
+ diff --git a/frontend/src/app/data-entry/episode-form/episode-source-text-form/episode-source-text-form.component.scss b/frontend/src/app/data-entry/episode-form/episode-source-text-form/episode-source-text-form.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/app/data-entry/episode-form/episode-source-text-form/episode-source-text-form.component.spec.ts b/frontend/src/app/data-entry/episode-form/episode-source-text-form/episode-source-text-form.component.spec.ts new file mode 100644 index 00000000..cdeea205 --- /dev/null +++ b/frontend/src/app/data-entry/episode-form/episode-source-text-form/episode-source-text-form.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { EpisodeSourceTextFormComponent } from "./episode-source-text-form.component"; +import { SharedTestingModule } from "@shared/shared-testing.module"; + +describe("EpisodeSourceTextFormComponent", () => { + let component: EpisodeSourceTextFormComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [EpisodeSourceTextFormComponent], + imports: [SharedTestingModule], + }); + fixture = TestBed.createComponent(EpisodeSourceTextFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/data-entry/episode-form/episode-source-text-form/episode-source-text-form.component.ts b/frontend/src/app/data-entry/episode-form/episode-source-text-form/episode-source-text-form.component.ts new file mode 100644 index 00000000..c645e360 --- /dev/null +++ b/frontend/src/app/data-entry/episode-form/episode-source-text-form/episode-source-text-form.component.ts @@ -0,0 +1,85 @@ +import { Component, DestroyRef, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormControl, FormGroup } from "@angular/forms"; +import { ActivatedRoute } from "@angular/router"; +import { ToastService } from "@services/toast.service"; +import { + DataEntryEpisodeSourceTextMentionGQL, + DataEntryUpdateEpisodeGQL, +} from "generated/graphql"; +import { + debounceTime, + filter, + map, + Observable, + switchMap, + withLatestFrom, +} from "rxjs"; + +@Component({ + selector: "lc-episode-source-text-form", + templateUrl: "./episode-source-text-form.component.html", + styleUrls: ["./episode-source-text-form.component.scss"], +}) +export class EpisodeSourceTextFormComponent implements OnInit { + private id$: Observable = this.route.params.pipe( + map((params) => params["id"]) + ); + + public episode$ = this.id$.pipe( + switchMap((id) => this.episodeQuery.watch({ id }).valueChanges), + map((result) => result.data.episode) + ); + + public form = new FormGroup({ + designators: new FormControl([], { nonNullable: true }), + book: new FormControl("", { nonNullable: true }), + chapter: new FormControl("", { nonNullable: true }), + page: new FormControl("", { nonNullable: true }), + }); + + constructor( + private destroyRef: DestroyRef, + private route: ActivatedRoute, + private toastService: ToastService, + private episodeQuery: DataEntryEpisodeSourceTextMentionGQL, + private episodeMutation: DataEntryUpdateEpisodeGQL + ) {} + + ngOnInit(): void { + this.episode$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((episode) => { + if (!episode) { + return; + } + this.form.patchValue(episode); + }); + + this.form.valueChanges + .pipe( + map(() => this.form.getRawValue()), + filter(() => this.form.valid), + debounceTime(300), + withLatestFrom(this.id$), + switchMap(([episode, id]) => + this.episodeMutation.mutate({ + input: { + id, + ...episode, + }, + }) + ) + ) + .subscribe((result) => { + const errors = result.data?.updateEpisode?.errors; + if (errors && errors.length > 0) { + this.toastService.show({ + body: errors.map((error) => error.messages).join("\n"), + type: "danger", + header: "Update failed", + }); + } + }); + } +} diff --git a/frontend/src/app/data-entry/episode-form/episode-source-text-form/episode-source-text-mention-form.graphql b/frontend/src/app/data-entry/episode-form/episode-source-text-form/episode-source-text-mention-form.graphql new file mode 100644 index 00000000..bf14a3e4 --- /dev/null +++ b/frontend/src/app/data-entry/episode-form/episode-source-text-form/episode-source-text-mention-form.graphql @@ -0,0 +1,9 @@ +query DataEntryEpisodeSourceTextMention($id: ID!) { + episode(id: $id) { + id + designators + book + chapter + page + } +} diff --git a/frontend/src/app/data-entry/episode-form/episode.graphql b/frontend/src/app/data-entry/episode-form/episode.graphql new file mode 100644 index 00000000..a10b0db3 --- /dev/null +++ b/frontend/src/app/data-entry/episode-form/episode.graphql @@ -0,0 +1,23 @@ +query DataEntryEpisodeForm($id: ID!) { + episode(id: $id) { + id + name + description + source { + id + name + } + } +} + +mutation DataEntryUpdateEpisode( + $input: UpdateEpisodeMutationInput! +) { + updateEpisode(input: $input) { + ok + errors { + field + messages + } + } +} diff --git a/frontend/src/app/data-entry/shared/designators-control/designators-control.component.html b/frontend/src/app/data-entry/shared/designators-control/designators-control.component.html index af069c8e..d4b464d4 100644 --- a/frontend/src/app/data-entry/shared/designators-control/designators-control.component.html +++ b/frontend/src/app/data-entry/shared/designators-control/designators-control.component.html @@ -1,5 +1,8 @@

Designators

+

+ What expressions are used in the text to refer to this entity? +

No designators added.

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 b6d276a8..ab68d666 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,7 +12,7 @@
diff --git a/frontend/src/app/routes.ts b/frontend/src/app/routes.ts index 166c24eb..af8a2515 100644 --- a/frontend/src/app/routes.ts +++ b/frontend/src/app/routes.ts @@ -14,6 +14,7 @@ import { GiftFormComponent } from './data-entry/gift-form/gift-form.component'; import { LetterFormComponent } from './data-entry/letter-form/letter-form.component'; import { AgentFormComponent } from './data-entry/agent-form/agent-form.component'; import { SourceComponent } from './data-entry/source/source.component'; +import { EpisodeFormComponent } from './data-entry/episode-form/episode-form.component'; const routes: Routes = [ @@ -73,6 +74,10 @@ const routes: Routes = [ path: 'sources/:id', component: SourceComponent }, + { + path: 'episodes/:id', + component: EpisodeFormComponent, + }, { path: '', pathMatch: 'full', diff --git a/frontend/src/app/shared/shared-testing.module.ts b/frontend/src/app/shared/shared-testing.module.ts index 36372c0a..a8f7377a 100644 --- a/frontend/src/app/shared/shared-testing.module.ts +++ b/frontend/src/app/shared/shared-testing.module.ts @@ -4,6 +4,7 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { RouterTestingModule } from '@angular/router/testing'; import { SharedModule } from './shared.module'; import { ApolloTestingModule } from 'apollo-angular/testing'; +import { DataEntrySharedModule } from '../data-entry/shared/data-entry-shared.module'; @NgModule({ @@ -12,12 +13,14 @@ import { ApolloTestingModule } from 'apollo-angular/testing'; HttpClientTestingModule, NoopAnimationsModule, RouterTestingModule, + DataEntrySharedModule ], exports: [ SharedModule, HttpClientTestingModule, NoopAnimationsModule, RouterTestingModule, ApolloTestingModule, + DataEntrySharedModule, ] }) export class SharedTestingModule { } diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index c3cf2744..bc422cf2 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -29,3 +29,7 @@ content: ",\00A0"; } } + +.subform-header { + margin-top: 1.5rem; +}