diff --git a/frontend/generated/graphql.ts b/frontend/generated/graphql.ts index 7910ea5a..7765a056 100644 --- a/frontend/generated/graphql.ts +++ b/frontend/generated/graphql.ts @@ -1117,6 +1117,20 @@ export type DataEntryAgentQueryVariables = Exact<{ export type DataEntryAgentQuery = { __typename?: 'Query', agentDescription?: { __typename?: 'AgentDescriptionType', id: string, name: string, description: string, isGroup: boolean, source: { __typename?: 'SourceType', id: string, name: string } } | null }; +export type DataEntryUpdateAgentMutationVariables = Exact<{ + input: UpdateAgentInput; +}>; + + +export type DataEntryUpdateAgentMutation = { __typename?: 'Mutation', updateAgent?: { __typename?: 'UpdateAgentMutation', ok: boolean, errors: Array<{ __typename?: 'LettercraftErrorType', field: string, messages: Array }> } | null }; + +export type DataEntryDeleteAgentMutationVariables = Exact<{ + id: Scalars['ID']['input']; +}>; + + +export type DataEntryDeleteAgentMutation = { __typename?: 'Mutation', deleteAgent?: { __typename?: 'DeleteAgentMutation', ok: boolean, errors: Array<{ __typename?: 'LettercraftErrorType', messages: Array, field: string }> } | null }; + export type DataEntryEpisodeContentsQueryVariables = Exact<{ id: Scalars['ID']['input']; }>; @@ -1263,7 +1277,7 @@ export type DataEntrySourceDetailQueryVariables = Exact<{ }>; -export type DataEntrySourceDetailQuery = { __typename?: 'Query', source: { __typename?: 'SourceType', id: string, name: string, editionAuthor: string, editionTitle: string, medievalAuthor: string, medievalTitle: string, numOfEpisodes: number, 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, describes?: Array<{ __typename?: 'HistoricalPersonType', id: string, identifiable: boolean } | null> | null }>, gifts: Array<{ __typename?: 'GiftDescriptionType', id: string, name: string }>, letters: Array<{ __typename?: 'LetterDescriptionType', id: string, name: string }>, spaces: Array<{ __typename?: 'SpaceDescriptionType', id: string, name: string }> }> } }; +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, describes?: Array<{ __typename?: 'HistoricalPersonType', id: string, identifiable: boolean } | null> | null }>, gifts: Array<{ __typename?: 'GiftDescriptionType', id: string, name: string }>, letters: Array<{ __typename?: 'LetterDescriptionType', id: string, name: string }>, spaces: Array<{ __typename?: 'SpaceDescriptionType', id: string, name: string }> }> } }; export type DataEntrySourceListQueryVariables = Exact<{ [key: string]: never; }>; @@ -1393,6 +1407,50 @@ export const DataEntryAgentDocument = gql` export class DataEntryAgentGQL extends Apollo.Query { override document = DataEntryAgentDocument; + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const DataEntryUpdateAgentDocument = gql` + mutation DataEntryUpdateAgent($input: UpdateAgentInput!) { + updateAgent(agentData: $input) { + ok + errors { + field + messages + } + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class DataEntryUpdateAgentGQL extends Apollo.Mutation { + override document = DataEntryUpdateAgentDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const DataEntryDeleteAgentDocument = gql` + mutation DataEntryDeleteAgent($id: ID!) { + deleteAgent(id: $id) { + ok + errors { + messages + field + } + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class DataEntryDeleteAgentGQL extends Apollo.Mutation { + override document = DataEntryDeleteAgentDocument; + constructor(apollo: Apollo.Apollo) { super(apollo); } @@ -1874,11 +1932,6 @@ export const DataEntrySourceDetailDocument = gql` source(id: $id) { id name - editionAuthor - editionTitle - medievalAuthor - medievalTitle - numOfEpisodes episodes { id name diff --git a/frontend/src/app/data-entry/agent-form/agent-description-form/agent-description-form.component.spec.ts b/frontend/src/app/data-entry/agent-form/agent-description-form/agent-description-form.component.spec.ts index 6ea7499f..dc016b60 100644 --- a/frontend/src/app/data-entry/agent-form/agent-description-form/agent-description-form.component.spec.ts +++ b/frontend/src/app/data-entry/agent-form/agent-description-form/agent-description-form.component.spec.ts @@ -3,6 +3,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { AgentDescriptionFormComponent } from './agent-description-form.component'; import { SharedTestingModule } from '@shared/shared-testing.module'; import { DataEntrySharedModule } from '../../shared/data-entry-shared.module'; +import { FormService } from '../../shared/form.service'; describe('AgentDescriptionFormComponent', () => { let component: AgentDescriptionFormComponent; @@ -12,6 +13,7 @@ describe('AgentDescriptionFormComponent', () => { TestBed.configureTestingModule({ declarations: [AgentDescriptionFormComponent], imports: [SharedTestingModule, DataEntrySharedModule,], + providers: [FormService], }); fixture = TestBed.createComponent(AgentDescriptionFormComponent); component = fixture.componentInstance; diff --git a/frontend/src/app/data-entry/agent-form/agent-description-form/agent-description-form.component.ts b/frontend/src/app/data-entry/agent-form/agent-description-form/agent-description-form.component.ts index ae7025c3..9704d1a2 100644 --- a/frontend/src/app/data-entry/agent-form/agent-description-form/agent-description-form.component.ts +++ b/frontend/src/app/data-entry/agent-form/agent-description-form/agent-description-form.component.ts @@ -1,17 +1,26 @@ -import { Component, Input, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; +import { Component, OnDestroy } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup } from '@angular/forms'; +import { ToastService } from '@services/toast.service'; +import { MutationResult } from 'apollo-angular'; import { DataEntryAgentDescriptionGQL, DataEntryAgentDescriptionQuery, + DataEntryUpdateAgentGQL, + DataEntryUpdateAgentMutation, + Gender, PersonAgentDescriptionGenderGenderChoices as GenderChoices, PersonAgentDescriptionGenderSourceMentionChoices as GenderSourceMentionChoices, LocationsInSourceListGQL, LocationsInSourceListQuery, PersonAgentDescriptionSourceMentionChoices as LocationSourceMentionChoices, + SourceMention, + UpdateAgentInput, } from 'generated/graphql'; -import { Observable, map, Subject, switchMap, shareReplay, filter } from 'rxjs'; +import { Observable, map, switchMap, shareReplay, filter, debounceTime, distinctUntilChanged, withLatestFrom, BehaviorSubject, tap, skip } from 'rxjs'; import _ from 'underscore'; +import { FormService } from '../../shared/form.service'; +import { FormStatus } from '../../shared/types'; @Component({ @@ -19,9 +28,7 @@ import _ from 'underscore'; templateUrl: './agent-description-form.component.html', styleUrls: ['./agent-description-form.component.scss'], }) -export class AgentDescriptionFormComponent implements OnChanges, OnDestroy { - @Input() id?: string; - +export class AgentDescriptionFormComponent implements OnDestroy { genderOptions: { value: GenderChoices, label: string }[] = [ { value: GenderChoices.Female, label: 'Female' }, { value: GenderChoices.Male, label: 'Male' }, @@ -58,13 +65,20 @@ export class AgentDescriptionFormComponent implements OnChanges, OnDestroy { isGroup$: Observable; locations$: Observable; - private id$ = new Subject(); + status$ = new BehaviorSubject('idle'); + + private id$ = this.formService.id$; private data$: Observable; + private formName = 'description'; constructor( private agentQuery: DataEntryAgentDescriptionGQL, private locationsQuery: LocationsInSourceListGQL, + private agentMutation: DataEntryUpdateAgentGQL, + private toastService: ToastService, + private formService: FormService, ) { + this.formService.attachForm(this.formName, this.status$); this.data$ = this.id$.pipe( switchMap(id => this.agentQuery.watch({ id }).valueChanges), map(result => result.data), @@ -82,16 +96,18 @@ export class AgentDescriptionFormComponent implements OnChanges, OnDestroy { shareReplay(1), ); this.data$.subscribe(this.updateFormData.bind(this)); - } - - ngOnChanges(changes: SimpleChanges): void { - if (changes['id'] && this.id) { - this.id$.next(this.id); - } - } - ngOnDestroy(): void { - this.id$.complete(); + this.form.valueChanges.pipe( + debounceTime(500), + distinctUntilChanged(_.isEqual), + skip(1), + filter(this.isValid.bind(this)), + tap(() => this.status$.next('loading')), + withLatestFrom(this.id$), + map(this.toMutationInput), + switchMap(this.makeMutation.bind(this)), + takeUntilDestroyed(), + ).subscribe(this.onMutationResult.bind(this)); } updateFormData(data: DataEntryAgentDescriptionQuery) { @@ -111,4 +127,62 @@ export class AgentDescriptionFormComponent implements OnChanges, OnDestroy { }); } + ngOnDestroy(): void { + this.formService.detachForm(this.formName); + } + + private isValid(): boolean { + return this.form.valid; + } + + private toMutationInput([data, id]: [Partial, string]): UpdateAgentInput { + return { + id, + designators: data.designators, + gender: { + gender: data.gender?.gender as Gender, + sourceMention: data.gender?.sourceMention as SourceMention, + note: data.gender?.note, + }, + location: data.location?.hasLocation && data.location.location ? { + location: data.location.location, + sourceMention: data.location.sourceMention as SourceMention, + note: data.location.note, + } : null, + }; + } + + private makeMutation(input: UpdateAgentInput): + Observable> { + return this.agentMutation.mutate({ input }, { + errorPolicy: 'all', + refetchQueries: [ + 'DataEntryAgentDescription' + ], + }); + } + + private onMutationResult(result: MutationResult): void { + if (result.errors?.length) { + this.status$.next('error'); + const messages = result.errors.map(error => error.message); + this.toastService.show({ + type: 'danger', + header: 'Failed to save form', + body: messages.join('\n\n'), + }); + } else if (result.data?.updateAgent?.errors.length) { + this.status$.next('error'); + const errors = result.data.updateAgent.errors; + const messages = errors.map(error => `${error.field}: ${error.messages.join('\n')}`); + this.toastService.show({ + type: 'danger', + header: 'Failed to save form', + body: messages.join('\n\n'), + }); + } else { + this.status$.next('saved'); + } + } + } diff --git a/frontend/src/app/data-entry/agent-form/agent-form.component.html b/frontend/src/app/data-entry/agent-form/agent-form.component.html index 5ca85210..b16dd437 100644 --- a/frontend/src/app/data-entry/agent-form/agent-form.component.html +++ b/frontend/src/app/data-entry/agent-form/agent-form.component.html @@ -10,15 +10,26 @@

-

- {{agentDescription.description}} +

+ + {{agentDescription.description}} + + + (No description) +

+ + +
+ +
+

Identification

- +

Description in source text

- +

Episodes

diff --git a/frontend/src/app/data-entry/agent-form/agent-form.component.spec.ts b/frontend/src/app/data-entry/agent-form/agent-form.component.spec.ts index d09b111c..2b24a606 100644 --- a/frontend/src/app/data-entry/agent-form/agent-form.component.spec.ts +++ b/frontend/src/app/data-entry/agent-form/agent-form.component.spec.ts @@ -5,6 +5,7 @@ import { SharedTestingModule } from '@shared/shared-testing.module'; import { AgentDescriptionFormComponent } from './agent-description-form/agent-description-form.component'; import { AgentIdentificationFormComponent } from './agent-identification-form/agent-identification-form.component'; import { DataEntrySharedModule } from '../shared/data-entry-shared.module'; +import { FormService } from '../shared/form.service'; describe('AgentFormComponent', () => { let component: AgentFormComponent; @@ -21,6 +22,9 @@ describe('AgentFormComponent', () => { SharedTestingModule, DataEntrySharedModule, ], + providers: [ + FormService, + ] }); fixture = TestBed.createComponent(AgentFormComponent); component = fixture.componentInstance; diff --git a/frontend/src/app/data-entry/agent-form/agent-form.component.ts b/frontend/src/app/data-entry/agent-form/agent-form.component.ts index 499cc96e..a7bf3d1d 100644 --- a/frontend/src/app/data-entry/agent-form/agent-form.component.ts +++ b/frontend/src/app/data-entry/agent-form/agent-form.component.ts @@ -1,31 +1,41 @@ import { Component } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; import { Breadcrumb } from '@shared/breadcrumb/breadcrumb.component'; import { dataIcons } from '@shared/icons'; import { DataEntryAgentGQL, DataEntryAgentQuery } from 'generated/graphql'; import { map, Observable, switchMap } from 'rxjs'; +import { FormService } from '../shared/form.service'; @Component({ - selector: 'lc-agent-form', - templateUrl: './agent-form.component.html', - styleUrls: ['./agent-form.component.scss'] + selector: 'lc-agent-form', + templateUrl: './agent-form.component.html', + styleUrls: ['./agent-form.component.scss'], + providers: [FormService], }) export class AgentFormComponent { - id$: Observable; + id$: Observable = this.formService.id$; data$: Observable; dataIcons = dataIcons; - constructor(private route: ActivatedRoute, private agentQuery: DataEntryAgentGQL) { - this.id$ = this.route.params.pipe( - map(params => params['id']), - ); + status$ = this.formService.status$; + + constructor( + private agentQuery: DataEntryAgentGQL, + private formService: FormService, + ) { this.data$ = this.id$.pipe( switchMap(id => this.agentQuery.watch({ id }).valueChanges), map(result => result.data), ); } + sourceLink(data: DataEntryAgentQuery): string[] | undefined { + if (data.agentDescription?.source) { + return ['/data-entry', 'sources', data.agentDescription.source.id] + } + return undefined; + } + getBreadcrumbs(data: DataEntryAgentQuery): Breadcrumb[] { if (data.agentDescription) { return [ diff --git a/frontend/src/app/data-entry/agent-form/agent-form.module.ts b/frontend/src/app/data-entry/agent-form/agent-form.module.ts index 498f1ca3..46c71a58 100644 --- a/frontend/src/app/data-entry/agent-form/agent-form.module.ts +++ b/frontend/src/app/data-entry/agent-form/agent-form.module.ts @@ -4,6 +4,7 @@ import { SharedModule } from '@shared/shared.module'; import { AgentIdentificationFormComponent } from './agent-identification-form/agent-identification-form.component'; import { AgentDescriptionFormComponent } from './agent-description-form/agent-description-form.component'; import { DataEntrySharedModule } from "../shared/data-entry-shared.module"; +import { DeleteAgentComponent } from './delete-agent/delete-agent.component'; @@ -12,6 +13,7 @@ import { DataEntrySharedModule } from "../shared/data-entry-shared.module"; AgentFormComponent, AgentIdentificationFormComponent, AgentDescriptionFormComponent, + DeleteAgentComponent, ], imports: [ SharedModule, diff --git a/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.html b/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.html index c9cebc5f..63e84889 100644 --- a/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.html +++ b/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.html @@ -10,7 +10,13 @@

+ [formControl]="form.controls.name" + [class.is-invalid]="form.controls.name.invalid" + aria-required="true" + [attr.aria-invalid]="form.controls.name.invalid"> +
+ This field is required +
diff --git a/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.spec.ts b/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.spec.ts index 2531c1ee..877736a1 100644 --- a/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.spec.ts +++ b/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.spec.ts @@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { AgentIdentificationFormComponent } from './agent-identification-form.component'; import { SharedTestingModule } from '@shared/shared-testing.module'; +import { FormService } from '../../shared/form.service'; describe('AgentIdentificationFormComponent', () => { let component: AgentIdentificationFormComponent; @@ -11,6 +12,7 @@ describe('AgentIdentificationFormComponent', () => { TestBed.configureTestingModule({ declarations: [AgentIdentificationFormComponent], imports: [SharedTestingModule], + providers: [FormService], }); fixture = TestBed.createComponent(AgentIdentificationFormComponent); component = fixture.componentInstance; diff --git a/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.ts b/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.ts index 70e7f1be..de8efdba 100644 --- a/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.ts +++ b/frontend/src/app/data-entry/agent-form/agent-identification-form/agent-identification-form.component.ts @@ -1,41 +1,86 @@ -import { Component, Input, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; +import { Component, OnDestroy } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { FormControl, FormGroup } from '@angular/forms'; -import { DataEntryAgentIdentificationGQL, DataEntryAgentIdentificationQuery } from 'generated/graphql'; -import { map, Subject, switchMap } from 'rxjs'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { ToastService } from '@services/toast.service'; +import { MutationResult } from 'apollo-angular'; +import { + DataEntryAgentIdentificationGQL, DataEntryAgentIdentificationQuery, + DataEntryUpdateAgentGQL, DataEntryUpdateAgentMutation, UpdateAgentInput +} from 'generated/graphql'; +import { + map, switchMap, filter, debounceTime, withLatestFrom, Observable, + distinctUntilChanged, + tap, + BehaviorSubject, + skip +} from 'rxjs'; +import _ from 'underscore'; +import { FormService } from '../../shared/form.service'; +import { FormStatus } from '../../shared/types'; + +interface FormData { + name: string; + description: string; + isGroup: boolean; +} @Component({ selector: 'lc-agent-identification-form', templateUrl: './agent-identification-form.component.html', styleUrls: ['./agent-identification-form.component.scss'] }) -export class AgentIdentificationFormComponent implements OnChanges, OnDestroy { - @Input() id?: string; - +export class AgentIdentificationFormComponent implements OnDestroy { form = new FormGroup({ - name: new FormControl(''), - description: new FormControl(''), - isGroup: new FormControl(false), + name: new FormControl('', { + nonNullable: true, + validators: [Validators.required] + }), + description: new FormControl('', { nonNullable: true }), + isGroup: new FormControl(false, { nonNullable: true }), }); - private id$ = new Subject(); + status$ = new BehaviorSubject('idle'); + + private id$ = this.formService.id$; + private formName = 'identification'; + + constructor( + private agentQuery: DataEntryAgentIdentificationGQL, + private agentMutation: DataEntryUpdateAgentGQL, + private toastService: ToastService, + private formService: FormService, + ) { + this.formService.attachForm(this.formName, this.status$); - constructor(private agentQuery: DataEntryAgentIdentificationGQL) { this.id$.pipe( switchMap(id => this.agentQuery.watch({ id }).valueChanges), map(result => result.data), takeUntilDestroyed(), ).subscribe(this.updateFormData.bind(this)); - } - ngOnChanges(changes: SimpleChanges): void { - if (changes['id'] && this.id) { - this.id$.next(this.id); - } + + + + this.form.statusChanges.pipe( + filter(status => status == 'INVALID'), + takeUntilDestroyed(), + ).subscribe(() => this.status$.next('invalid')); + + this.form.valueChanges.pipe( + debounceTime(500), + distinctUntilChanged(_.isEqual), + skip(1), + filter(this.isValid.bind(this)), + tap(() => this.status$.next('loading')), + withLatestFrom(this.id$), + map(this.toMutationInput), + switchMap(this.makeMutation.bind(this)), + takeUntilDestroyed(), + ).subscribe(this.onMutationResult.bind(this)); } ngOnDestroy(): void { - this.id$.complete(); + this.formService.detachForm(this.formName); } updateFormData(data: DataEntryAgentIdentificationQuery) { @@ -45,4 +90,48 @@ export class AgentIdentificationFormComponent implements OnChanges, OnDestroy { isGroup: data.agentDescription?.isGroup || false, }); } + + private isValid(): boolean { + return this.form.valid; + } + + private toMutationInput([data, id]: [Partial, string]): UpdateAgentInput { + return { id, ...data }; + } + + private makeMutation(input: UpdateAgentInput): + Observable> { + return this.agentMutation.mutate({ input }, { + errorPolicy: 'all', + refetchQueries: [ + 'DataEntryAgent', + 'DataEntryAgentIdentification', + 'DataEntryAgentDescription' + ], + }); + } + + private onMutationResult(result: MutationResult): void { + if (result.errors?.length) { + this.status$.next('error'); + const messages = result.errors.map(error => error.message); + this.toastService.show({ + type: 'danger', + header: 'Failed to save form', + body: messages.join('\n\n'), + }); + } else if (result.data?.updateAgent?.errors.length) { + this.status$.next('error'); + const errors = result.data.updateAgent.errors; + const messages = errors.map(error => `${error.field}: ${error.messages.join('\n')}`); + this.toastService.show({ + type: 'danger', + header: 'Failed to save form', + body: messages.join('\n\n'), + }); + } else { + this.status$.next('saved'); + } + } + } diff --git a/frontend/src/app/data-entry/agent-form/agent.graphql b/frontend/src/app/data-entry/agent-form/agent.graphql index 3fd81287..b3d56359 100644 --- a/frontend/src/app/data-entry/agent-form/agent.graphql +++ b/frontend/src/app/data-entry/agent-form/agent.graphql @@ -10,3 +10,17 @@ query DataEntryAgent($id: ID!) { } } } + +mutation DataEntryUpdateAgent( + $input: UpdateAgentInput! +) { + updateAgent( + agentData: $input + ) { + ok + errors { + field + messages + } + } +} diff --git a/frontend/src/app/data-entry/agent-form/delete-agent/delete-agent.component.html b/frontend/src/app/data-entry/agent-form/delete-agent/delete-agent.component.html new file mode 100644 index 00000000..a44b74a6 --- /dev/null +++ b/frontend/src/app/data-entry/agent-form/delete-agent/delete-agent.component.html @@ -0,0 +1,28 @@ + + + + + + + diff --git a/frontend/src/app/data-entry/agent-form/delete-agent/delete-agent.component.scss b/frontend/src/app/data-entry/agent-form/delete-agent/delete-agent.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/app/data-entry/agent-form/delete-agent/delete-agent.component.spec.ts b/frontend/src/app/data-entry/agent-form/delete-agent/delete-agent.component.spec.ts new file mode 100644 index 00000000..2729fc7d --- /dev/null +++ b/frontend/src/app/data-entry/agent-form/delete-agent/delete-agent.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DeleteAgentComponent } from './delete-agent.component'; +import { FormService } from '../../shared/form.service'; +import { SharedTestingModule } from '@shared/shared-testing.module'; + +describe('DeleteAgentComponent', () => { + let component: DeleteAgentComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [DeleteAgentComponent], + imports: [SharedTestingModule], + providers: [FormService], + }); + fixture = TestBed.createComponent(DeleteAgentComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/data-entry/agent-form/delete-agent/delete-agent.component.ts b/frontend/src/app/data-entry/agent-form/delete-agent/delete-agent.component.ts new file mode 100644 index 00000000..68e15142 --- /dev/null +++ b/frontend/src/app/data-entry/agent-form/delete-agent/delete-agent.component.ts @@ -0,0 +1,91 @@ +import { Component, Input, OnDestroy, TemplateRef } from '@angular/core'; +import { Router } from '@angular/router'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { ToastService } from '@services/toast.service'; +import { actionIcons } from '@shared/icons'; +import { DataEntryDeleteAgentGQL, LettercraftErrorType } from 'generated/graphql'; +import _ from 'underscore'; +import { FormService } from '../../shared/form.service'; +import { BehaviorSubject } from 'rxjs'; +import { FormStatus } from '../../shared/types'; + +@Component({ + selector: 'lc-delete-agent', + templateUrl: './delete-agent.component.html', + styleUrls: ['./delete-agent.component.scss'] +}) +export class DeleteAgentComponent implements OnDestroy { + @Input() navigateOnDelete?: any[]; + + actionIcons = actionIcons; + + id$ = this.formService.id$; + status$ = new BehaviorSubject('idle'); + private formName = 'delete'; + + constructor( + private modalService: NgbModal, + private deleteMutation: DataEntryDeleteAgentGQL, + private toastService: ToastService, + private router: Router, + private formService: FormService, + ) { + this.formService.attachForm(this.formName, this.status$); + } + + ngOnDestroy(): void { + this.formService.detachForm(this.formName); + } + + open(content: TemplateRef) { + this.modalService.open(content, { ariaLabelledBy: 'modal-title' }).result.then( + this.deleteAgent.bind(this), + _.constant(undefined), // do nothing on dismiss + ); + } + + deleteAgent(id: string) { + this.status$.next('loading'); + this.deleteMutation.mutate({ id }, { + refetchQueries: [ + 'source' + ], + }).subscribe({ + next: (result) => { + if (result.data?.deleteAgent?.ok) { + this.onSuccess(); + } else { + this.onFail(result.data?.deleteAgent?.errors); + } + }, + error: (error) => { + this.onFail(error); + } + }); + } + + onSuccess() { + this.status$.next('saved'); + this.toastService.show({ + type: 'success', + header: 'Agent deleted', + body: 'Agent successfully deleted' + }) + if (this.navigateOnDelete) { + this.router.navigate(this.navigateOnDelete); + } + } + + onFail(errors?: LettercraftErrorType[] | any) { + this.status$.next('error'); + console.error(errors); + const messages = errors?.map?.call( + (error: any) => error.messages?.join('\n') + ) || 'Unexpected error'; + this.toastService.show({ + type: 'danger', + header: 'Deletion failed', + body: `Deleting agent failed:\n${messages}` + }); + } +} diff --git a/frontend/src/app/data-entry/agent-form/delete-agent/delete-agent.graphql b/frontend/src/app/data-entry/agent-form/delete-agent/delete-agent.graphql new file mode 100644 index 00000000..5c2f98a3 --- /dev/null +++ b/frontend/src/app/data-entry/agent-form/delete-agent/delete-agent.graphql @@ -0,0 +1,9 @@ +mutation DataEntryDeleteAgent($id: ID!) { + deleteAgent(id: $id) { + ok + errors { + messages + field + } + } +} diff --git a/frontend/src/app/data-entry/shared/data-entry-shared.module.ts b/frontend/src/app/data-entry/shared/data-entry-shared.module.ts index 5395a06f..b333a16a 100644 --- a/frontend/src/app/data-entry/shared/data-entry-shared.module.ts +++ b/frontend/src/app/data-entry/shared/data-entry-shared.module.ts @@ -3,18 +3,21 @@ import { SharedModule } from "@shared/shared.module"; import { DesignatorsControlComponent } from "./designators-control/designators-control.component"; import { MultiselectComponent } from "./multiselect/multiselect.component"; import { LabelSelectComponent } from "./label-select/label-select.component"; +import { FormStatusComponent } from './form-status/form-status.component'; @NgModule({ declarations: [ DesignatorsControlComponent, MultiselectComponent, LabelSelectComponent, + FormStatusComponent, ], imports: [SharedModule], exports: [ DesignatorsControlComponent, MultiselectComponent, LabelSelectComponent, + FormStatusComponent, ], }) export class DataEntrySharedModule {} diff --git a/frontend/src/app/data-entry/shared/form-status/form-status.component.html b/frontend/src/app/data-entry/shared/form-status/form-status.component.html new file mode 100644 index 00000000..667f06e3 --- /dev/null +++ b/frontend/src/app/data-entry/shared/form-status/form-status.component.html @@ -0,0 +1,4 @@ +

+ + {{messages[status]}} +

diff --git a/frontend/src/app/data-entry/shared/form-status/form-status.component.scss b/frontend/src/app/data-entry/shared/form-status/form-status.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/app/data-entry/shared/form-status/form-status.component.spec.ts b/frontend/src/app/data-entry/shared/form-status/form-status.component.spec.ts new file mode 100644 index 00000000..9fcc0c8c --- /dev/null +++ b/frontend/src/app/data-entry/shared/form-status/form-status.component.spec.ts @@ -0,0 +1,21 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FormStatusComponent } from './form-status.component'; + +describe('FormStatusComponent', () => { + let component: FormStatusComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [FormStatusComponent] + }); + fixture = TestBed.createComponent(FormStatusComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/data-entry/shared/form-status/form-status.component.ts b/frontend/src/app/data-entry/shared/form-status/form-status.component.ts new file mode 100644 index 00000000..869f9a91 --- /dev/null +++ b/frontend/src/app/data-entry/shared/form-status/form-status.component.ts @@ -0,0 +1,31 @@ +import { Component, Input } from '@angular/core'; +import { FormStatus } from '../types'; +import { Observable } from 'rxjs'; +import { statusIcons } from '@shared/icons'; + +@Component({ + selector: 'lc-form-status', + templateUrl: './form-status.component.html', + styleUrls: ['./form-status.component.scss'] +}) +export class FormStatusComponent { + @Input({ required: true }) status$!: Observable; + + statusIcons = statusIcons; + + messages: Record = { + 'idle': 'No changes made', + 'invalid': 'Form contains errors', + 'loading': 'Loading...', + 'saved': 'Changes saved', + 'error': 'Saving failed', + }; + + classes: Record = { + 'idle': 'text-secondary', + 'invalid': 'text-danger', + 'loading': 'text-secondary', + 'saved': 'text-success', + 'error': 'text-danger', + }; +} diff --git a/frontend/src/app/data-entry/shared/form.service.spec.ts b/frontend/src/app/data-entry/shared/form.service.spec.ts new file mode 100644 index 00000000..e084d534 --- /dev/null +++ b/frontend/src/app/data-entry/shared/form.service.spec.ts @@ -0,0 +1,20 @@ +import { TestBed } from '@angular/core/testing'; + +import { FormService } from './form.service'; +import { SharedTestingModule } from '@shared/shared-testing.module'; + +describe('FormService', () => { + let service: FormService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [FormService], + imports: [SharedTestingModule], + }); + service = TestBed.inject(FormService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/data-entry/shared/form.service.ts b/frontend/src/app/data-entry/shared/form.service.ts new file mode 100644 index 00000000..62f3fd15 --- /dev/null +++ b/frontend/src/app/data-entry/shared/form.service.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { asyncScheduler, BehaviorSubject, combineLatest, map, Observable, shareReplay, switchMap, throttleTime } from 'rxjs'; +import { FormStatus } from './types'; +import _ from 'underscore'; + +@Injectable() +export class FormService { + id$: Observable; + + status$: Observable; + + private statuses$ = new BehaviorSubject>>({}); + + constructor(private route: ActivatedRoute) { + this.id$ = this.route.params.pipe( + map(params => params['id']) + ); + this.status$ = this.statuses$.pipe( + switchMap(statuses => combineLatest(_.values(statuses))), + map(this.combinedStatus), + throttleTime(400, asyncScheduler, { trailing: true }), + ); + } + + attachForm(name: string, status$: Observable) { + const statuses = this.statuses$.value; + if (_.has(statuses, name)) { + throw Error(`A form named ${name} is already registered`); + } + const newValue = _.extend(statuses, { [name]: status$.pipe(shareReplay(1)) }) + this.statuses$.next(newValue); + } + + detachForm(name: string) { + const statuses = this.statuses$.value; + const newValue = _.omit(statuses, name); + this.statuses$.next(newValue); + } + + private combinedStatus(statuses: FormStatus[]) { + if (statuses.includes('error')) { + return 'error'; + } else if (statuses.includes('loading')) { + return 'loading'; + } else if (statuses.includes('invalid')) { + return 'invalid'; + } else if (statuses.includes('saved')) { + return 'saved'; + } else { + return 'idle'; + } + } +} diff --git a/frontend/src/app/data-entry/shared/types.d.ts b/frontend/src/app/data-entry/shared/types.d.ts new file mode 100644 index 00000000..38e67f13 --- /dev/null +++ b/frontend/src/app/data-entry/shared/types.d.ts @@ -0,0 +1 @@ +export type FormStatus = 'idle' | 'invalid' | 'loading' | 'saved' | 'error'; 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 7adfc134..cd62b2fb 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 @@ -30,7 +30,7 @@ class="inline-list-item" > @@ -51,7 +51,7 @@ class="inline-list-item" > @@ -90,12 +90,7 @@ class="inline-list-item" > diff --git a/frontend/src/app/data-entry/source/source.component.html b/frontend/src/app/data-entry/source/source.component.html index c008649b..3d0cc876 100644 --- a/frontend/src/app/data-entry/source/source.component.html +++ b/frontend/src/app/data-entry/source/source.component.html @@ -3,7 +3,7 @@

- {{ source.medievalAuthor }}, {{ source.medievalTitle }} + {{ source.name }}

diff --git a/frontend/src/app/data-entry/source/source.component.ts b/frontend/src/app/data-entry/source/source.component.ts index b958bee8..8f0016d5 100644 --- a/frontend/src/app/data-entry/source/source.component.ts +++ b/frontend/src/app/data-entry/source/source.component.ts @@ -36,7 +36,7 @@ export class SourceComponent { public sourceTitle = toSignal( this.source$.pipe( - map((source) => `${source.medievalAuthor}, ${source.medievalTitle}`) + map((source) => source.name) ), { initialValue: "" } ); diff --git a/frontend/src/app/data-entry/source/source.graphql b/frontend/src/app/data-entry/source/source.graphql index 06d2004c..0d3d633e 100644 --- a/frontend/src/app/data-entry/source/source.graphql +++ b/frontend/src/app/data-entry/source/source.graphql @@ -2,11 +2,6 @@ query DataEntrySourceDetail($id: ID!) { source(id: $id) { id name - editionAuthor - editionTitle - medievalAuthor - medievalTitle - numOfEpisodes episodes { id name diff --git a/frontend/src/app/shared/icons.ts b/frontend/src/app/shared/icons.ts index 254f116b..33ba1cac 100644 --- a/frontend/src/app/shared/icons.ts +++ b/frontend/src/app/shared/icons.ts @@ -35,5 +35,13 @@ export const dataIcons = { letter: 'envelope', }; +export const statusIcons = { + idle: 'pencil', + invalid: 'exclamation-circle', + loading: 'hourglass', + saved: 'check-lg', + error: 'exclamation-circle', +} +