diff --git a/alfa-client/apps/admin/src/app/app.module.ts b/alfa-client/apps/admin/src/app/app.module.ts index 089a1851424f1c2eca17a73b15d0b0ad3f132588..4de0f0c0ab18a43a6295d9dbb7b6a141161a8b72 100644 --- a/alfa-client/apps/admin/src/app/app.module.ts +++ b/alfa-client/apps/admin/src/app/app.module.ts @@ -41,10 +41,11 @@ import { appRoutes } from './app.routes'; HttpClientModule, ApiRootModule, EnvironmentModule, - environment.production ? [] : StoreDevtoolsModule.instrument(), + TechSharedModule, StoreModule.forRoot({}), EffectsModule.forRoot(), StoreRouterConnectingModule.forRoot(), + environment.production ? [] : StoreDevtoolsModule.instrument(), FormsModule, ReactiveFormsModule, AdminSettingsModule, @@ -53,7 +54,6 @@ import { appRoutes } from './app.routes'; sendAccessToken: true, }, }), - TechSharedModule, ], providers: [ { diff --git a/alfa-client/libs/admin-settings/src/lib/admin-settings-resource.service.ts b/alfa-client/libs/admin-settings/src/lib/admin-settings-resource.service.ts index 67ded869c2c64e15d7a382d8ede37ee6e3eb458b..011b3bab8ada5b5ee5456caeb9c6e46151cc7590 100644 --- a/alfa-client/libs/admin-settings/src/lib/admin-settings-resource.service.ts +++ b/alfa-client/libs/admin-settings/src/lib/admin-settings-resource.service.ts @@ -28,7 +28,7 @@ function buildConfig( return { baseResource: configurationService.get(), createLinkRel: SettingListLinkRel.CREATE, - listLinkRel: ConfigurationLinkRel.SETTING, + getLinkRel: ConfigurationLinkRel.SETTING, listResourceListLinkRel: SettingListLinkRel.LIST, }; } diff --git a/alfa-client/libs/admin-settings/src/lib/admin-settings.module.ts b/alfa-client/libs/admin-settings/src/lib/admin-settings.module.ts index 99b04e136805e4fec867cfe801aa82ab6ded3936..6aa7743d3314ea8df91d15448eba4db58ba28b15 100644 --- a/alfa-client/libs/admin-settings/src/lib/admin-settings.module.ts +++ b/alfa-client/libs/admin-settings/src/lib/admin-settings.module.ts @@ -1,17 +1,20 @@ import { ApiRootService } from '@alfa-client/api-root-shared'; import { Environment, ENVIRONMENT_CONFIG } from '@alfa-client/environment-shared'; -import { ResourceRepository, TechSharedModule } from '@alfa-client/tech-shared'; +import { ResourceRepository, StateService, TechSharedModule } from '@alfa-client/tech-shared'; import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { ReactiveFormsModule } from '@angular/forms'; import { RouterModule } from '@angular/router'; import KcAdminClient from '@keycloak/keycloak-admin-client'; +import { StoreModule } from '@ngrx/store'; import { createSettingListResourceService, SettingListResourceService, } from './admin-settings-resource.service'; import { SettingsService } from './admin-settings.service'; import { + CONFIGURATION_FEATURE_KEY, + configurationReducer, ConfigurationResourceService, createConfigurationResourceService, } from './configuration/configuration-resource.service'; @@ -26,6 +29,8 @@ import { PostfachFormComponent } from './postfach/postfach-container/postfach-fo import { PostfachNavigationItemComponent } from './postfach/postfach-navigation-item/postfach-navigation-item.component'; import { createPostfachResourceService, + POSTFACH_FEATURE_KEY, + postfachReducer, PostfachResourceService, } from './postfach/postfach-resource.service'; import { PostfachService } from './postfach/postfach.service'; @@ -55,7 +60,14 @@ import { TextFieldComponent } from './shared/text-field/text-field.component'; MoreItemButtonComponent, SpinnerComponent, ], - imports: [CommonModule, TechSharedModule, RouterModule, ReactiveFormsModule], + imports: [ + CommonModule, + TechSharedModule, + RouterModule, + ReactiveFormsModule, + StoreModule.forFeature(CONFIGURATION_FEATURE_KEY, configurationReducer), + StoreModule.forFeature(POSTFACH_FEATURE_KEY, postfachReducer), + ], exports: [ PostfachContainerComponent, OrganisationseinheitContainerComponent, @@ -75,21 +87,21 @@ import { TextFieldComponent } from './shared/text-field/text-field.component'; }), deps: [ENVIRONMENT_CONFIG], }, - { - provide: PostfachResourceService, - useFactory: createPostfachResourceService, - deps: [ResourceRepository, SettingsService], - }, { provide: ConfigurationResourceService, useFactory: createConfigurationResourceService, - deps: [ResourceRepository, ApiRootService], + deps: [ResourceRepository, ApiRootService, StateService], }, { provide: SettingListResourceService, useFactory: createSettingListResourceService, deps: [ResourceRepository, ConfigurationService], }, + { + provide: PostfachResourceService, + useFactory: createPostfachResourceService, + deps: [ResourceRepository, SettingsService, StateService], + }, ], }) export class AdminSettingsModule {} diff --git a/alfa-client/libs/admin-settings/src/lib/configuration/configuration-resource.service.ts b/alfa-client/libs/admin-settings/src/lib/configuration/configuration-resource.service.ts index f73458583b348631669fe5479cbcc597f0cf5c61..1e70b5a06edc9137428c2d301326388353dd31c4 100644 --- a/alfa-client/libs/admin-settings/src/lib/configuration/configuration-resource.service.ts +++ b/alfa-client/libs/admin-settings/src/lib/configuration/configuration-resource.service.ts @@ -3,9 +3,17 @@ import { ApiResourceService, ResourceRepository, ResourceServiceConfig, + SingleResourceReducer, + StateService, + createSingleResourceActions, } from '@alfa-client/tech-shared'; +import { Action, ActionReducerMap } from '@ngrx/store'; import { ConfigurationResource } from './configuration.model'; +export const CONFIGURATION_FEATURE_KEY = 'ConfigurationState'; + +export const CONFIGURATION_PATH = 'configuration'; + export class ConfigurationResourceService extends ApiResourceService< ApiRootResource, ConfigurationResource @@ -14,13 +22,26 @@ export class ConfigurationResourceService extends ApiResourceService< export function createConfigurationResourceService( repository: ResourceRepository, apiRootService: ApiRootService, + stateService: StateService, ) { - return new ApiResourceService(buildConfig(apiRootService), repository); + return new ApiResourceService(buildConfig(apiRootService), stateService, repository); } function buildConfig(apiRootService: ApiRootService): ResourceServiceConfig<ApiRootResource> { return { - resource: apiRootService.getApiRoot(), + stateInfo: { name: CONFIGURATION_FEATURE_KEY, path: CONFIGURATION_PATH }, + baseResource: apiRootService.getApiRoot(), getLinkRel: ApiRootLinkRel.CONFIGURATION, }; } + +function configurationResourceReducer(state: any, action: Action) { + const resourceReducer: SingleResourceReducer = new SingleResourceReducer( + createSingleResourceActions({ name: CONFIGURATION_FEATURE_KEY, path: CONFIGURATION_PATH }), + ); + return resourceReducer.reducer(state, action); +} + +export const configurationReducer: ActionReducerMap<any> = { + [CONFIGURATION_PATH]: configurationResourceReducer, +}; diff --git a/alfa-client/libs/admin-settings/src/lib/postfach/postfach-resource.service.ts b/alfa-client/libs/admin-settings/src/lib/postfach/postfach-resource.service.ts index 07f664fc2293fa9b9e5418d0ffc4a5cfe847a084..456685a75a08e35669939e597bf3d83232a17e07 100644 --- a/alfa-client/libs/admin-settings/src/lib/postfach/postfach-resource.service.ts +++ b/alfa-client/libs/admin-settings/src/lib/postfach/postfach-resource.service.ts @@ -2,11 +2,20 @@ import { ApiResourceService, ResourceRepository, ResourceServiceConfig, + SingleResourceReducer, + StateService, + createSingleResourceActions, } from '@alfa-client/tech-shared'; +import { Action, ActionReducerMap } from '@ngrx/store'; +import { SingleResourceState } from 'libs/tech-shared/src/lib/ngrx/state.model'; import { SettingsService } from '../admin-settings.service'; import { PostfachLinkRel } from './postfach.linkrel'; import { PostfachResource } from './postfach.model'; +export const POSTFACH_FEATURE_KEY = 'PostfachState'; + +export const POSTFACH_PATH = 'postfach'; + export class PostfachResourceService extends ApiResourceService< PostfachResource, PostfachResource @@ -15,14 +24,26 @@ export class PostfachResourceService extends ApiResourceService< export function createPostfachResourceService( repository: ResourceRepository, settingService: SettingsService, + stateService: StateService, ) { - return new ApiResourceService(buildConfig(settingService), repository); + return new ApiResourceService(buildConfig(settingService), stateService, repository); } function buildConfig(settingService: SettingsService): ResourceServiceConfig<PostfachResource> { return { - resource: settingService.getPostfach(), + stateInfo: { name: POSTFACH_FEATURE_KEY, path: POSTFACH_PATH }, + baseResource: settingService.getPostfach(), getLinkRel: PostfachLinkRel.SELF, edit: { linkRel: PostfachLinkRel.SELF }, }; } + +export function postfachResourceReducer(state: SingleResourceState, action: Action) { + const resourceReducer: SingleResourceReducer = new SingleResourceReducer( + createSingleResourceActions({ name: POSTFACH_FEATURE_KEY, path: POSTFACH_PATH }), + ); + return resourceReducer.reducer(state, action); +} +export const postfachReducer: ActionReducerMap<any> = { + [POSTFACH_PATH]: postfachResourceReducer, +}; diff --git a/alfa-client/libs/bescheid-shared/src/lib/+state/bescheid.reducer.ts b/alfa-client/libs/bescheid-shared/src/lib/+state/bescheid.reducer.ts index 77d5d488988eb02164f9aab52f09c6b5a3553131..f8ff50bcb84c5325e570123c96385846e18377ab 100644 --- a/alfa-client/libs/bescheid-shared/src/lib/+state/bescheid.reducer.ts +++ b/alfa-client/libs/bescheid-shared/src/lib/+state/bescheid.reducer.ts @@ -6,21 +6,32 @@ import { } from '@alfa-client/command-shared'; import { ApiError, + SingleResourceReducer, StateResource, createEmptyStateResource, createErrorStateResource, + createSingleResourceActions, createStateResource, } from '@alfa-client/tech-shared'; import { HttpErrorResponse } from '@angular/common/http'; -import { Action, ActionReducer, createReducer, on } from '@ngrx/store'; +import { Action, ActionReducer, ActionReducerMap, createReducer, on } from '@ngrx/store'; +import { SingleResourceState } from 'libs/tech-shared/src/lib/ngrx/state.model'; import { isCreateBescheidCommand } from '../bescheid.util'; import * as CommandActions from '../../../../command-shared/src/lib/+state/command.actions'; export const BESCHEID_FEATURE_KEY = 'BescheidState'; +export const BESCHEID_PATH = 'bescheid'; +export const BESCHEID_DRAFT_PATH = 'bescheidDraft'; + export interface BescheidPartialState { - readonly [BESCHEID_FEATURE_KEY]: BescheidState; + readonly [BESCHEID_FEATURE_KEY]: BescheidParentState; +} + +export interface BescheidParentState { + bescheid: BescheidState; + [BESCHEID_DRAFT_PATH]?: SingleResourceState; } export interface BescheidState { @@ -31,7 +42,7 @@ export const initialState: BescheidState = { bescheidCommand: createEmptyStateResource(), }; -const bescheidReducer: ActionReducer<BescheidState, Action> = createReducer( +export const reducer: ActionReducer<BescheidState, Action> = createReducer( initialState, on( CommandActions.createCommand, @@ -64,6 +75,17 @@ const bescheidReducer: ActionReducer<BescheidState, Action> = createReducer( ), ); -export function reducer(state: BescheidState, action: Action): BescheidState { - return bescheidReducer(state, action); +export const bescheidReducer: ActionReducerMap<BescheidParentState> = { + [BESCHEID_PATH]: reducer, + [BESCHEID_DRAFT_PATH]: bescheidDraftResourceReducer, +}; + +function bescheidDraftResourceReducer( + state: SingleResourceState, + action: Action, +): SingleResourceState { + const resourceReducer: SingleResourceReducer = new SingleResourceReducer( + createSingleResourceActions({ name: BESCHEID_FEATURE_KEY, path: BESCHEID_DRAFT_PATH }), + ); + return resourceReducer.reducer(state, action); } diff --git a/alfa-client/libs/bescheid-shared/src/lib/+state/bescheid.selectors.spec.ts b/alfa-client/libs/bescheid-shared/src/lib/+state/bescheid.selectors.spec.ts index 04f7dfd10e8da45695ae20069e5d11713af0e6b0..d9e495c4069edb5f23830aa297913d0dc0d121e1 100644 --- a/alfa-client/libs/bescheid-shared/src/lib/+state/bescheid.selectors.spec.ts +++ b/alfa-client/libs/bescheid-shared/src/lib/+state/bescheid.selectors.spec.ts @@ -14,8 +14,10 @@ describe('Bescheid Selectors', () => { beforeEach(() => { state = { BescheidState: { - ...initialState, - bescheidCommand, + bescheid: { + ...initialState, + bescheidCommand, + }, }, }; }); diff --git a/alfa-client/libs/bescheid-shared/src/lib/+state/bescheid.selectors.ts b/alfa-client/libs/bescheid-shared/src/lib/+state/bescheid.selectors.ts index 78facfef40dad11ac7ae2cb68ce9679e41301e5b..cec1f1f96bb6c05eb333bbd160425c170c50a47e 100644 --- a/alfa-client/libs/bescheid-shared/src/lib/+state/bescheid.selectors.ts +++ b/alfa-client/libs/bescheid-shared/src/lib/+state/bescheid.selectors.ts @@ -1,12 +1,15 @@ import { CommandResource } from '@alfa-client/command-shared'; import { StateResource } from '@alfa-client/tech-shared'; import { createFeatureSelector, createSelector, MemoizedSelector } from '@ngrx/store'; -import { BESCHEID_FEATURE_KEY, BescheidState } from './bescheid.reducer'; +import { BESCHEID_FEATURE_KEY, BescheidParentState, BescheidState } from './bescheid.reducer'; -export const getBescheidState: MemoizedSelector<object, BescheidState> = - createFeatureSelector<BescheidState>(BESCHEID_FEATURE_KEY); +export const getBescheidState: MemoizedSelector<object, BescheidParentState> = + createFeatureSelector<BescheidParentState>(BESCHEID_FEATURE_KEY); export const bescheidCommand: MemoizedSelector< BescheidState, StateResource<CommandResource> -> = createSelector(getBescheidState, (state: BescheidState) => state.bescheidCommand); +> = createSelector( + getBescheidState, + (state: BescheidParentState) => state.bescheid.bescheidCommand, +); diff --git a/alfa-client/libs/bescheid-shared/src/lib/bescheid-shared.module.ts b/alfa-client/libs/bescheid-shared/src/lib/bescheid-shared.module.ts index 9d1ae76fdae87d4475e12e7acc14eea3e518dc0f..9470079ed300804cf7edce1100d4047f06ed724c 100644 --- a/alfa-client/libs/bescheid-shared/src/lib/bescheid-shared.module.ts +++ b/alfa-client/libs/bescheid-shared/src/lib/bescheid-shared.module.ts @@ -1,9 +1,9 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { StoreModule } from '@ngrx/store'; -import { BESCHEID_FEATURE_KEY, reducer } from './+state/bescheid.reducer'; +import { BESCHEID_FEATURE_KEY, bescheidReducer } from './+state/bescheid.reducer'; @NgModule({ - imports: [CommonModule, StoreModule.forFeature(BESCHEID_FEATURE_KEY, reducer)], + imports: [CommonModule, StoreModule.forFeature(BESCHEID_FEATURE_KEY, bescheidReducer)], }) export class BescheidSharedModule {} diff --git a/alfa-client/libs/bescheid-shared/src/lib/bescheid.service.spec.ts b/alfa-client/libs/bescheid-shared/src/lib/bescheid.service.spec.ts index 66e7b66d411ac06720663d67414a8d3d93291642..62606384a666a79170e710f66297eae5ea191b8a 100644 --- a/alfa-client/libs/bescheid-shared/src/lib/bescheid.service.spec.ts +++ b/alfa-client/libs/bescheid-shared/src/lib/bescheid.service.spec.ts @@ -11,9 +11,9 @@ import { } from '@alfa-client/command-shared'; import { ApiError, - EMPTY_STRING, HttpError, StateResource, + StateService, createEmptyStateResource, createErrorStateResource, createStateResource, @@ -45,7 +45,7 @@ import { } from '../../../command-shared/test/command'; import { ResourceRepository } from '../../../tech-shared/src/lib/resource/resource.repository'; import { createFile } from '../../../tech-shared/test/file'; -import { singleCold, singleColdCompleted } from '../../../tech-shared/test/marbles'; +import { CLOSED_FRAME, singleCold, singleColdCompleted } from '../../../tech-shared/test/marbles'; import { createBescheid, createBescheidListResource, @@ -79,6 +79,7 @@ describe('BescheidService', () => { let commandService: Mock<CommandService>; let vorgangCommandService: Mock<VorgangCommandService>; let binaryFileService: Mock<BinaryFileService>; + let stateService: Mock<StateService>; const vorgangWithEingangStateResource: StateResource<VorgangWithEingangResource> = createStateResource(createVorgangWithEingangResource()); @@ -88,6 +89,7 @@ describe('BescheidService', () => { resourceRepository = mock(ResourceRepository); commandService = mock(CommandService); vorgangCommandService = mock(VorgangCommandService); + stateService = mock(StateService); vorgangService = mock(VorgangService); vorgangService.getVorgangWithEingang.mockReturnValue(of(vorgangWithEingangStateResource)); @@ -101,6 +103,7 @@ describe('BescheidService', () => { useFromMock(vorgangCommandService), useFromMock(binaryFileService), useFromMock(resourceRepository), + useFromMock(stateService), ); }); @@ -215,7 +218,7 @@ describe('BescheidService', () => { const command$: Observable<StateResource<CommandResource>> = service.bescheidErstellungUeberspringen(vorgangWithEingangResource); - expect(command$).toBeObservable(cold('(a|)', { a: commandStateResource })); + expect(command$).toBeObservable(singleCold(commandStateResource, CLOSED_FRAME)); }); }); @@ -243,7 +246,7 @@ describe('BescheidService', () => { const command$: Observable<StateResource<CommandResource>> = service.bescheidErstellungUeberspringen(vorgangWithEingangResource); - expect(command$).toBeObservable(cold('(a|)', { a: commandStateResource })); + expect(command$).toBeObservable(singleCold(commandStateResource, CLOSED_FRAME)); }); }); }); @@ -343,7 +346,9 @@ describe('BescheidService', () => { bescheidResource, ); - expect(command$).toBeObservable(cold('(a|)', { a: vorgangAbschliessenCommandStateResource })); + expect(command$).toBeObservable( + singleCold(vorgangAbschliessenCommandStateResource, CLOSED_FRAME), + ); }); }); @@ -372,103 +377,71 @@ describe('BescheidService', () => { vorgangWithEingangResource, ); - expect(command$).toBeObservable(cold('(a|)', { a: commandStateResource })); + expect(command$).toBeObservable(singleCold(commandStateResource, CLOSED_FRAME)); }); }); describe('delete bescheid', () => { + const commandStateResource: StateResource<CommandResource> = createEmptyStateResource(); const bescheidResource: BescheidResource = createBescheidResource(); - it('should create command', () => { + beforeEach(() => { + service.bescheidDraftService.delete = jest.fn().mockReturnValue(of(commandStateResource)); + }); + + it('should call bescheid draft service', () => { service.deleteBescheid(bescheidResource); - const expectedProps: CreateCommandProps = { - resource: bescheidResource, - linkRel: BescheidLinkRel.DELETE, - command: { - order: CommandOrder.DELETE_BESCHEID, - body: null, - }, - snackBarMessage: EMPTY_STRING, - }; - expect(commandService.createCommandByProps).toHaveBeenCalledWith(expectedProps); + expect(service.bescheidDraftService.delete).toHaveBeenCalled(); }); it('should return command', () => { - const commandStateResource: StateResource<CommandResource> = createEmptyStateResource(); - commandService.createCommandByProps.mockReturnValue(commandStateResource); - - const createdCommand: Observable<StateResource<CommandResource>> = + const deleteCommand$: Observable<StateResource<CommandResource>> = service.deleteBescheid(bescheidResource); - expect(createdCommand).toEqual(commandStateResource); + expect(deleteCommand$).toBeObservable(singleCold(commandStateResource, CLOSED_FRAME)); }); }); describe('update bescheid', () => { - const bescheidResource: BescheidResource = createBescheidResource(); const bescheid: Bescheid = createBescheid(); const commandResource: CommandResource = createCommandResource([ CommandLinkRel.EFFECTED_RESOURCE, ]); const commandStateResource: StateResource<CommandResource> = createStateResource(commandResource); - const createCommandProps: CreateCommandProps = createCreateCommandProps(); - let buildUpdateBescheidCommandPropsSpy: jest.SpyInstance; beforeEach(() => { - buildUpdateBescheidCommandPropsSpy = jest - .spyOn(BescheidUtil, 'buildUpdateBescheidCommandProps') - .mockReturnValue(createCommandProps); - service.bescheidDraftService.stateResource.next(createStateResource(bescheidResource)); - commandService.createCommandByProps.mockReturnValue(of(commandStateResource)); - service.bescheidDraftService.loadByResourceUri = jest.fn(); - service.getResource = jest.fn().mockReturnValue(createBescheidResource()); - }); - - it('should build update bescheid command props', () => { - service.updateBescheid(bescheid); - - expect(buildUpdateBescheidCommandPropsSpy).toHaveBeenCalledWith( - service.getResource(), - bescheid, - ); + service.bescheidDraftService.save = jest.fn().mockReturnValue(of(commandStateResource)); }); - it('should create command', () => { - service.updateBescheid(bescheid); + it('should call draft service save', () => { + service.updateBescheid(bescheid).pipe(first()).subscribe(); - expect(commandService.createCommandByProps).toHaveBeenCalledWith(createCommandProps); + expect(service.bescheidDraftService.save).toHaveBeenCalledWith(bescheid); }); it('should return command', () => { const updateBescheid$: Observable<StateResource<CommandResource>> = service.updateBescheid(bescheid); - expect(updateBescheid$).toBeObservable(cold('(a|)', { a: commandStateResource })); + expect(updateBescheid$).toBeObservable(singleCold(commandStateResource, CLOSED_FRAME)); }); - it('should set resource by uri', (done) => { + it('should clear create bescheid document in progress', (done) => { + service.createBescheidDocumentInProgress$.next(createCommandStateResource()); + service .updateBescheid(bescheid) .pipe(first()) - .subscribe((commandStateResource: StateResource<CommandResource>) => { - expect(service.bescheidDraftService.loadByResourceUri).toHaveBeenCalledWith( - getUrl(commandStateResource.resource, CommandLinkRel.EFFECTED_RESOURCE), + .subscribe(() => { + expect(service.createBescheidDocumentInProgress$.value).toEqual( + createEmptyStateResource(), ); done(); }); }); - it('should clear create bescheid document in progress', (done) => { - service.createBescheidDocumentInProgress$.next(createCommandStateResource()); - - service.updateBescheid(bescheid).subscribe(() => { - expect(service.createBescheidDocumentInProgress$.value).toEqual(createEmptyStateResource()); - done(); - }); - }); - it('should clear upload bescheid document in progress', (done) => { service.uploadBescheidDocumentInProgress$.next(createUploadFileInProgress()); @@ -523,7 +496,7 @@ describe('BescheidService', () => { linkRel, ); - expect(command$).toBeObservable(cold('(a|)', { a: commandStateResource })); + expect(command$).toBeObservable(singleCold(commandStateResource, CLOSED_FRAME)); }); }); @@ -577,34 +550,6 @@ describe('BescheidService', () => { }); }); - describe('do update bescheid', () => { - const bescheid: Bescheid = createBescheid(); - const bescheidResource: BescheidResource = createBescheidResource(); - - const createCommandProps: CreateCommandProps = createCreateCommandProps(); - let buildUpdateBescheidCommandPropsSpy: jest.SpyInstance; - - beforeEach(() => { - buildUpdateBescheidCommandPropsSpy = jest - .spyOn(BescheidUtil, 'buildUpdateBescheidCommandProps') - .mockReturnValue(createCommandProps); - commandService.createCommandByProps.mockClear(); - commandService.createCommandByProps.mockReturnValue(of(createCommandStateResource())); - }); - - it('should build update bescheid command props', () => { - service.doUpdateBescheid(bescheidResource, bescheid); - - expect(buildUpdateBescheidCommandPropsSpy).toHaveBeenCalledWith(bescheidResource, bescheid); - }); - - it('should call command service', () => { - service.doUpdateBescheid(bescheidResource, bescheid).subscribe(); - - expect(commandService.createCommandByProps).toHaveBeenCalledWith(createCommandProps); - }); - }); - describe('getAttachments', () => { let bescheidResource: BescheidResource; let binaryFileListResource: BinaryFileListResource; diff --git a/alfa-client/libs/bescheid-shared/src/lib/bescheid.service.ts b/alfa-client/libs/bescheid-shared/src/lib/bescheid.service.ts index 12e091a11c8e665039db2b52c00d535af90921a5..c3bb3fd12e66403ad28cb9e452a37c4fff051334 100644 --- a/alfa-client/libs/bescheid-shared/src/lib/bescheid.service.ts +++ b/alfa-client/libs/bescheid-shared/src/lib/bescheid.service.ts @@ -16,6 +16,7 @@ import { HttpError, ResourceListService, StateResource, + StateService, createEmptyStateResource, createStateResource, filterIsLoadedOrHasError, @@ -51,8 +52,8 @@ import { ResourceServiceConfig, } from '../../../tech-shared/src/lib/resource/resource.model'; import { ResourceRepository } from '../../../tech-shared/src/lib/resource/resource.repository'; -import { ResourceService } from '../../../tech-shared/src/lib/resource/resource.service'; import { BescheidFacade } from './+state/bescheid.facade'; +import { BESCHEID_DRAFT_PATH, BESCHEID_FEATURE_KEY } from './+state/bescheid.reducer'; import { BescheidLinkRel, BescheidListLinkRel } from './bescheid.linkrel'; import { Bescheid, @@ -65,16 +66,14 @@ import { buildCreateBescheidCommand, buildCreateBescheidDocumentCommandProps, buildCreateBescheidDocumentFromFileProps, - buildDeleteBescheidCommandProps, buildSendBescheidCommandProps, - buildUpdateBescheidCommandProps, } from './bescheid.util'; import { DocumentLinkRel } from './document.linkrel'; import { DocumentResource } from './document.model'; @Injectable({ providedIn: 'root' }) export class BescheidService { - bescheidDraftService: ResourceService<VorgangWithEingangResource, BescheidResource>; + bescheidDraftService: CommandResourceService<VorgangWithEingangResource, BescheidResource>; bescheidListService: ResourceListService< VorgangWithEingangResource, BescheidListResource, @@ -116,10 +115,11 @@ export class BescheidService { private readonly vorgangCommandService: VorgangCommandService, private readonly binaryFileService: BinaryFileService, private readonly repository: ResourceRepository, + private readonly stateService: StateService, ) { this.bescheidDraftService = new CommandResourceService( this.buildBescheidDraftServiceConfig(), - repository, + this.stateService, this.commandService, ); this.bescheidListService = new ResourceListService( @@ -130,7 +130,8 @@ export class BescheidService { buildBescheidDraftServiceConfig(): ResourceServiceConfig<VorgangWithEingangResource> { return { - resource: this.vorgangService.getVorgangWithEingang(), + stateInfo: { name: BESCHEID_FEATURE_KEY, path: BESCHEID_DRAFT_PATH }, + baseResource: this.vorgangService.getVorgangWithEingang(), getLinkRel: VorgangWithEingangLinkRel.BESCHEID_DRAFT, delete: { linkRel: BescheidLinkRel.DELETE, order: CommandOrder.DELETE_BESCHEID }, edit: { linkRel: BescheidLinkRel.UPDATE, order: CommandOrder.UPDATE_BESCHEID }, @@ -140,17 +141,12 @@ export class BescheidService { buildBescheidListServiceConfig(): ListResourceServiceConfig<VorgangWithEingangResource> { return { baseResource: this.vorgangService.getVorgangWithEingang(), - listLinkRel: VorgangWithEingangLinkRel.BESCHEIDE, + getLinkRel: VorgangWithEingangLinkRel.BESCHEIDE, listResourceListLinkRel: BescheidListLinkRel.BESCHEID_LIST, }; } public init(): void { - this.bescheidDraftService = new CommandResourceService( - this.buildBescheidDraftServiceConfig(), - this.repository, - this.commandService, - ); this.bescheidDocumentFile$.next(createEmptyStateResource()); this.bescheidDocumentUri$.next(null); this.uploadBescheidDocumentInProgress$.next({ loading: false }); @@ -224,9 +220,8 @@ export class BescheidService { } public updateBescheid(bescheid: Bescheid): Observable<StateResource<CommandResource>> { - return this.doUpdateBescheid(this.getResource(), bescheid).pipe( - tapOnCommandSuccessfullyDone((commandStateResource: StateResource<CommandResource>) => { - this.updateBescheidDraft(commandStateResource.resource); + return this.bescheidDraftService.save(bescheid).pipe( + tapOnCommandSuccessfullyDone(() => { this.clearCreateBescheidDocumentInProgress(); this.clearUploadBescheidDocumentInProgress(); }), @@ -259,15 +254,6 @@ export class BescheidService { ); } - doUpdateBescheid( - bescheidResource: BescheidResource, - bescheid: Bescheid, - ): Observable<StateResource<CommandResource>> { - return this.commandService.createCommandByProps( - buildUpdateBescheidCommandProps(bescheidResource, bescheid), - ); - } - private updateBescheidDraft(command: CommandResource): void { this.bescheidDraftService.loadByResourceUri(getEffectedResourceUrl(command)); } @@ -472,7 +458,7 @@ export class BescheidService { } deleteBescheid(bescheid: BescheidResource): Observable<StateResource<CommandResource>> { - return this.commandService.createCommandByProps(buildDeleteBescheidCommandProps(bescheid)); + return this.bescheidDraftService.delete(); } /** diff --git a/alfa-client/libs/command-shared/src/lib/command-resource.service.spec.ts b/alfa-client/libs/command-shared/src/lib/command-resource.service.spec.ts index 01af08536ee53c2b081a2da1a34d0823958d5e5c..8232b15b5fe17211a9b4cac1977aee035b51142d 100644 --- a/alfa-client/libs/command-shared/src/lib/command-resource.service.spec.ts +++ b/alfa-client/libs/command-shared/src/lib/command-resource.service.spec.ts @@ -1,50 +1,71 @@ import { EMPTY_STRING, LinkRelationName, - ResourceRepository, ResourceServiceConfig, + SingleResourceStateService, StateResource, + StateService, + createEmptyStateResource, createStateResource, } from '@alfa-client/tech-shared'; import { Mock, mock, useFromMock } from '@alfa-client/test-utils'; import { Resource } from '@ngxp/rest'; -import { Observable, of } from 'rxjs'; -import { createCommandResource } from '../../../command-shared/test/command'; -import { singleCold, singleHot } from '../../../tech-shared/test/marbles'; +import { Observable, of, take } from 'rxjs'; +import { + createCommandResource, + createCreateCommandProps, +} from '../../../command-shared/test/command'; +import { SECOND_FRAME, multiCold, singleCold } from '../../../tech-shared/test/marbles'; import { createDummyResource } from '../../../tech-shared/test/resource'; import { CommandResourceService } from './command-resource.service'; -import { CommandResource } from './command.model'; +import { CommandLinkRel } from './command.linkrel'; +import { + CommandResource, + CreateCommandProps, + DeleteCommandProps, + SaveCommandProps, +} from './command.model'; import { CommandService } from './command.service'; +import { getEffectedResourceUrl } from './command.util'; describe('CommandResourceService', () => { let service: CommandResourceService<Resource, Resource>; let config: ResourceServiceConfig<Resource>; - let repository: Mock<ResourceRepository>; + let stateService: Mock<StateService>; let commandService: Mock<CommandService>; - const configResource: Resource = createDummyResource(); - const configStateResource: StateResource<Resource> = createStateResource(configResource); - const configStateResource$: Observable<StateResource<Resource>> = of(configStateResource); + const getLinkRel: LinkRelationName = 'dummyGetLinkRel'; const editLinkRel: string = 'dummyEditLinkRel'; - const getLinkRel: LinkRelationName = 'dummyGetLinkRel'; + const editOrder: string = 'dummyEditOrder'; const deleteOrder: string = 'dummyDeleteOrder'; const deleteLinkRel: string = 'dummyDeleteLinkRel'; + const dummyResource: Resource = createDummyResource(); + const dummyStateResource: StateResource<Resource> = createStateResource(dummyResource); + + const configResource: Resource = createDummyResource([getLinkRel, editLinkRel]); + const configStateResource: StateResource<Resource> = createStateResource(configResource); + const configStateResource$: Observable<StateResource<Resource>> = of(configStateResource); + beforeEach(() => { config = { - resource: configStateResource$, + stateInfo: { name: 'dummyStateName', path: 'dummyStatePath' }, + baseResource: configStateResource$, getLinkRel, - edit: { linkRel: editLinkRel }, + edit: { order: editOrder, linkRel: editLinkRel }, delete: { order: deleteOrder, linkRel: deleteLinkRel }, }; - repository = mock(ResourceRepository); + stateService = mock(StateService); + stateService.createSingleResourceService = jest + .fn() + .mockReturnValue(mock(SingleResourceStateService)); commandService = mock(CommandService); service = new CommandResourceService( config, - useFromMock(repository), + useFromMock(stateService), useFromMock(commandService), ); }); @@ -53,37 +74,259 @@ describe('CommandResourceService', () => { expect(service).toBeTruthy(); }); - describe('delete', () => { - const resourceWithDeleteLinkRel: Resource = createDummyResource([deleteLinkRel]); - const stateResourceWithDeleteLink: StateResource<Resource> = - createStateResource(resourceWithDeleteLinkRel); + it('should be create resource state service', () => { + expect(service.resourceStateService).toBeTruthy(); + }); + + describe('save', () => { + const dummyToSave: unknown = {}; + + const commandResource: CommandResource = createCommandResource([ + CommandLinkRel.EFFECTED_RESOURCE, + ]); + const commandStateResource: StateResource<CommandResource> = + createStateResource(commandResource); beforeEach(() => { - commandService.createCommandByProps.mockReturnValue( - of(createStateResource(createCommandResource())), + service.doSave = jest.fn().mockReturnValue(of(commandStateResource)); + service.resourceStateService.selectResource = jest + .fn() + .mockReturnValue(of(dummyStateResource)); + service.loadByResourceUri = jest.fn(); + }); + + it('should do save', () => { + service.save(dummyToSave).pipe(take(2)).subscribe(); + + expect(service.doSave).toHaveBeenCalledWith(dummyResource, dummyToSave, { + snackBarMessage: EMPTY_STRING, + }); + }); + + it('should loadByResourceUri on sucessfully done command', () => { + service.save(dummyToSave).pipe(take(2)).subscribe(); + + expect(service.loadByResourceUri).toHaveBeenCalledWith( + getEffectedResourceUrl(commandResource), ); - service.stateResource.next(stateResourceWithDeleteLink); + }); + + it('should return initial value', () => { + service.doSave = jest.fn().mockReturnValue(singleCold(commandStateResource, SECOND_FRAME)); + + const initialResponse$: Observable<StateResource<CommandResource>> = + service.save(dummyToSave); + + expect(initialResponse$).toBeObservable( + multiCold({ a: createEmptyStateResource(true), b: commandStateResource }), + ); + }); + }); + + describe('do save', () => { + const dummyToSave: unknown = {}; + const dummy: Resource = createDummyResource(); + const saveCommandProps: SaveCommandProps = { snackBarMessage: EMPTY_STRING }; + const createCommandProps: CreateCommandProps = createCreateCommandProps(); + + let buildSaveBescheidCommandPropsSpy: jest.SpyInstance; + + beforeEach(() => { + buildSaveBescheidCommandPropsSpy = service.buildSaveBescheidCommandProps = jest + .fn() + .mockReturnValue(createCommandProps); }); it('should call command service', () => { - service.delete(); + service.doSave(dummy, dummyToSave, saveCommandProps); + + expect(commandService.createCommandByProps).toHaveBeenCalledWith(createCommandProps); + }); + + it('should build command', () => { + service.doSave(dummy, dummyToSave, saveCommandProps); + + expect(buildSaveBescheidCommandPropsSpy).toHaveBeenCalledWith( + dummy, + dummyToSave, + saveCommandProps, + ); + }); + }); + + describe('build save bescheid command props', () => { + const resource: Resource = createDummyResource(); + const dummyToSave: unknown = {}; + const saveCommandProps: SaveCommandProps = { snackBarMessage: EMPTY_STRING }; + + it('should have resource', () => { + const props: CreateCommandProps = service.buildSaveBescheidCommandProps( + resource, + dummyToSave, + saveCommandProps, + ); + + expect(props.resource).toBe(resource); + }); + + it('should have linkRel', () => { + const props: CreateCommandProps = service.buildSaveBescheidCommandProps( + resource, + dummyToSave, + saveCommandProps, + ); + + expect(props.linkRel).toBe(editLinkRel); + }); + + describe('command', () => { + it('should have order', () => { + const props: CreateCommandProps = service.buildSaveBescheidCommandProps( + resource, + dummyToSave, + saveCommandProps, + ); + + expect(props.command.order).toBe(editOrder); + }); + + it('should have body', () => { + const props: CreateCommandProps = service.buildSaveBescheidCommandProps( + resource, + dummyToSave, + saveCommandProps, + ); - expect(commandService.createCommandByProps).toHaveBeenCalledWith({ - resource: resourceWithDeleteLinkRel, - linkRel: deleteLinkRel, - command: { order: deleteOrder, body: null }, + expect(props.command.body).toBe(dummyToSave); + }); + }); + + it('should have snackBarMessage', () => { + const props: CreateCommandProps = service.buildSaveBescheidCommandProps( + resource, + dummyToSave, + saveCommandProps, + ); + + expect(props.snackBarMessage).toBe(saveCommandProps.snackBarMessage); + }); + }); + + describe('delete', () => { + const commandResource: CommandResource = createCommandResource([ + CommandLinkRel.EFFECTED_RESOURCE, + ]); + const commandStateResource: StateResource<CommandResource> = + createStateResource(commandResource); + + beforeEach(() => { + service.doDelete = jest.fn().mockReturnValue(of(commandStateResource)); + service.resourceStateService.selectResource = jest + .fn() + .mockReturnValue(of(dummyStateResource)); + service.clearResource = jest.fn(); + }); + + it('should do delete', () => { + service.delete().pipe(take(2)).subscribe(); + + expect(service.doDelete).toHaveBeenCalledWith(dummyResource, { snackBarMessage: EMPTY_STRING, }); }); - it('should return value', () => { - const deleteResource: Resource = createDummyResource(); - const deleteStateResource: StateResource<Resource> = createStateResource(deleteResource); - commandService.createCommandByProps.mockReturnValue(singleHot(deleteStateResource)); + it('should loadByResourceUri on sucessfully done command', () => { + service.delete().pipe(take(2)).subscribe(); + + expect(service.clearResource).toHaveBeenCalled(); + }); + + it('should return initial value', () => { + service.doDelete = jest.fn().mockReturnValue(singleCold(commandStateResource, SECOND_FRAME)); + + const initialResponse$: Observable<StateResource<CommandResource>> = service.delete(); + + expect(initialResponse$).toBeObservable( + multiCold({ a: createEmptyStateResource(true), b: commandStateResource }), + ); + }); + }); + + describe('do delete', () => { + const dummy: Resource = createDummyResource(); + const deleteCommandProps: DeleteCommandProps = { snackBarMessage: EMPTY_STRING }; + const createCommandProps: CreateCommandProps = createCreateCommandProps(); + + let buildDeleteCommandPropsSpy: jest.SpyInstance; + + beforeEach(() => { + buildDeleteCommandPropsSpy = service.buildDeleteCommandProps = jest + .fn() + .mockReturnValue(createCommandProps); + }); + + it('should call command service', () => { + service.doDelete(dummy, deleteCommandProps); + + expect(commandService.createCommandByProps).toHaveBeenCalledWith(createCommandProps); + }); + + it('should build command', () => { + service.doDelete(dummy, deleteCommandProps); + + expect(buildDeleteCommandPropsSpy).toHaveBeenCalledWith(dummy, deleteCommandProps); + }); + }); + + describe('build delete bescheid command props', () => { + const resource: Resource = createDummyResource(); + const deleteCommandProps: SaveCommandProps = { snackBarMessage: EMPTY_STRING }; + + it('should have resource', () => { + const props: CreateCommandProps = service.buildDeleteCommandProps( + resource, + deleteCommandProps, + ); + + expect(props.resource).toBe(resource); + }); + + it('should have linkRel', () => { + const props: CreateCommandProps = service.buildDeleteCommandProps( + resource, + deleteCommandProps, + ); - const deletedResource: Observable<StateResource<CommandResource>> = service.delete(); + expect(props.linkRel).toBe(deleteLinkRel); + }); + + describe('command', () => { + it('should have order', () => { + const props: CreateCommandProps = service.buildDeleteCommandProps( + resource, + deleteCommandProps, + ); + + expect(props.command.order).toBe(deleteOrder); + }); + + it('should have body', () => { + const props: CreateCommandProps = service.buildDeleteCommandProps( + resource, + deleteCommandProps, + ); + + expect(props.command.body).toBeNull(); + }); + }); + + it('should have snackBarMessage', () => { + const props: CreateCommandProps = service.buildDeleteCommandProps( + resource, + deleteCommandProps, + ); - expect(deletedResource).toBeObservable(singleCold(deleteStateResource)); + expect(props.snackBarMessage).toBe(deleteCommandProps.snackBarMessage); }); }); }); diff --git a/alfa-client/libs/command-shared/src/lib/command-resource.service.ts b/alfa-client/libs/command-shared/src/lib/command-resource.service.ts index 9a564442fd23862a0c85f645a6a93573ddca3612..cd2b14471e15e1765d9a0b69a5f5aacc2daef349 100644 --- a/alfa-client/libs/command-shared/src/lib/command-resource.service.ts +++ b/alfa-client/libs/command-shared/src/lib/command-resource.service.ts @@ -1,46 +1,112 @@ import { EMPTY_STRING, - ResourceRepository, ResourceService, ResourceServiceConfig, StateResource, + StateService, createEmptyStateResource, + isStateResoureStable, } from '@alfa-client/tech-shared'; import { Resource } from '@ngxp/rest'; -import { BehaviorSubject, Observable } from 'rxjs'; -import { CommandResource, CreateCommandProps } from './command.model'; +import { Observable, filter, startWith, switchMap } from 'rxjs'; +import { + CommandResource, + CreateCommandProps, + DeleteCommandProps, + SaveCommandProps, +} from './command.model'; +import { tapOnCommandSuccessfullyDone } from './command.rxjs.operator'; import { CommandService } from './command.service'; +import { getEffectedResourceUrl } from './command.util'; export class CommandResourceService<B extends Resource, T extends Resource> extends ResourceService< B, T > { - deleteStateCommandResource: BehaviorSubject<StateResource<CommandResource>> = new BehaviorSubject< - StateResource<CommandResource> - >(createEmptyStateResource()); - constructor( protected config: ResourceServiceConfig<B>, - protected repository: ResourceRepository, + protected stateService: StateService, private commandService: CommandService, ) { - super(config, repository); + super(config, stateService); + } + + public save( + toSave: unknown, + saveCommandProps: SaveCommandProps = { snackBarMessage: EMPTY_STRING }, + ): Observable<StateResource<CommandResource>> { + return this.selectResource().pipe( + switchMap((stateResource: StateResource<T>) => + this.doSave(stateResource.resource, toSave, saveCommandProps), + ), + tapOnCommandSuccessfullyDone((commandStateResource: StateResource<CommandResource>) => + this.loadByResourceUri(getEffectedResourceUrl(commandStateResource.resource)), + ), + filter(isStateResoureStable), + startWith(createEmptyStateResource<CommandResource>(true)), + ); + } + + doSave( + resource: T, + toSave: unknown, + saveCommandProps: SaveCommandProps, + ): Observable<StateResource<CommandResource>> { + return this.commandService.createCommandByProps( + this.buildSaveBescheidCommandProps<T>(resource, toSave, saveCommandProps), + ); + } + + buildSaveBescheidCommandProps<T extends Resource>( + resource: T, + toSave: unknown, + saveCommandProps: SaveCommandProps, + ): CreateCommandProps { + return { + resource, + linkRel: this.config.edit.linkRel, + command: { + order: this.config.edit.order, + body: toSave, + }, + snackBarMessage: saveCommandProps.snackBarMessage, + }; } - doSave(resource: T, toSave: unknown): Observable<T> { - throw new Error('Method not implemented.'); + public delete( + deleteCommandProps: DeleteCommandProps = { snackBarMessage: EMPTY_STRING }, + ): Observable<StateResource<CommandResource>> { + return this.selectResource().pipe( + switchMap((stateResource: StateResource<T>) => + this.doDelete(stateResource.resource, deleteCommandProps), + ), + tapOnCommandSuccessfullyDone(() => this.clearResource()), + filter(isStateResoureStable), + startWith(createEmptyStateResource<CommandResource>(true)), + ); } - public delete(): Observable<StateResource<CommandResource>> { - return this.commandService.createCommandByProps(this.buildDeleteCommandProps()); + doDelete( + resource: T, + deleteCommandProps: DeleteCommandProps, + ): Observable<StateResource<CommandResource>> { + return this.commandService.createCommandByProps( + this.buildDeleteCommandProps(resource, deleteCommandProps), + ); } - private buildDeleteCommandProps(): CreateCommandProps { + buildDeleteCommandProps<T extends Resource>( + resource: T, + deleteCommandProps: DeleteCommandProps, + ): CreateCommandProps { return { - resource: this.stateResource.value.resource, + resource, linkRel: this.config.delete.linkRel, - command: { order: this.config.delete.order, body: null }, - snackBarMessage: EMPTY_STRING, + command: { + order: this.config.delete.order, + body: null, + }, + snackBarMessage: deleteCommandProps.snackBarMessage, }; } } diff --git a/alfa-client/libs/command-shared/src/lib/command.model.ts b/alfa-client/libs/command-shared/src/lib/command.model.ts index 947b7251c473ceec77e0438c299eca6c799a0752..49fa06eef50a1273187099e6d2f604d14a80df07 100644 --- a/alfa-client/libs/command-shared/src/lib/command.model.ts +++ b/alfa-client/libs/command-shared/src/lib/command.model.ts @@ -88,6 +88,7 @@ export enum CommandOrder { SEND_BESCHEID = 'SEND_BESCHEID', } +//TODO rename CreateCommandProps -> CreateCommandActionProps export interface CreateCommandProps { resource: Resource; linkRel: string; @@ -96,3 +97,11 @@ export interface CreateCommandProps { snackBarMessage?: string; snackBarErrorMessage?: string; } + +export interface SaveCommandProps { + snackBarMessage: string; +} + +export interface DeleteCommandProps { + snackBarMessage: string; +} diff --git a/alfa-client/libs/tech-shared/src/index.ts b/alfa-client/libs/tech-shared/src/index.ts index 64e34b9ffcb66244bb7687070f685ed6090b4950..7f8a805b1726ef9f0f36aa3b4b749e843d9805cd 100644 --- a/alfa-client/libs/tech-shared/src/index.ts +++ b/alfa-client/libs/tech-shared/src/index.ts @@ -31,6 +31,8 @@ export * from './lib/form.util'; export * from './lib/http.util'; export * from './lib/message-code'; export * from './lib/ngrx/actions'; +export * from './lib/ngrx/resource.reducer'; +export * from './lib/ngrx/state.service'; export * from './lib/pipe/convert-api-error-to-error-messages.pipe'; export * from './lib/pipe/convert-for-data-test.pipe'; export * from './lib/pipe/convert-to-boolean.pipe'; diff --git a/alfa-client/libs/tech-shared/src/lib/decorator/skip-error-interceptor.decorator.spec.ts b/alfa-client/libs/tech-shared/src/lib/decorator/skip-error-interceptor.decorator.spec.ts index d2096fdd1f39ae8c44dd8d7807a44a0ae81026b2..b24febea161d32ce63d2ed81c0bee5aa140e3a0a 100644 --- a/alfa-client/libs/tech-shared/src/lib/decorator/skip-error-interceptor.decorator.spec.ts +++ b/alfa-client/libs/tech-shared/src/lib/decorator/skip-error-interceptor.decorator.spec.ts @@ -21,17 +21,16 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ -import { Mock, mock } from '@alfa-client/test-utils'; -import { HttpErrorHandler } from '../error/error.handler'; +import { SkipInterceptor } from './skip-error-interceptor.decorator'; describe('SkipInterceptor Decorator', () => { - let httpErrorHandler: Mock<HttpErrorHandler>; + let interceptor: MethodDecorator; beforeEach(() => { - httpErrorHandler = mock(HttpErrorHandler); + interceptor = SkipInterceptor(); }); - it.skip('should be created', () => { - //expect(SkipInterceptor()).toBeTruthy(); + it('should be created', () => { + expect(interceptor).toBeTruthy(); }); }); diff --git a/alfa-client/libs/tech-shared/src/lib/ngrx/actions.ts b/alfa-client/libs/tech-shared/src/lib/ngrx/actions.ts index 907ccaf7749c9f9cb7438addf98a0da8b56de6dc..331f65845f7cbcd5fae4f014cdd7aca56c7ae665 100644 --- a/alfa-client/libs/tech-shared/src/lib/ngrx/actions.ts +++ b/alfa-client/libs/tech-shared/src/lib/ngrx/actions.ts @@ -21,10 +21,11 @@ * Die sprachspezifischen Genehmigungen und Beschränkungen * unter der Lizenz sind dem Lizenztext zu entnehmen. */ -import { Action, ActionCreator } from '@ngrx/store'; +import { Action, ActionCreator, createAction, props } from '@ngrx/store'; import { TypedAction } from '@ngrx/store/src/models'; -import { ResourceUri } from '@ngxp/rest'; -import { ApiError } from '../tech.model'; +import { Resource, ResourceUri } from '@ngxp/rest'; +import { StateInfo } from '../resource/resource.model'; +import { ApiError, HttpError } from '../tech.model'; export const EMPTY_ACTION: Action = {} as Action; @@ -40,3 +41,64 @@ export interface ApiErrorAction { export interface ResourceUriProps { resourceUri: ResourceUri; } + +export interface LoadResourceSuccessProps { + resource: Resource; +} + +export interface LoadResourceFailureProps { + error: HttpError; +} +export interface ResourceActions { + loadAction: TypedActionCreatorWithProps<ResourceUriProps>; + loadFailureAction: TypedActionCreatorWithProps<LoadResourceFailureProps>; + clearAction: TypedActionCreator; + reloadAction: TypedActionCreator; +} + +export interface SingleResourceLoadActions extends ResourceActions { + loadSuccessAction: TypedActionCreatorWithProps<LoadResourceSuccessProps>; +} + +export function createSingleResourceActions(stateInfo: StateInfo): SingleResourceLoadActions { + const actions: ResourceActions = createResourceActions( + stateInfo.name, + `${stateInfo.path} Resource`, + ); + return { ...actions, loadSuccessAction: createLoadSuccessAction(stateInfo) }; +} + +export interface SingleResourceLoadActions extends ResourceActions { + loadSuccessAction: TypedActionCreatorWithProps<LoadResourceSuccessProps>; +} + +function createLoadSuccessAction( + stateInfo: StateInfo, +): TypedActionCreatorWithProps<LoadResourceSuccessProps> { + return createAction( + createActionType(stateInfo.name, `Load ${stateInfo.path} Resource Success`), + props<LoadResourceSuccessProps>(), + ); +} + +function createResourceActions(stateName: string, message: string): ResourceActions { + const loadAction: TypedActionCreatorWithProps<ResourceUriProps> = createAction( + createActionType(stateName, `Load ${message}`), + props<ResourceUriProps>(), + ); + const loadFailureAction: TypedActionCreatorWithProps<LoadResourceFailureProps> = createAction( + createActionType(stateName, `Load ${message} Failure`), + props<LoadResourceFailureProps>(), + ); + const clearAction: TypedActionCreator = createAction( + createActionType(stateName, `Clear ${message}`), + ); + const reloadAction: TypedActionCreator = createAction( + createActionType(stateName, `Reload ${message}`), + ); + return { loadAction, loadFailureAction, clearAction, reloadAction }; +} + +function createActionType(stateName: string, message: string): string { + return `[${stateName}] ${message}`; +} diff --git a/alfa-client/libs/tech-shared/src/lib/ngrx/effects.service.spec.ts b/alfa-client/libs/tech-shared/src/lib/ngrx/effects.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..84ddd441009d1dbfda1116bbcf80708bc1552c51 --- /dev/null +++ b/alfa-client/libs/tech-shared/src/lib/ngrx/effects.service.spec.ts @@ -0,0 +1,31 @@ +import { Mock, mock, useFromMock } from '@alfa-client/test-utils'; +import { Actions, EffectSources } from '@ngrx/effects'; +import { of } from 'rxjs'; +import { ResourceRepository } from '../resource/resource.repository'; +import { SingleResourceLoadActions } from './actions'; +import { EffectService } from './effects.service'; + +describe('EffectService', () => { + let service: EffectService; + let actions: Actions; + let effectSources: Mock<EffectSources>; + let repository: Mock<ResourceRepository>; + + const resourceActions: Mock<SingleResourceLoadActions> = <any>{}; + + beforeEach(() => { + actions = of(); + effectSources = mock(EffectSources); + repository = mock(ResourceRepository); + + service = new EffectService(actions, useFromMock(effectSources), useFromMock(repository)); + }); + + describe('add single resource effect', () => { + it('should call effect sources to add effect', () => { + service.addSingleResourceEffects(useFromMock(resourceActions)); + + expect(effectSources.addEffects).toHaveBeenCalled(); + }); + }); +}); diff --git a/alfa-client/libs/tech-shared/src/lib/ngrx/effects.service.ts b/alfa-client/libs/tech-shared/src/lib/ngrx/effects.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..cba87ff7d15b3897c545f50e2dac2b9f6f8eea60 --- /dev/null +++ b/alfa-client/libs/tech-shared/src/lib/ngrx/effects.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; +import { Actions, EffectSources } from '@ngrx/effects'; +import { ResourceRepository } from '../resource/resource.repository'; +import { SingleResourceLoadActions } from './actions'; +import { ResourceEffects } from './resource.effects'; + +@Injectable() +export class EffectService { + constructor( + private actions$: Actions, + private effectSources: EffectSources, + private repository: ResourceRepository, + ) {} + + public addSingleResourceEffects(resourceActions: SingleResourceLoadActions): void { + this.effectSources.addEffects({ + ...new ResourceEffects(this.actions$, this.repository, resourceActions), + }); + } +} diff --git a/alfa-client/libs/tech-shared/src/lib/ngrx/resource.effects.spec.ts b/alfa-client/libs/tech-shared/src/lib/ngrx/resource.effects.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..5eeb93862c13d746400e24a4d6953b289d5b5cd1 --- /dev/null +++ b/alfa-client/libs/tech-shared/src/lib/ngrx/resource.effects.spec.ts @@ -0,0 +1,118 @@ +import { + ApiError, + LoadResourceFailureProps, + LoadResourceSuccessProps, + ResourceRepository, + ResourceUriProps, + SingleResourceLoadActions, + TypedActionCreator, + TypedActionCreatorWithProps, +} from '@alfa-client/tech-shared'; +import { Mock, mock } from '@alfa-client/test-utils'; +import { TestBed } from '@angular/core/testing'; +import { Actions } from '@ngrx/effects'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Action, createAction, props } from '@ngrx/store'; +import { provideMockStore } from '@ngrx/store/testing'; +import { Resource, ResourceUri } from '@ngxp/rest'; +import { ObservableWithSubscriptions, cold, hot } from 'jest-marbles'; +import { createApiError } from 'libs/tech-shared/test/error'; +import { createDummyResource } from 'libs/tech-shared/test/resource'; +import { Observable, of } from 'rxjs'; +import { ResourceEffects } from './resource.effects'; + +describe('ResourceEffects', () => { + let effects: ResourceEffects<Resource>; + + let actions: Observable<Action>; + let repository: Mock<ResourceRepository> = mock(ResourceRepository); + + const loadAction: TypedActionCreatorWithProps<ResourceUriProps> = createAction( + 'DummyLoadAction', + props<ResourceUriProps>(), + ); + + const loadSuccessAction: TypedActionCreatorWithProps<LoadResourceSuccessProps> = createAction( + 'DummyLoadSuccessAction', + props<LoadResourceSuccessProps>(), + ); + + const loadFailureAction: TypedActionCreatorWithProps<LoadResourceFailureProps> = createAction( + 'DummyLoadFailureAction', + props<LoadResourceFailureProps>(), + ); + + const clearAction: TypedActionCreator = createAction('DummyClearAction'); + + const reloadAction: TypedActionCreator = createAction('DummyReloadAction'); + + const resourceActions: SingleResourceLoadActions = { + loadAction, + loadSuccessAction, + loadFailureAction, + clearAction, + reloadAction, + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideMockActions(() => actions), + provideMockStore(), + { + provide: ResourceRepository, + useValue: repository, + }, + { + provide: ResourceEffects, + useFactory: (actions$: Actions, repository: ResourceRepository) => + new ResourceEffects<Resource>(actions$, repository, resourceActions), + deps: [Actions, ResourceRepository], + }, + ], + }); + + effects = TestBed.inject(ResourceEffects); + }); + + describe('loadVorgangList', () => { + const resourceUri: ResourceUri = 'dummyResourceUri'; + const dummyResource: Resource = createDummyResource(); + + beforeEach(() => { + repository.getResource.mockReturnValue(of(dummyResource)); + }); + + it('should call repository', (done) => { + actions = of(loadAction({ resourceUri })); + + effects.loadByUri$.subscribe(() => { + expect(repository.getResource).toHaveBeenCalledWith(resourceUri); + done(); + }); + }); + + it('should dispatch loadSuccessAction action', () => { + actions = hot('-a-|', { a: loadAction({ resourceUri }) }); + + const expected: ObservableWithSubscriptions = hot('-a-|', { + a: resourceActions.loadSuccessAction({ resource: dummyResource }), + }); + + expect(effects.loadByUri$).toBeObservable(expected); + }); + + it('should dispatch loadFailureAction action', () => { + const apiError: ApiError = createApiError(); + const errorResponse: ObservableWithSubscriptions = cold('-#', {}, apiError); + repository.getResource = jest.fn(() => errorResponse); + + actions = hot('-a', { a: loadAction({ resourceUri }) }); + + const expected: ObservableWithSubscriptions = cold('--b', { + b: resourceActions.loadFailureAction({ error: apiError }), + }); + expect(effects.loadByUri$).toBeObservable(expected); + }); + }); +}); diff --git a/alfa-client/libs/tech-shared/src/lib/ngrx/resource.effects.ts b/alfa-client/libs/tech-shared/src/lib/ngrx/resource.effects.ts new file mode 100644 index 0000000000000000000000000000000000000000..ca5100d16f7fce7054fb5c607f2aa32230c95088 --- /dev/null +++ b/alfa-client/libs/tech-shared/src/lib/ngrx/resource.effects.ts @@ -0,0 +1,26 @@ +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { Resource } from '@ngxp/rest'; +import { of } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; +import { ResourceRepository } from '../resource/resource.repository'; +import { ResourceUriProps, SingleResourceLoadActions } from './actions'; + +export class ResourceEffects<T extends Resource> { + constructor( + private actions$: Actions, + private repository: ResourceRepository, + private resourceActions: SingleResourceLoadActions, + ) {} + + loadByUri$ = createEffect(() => + this.actions$.pipe( + ofType(this.resourceActions.loadAction), + switchMap((props: ResourceUriProps) => { + return this.repository.getResource<T>(props.resourceUri).pipe( + map((resource: T) => this.resourceActions.loadSuccessAction({ resource })), + catchError((error) => of(this.resourceActions.loadFailureAction({ error }))), + ); + }), + ), + ); +} diff --git a/alfa-client/libs/tech-shared/src/lib/ngrx/resource.redicer.spec.ts b/alfa-client/libs/tech-shared/src/lib/ngrx/resource.redicer.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd51a4cdaf28a7f170c3d06663d59e9eee16a586 --- /dev/null +++ b/alfa-client/libs/tech-shared/src/lib/ngrx/resource.redicer.spec.ts @@ -0,0 +1,99 @@ +import faker from '@faker-js/faker'; +import { Action } from '@ngrx/store'; +import { TypedAction } from '@ngrx/store/src/models'; +import { Resource, ResourceUri } from '@ngxp/rest'; +import { createApiError } from 'libs/tech-shared/test/error'; +import { singleResourceActions } from 'libs/tech-shared/test/ngrx'; +import { createDummyResource } from 'libs/tech-shared/test/resource'; +import { createEmptyStateResource, createStateResource } from '../resource/resource.util'; +import { ApiError } from '../tech.model'; +import { + LoadResourceFailureProps, + LoadResourceSuccessProps, + SingleResourceLoadActions, +} from './actions'; +import { SingleResourceReducer } from './resource.reducer'; +import { SingleResourceState, initialResourceState } from './state.model'; + +describe('SingleResourceReducer', () => { + const resourceActions: SingleResourceLoadActions = singleResourceActions; + + const reducer = new SingleResourceReducer(resourceActions); + + describe('unknown action', () => { + it('should return current state', () => { + const action = {} as Action; + + const result = reducer.reducer(initialResourceState, action); + + expect(result).toBe(initialResourceState); + }); + }); + describe('on load action', () => { + const resourceUri: ResourceUri = faker.internet.url(); + + it('should set loading to true', () => { + const action = resourceActions.loadAction({ resourceUri }); + + const state: SingleResourceState = reducer.reducer(initialResourceState, action); + + expect(state.resource.loading).toBeTruthy(); + }); + }); + + describe('on load success action', () => { + const resource: Resource = createDummyResource(); + const action: LoadResourceSuccessProps & TypedAction<string> = + resourceActions.loadSuccessAction({ resource }); + + it('should set resource', () => { + const state: SingleResourceState = reducer.reducer(initialResourceState, action); + + expect(state.resource.resource).toStrictEqual(resource); + }); + }); + + describe('on load failure action', () => { + const apiError: ApiError = createApiError(); + const action: LoadResourceFailureProps & TypedAction<string> = + resourceActions.loadFailureAction({ + error: apiError, + }); + + it('should set apiError', () => { + const state: SingleResourceState = reducer.reducer(initialResourceState, action); + + expect(state.resource.error).toStrictEqual(apiError); + }); + }); + + describe('on clear action', () => { + const action: TypedAction<string> = resourceActions.clearAction(); + + it('should clear stateresource', () => { + const initialState: SingleResourceState = { + ...initialResourceState, + resource: createStateResource(createDummyResource()), + }; + + const state: SingleResourceState = reducer.reducer(initialState, action); + + expect(state.resource).toEqual(createEmptyStateResource()); + }); + }); + + describe('on reload action', () => { + const action: TypedAction<string> = resourceActions.reloadAction(); + + it('should mark stateresource as reload', () => { + const initialState: SingleResourceState = { + ...initialResourceState, + resource: createEmptyStateResource(), + }; + + const state: SingleResourceState = reducer.reducer(initialState, action); + + expect(state.resource.reload).toBeTruthy(); + }); + }); +}); diff --git a/alfa-client/libs/tech-shared/src/lib/ngrx/resource.reducer.ts b/alfa-client/libs/tech-shared/src/lib/ngrx/resource.reducer.ts new file mode 100644 index 0000000000000000000000000000000000000000..96ed418280bd62c755cba690d74f286e731a8df2 --- /dev/null +++ b/alfa-client/libs/tech-shared/src/lib/ngrx/resource.reducer.ts @@ -0,0 +1,62 @@ +import { Action, ActionReducer, createReducer, on } from '@ngrx/store'; +import { + createEmptyStateResource, + createErrorStateResource, + createStateResource, +} from '../resource/resource.util'; +import { + LoadResourceFailureProps, + LoadResourceSuccessProps, + SingleResourceLoadActions, +} from './actions'; +import { SingleResourceState, initialResourceState } from './state.model'; + +export class SingleResourceReducer { + public reducer: ActionReducer<SingleResourceState, Action>; + + constructor(private actions: SingleResourceLoadActions) { + this.initReducer(); + } + + initReducer(): void { + this.reducer = createReducer( + initialResourceState, + on(this.actions.loadAction, (state: SingleResourceState): SingleResourceState => { + return { + ...state, + resource: { ...state.resource, loading: true }, + }; + }), + on( + this.actions.loadSuccessAction, + (state: SingleResourceState, props: LoadResourceSuccessProps): SingleResourceState => { + return { + ...state, + resource: createStateResource(props.resource), + }; + }, + ), + on( + this.actions.loadFailureAction, + (state: SingleResourceState, props: LoadResourceFailureProps): SingleResourceState => ({ + ...state, + resource: createErrorStateResource(props.error), + }), + ), + on( + this.actions.clearAction, + (state: SingleResourceState): SingleResourceState => ({ + ...state, + resource: createEmptyStateResource(), + }), + ), + on( + this.actions.reloadAction, + (state: SingleResourceState): SingleResourceState => ({ + ...state, + resource: { ...state.resource, reload: true }, + }), + ), + ); + } +} diff --git a/alfa-client/libs/tech-shared/src/lib/ngrx/selector.spec.ts b/alfa-client/libs/tech-shared/src/lib/ngrx/selector.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..a3e56a459e8dd094e0d0221f837ab719de53694c --- /dev/null +++ b/alfa-client/libs/tech-shared/src/lib/ngrx/selector.spec.ts @@ -0,0 +1,48 @@ +import { Resource } from '@ngxp/rest'; +import { + FUNCTIONAL_FEATURE_PATH_KEY, + SingleResourceParentState, + StateInfoTestFactory, +} from 'libs/tech-shared/test/ngrx'; +import { createDummyResource } from 'libs/tech-shared/test/resource'; +import { StateResource, createStateResource } from '../resource/resource.util'; +import { initialResourceState } from './state.model'; + +import * as Selectors from './selector'; + +describe('Selector', () => { + let state: SingleResourceParentState; + + const stateResource: StateResource<Resource> = createStateResource(createDummyResource()); + + beforeEach(() => { + state = { + FunctionalState: { + FunctionalResourcePath: { + ...initialResourceState, + resource: stateResource, + }, + }, + }; + }); + + it('should return resource from state', () => { + const result: StateResource<Resource> = <any>( + Selectors.selectResource(StateInfoTestFactory.create()).projector( + state.FunctionalState[FUNCTIONAL_FEATURE_PATH_KEY], + ) + ); + + expect(result).toBe(stateResource); + }); + + it('should check resource in state exists', () => { + const result: StateResource<Resource> = <any>( + Selectors.selectResource(StateInfoTestFactory.create()).projector( + state.FunctionalState[FUNCTIONAL_FEATURE_PATH_KEY], + ) + ); + + expect(result).toBeTruthy(); + }); +}); diff --git a/alfa-client/libs/tech-shared/src/lib/ngrx/selector.ts b/alfa-client/libs/tech-shared/src/lib/ngrx/selector.ts new file mode 100644 index 0000000000000000000000000000000000000000..c7c2c27cacef25486909ed0dfcfb01a348daded0 --- /dev/null +++ b/alfa-client/libs/tech-shared/src/lib/ngrx/selector.ts @@ -0,0 +1,21 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; +import { StateInfo } from '../resource/resource.model'; +import { SingleResourceState } from './state.model'; + +export const selectResource = <T>(stateInfo: StateInfo) => + createSelector( + createFeatureSelector<SingleResourceState>(stateInfo.name), + (state: SingleResourceState) => + <T>getFeatureState<SingleResourceState>(stateInfo, state).resource, + ); + +export const existResource = <T>(stateInfo: StateInfo) => + createSelector( + createFeatureSelector<SingleResourceState>(stateInfo.name), + (state: SingleResourceState) => + <T>getFeatureState<SingleResourceState>(stateInfo, state).resource.loaded, + ); + +function getFeatureState<T>(stateInfo: StateInfo, state: any): T { + return <T>state.hasOwnProperty(stateInfo.path) ? state[stateInfo.path] : state; +} diff --git a/alfa-client/libs/tech-shared/src/lib/ngrx/state.model.ts b/alfa-client/libs/tech-shared/src/lib/ngrx/state.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9c0728a3d795a2da96dfdf14af240fbea23189e --- /dev/null +++ b/alfa-client/libs/tech-shared/src/lib/ngrx/state.model.ts @@ -0,0 +1,10 @@ +import { Resource } from '@ngxp/rest'; +import { StateResource, createEmptyStateResource } from '../resource/resource.util'; + +export interface SingleResourceState { + resource: StateResource<Resource>; +} + +export const initialResourceState: SingleResourceState = { + resource: createEmptyStateResource<Resource>(), +}; diff --git a/alfa-client/libs/tech-shared/src/lib/ngrx/state.service.spec.ts b/alfa-client/libs/tech-shared/src/lib/ngrx/state.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..b2667c5aa70a87ec8691a7534daacf5660199e75 --- /dev/null +++ b/alfa-client/libs/tech-shared/src/lib/ngrx/state.service.spec.ts @@ -0,0 +1,172 @@ +import { Mock, mock, useFromMock } from '@alfa-client/test-utils'; +import faker from '@faker-js/faker'; +import { MemoizedSelector, Store } from '@ngrx/store'; +import { Resource, ResourceUri } from '@ngxp/rest'; +import { singleCold } from 'libs/tech-shared/test/marbles'; +import { createDummyResource } from 'libs/tech-shared/test/resource'; +import { Observable, of } from 'rxjs'; +import { StateInfoTestFactory, dummySelector } from '../../../test/ngrx'; +import { StateInfo } from '../resource/resource.model'; +import { StateResource, createStateResource } from '../resource/resource.util'; +import { SingleResourceLoadActions, createSingleResourceActions } from './actions'; +import { EffectService } from './effects.service'; +import { SingleResourceState } from './state.model'; +import { ResourceStateService, SingleResourceStateService } from './state.service'; + +import * as ResourceSelectors from './selector'; + +describe('StateService', () => { + let store: Mock<Store>; + let effectService: Mock<EffectService>; + + const stateInfo: StateInfo = StateInfoTestFactory.create(); + + beforeEach(() => { + store = mock(Store); + effectService = mock(EffectService); + }); + + describe('Resource State Service', () => { + let service: DummyResoureStateService<Resource>; + + beforeEach(() => { + service = new DummyResoureStateService<Resource>( + stateInfo, + useFromMock(store), + useFromMock(effectService), + ); + }); + + it('should clear resource', () => { + service.clearResource(); + + expect(store.dispatch).toHaveBeenCalledWith(service.actions.clearAction()); + }); + + it('should load resource', () => { + const resourceUri: ResourceUri = faker.random.word(); + + service.loadResource(resourceUri); + + expect(store.dispatch).toHaveBeenCalledWith(service.actions.loadAction({ resourceUri })); + }); + + it('should reload resource', () => { + service.reloadResource(); + + expect(store.dispatch).toHaveBeenCalledWith(service.actions.reloadAction()); + }); + }); + + describe('Single Resource State Service', () => { + let service: SingleResourceStateService<Resource>; + + beforeEach(() => { + service = new SingleResourceStateService<Resource>( + stateInfo, + useFromMock(store), + useFromMock(effectService), + ); + }); + + it('should init actions', () => { + service.actions = undefined; + + service.init(); + + expect(service.actions).not.toBeUndefined(); + }); + + it('should add effects', () => { + service.init(); + + expect(effectService.addSingleResourceEffects).toHaveBeenCalledWith(service.actions); + }); + + describe('select resource', () => { + const stateValue: MemoizedSelector<object, unknown, (s1: SingleResourceState) => unknown> = + dummySelector; + const dummyStateResource: StateResource<Resource> = + createStateResource(createDummyResource()); + + let selectResourceSpy: jest.SpyInstance; + + beforeEach(() => { + store.select.mockReturnValue(of(dummyStateResource)); + selectResourceSpy = jest + .spyOn(ResourceSelectors, 'selectResource') + .mockReturnValue(stateValue); + }); + + it('should call selector', () => { + service.selectResource(); + + expect(selectResourceSpy).toHaveBeenCalledWith(stateInfo); + }); + + it('should call store', () => { + service.selectResource(); + + expect(store.select).toHaveBeenCalledWith(stateValue); + }); + + it('should return from store selected value', () => { + store.select.mockReturnValue(singleCold(dummyStateResource)); + + const selectedResource$: Observable<StateResource<Resource>> = service.selectResource(); + + expect(selectedResource$).toBeObservable(singleCold(dummyStateResource)); + }); + }); + + describe('exists resource', () => { + const stateValue: MemoizedSelector<object, unknown, (s1: SingleResourceState) => unknown> = + dummySelector; + const dummyStateResource: StateResource<Resource> = + createStateResource(createDummyResource()); + + let existsResourceSpy: jest.SpyInstance; + + beforeEach(() => { + store.select.mockReturnValue(of(dummyStateResource)); + existsResourceSpy = jest + .spyOn(ResourceSelectors, 'existResource') + .mockReturnValue(stateValue); + }); + + it('should call selector', () => { + service.existsResource(); + + expect(existsResourceSpy).toHaveBeenCalledWith(stateInfo); + }); + + it('should call store', () => { + service.existsResource(); + + expect(store.select).toHaveBeenCalledWith(stateValue); + }); + + it('should return from store selected value', () => { + store.select.mockReturnValue(singleCold(true)); + + const exists$: Observable<boolean> = service.existsResource(); + + expect(exists$).toBeObservable(singleCold(true)); + }); + }); + }); +}); + +class DummyResoureStateService<Resource> extends ResourceStateService<Resource> { + actions: SingleResourceLoadActions; + + protected initActions(): void { + this.actions = createSingleResourceActions(this.stateInfo); + } + protected initEffects(): void { + this.effectService.addSingleResourceEffects(this.actions); + } + public selectResource(): Observable<StateResource<Resource>> { + throw new Error('Method not implemented.'); + } +} diff --git a/alfa-client/libs/tech-shared/src/lib/ngrx/state.service.ts b/alfa-client/libs/tech-shared/src/lib/ngrx/state.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ac8339252c62390a61910e0d7d63d681496b51f --- /dev/null +++ b/alfa-client/libs/tech-shared/src/lib/ngrx/state.service.ts @@ -0,0 +1,85 @@ +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { ResourceUri } from '@ngxp/rest'; +import { Observable } from 'rxjs'; +import { StateInfo } from '../resource/resource.model'; +import { StateResource } from '../resource/resource.util'; +import { ResourceActions, SingleResourceLoadActions, createSingleResourceActions } from './actions'; +import { EffectService } from './effects.service'; + +import * as ResourceSelectors from './selector'; + +@Injectable() +export class StateService { + constructor( + private store: Store, + private effectService: EffectService, + ) {} + + public createSingleResourceService<T>(stateInfo: StateInfo): SingleResourceStateService<T> { + return new SingleResourceStateService(stateInfo, this.store, this.effectService); + } +} + +export abstract class ResourceStateService<T> { + actions: ResourceActions; + + constructor( + protected stateInfo: StateInfo, + protected store: Store, + protected effectService: EffectService, + ) { + this.init(); + } + + public init(): void { + this.initActions(); + this.initEffects(); + } + + protected abstract initActions(): void; + + protected abstract initEffects(): void; + + public clearResource(): void { + this.store.dispatch(this.actions.clearAction()); + } + + public loadResource(resourceUri: ResourceUri): void { + this.store.dispatch(this.actions.loadAction({ resourceUri })); + } + + public reloadResource(): void { + this.store.dispatch(this.actions.reloadAction()); + } + + public abstract selectResource(): Observable<StateResource<T>>; +} + +export class SingleResourceStateService<T> extends ResourceStateService<T> { + actions: SingleResourceLoadActions; + + constructor( + protected stateInfo: StateInfo, + protected store: Store, + protected effectService: EffectService, + ) { + super(stateInfo, store, effectService); + } + + protected initActions(): void { + this.actions = createSingleResourceActions(this.stateInfo); + } + + protected initEffects(): void { + this.effectService.addSingleResourceEffects(this.actions); + } + + public selectResource(): Observable<StateResource<T>> { + return this.store.select(ResourceSelectors.selectResource(this.stateInfo)); + } + + public existsResource(): Observable<boolean> { + return this.store.select(ResourceSelectors.existResource(this.stateInfo)); + } +} diff --git a/alfa-client/libs/tech-shared/src/lib/resource/api-resource.service.spec.ts b/alfa-client/libs/tech-shared/src/lib/resource/api-resource.service.spec.ts index 8b559b8b81e03d6f4f1a56f56ac6e1e67e14cf64..cb5d71b89a2f08863ca1f8a39cb50971ed416c8d 100644 --- a/alfa-client/libs/tech-shared/src/lib/resource/api-resource.service.spec.ts +++ b/alfa-client/libs/tech-shared/src/lib/resource/api-resource.service.spec.ts @@ -2,19 +2,21 @@ import { Mock, mock, useFromMock } from '@alfa-client/test-utils'; import { fakeAsync, tick } from '@angular/core/testing'; import { Resource } from '@ngxp/rest'; import { Observable, of, throwError } from 'rxjs'; -import { singleCold, singleHot } from '../../../test//marbles'; +import { SECOND_FRAME, multiCold, singleCold } from '../../../test//marbles'; import { createProblemDetail } from '../../../test/error'; import { createDummyResource } from '../../../test/resource'; +import { SingleResourceStateService, StateService } from '../ngrx/state.service'; import { HttpError, ProblemDetail } from '../tech.model'; import { ApiResourceService } from './api-resource.service'; import { LinkRelationName, ResourceServiceConfig, SaveResourceData } from './resource.model'; import { ResourceRepository } from './resource.repository'; -import { StateResource, createStateResource } from './resource.util'; +import { StateResource, createEmptyStateResource, createStateResource } from './resource.util'; describe('ApiResourceService', () => { let service: ApiResourceService<Resource, Resource>; let config: ResourceServiceConfig<Resource>; let repository: Mock<ResourceRepository>; + let stateService: Mock<StateService>; const configResource: Resource = createDummyResource(); const configStateResource: StateResource<Resource> = createStateResource(configResource); @@ -26,28 +28,43 @@ describe('ApiResourceService', () => { beforeEach(() => { config = { - resource: configStateResource$, + stateInfo: { name: 'dummyStateName', path: 'dummyStatePath' }, + baseResource: configStateResource$, getLinkRel, edit: { linkRel: editLinkRel }, delete: { linkRel: deleteLinkRel }, }; repository = mock(ResourceRepository); + stateService = mock(StateService); + stateService.createSingleResourceService = jest + .fn() + .mockReturnValue(mock(SingleResourceStateService)); - service = new ApiResourceService(config, useFromMock(repository)); + service = new ApiResourceService(config, useFromMock(stateService), useFromMock(repository)); }); it('should be created', () => { expect(service).toBeTruthy(); }); + it('should be create resource state service', () => { + expect(service.resourceStateService).toBeTruthy(); + }); + describe('save', () => { const dummyToSave: unknown = {}; const loadedResource: Resource = createDummyResource(); const resourceWithEditLinkRel: Resource = createDummyResource([editLinkRel]); + beforeEach(() => { + service.selectResource = jest + .fn() + .mockReturnValue(of(createStateResource(resourceWithEditLinkRel))); + service.resourceStateService.reloadResource = jest.fn(); + }); + it('should call repository', fakeAsync(() => { - service.stateResource.next(createStateResource(resourceWithEditLinkRel)); repository.save.mockReturnValue(of(loadedResource)); service.save(dummyToSave).subscribe(); @@ -62,16 +79,16 @@ describe('ApiResourceService', () => { })); it('should return saved object', () => { - service.stateResource.next(createStateResource(resourceWithEditLinkRel)); - repository.save.mockReturnValue(singleHot(loadedResource)); + repository.save.mockReturnValue(singleCold(loadedResource, SECOND_FRAME)); - const saved: Observable<StateResource<Resource | HttpError>> = service.save(dummyToSave); + const saved$: Observable<StateResource<Resource | HttpError>> = service.save(dummyToSave); - expect(saved).toBeObservable(singleCold(createStateResource(loadedResource))); + expect(saved$).toBeObservable( + multiCold({ a: createEmptyStateResource(true), b: createStateResource(loadedResource) }), + ); }); it('should call handleError', () => { - service.stateResource.next(createStateResource(createDummyResource([config.edit.linkRel]))); const errorResponse: ProblemDetail = createProblemDetail(); repository.save.mockReturnValue(throwError(() => errorResponse)); service.handleError = jest.fn(); @@ -82,13 +99,13 @@ describe('ApiResourceService', () => { }); it('should update state resource subject', fakeAsync(() => { - service.stateResource.next(createStateResource(resourceWithEditLinkRel)); + service.resourceStateService.reloadResource = jest.fn(); repository.save.mockReturnValue(of(loadedResource)); service.save(dummyToSave).subscribe(); tick(); - expect(service.stateResource.value).toEqual(createStateResource(loadedResource)); + expect(service.resourceStateService.reloadResource).toHaveBeenCalled(); })); }); }); diff --git a/alfa-client/libs/tech-shared/src/lib/resource/api-resource.service.ts b/alfa-client/libs/tech-shared/src/lib/resource/api-resource.service.ts index d19c4a6fdc52813b270661c560bf76bed7f3ba24..a5232c272a4bdd2a8450029159bd6e047d71e1ce 100644 --- a/alfa-client/libs/tech-shared/src/lib/resource/api-resource.service.ts +++ b/alfa-client/libs/tech-shared/src/lib/resource/api-resource.service.ts @@ -1,8 +1,18 @@ +import { HttpErrorResponse } from '@angular/common/http'; import { Resource } from '@ngxp/rest'; -import { Observable } from 'rxjs'; +import { Observable, catchError, map, of, startWith, switchMap, tap, throwError } from 'rxjs'; +import { isUnprocessableEntity } from '../http.util'; +import { StateService } from '../ngrx/state.service'; +import { HttpError } from '../tech.model'; import { ResourceServiceConfig } from './resource.model'; import { ResourceRepository } from './resource.repository'; import { ResourceService } from './resource.service'; +import { + StateResource, + createEmptyStateResource, + createErrorStateResource, + createStateResource, +} from './resource.util'; export class ApiResourceService<B extends Resource, T extends Resource> extends ResourceService< B, @@ -10,16 +20,37 @@ export class ApiResourceService<B extends Resource, T extends Resource> extends > { constructor( protected config: ResourceServiceConfig<B>, + protected stateService: StateService, protected repository: ResourceRepository, ) { - super(config, repository); + super(config, stateService); + } + + public save(toSave: unknown): Observable<StateResource<T | HttpError>> { + return this.selectResource().pipe( + switchMap((stateResource: StateResource<T>) => + this.doSave(stateResource.resource, toSave).pipe( + tap(() => this.reloadResource()), + map((value: T) => createStateResource<T>(value)), + catchError((errorResponse: HttpErrorResponse) => this.handleError(errorResponse)), + ), + ), + startWith(createEmptyStateResource<T | HttpError>(true)), + ); } doSave(resource: T, toSave: unknown): Observable<T> { - return <Observable<T>>this.repository.save({ + return this.repository.save<T>({ resource, linkRel: this.config.edit.linkRel, toSave, }); } + + handleError(errorResponse: HttpErrorResponse): Observable<StateResource<HttpError>> { + if (isUnprocessableEntity(errorResponse.status)) { + return of(createErrorStateResource((<any>errorResponse) as HttpError)); + } + return throwError(() => errorResponse); + } } diff --git a/alfa-client/libs/tech-shared/src/lib/resource/list-resource.service.itcase.spec.ts b/alfa-client/libs/tech-shared/src/lib/resource/list-resource.service.itcase.spec.ts deleted file mode 100644 index 9e8d68e04cac794472b2a4142bf9d8888c31a452..0000000000000000000000000000000000000000 --- a/alfa-client/libs/tech-shared/src/lib/resource/list-resource.service.itcase.spec.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { Mock, mock, useFromMock } from '@alfa-client/test-utils'; -import { fakeAsync, tick } from '@angular/core/testing'; -import { Resource } from '@ngxp/rest'; -import { DummyLinkRel, DummyListLinkRel } from 'libs/tech-shared/test/dummy'; -import { createDummyListResource, createDummyResource } from 'libs/tech-shared/test/resource'; -import { BehaviorSubject, of, skip } from 'rxjs'; -import { ResourceListService } from './list-resource.service'; -import { ListResourceServiceConfig } from './resource.model'; -import { ResourceRepository } from './resource.repository'; -import { - ListResource, - StateResource, - createEmptyStateResource, - createStateResource, -} from './resource.util'; - -//Der Test muss nochmal ueberarbeitet werden - siehe resource.service.itcase.spec.ts -describe.skip('ResourceListService ITCase', () => { - let service: ResourceListService<Resource, ListResource, Resource>; - let config: ListResourceServiceConfig<Resource>; - let resourceRepository: Mock<ResourceRepository>; - - const listLinkRel: string = DummyListLinkRel.LIST; - const listResourceListLinkRel: string = DummyListLinkRel.LIST; - const createLinkRel: string = DummyLinkRel.DUMMY; - - const baseResource: StateResource<Resource> = createStateResource(createDummyResource()); - - const loadedListResource: ListResource = createDummyListResource(); - - describe('getList', () => { - const loadedListResource: ListResource = createDummyListResource(); - // * - GIVEN listResource=leer, baseResource=leer WHEN baseResource neuer Eintrag THEN load ausführen AND wenn fertig listResource liefert Wert - describe('with empty listResource and empty baseResource', () => { - const baseResourceSubj: BehaviorSubject<StateResource<Resource>> = new BehaviorSubject< - StateResource<Resource> - >(createEmptyStateResource()); - - beforeEach(() => { - config = createConfigWithBaseResource(baseResourceSubj); - initServiceAndMocksWithBaseResourceSubj(config); - - resourceRepository.getListResource.mockReturnValue(of(loadedListResource)); - }); - - it('should load and return listResource on baseResource change', (done) => { - let emitted: number = 0; - service.getList().subscribe((listStateResource) => { - emitted++; - if (emitted == 1) { - expect(listStateResource).toEqual(createEmptyStateResource()); - } - if (emitted == 2) { - expect(listStateResource.loading).toBeTruthy(); - expect(resourceRepository.getListResource).toHaveBeenCalled(); - } - if (emitted === 3) { - expect(listStateResource).toEqual(createStateResource(loadedListResource)); - done(); - } - }); - baseResourceSubj.next(createStateResource(createDummyResource())); - }); - }); - // * - GIVEN listResource=befüllt, baseResource=befüllt WHEN subscribe THEN nicht laden, listResource liefert Wert - describe('with filled listResource and filled baseResource', () => { - const listStateResource: StateResource<ListResource> = - createStateResource(createDummyListResource()); - - const baseResourceSubj: BehaviorSubject<StateResource<Resource>> = new BehaviorSubject< - StateResource<Resource> - >(baseResource); - - beforeEach(() => { - config = createConfigWithBaseResource(baseResourceSubj); - initServiceAndMocksWithBaseResourceSubj(config); - - service.listResource.next(listStateResource); - }); - - it('should return listResource', (done) => { - service.getList().subscribe((response) => { - expect(response).toBe(listStateResource); - done(); - }); - }); - - it('should not call repository', fakeAsync(() => { - service.getList().subscribe(); - tick(); - - expect(resourceRepository.getListResource).not.toHaveBeenCalled(); - })); - - // * - GIVEN listResource=befüllt, baseResource=befüllt WHEN baseResource wird leer THEN listResource leer - describe('on baseResource changed to null', () => { - it('should return empty state resource', (done) => { - service - .getList() - .pipe(skip(1)) - .subscribe((listStateResource) => { - expect(listStateResource).toEqual(createEmptyStateResource()); - done(); - }); - - baseResourceSubj.next(createEmptyStateResource()); - }); - }); - - // * - GIVEN listResource=befüllt, baseResource=befüllt WHEN baseResource wechselt Wert THEN load ausführen AND wenn fertig listResource liefert neuen Wert - describe('on baseResource changed', () => { - it('should reloaded value', (done) => { - let emitted: number = 0; - service.getList().subscribe((response) => { - emitted++; - if (emitted === 1) { - expect(response).toEqual(listStateResource); - } - if (emitted === 2) { - expect(response.loading).toBeTruthy(); - expect(resourceRepository.getListResource).toHaveBeenCalled(); - } - if (emitted === 3) { - done(); - } - }); - - baseResourceSubj.next(createStateResource(createDummyResource())); - }); - }); - }); - }); - - function createConfigWithBaseResource(baseResource: BehaviorSubject<StateResource<Resource>>) { - return { - baseResource, - listLinkRel, - listResourceListLinkRel, - createLinkRel, - }; - } - - function initServiceAndMocksWithBaseResourceSubj( - config: ListResourceServiceConfig<Resource>, - ): void { - resourceRepository = mock(ResourceRepository); - service = new ResourceListService<Resource, ListResource, Resource>( - config, - useFromMock(resourceRepository), - ); - - resourceRepository.getListResource.mockReturnValue(of(loadedListResource)); - } -}); diff --git a/alfa-client/libs/tech-shared/src/lib/resource/list-resource.service.spec.ts b/alfa-client/libs/tech-shared/src/lib/resource/list-resource.service.spec.ts index 9983f8acf61e11a6c642761e7b0d6e8e3351c253..931dac12de04544bec86596a778b20656075c34b 100644 --- a/alfa-client/libs/tech-shared/src/lib/resource/list-resource.service.spec.ts +++ b/alfa-client/libs/tech-shared/src/lib/resource/list-resource.service.spec.ts @@ -35,10 +35,10 @@ describe('ListResourceService', () => { let config: ListResourceServiceConfig<Resource>; let resourceRepository: Mock<ResourceRepository>; - const listLinkRel: LinkRelationName = DummyListLinkRel.LIST; + const getLinkRel: LinkRelationName = DummyListLinkRel.LIST; const createLinkRel: LinkRelationName = DummyLinkRel.DUMMY; const listResourceListLinkRel: LinkRelationName = DummyListLinkRel.LIST; - const listResource: ListResource = createDummyListResource([listLinkRel, createLinkRel]); + const listResource: ListResource = createDummyListResource([getLinkRel, createLinkRel]); const baseResource: Resource = createDummyResource(); const baseStateResource: StateResource<Resource> = createStateResource(baseResource); @@ -49,7 +49,7 @@ describe('ListResourceService', () => { beforeEach(() => { config = { baseResource: baseResourceSubj, - listLinkRel, + getLinkRel, listResourceListLinkRel, createLinkRel, }; @@ -148,7 +148,7 @@ describe('ListResourceService', () => { service.handleChanges(listStateResource, baseResource); - expect(service.loadListResource).toHaveBeenCalledWith(baseResource, listLinkRel); + expect(service.loadListResource).toHaveBeenCalledWith(baseResource, getLinkRel); }); it('should NOT load resource on shouldLoadResource false', () => { @@ -177,18 +177,18 @@ describe('ListResourceService', () => { it('should load list on stable state resource', () => { service.loadListResource = jest.fn(); service.listResource.next(createStateResource(createDummyListResource())); - const configResuorce: Resource = createDummyListResource([listLinkRel]); + const configResuorce: Resource = createDummyListResource([getLinkRel]); service.handleConfigResourceChanges(configResuorce); - expect(service.loadListResource).toHaveBeenCalledWith(service.baseResource, listLinkRel); + expect(service.loadListResource).toHaveBeenCalledWith(service.baseResource, getLinkRel); }); it('should NOT load list on stable unstable resource', () => { jest.spyOn(ResourceUtil, 'isStateResoureStable').mockReturnValue(false); service.loadListResource = jest.fn(); service.listResource.next(createStateResource(createDummyListResource())); - const configResuorce: Resource = createDummyListResource([listLinkRel]); + const configResuorce: Resource = createDummyListResource([getLinkRel]); service.handleConfigResourceChanges(configResuorce); diff --git a/alfa-client/libs/tech-shared/src/lib/resource/list-resource.service.ts b/alfa-client/libs/tech-shared/src/lib/resource/list-resource.service.ts index 631e1c685eba0e9f96e5a4e1d97e1314a1cb7e9f..830d1ff58ec54181a8470bbe87fa8e171066e2c7 100644 --- a/alfa-client/libs/tech-shared/src/lib/resource/list-resource.service.ts +++ b/alfa-client/libs/tech-shared/src/lib/resource/list-resource.service.ts @@ -72,21 +72,21 @@ export class ResourceListService< if (!isEqual(this.baseResource, configResource)) { this.handleConfigResourceChanges(configResource); } else if (this.shouldLoadResource(stateResource, configResource)) { - this.loadListResource(configResource, this.config.listLinkRel); + this.loadListResource(configResource, this.config.getLinkRel); } } handleConfigResourceChanges(newConfigResource: B): void { this.baseResource = newConfigResource; if (this.hasListLinkRel() && isStateResoureStable(this.listResource.value)) { - this.loadListResource(this.baseResource, this.config.listLinkRel); + this.loadListResource(this.baseResource, this.config.getLinkRel); } else if (!this.hasListLinkRel() && isStateResoureStable(this.listResource.value)) { this.clearCurrentListResource(); } } private hasListLinkRel(): boolean { - return hasLink(this.baseResource, this.config.listLinkRel); + return hasLink(this.baseResource, this.config.getLinkRel); } shouldLoadResource(stateResource: StateResource<T>, configResource: B): boolean { @@ -158,7 +158,7 @@ export class ResourceListService< existsUriInList(uri: ResourceUri): boolean { const listResources: Resource[] = getEmbeddedResources( this.listResource.value, - this.config.listLinkRel, + this.config.getLinkRel, ); return isNotUndefined(listResources.find((resource) => getUrl(resource) === uri)); diff --git a/alfa-client/libs/tech-shared/src/lib/resource/resource.loader.spec.ts b/alfa-client/libs/tech-shared/src/lib/resource/resource.loader.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..1226221c0935c1e0bca2825ac8b6a3fd5865587b --- /dev/null +++ b/alfa-client/libs/tech-shared/src/lib/resource/resource.loader.spec.ts @@ -0,0 +1,316 @@ +import { mock, useFromMock } from '@alfa-client/test-utils'; +import { fakeAsync, tick } from '@angular/core/testing'; +import { Resource, getUrl } from '@ngxp/rest'; +import { CLOSED_FRAME, singleCold } from 'libs/tech-shared/test/marbles'; +import { Observable, of } from 'rxjs'; +import { createDummyResource } from '../../../test/resource'; +import { SingleResourceStateService } from '../ngrx/state.service'; +import { ResourceLoader } from './resource.loader'; +import { LinkRelationName, ResourceServiceConfig } from './resource.model'; +import { StateResource, createEmptyStateResource, createStateResource } from './resource.util'; + +import * as ResourceUtil from './resource.util'; + +describe('ResourceLoader', () => { + let service: ResourceLoader<Resource, Resource>; + let config: ResourceServiceConfig<Resource>; + let resourceStateService: SingleResourceStateService<Resource>; + + const editLinkRel: string = 'dummyEditLinkRel'; + const getLinkRel: LinkRelationName = 'dummyGetLinkRel'; + const deleteLinkRel: LinkRelationName = 'dummyDeleteLinkRel'; + + const configResource: Resource = createDummyResource([getLinkRel]); + const configStateResource: StateResource<Resource> = createStateResource(configResource); + const configStateResource$: Observable<StateResource<Resource>> = of(configStateResource); + + const dummyResource: Resource = createDummyResource(); + const dummyStateResource: StateResource<Resource> = createStateResource(dummyResource); + + beforeEach(() => { + config = { + baseResource: configStateResource$, + getLinkRel, + edit: { linkRel: editLinkRel }, + delete: { linkRel: deleteLinkRel }, + }; + resourceStateService = <any>mock(SingleResourceStateService); + + service = new ResourceLoader(config, useFromMock(<any>resourceStateService)); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('get', () => { + let isInvalidResourceCombinationSpy: jest.SpyInstance; + + beforeEach(() => { + resourceStateService.selectResource = jest.fn().mockReturnValue(of(dummyStateResource)); + + service.handleResourceChanges = jest.fn(); + isInvalidResourceCombinationSpy = jest + .spyOn(ResourceUtil, 'isInvalidResourceCombination') + .mockReturnValue(true); + }); + + it('should handle config resource changed', fakeAsync(() => { + service.get().subscribe(); + tick(); + + expect(service.handleResourceChanges).toHaveBeenCalledWith( + dummyStateResource, + configResource, + ); + })); + + it('should call isInvalidResourceCombinationSpy', fakeAsync(() => { + service.get().subscribe(); + tick(); + + expect(isInvalidResourceCombinationSpy).toHaveBeenCalled(); + })); + + it('should return initial value', () => { + const apiRootStateResource$: Observable<StateResource<Resource>> = service.get(); + + expect(apiRootStateResource$).toBeObservable( + singleCold(createEmptyStateResource(true), CLOSED_FRAME), + ); + }); + }); + + describe('handle resource changes', () => { + const stateResource: StateResource<Resource> = createStateResource(createDummyResource()); + const changedConfigResource: Resource = createDummyResource(); + + describe('on different config resource', () => { + beforeEach(() => { + service.handleConfigResourceChanges = jest.fn(); + }); + + it('should update state resource by config resource', () => { + service.configResource = createDummyResource(); + + service.handleResourceChanges(stateResource, changedConfigResource); + + expect(service.handleConfigResourceChanges).toHaveBeenCalled(); + }); + }); + + describe('on same config resource', () => { + const stateResource: StateResource<Resource> = createStateResource(createDummyResource()); + + beforeEach(() => { + service.configResource = configResource; + }); + + it('should call shouldLoadResource', () => { + service.shouldLoadResource = jest.fn(); + + service.handleResourceChanges(stateResource, configResource); + + expect(service.shouldLoadResource).toHaveBeenCalledWith(stateResource, configResource); + }); + + it('should load resource', () => { + service.shouldLoadResource = jest.fn().mockReturnValue(true); + service.loadResource = jest.fn(); + + service.handleResourceChanges(stateResource, configResource); + + expect(service.loadResource).toHaveBeenCalledWith(configResource); + }); + + it('should NOT load resource', () => { + service.loadResource = jest.fn(); + service.shouldLoadResource = jest.fn().mockReturnValue(false); + + service.handleResourceChanges(stateResource, configResource); + + expect(service.loadResource).not.toHaveBeenCalled(); + }); + }); + }); + + describe('handle config resource changes', () => { + const stateResource: StateResource<Resource> = createStateResource(createDummyResource()); + + beforeEach(() => { + service.updateStateResourceByConfigResource = jest.fn(); + }); + + it('should update configresource', () => { + service.configResource = createDummyResource(); + + service.handleConfigResourceChanges(stateResource, configResource); + + expect(service.configResource).toBe(configResource); + }); + + describe('on stable stateresource', () => { + beforeEach(() => { + jest.spyOn(ResourceUtil, 'isStateResoureStable').mockReturnValue(true); + service.updateStateResourceByConfigResource = jest.fn(); + }); + + it('should update stateresource by configresource', () => { + service.handleConfigResourceChanges(stateResource, configResource); + + expect(service.updateStateResourceByConfigResource).toHaveBeenCalledWith( + stateResource, + configResource, + ); + }); + }); + + describe('on instable stateresource', () => { + beforeEach(() => { + jest.spyOn(ResourceUtil, 'isStateResoureStable').mockReturnValue(false); + }); + + it('should NOT update stateresource by configresource', () => { + service.updateStateResourceByConfigResource = jest.fn(); + + service.handleConfigResourceChanges(stateResource, configResource); + + expect(service.updateStateResourceByConfigResource).not.toHaveBeenCalled(); + }); + }); + }); + + describe('update stateresource by configresource', () => { + it('should check if should clear stateresource', () => { + resourceStateService.clearResource = jest.fn(); + service.shouldClearStateResource = jest.fn().mockRejectedValue(true); + + service.updateStateResourceByConfigResource(dummyStateResource, configResource); + + expect(service.shouldClearStateResource).toHaveBeenCalled(); + }); + + it('should clear resource if should', () => { + resourceStateService.clearResource = jest.fn(); + resourceStateService.selectResource = jest + .fn() + .mockReturnValue(of(createStateResource(createDummyResource()))); + service.shouldClearStateResource = jest.fn().mockReturnValue(true); + + service.updateStateResourceByConfigResource(dummyStateResource, configResource); + + expect(resourceStateService.clearResource).toHaveBeenCalled(); + }); + + describe('on NOT clearing stateresource', () => { + beforeEach(() => { + service.shouldClearStateResource = jest.fn().mockReturnValue(false); + }); + + it('should check if get link exists', () => { + service.hasGetLink = jest.fn(); + + service.updateStateResourceByConfigResource(dummyStateResource, configResource); + + expect(service.hasGetLink).toHaveBeenCalledWith(configResource); + }); + + it('should load resource on existing get link', () => { + service.hasGetLink = jest.fn().mockReturnValue(true); + service.loadResource = jest.fn(); + + service.updateStateResourceByConfigResource(dummyStateResource, configResource); + + expect(service.loadResource).toHaveBeenCalledWith(configResource); + }); + }); + }); + + describe('should clear stateresource', () => { + describe('on existing stateresource', () => { + beforeEach(() => { + resourceStateService.selectResource = jest.fn().mockReturnValue(of(dummyStateResource)); + }); + + it('should return true if configresource is null', () => { + const shouldClear: boolean = service.shouldClearStateResource(dummyStateResource, null); + + expect(shouldClear).toBeTruthy(); + }); + + it('should return true if configresource has no get link', () => { + const shouldClear: boolean = service.shouldClearStateResource( + dummyStateResource, + createDummyResource(), + ); + + expect(shouldClear).toBeTruthy(); + }); + }); + + describe('on empty stateresource', () => { + it('should return false', () => { + const shouldClear: boolean = service.shouldClearStateResource( + ResourceUtil.createEmptyStateResource(), + null, + ); + + expect(shouldClear).toBeFalsy(); + }); + + it('should return false if configresource has no get link', () => { + const shouldClear: boolean = service.shouldClearStateResource( + ResourceUtil.createEmptyStateResource(), + createDummyResource(), + ); + + expect(shouldClear).toBeFalsy(); + }); + }); + }); + + describe('should load resource', () => { + const resource: Resource = createDummyResource(); + const stateResource: StateResource<Resource> = createStateResource(resource); + + let isLoadingRequiredSpy: jest.SpyInstance<boolean>; + + beforeEach(() => { + isLoadingRequiredSpy = jest.spyOn(ResourceUtil, 'isLoadingRequired'); + }); + + it('should return true on existing configresource and loading is required', () => { + isLoadingRequiredSpy.mockReturnValue(true); + + const shouldLoad: boolean = service.shouldLoadResource(stateResource, configResource); + + expect(shouldLoad).toBeTruthy(); + }); + + it('should call isLoadingRequired', () => { + service.shouldLoadResource(stateResource, configResource); + + expect(isLoadingRequiredSpy).toBeCalledWith(stateResource); + }); + + it('should return false if configresource exists but loading is NOT required', () => { + isLoadingRequiredSpy.mockReturnValue(false); + + const shouldLoad: boolean = service.shouldLoadResource(stateResource, configResource); + + expect(shouldLoad).toBeFalsy(); + }); + }); + + describe('load resource', () => { + it('should call resource state service load resource', () => { + resourceStateService.loadResource = jest.fn(); + + service.loadResource(configResource); + + expect(resourceStateService.loadResource).toHaveBeenCalledWith( + getUrl(configResource, getLinkRel), + ); + }); + }); +}); diff --git a/alfa-client/libs/tech-shared/src/lib/resource/resource.loader.ts b/alfa-client/libs/tech-shared/src/lib/resource/resource.loader.ts new file mode 100644 index 0000000000000000000000000000000000000000..5c38968831a043ebffeab3de3bc78f1136757321 --- /dev/null +++ b/alfa-client/libs/tech-shared/src/lib/resource/resource.loader.ts @@ -0,0 +1,102 @@ +import { Resource, getUrl, hasLink } from '@ngxp/rest'; +import { isEqual, isNull } from 'lodash-es'; +import { Observable, combineLatest, filter, startWith, tap } from 'rxjs'; +import { ResourceStateService } from '../ngrx/state.service'; +import { isNotNull } from '../tech.util'; +import { ServiceConfig } from './resource.model'; +import { mapToFirst, mapToResource } from './resource.rxjs.operator'; +import { + StateResource, + createEmptyStateResource, + isInvalidResourceCombination, + isLoadingRequired, + isStateResoureStable, +} from './resource.util'; + +export class ResourceLoader<B extends Resource, T extends Resource> { + configResource: B = null; + + constructor( + private config: ServiceConfig<B>, + private resourceStateService: ResourceStateService<T>, + ) {} + + public get(): Observable<StateResource<T>> { + return combineLatest([ + this.resourceStateService.selectResource(), + this.getConfigResource(), + ]).pipe( + tap(([stateResource, configResource]) => + this.handleResourceChanges(stateResource, configResource), + ), + filter( + ([stateResource, configResource]) => + !isInvalidResourceCombination(stateResource, configResource), + ), + mapToFirst<T, B>(), + startWith(createEmptyStateResource<T>(true)), + ); + } + + private getConfigResource(): Observable<B> { + return this.config.baseResource.pipe(filter(isStateResoureStable), mapToResource<B>()); + } + + handleResourceChanges(stateResource: StateResource<T>, configResource: B): void { + if (!isEqual(this.configResource, configResource)) { + this.handleConfigResourceChanges(stateResource, configResource); + } else if (this.shouldLoadResource(stateResource, configResource)) { + this.loadResource(configResource); + } + } + + handleConfigResourceChanges(stateResource: StateResource<T>, configResource: B) { + this.configResource = configResource; + if (isStateResoureStable(stateResource)) { + this.updateStateResourceByConfigResource(stateResource, configResource); + } + } + + updateStateResourceByConfigResource(stateResource: StateResource<T>, configResource: B): void { + if (this.shouldClearStateResource(stateResource, configResource)) { + this.resourceStateService.clearResource(); + } else if (this.hasGetLink(configResource)) { + this.loadResource(configResource); + } + } + + shouldClearStateResource(stateResource: StateResource<T>, configResource: B): boolean { + return ( + (isNull(configResource) || this.hasNotGetLink(configResource)) && + !this.isStateResourceEmpty(stateResource) + ); + } + + private hasNotGetLink(configResource: B) { + return !this.hasGetLink(configResource); + } + + hasGetLink(configResource: B): boolean { + return isNotNull(configResource) && hasLink(configResource, this.config.getLinkRel); + } + + private isStateResourceEmpty(stateResource: StateResource<T>): boolean { + return isEqual(stateResource, createEmptyStateResource()); + } + + shouldLoadResource(stateResource: StateResource<T>, configResource: B): boolean { + return ( + isNotNull(configResource) && + hasLink(configResource, this.config.getLinkRel) && + isLoadingRequired(stateResource) + ); + } + + loadResource(configResource: B): void { + this.resourceStateService.loadResource(this.getGetUrl(configResource)); + } + + private getGetUrl(configResource: B): string { + return getUrl(configResource, this.config.getLinkRel); + } +} diff --git a/alfa-client/libs/tech-shared/src/lib/resource/resource.model.ts b/alfa-client/libs/tech-shared/src/lib/resource/resource.model.ts index 5fa5264f32b11a057af8755641f61d02b001fc46..f08c137d763e1557b6596bc91c0e49c92e214f8f 100644 --- a/alfa-client/libs/tech-shared/src/lib/resource/resource.model.ts +++ b/alfa-client/libs/tech-shared/src/lib/resource/resource.model.ts @@ -2,9 +2,18 @@ import { Resource } from '@ngxp/rest'; import { Observable } from 'rxjs'; import { StateResource } from './resource.util'; -export interface ListResourceServiceConfig<B> { +export interface StateInfo { + name: string; + path: string; +} + +export interface ServiceConfig<B> { + stateInfo?: StateInfo; baseResource: Observable<StateResource<B>>; - listLinkRel: LinkRelationName; + getLinkRel: LinkRelationName; +} + +export interface ListResourceServiceConfig<B> extends ServiceConfig<B> { listResourceListLinkRel: LinkRelationName; createLinkRel?: LinkRelationName; } @@ -24,9 +33,12 @@ export interface SaveResourceData<T> { export interface ListItemResource extends Resource {} export declare type LinkRelationName = string; -export interface ResourceServiceConfig<B> { - resource: Observable<StateResource<B>>; - getLinkRel: LinkRelationName; - delete?: { linkRel: LinkRelationName; order?: string }; - edit?: { linkRel: LinkRelationName; order?: string }; +export interface ResourceServiceConfig<B> extends ServiceConfig<B> { + delete?: ConfigAction; + edit?: ConfigAction; +} + +interface ConfigAction { + linkRel: LinkRelationName; + order?: string; } diff --git a/alfa-client/libs/tech-shared/src/lib/resource/resource.repository.ts b/alfa-client/libs/tech-shared/src/lib/resource/resource.repository.ts index 386819910fd1aa3b9fd58349e7a9034f72530982..2328613ee2ab9f1d74b2fcb1e42ba5a527b2f5c8 100644 --- a/alfa-client/libs/tech-shared/src/lib/resource/resource.repository.ts +++ b/alfa-client/libs/tech-shared/src/lib/resource/resource.repository.ts @@ -30,7 +30,7 @@ export class ResourceRepository { return this.resourceFactory.fromId(uri).get(); } - public save(saveResourceData: SaveResourceData<Resource>): Observable<Resource> { + public save<T>(saveResourceData: SaveResourceData<Resource>): Observable<T> { return this.resourceFactory .from(saveResourceData.resource) .put(saveResourceData.linkRel, saveResourceData.toSave); diff --git a/alfa-client/libs/tech-shared/src/lib/resource/resource.service.itcase.spec.ts b/alfa-client/libs/tech-shared/src/lib/resource/resource.service.itcase.spec.ts deleted file mode 100644 index cff64cc4e17979a6d14e59a8f017e1b7cd2c9076..0000000000000000000000000000000000000000 --- a/alfa-client/libs/tech-shared/src/lib/resource/resource.service.itcase.spec.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { Mock, mock, useFromMock } from '@alfa-client/test-utils'; -import { fakeAsync, tick } from '@angular/core/testing'; -import faker from '@faker-js/faker'; -import { Resource, getUrl } from '@ngxp/rest'; -import { BehaviorSubject, of } from 'rxjs'; -import { createDummyResource } from '../../../test/resource'; -import { LinkRelationName, ResourceServiceConfig } from './resource.model'; -import { ResourceRepository } from './resource.repository'; -import { DummyResourceService } from './resource.service.spec'; -import { StateResource, createEmptyStateResource, createStateResource } from './resource.util'; - -describe.skip('FIXME: mocking.ts issue due to module test | ResourceService ITCase', () => { - let service: DummyResourceService<Resource, Resource>; - let config: ResourceServiceConfig<Resource>; - let repository: Mock<ResourceRepository>; - - const getLinkRel: LinkRelationName = faker.random.word(); - const editLinkRel: LinkRelationName = faker.random.word(); - const deleteLinkRel: LinkRelationName = faker.random.word(); - - const configResource: Resource = createDummyResource([getLinkRel, editLinkRel]); - const configStateResource: StateResource<Resource> = createStateResource(configResource); - const configResourceSubj: BehaviorSubject<StateResource<Resource>> = new BehaviorSubject< - StateResource<Resource> - >(configStateResource); - - const loadedResource: Resource = createDummyResource([getLinkRel, editLinkRel]); - - const EXPECTED_EMITTED_TIMES_FOR_GET: number = 3; - - beforeEach(() => { - config = { - resource: configResourceSubj, - getLinkRel, - edit: { linkRel: editLinkRel }, - delete: { linkRel: deleteLinkRel }, - }; - repository = mock(ResourceRepository); - - service = new DummyResourceService<Resource, Resource>(config, useFromMock(repository)); - - repository.getResource.mockReturnValueOnce(of(loadedResource)); - service.stateResource.next(createEmptyStateResource()); - }); - - describe('get', () => { - it('should emit initially loading stateResource', (done) => { - let emittedTimes: number = 0; - service.get().subscribe((response: StateResource<Resource>) => { - emittedTimes++; - if (emittedTimes === 1) { - console.info('RESPONSE 1: ', response); - expect(response.loading).toBeTruthy(); - expect(response.resource).toBeNull(); - done(); - } - }); - }); - - it('should emit loading stateResource', (done) => { - let emittedTimes: number = 0; - service.get().subscribe((response: StateResource<Resource>) => { - emittedTimes++; - if (emittedTimes === 2) { - expect(response.loading).toBeTruthy(); - expect(response.resource).toBeNull(); - done(); - } - }); - }); - - it('should emit loaded stateResource', (done) => { - let emittedTimes: number = 0; - service.get().subscribe((response: StateResource<Resource>) => { - emittedTimes++; - if (emittedTimes === EXPECTED_EMITTED_TIMES_FOR_GET) { - expect(repository.getResource).toHaveBeenCalledWith(getUrl(configResource, getLinkRel)); - expect(response.resource).toBe(loadedResource); - expect(response.loading).toBeFalsy(); - done(); - } - }); - }); - - it('should emit 3 times', async () => { - let emittedTimes: number = 0; - - service.get().subscribe((response) => { - emittedTimes++; - console.info('RESPONSE ON GET: ', response); - }); - console.info('EMITTED TIMES: ', emittedTimes); - expect(emittedTimes).toBe(EXPECTED_EMITTED_TIMES_FOR_GET); - }); - }); - - describe('get - change configResource', () => { - const reloadedResource: Resource = createDummyResource(); - - const EXPECTED_EMITTED_TIMES: number = EXPECTED_EMITTED_TIMES_FOR_GET + 2; - - const newConfigResource: Resource = createDummyResource([deleteLinkRel, getLinkRel]); - const newConfigStateResource: StateResource<Resource> = createStateResource(newConfigResource); - - beforeEach(() => { - repository.getResource.mockReturnValueOnce(of(reloadedResource)); - }); - - it('should emit loading stateResource', (done) => { - let emittedTimes: number = 0; - service.get().subscribe((response: StateResource<Resource>) => { - emittedTimes++; - doAfterGetIsDone(emittedTimes, () => configResourceSubj.next(newConfigStateResource)); - if (emittedTimes === 4) { - expect(response.loading).toBeTruthy(); - expect(response.resource).toBeNull(); - done(); - } - }); - }); - - it.skip('should emit reloaded stateResource', (done) => { - let emittedTimes: number = 0; - service.get().subscribe((response: StateResource<Resource>) => { - emittedTimes++; - doAfterGetIsDone(emittedTimes, () => configResourceSubj.next(newConfigStateResource)); - if (emittedTimes === EXPECTED_EMITTED_TIMES) { - expect(repository.getResource).toHaveBeenCalledTimes(2); - expect(repository.getResource).toHaveBeenCalledWith(getUrl(configResource, getLinkRel)); - expect(repository.getResource).toHaveBeenCalledWith( - getUrl(newConfigResource, getLinkRel), - ); - expect(response.resource).toBe(reloadedResource); - expect(response.loading).toBeFalsy(); - done(); - } - }); - }); - - it.skip('should emit 5 times', fakeAsync(async () => { - let emittedTimes: number = 0; - service.get().subscribe((response) => { - emittedTimes++; - console.info('RESPONSE ON GET: ', response); - doAfterGetIsDone(emittedTimes, () => configResourceSubj.next(newConfigStateResource)); - }); - tick(); - - console.info('EMITTED TIMES: ', emittedTimes); - expect(emittedTimes).toBe(EXPECTED_EMITTED_TIMES); - })); - }); - - describe('get - change configResource to null', () => { - const EXPECTED_EMITTED_TIMES_FOR_GET: number = 3; - const EXPECTED_EMITTED_TIMES: number = EXPECTED_EMITTED_TIMES_FOR_GET + 1; - - const emptyConfigStateResource: StateResource<Resource> = createEmptyStateResource(); - - it('should emit empty stateResource', (done) => { - let emittedTimes: number = 0; - service.get().subscribe((response: StateResource<Resource>) => { - emittedTimes++; - doAfterGetIsDone(emittedTimes, () => configResourceSubj.next(emptyConfigStateResource)); - if (emittedTimes === EXPECTED_EMITTED_TIMES) { - expect(response.loading).toBeFalsy(); - expect(response.resource).toBeNull(); - done(); - } - }); - }); - - it('should emit 4 times', fakeAsync(async () => { - let emittedTimes: number = 0; - service.get().subscribe(() => { - emittedTimes++; - doAfterGetIsDone(emittedTimes, () => configResourceSubj.next(emptyConfigStateResource)); - }); - tick(); - - expect(emittedTimes).toBe(EXPECTED_EMITTED_TIMES); - })); - }); - - describe.skip('FIXME (Funktioniert nicht mit den anderen Tests zusammen) refresh', () => { - const reloadedResource: Resource = createDummyResource(); - - const EXPECTED_EMITTED_TIMES_FOR_GET: number = 3; - const EXPECTED_EMITTED_TIMES: number = EXPECTED_EMITTED_TIMES_FOR_GET + 2; - - beforeEach(() => { - repository.getResource.mockReturnValueOnce(of(reloadedResource)); - }); - - it('should return loading stateResource', (done) => { - let emittedTimes: number = 0; - service.get().subscribe((response: StateResource<Resource>) => { - emittedTimes++; - doAfterGetIsDone(emittedTimes, () => service.refresh()); - if (emittedTimes === 4) { - expect(repository.getResource).toHaveBeenCalledWith(getUrl(configResource, getLinkRel)); - expect(response.loading).toBeTruthy(); - done(); - } - }); - }); - - it('should return reloaded stateResource', (done) => { - let emittedTimes: number = 0; - service.get().subscribe((response: StateResource<Resource>) => { - emittedTimes++; - doAfterGetIsDone(emittedTimes, () => service.refresh()); - if (emittedTimes === EXPECTED_EMITTED_TIMES) { - expect(response.resource).toBe(reloadedResource); - expect(response.loading).toBeFalsy(); - done(); - } - }); - }); - - it('should emit 5 times', fakeAsync(async () => { - let emittedTimes: number = 0; - service.get().subscribe(() => { - emittedTimes++; - doAfterGetIsDone(emittedTimes, () => service.refresh()); - }); - tick(); - - expect(emittedTimes).toBe(EXPECTED_EMITTED_TIMES); - })); - }); - - function doAfterGetIsDone(emittedTimes: number, runnable: () => void): void { - if (emittedTimes === EXPECTED_EMITTED_TIMES_FOR_GET) { - runnable(); - } - } -}); diff --git a/alfa-client/libs/tech-shared/src/lib/resource/resource.service.spec.ts b/alfa-client/libs/tech-shared/src/lib/resource/resource.service.spec.ts index 1a15c29a582000a5be2378c1163f3a487cfd6d1c..64ba2376bfcc9f26dcc7bf606aa79dc2818f7e64 100644 --- a/alfa-client/libs/tech-shared/src/lib/resource/resource.service.spec.ts +++ b/alfa-client/libs/tech-shared/src/lib/resource/resource.service.spec.ts @@ -1,31 +1,18 @@ import { Mock, mock, useFromMock } from '@alfa-client/test-utils'; -import { HttpErrorResponse } from '@angular/common/http'; -import { fakeAsync, tick } from '@angular/core/testing'; -import { faker } from '@faker-js/faker'; -import { Resource, ResourceUri, getUrl } from '@ngxp/rest'; -import { cold } from 'jest-marbles'; -import { Observable, lastValueFrom, of, throwError } from 'rxjs'; -import { createProblemDetail } from '../../../test//error'; -import { singleCold, singleHot } from '../../../test/marbles'; +import faker from '@faker-js/faker'; +import { Resource } from '@ngxp/rest'; +import { CLOSED_FRAME, singleCold } from 'libs/tech-shared/test/marbles'; +import { Observable, of } from 'rxjs'; import { createDummyResource } from '../../../test/resource'; -import { HttpError, ProblemDetail } from '../tech.model'; +import { SingleResourceStateService, StateService } from '../ngrx/state.service'; import { LinkRelationName, ResourceServiceConfig } from './resource.model'; -import { ResourceRepository } from './resource.repository'; import { ResourceService } from './resource.service'; -import { - StateResource, - createEmptyStateResource, - createErrorStateResource, - createStateResource, -} from './resource.util'; - -import * as ResourceUtil from './resource.util'; +import { StateResource, createStateResource } from './resource.util'; describe('ResourceService', () => { let service: DummyResourceService<Resource, Resource>; let config: ResourceServiceConfig<Resource>; - - let repository: Mock<ResourceRepository>; + let stateService: Mock<StateService>; const configResource: Resource = createDummyResource(); const configStateResource: StateResource<Resource> = createStateResource(configResource); @@ -40,465 +27,117 @@ describe('ResourceService', () => { beforeEach(() => { config = { - resource: configStateResource$, + stateInfo: { name: 'dummyFeatureState', path: 'dummyPath' }, + baseResource: configStateResource$, getLinkRel, edit: { linkRel: editLinkRel }, delete: { linkRel: deleteLinkRel }, }; - repository = mock(ResourceRepository); + stateService = mock(StateService); + stateService.createSingleResourceService = jest + .fn() + .mockReturnValue(mock(SingleResourceStateService)); - service = new DummyResourceService(config, useFromMock(repository)); + service = new DummyResourceService(config, useFromMock(stateService)); }); it('should be created', () => { expect(service).toBeTruthy(); }); - describe('get', () => { - const stateResource: StateResource<Resource> = createStateResource(configResource); - let isInvalidResourceCombinationSpy: jest.SpyInstance; - - beforeEach(() => { - service.stateResource.next(stateResource); - - service.handleResourceChanges = jest.fn(); - isInvalidResourceCombinationSpy = jest - .spyOn(ResourceUtil, 'isInvalidResourceCombination') - .mockReturnValue(true); - }); - - it('should handle config resource changed', fakeAsync(() => { - service.get().subscribe(); - tick(); - - expect(service.handleResourceChanges).toHaveBeenCalledWith(stateResource, configResource); - })); - - it('should call isInvalidResourceCombinationSpy', fakeAsync(() => { - service.get().subscribe(); - tick(); - - expect(isInvalidResourceCombinationSpy).toHaveBeenCalled(); - })); - - it('should return initial value', () => { - service.stateResource.asObservable = jest - .fn() - .mockReturnValue(singleHot(stateResource, '-a')); - - const apiRootStateResource$: Observable<StateResource<Resource>> = service.get(); - - expect(apiRootStateResource$).toBeObservable( - cold('a', { a: createEmptyStateResource(true) }), - ); - }); + it('should create state service', () => { + expect(service.resourceStateService).toBeTruthy(); }); - describe('handle resource changes', () => { - const stateResource: StateResource<Resource> = createStateResource(createDummyResource()); - const changedConfigResource: Resource = createDummyResource(); - - describe('on different config resource', () => { - beforeEach(() => { - service.handleConfigResourceChanges = jest.fn(); - }); - - it('should update state resource by config resource', () => { - service.configResource = createDummyResource(); - - service.handleResourceChanges(stateResource, changedConfigResource); - - expect(service.handleConfigResourceChanges).toHaveBeenCalled(); - }); - }); - - describe('on same config resource', () => { - const stateResource: StateResource<Resource> = createStateResource(createDummyResource()); - - beforeEach(() => { - service.configResource = configResource; - }); - - it('should call shouldLoadResource', () => { - service.shouldLoadResource = jest.fn(); - - service.handleResourceChanges(stateResource, configResource); - - expect(service.shouldLoadResource).toHaveBeenCalledWith(stateResource, configResource); - }); - - it('should load resource', () => { - service.shouldLoadResource = jest.fn().mockReturnValue(true); - service.loadResource = jest.fn(); - - service.handleResourceChanges(stateResource, configResource); - - expect(service.loadResource).toHaveBeenCalledWith(configResource); - }); - - it('should NOT load resource', () => { - service.loadResource = jest.fn(); - service.shouldLoadResource = jest.fn().mockReturnValue(false); - - service.handleResourceChanges(stateResource, configResource); - - expect(service.loadResource).not.toHaveBeenCalled(); - }); - }); + it('should create resource loader', () => { + expect(service.resourceLoader).toBeTruthy(); }); - describe('handle config resource changes', () => { - const stateResource: StateResource<Resource> = createStateResource(createDummyResource()); - - it('should update configresource', () => { - service.configResource = createDummyResource(); - - service.handleResourceChanges(stateResource, configResource); - - expect(service.configResource).toBe(configResource); - }); - - describe('on stable stateresource', () => { - beforeEach(() => { - jest.spyOn(ResourceUtil, 'isStateResoureStable').mockReturnValue(true); - service.updateStateResourceByConfigResource = jest.fn(); - }); - - it('should update stateresource by configresource', () => { - service.handleResourceChanges(stateResource, configResource); - - expect(service.updateStateResourceByConfigResource).toHaveBeenCalledWith( - stateResource, - configResource, - ); - }); - }); - - describe('on instable stateresource', () => { - beforeEach(() => { - jest.spyOn(ResourceUtil, 'isStateResoureStable').mockReturnValue(false); - }); - - it('should NOT update stateresource by configresource', () => { - service.updateStateResourceByConfigResource = jest.fn(); - - service.handleResourceChanges(stateResource, configResource); - - expect(service.updateStateResourceByConfigResource).not.toHaveBeenCalled(); - }); - }); - }); - - describe('update stateresource by configresource', () => { - it('should check if should clear stateresource', () => { - service.shouldClearStateResource = jest.fn(); - - service.updateStateResourceByConfigResource(dummyStateResource, configResource); - - expect(service.shouldClearStateResource).toHaveBeenCalled(); - }); - - it('should clear resource if should', () => { - service.stateResource.next(createStateResource(createDummyResource())); - service.shouldClearStateResource = jest.fn().mockReturnValue(true); - - service.updateStateResourceByConfigResource(dummyStateResource, configResource); - - expect(service.stateResource.value).toEqual(createEmptyStateResource()); - }); - - describe('on NOT clearing stateresource', () => { - beforeEach(() => { - service.shouldClearStateResource = jest.fn().mockReturnValue(false); - }); - - it('should load resource if link exists', () => { - service.hasGetLink = jest.fn(); - - service.updateStateResourceByConfigResource(dummyStateResource, configResource); - - expect(service.hasGetLink).toHaveBeenCalledWith(configResource); - }); - }); - }); - - describe('should clear stateresource', () => { - describe('on existing stateresource', () => { - beforeEach(() => { - service.stateResource.next(dummyStateResource); - }); - - it('should return true if configresource is null', () => { - const shouldClear: boolean = service.shouldClearStateResource(dummyStateResource, null); - - expect(shouldClear).toBeTruthy(); - }); - - it('should return true if configresource has no get link', () => { - const shouldClear: boolean = service.shouldClearStateResource( - dummyStateResource, - createDummyResource(), - ); - - expect(shouldClear).toBeTruthy(); - }); - }); - - describe('on empty stateresource', () => { - it('should return false', () => { - const shouldClear: boolean = service.shouldClearStateResource( - createEmptyStateResource(), - null, - ); - - expect(shouldClear).toBeFalsy(); - }); - - it('should return false if configresource has no get link', () => { - const shouldClear: boolean = service.shouldClearStateResource( - createEmptyStateResource(), - createDummyResource(), - ); - - expect(shouldClear).toBeFalsy(); - }); - }); - }); - - describe('should load resource', () => { - const resource: Resource = createDummyResource(); - const stateResource: StateResource<Resource> = createStateResource(resource); - - let isLoadingRequiredSpy: jest.SpyInstance<boolean>; - + describe('get', () => { beforeEach(() => { - isLoadingRequiredSpy = jest.spyOn(ResourceUtil, 'isLoadingRequired'); - }); - - it('should return true on existing configresource and loading is required', () => { - isLoadingRequiredSpy.mockReturnValue(true); - - const shouldLoad: boolean = service.shouldLoadResource(stateResource, configResource); - - expect(shouldLoad).toBeTruthy(); - }); - - it('should call isLoadingRequired', () => { - service.shouldLoadResource(stateResource, configResource); - - expect(isLoadingRequiredSpy).toBeCalledWith(stateResource); + service.resourceLoader.get = jest.fn().mockReturnValue(of(dummyStateResource)); }); - it('should return false if configresource exists but loading is NOT required', () => { - isLoadingRequiredSpy.mockReturnValue(false); - - const shouldLoad: boolean = service.shouldLoadResource(stateResource, configResource); + it('should call resource loader', () => { + service.get(); - expect(shouldLoad).toBeFalsy(); + expect(service.resourceLoader.get).toHaveBeenCalled(); }); - }); - - describe('load resource', () => { - const configResourceWithGetLinkRel: Resource = createDummyResource([getLinkRel]); - it('should call do load resource', () => { - service.doLoadResource = jest.fn(); + it('should return from loader returned value', () => { + const stateResource$: Observable<StateResource<Resource>> = service.get(); - service.loadResource(configResourceWithGetLinkRel); - - expect(service.doLoadResource).toHaveBeenCalledWith( - getUrl(configResourceWithGetLinkRel, config.getLinkRel), - ); + expect(stateResource$).toBeObservable(singleCold(dummyStateResource, CLOSED_FRAME)); }); }); - describe('set state resource loading', () => { - it('should set loading true', () => { - service.stateResource.next(createStateResource(createDummyResource())); - - service.setStateResourceLoading(); - - expect(service.stateResource.value.loading).toBeTruthy(); - }); - - it('should set reload false', () => { - service.stateResource.next({ ...createStateResource(createDummyResource()), reload: true }); + describe('reload resource', () => { + it('should call resource state service to reload resource', () => { + service.resourceStateService.reloadResource = jest.fn(); - service.setStateResourceLoading(); + service.reloadResource(); - expect(service.stateResource.value.reload).toBeFalsy(); + expect(service.resourceStateService.reloadResource).toHaveBeenCalled(); }); }); - describe('do load resource', () => { - let resourceUri: ResourceUri; - let loadedResource: Resource; - + describe('exist resource', () => { beforeEach(() => { - service.setStateResourceLoading = jest.fn(); - resourceUri = faker.internet.url(); - loadedResource = createDummyResource(); - repository.getResource.mockReturnValue(of(loadedResource)); - }); - - it('should set state resource to loading', () => { - service.doLoadResource(resourceUri); - - expect(service.setStateResourceLoading).toHaveBeenCalled(); - }); - - it('should call repository', () => { - service.doLoadResource(resourceUri); - - expect(repository.getResource).toHaveBeenCalledWith(resourceUri); - }); - - it('should update stateresource', () => { - service.updateStateResource = jest.fn(); - - service.doLoadResource(resourceUri); - - expect(service.updateStateResource).toHaveBeenCalledWith(loadedResource); - }); - }); - - describe('loadByResourceUri', () => { - it('should do load resource', () => { - service.doLoadResource = jest.fn(); - const resourceUri: ResourceUri = faker.internet.url(); - - service.loadByResourceUri(resourceUri); - - expect(service.doLoadResource).toHaveBeenCalledWith(resourceUri); + service.resourceStateService.existsResource = jest.fn().mockReturnValue(of(true)); }); - }); - - describe('update stateresource', () => { - const resourceToBeSet: Resource = createDummyResource(); - - it('should set resource as stateresource', () => { - service.stateResource.next(createEmptyStateResource()); - - service.updateStateResource(resourceToBeSet); - - expect(service.stateResource.value).toEqual(createStateResource(resourceToBeSet)); - }); - }); - - describe('save', () => { - const dummyToSave: unknown = {}; - const loadedResource: Resource = createDummyResource(); - - const resourceWithEditLinkRel: Resource = createDummyResource([editLinkRel]); - it('should do save', fakeAsync(() => { - const stateResource: StateResource<Resource> = createStateResource(resourceWithEditLinkRel); - service.stateResource.next(stateResource); - const doSaveMock: jest.Mock = (service.doSave = jest.fn()).mockReturnValue( - of(loadedResource), - ); + it('should call resource state service', () => { + service.existResource(); - service.save(dummyToSave).subscribe(); - tick(); - - expect(doSaveMock).toHaveBeenCalledWith(resourceWithEditLinkRel, dummyToSave); - })); - - it('should return saved object', () => { - service.stateResource.next(createStateResource(resourceWithEditLinkRel)); - service.doSave = jest.fn().mockReturnValue(singleHot(loadedResource)); - - const saved: Observable<StateResource<Resource | HttpError>> = service.save(dummyToSave); - - expect(saved).toBeObservable(singleCold(createStateResource(loadedResource))); + expect(service.resourceStateService.existsResource).toHaveBeenCalled(); }); - it('should call handleError', () => { - service.stateResource.next(createStateResource(createDummyResource([config.edit.linkRel]))); - const errorResponse: ProblemDetail = createProblemDetail(); - service.doSave = jest.fn().mockReturnValue(throwError(() => errorResponse)); - service.handleError = jest.fn(); - - service.save(<any>{}).subscribe(); + it('should return from service returned value', () => { + const existsValueFromService$: Observable<boolean> = service.existResource(); - expect(service.handleError).toHaveBeenCalledWith(errorResponse); + expect(existsValueFromService$).toBeObservable(singleCold(true, CLOSED_FRAME)); }); - - it('should update state resource subject', fakeAsync(() => { - service.stateResource.next(createStateResource(resourceWithEditLinkRel)); - service.doSave = jest.fn().mockReturnValue(of(loadedResource)); - - service.save(dummyToSave).subscribe(); - tick(); - - expect(service.stateResource.value).toEqual(createStateResource(loadedResource)); - })); }); - describe('handleError', () => { - it('should return error stateresource on problem unprocessable entity', (done: jest.DoneCallback) => { - const error: ProblemDetail = createProblemDetail(); - - service - .handleError(<HttpErrorResponse>(<any>error)) - .subscribe((responseError: StateResource<HttpError>) => { - expect(responseError).toEqual(createErrorStateResource(error)); - done(); - }); + describe('select resource', () => { + beforeEach(() => { + service.resourceStateService.selectResource = jest + .fn() + .mockReturnValue(of(dummyStateResource)); }); - it('should rethrow error', () => { - const error: HttpErrorResponse = <HttpErrorResponse>{ - status: 500, - statusText: 'Internal Server Error', - }; + it('should call resource state service', () => { + service.selectResource(); - const thrownError$: Observable<StateResource<HttpError>> = service.handleError(error); - - expect.assertions(1); - expect(lastValueFrom(thrownError$)).rejects.toThrowError('Internal Server Error'); + expect(service.resourceStateService.selectResource).toHaveBeenCalled(); }); - }); - - describe('refresh', () => { - beforeEach(() => { - service.loadResource = jest.fn(); - }); - - it('should set reload true on statresource', () => { - service.stateResource.next(createStateResource(createDummyResource())); - service.refresh(); + it('should return from service returned value', () => { + const resource$: Observable<StateResource<Resource>> = service.selectResource(); - expect(service.stateResource.value.reload).toBeTruthy(); + expect(resource$).toBeObservable(singleCold(dummyStateResource, CLOSED_FRAME)); }); }); - describe('exist resource', () => { - it('should return true on existing resource', () => { - service.stateResource.next(createStateResource(createDummyResource())); - - const existResource$: Observable<boolean> = service.existResource(); - - expect(existResource$).toBeObservable(singleCold(true)); - }); + describe('load by resource uri', () => { + const dummyUri: string = faker.random.word(); - it('should return false on null resource', () => { - service.stateResource.next(createEmptyStateResource()); + it('should call resource state service to clear resource', () => { + service.resourceStateService.loadResource = jest.fn(); - const existResource$: Observable<boolean> = service.existResource(); + service.loadByResourceUri(dummyUri); - expect(existResource$).toBeObservable(singleCold(false)); + expect(service.resourceStateService.loadResource).toHaveBeenCalledWith(dummyUri); }); }); - describe('select resource', () => { - it('should return state resource', () => { - service.stateResource.next(dummyStateResource); + describe('clear resource', () => { + it('should call resource state service to clear resource', () => { + service.resourceStateService.clearResource = jest.fn(); - const resource$: Observable<StateResource<Resource>> = service.selectResource(); + service.clearResource(); - expect(resource$).toBeObservable(singleCold(dummyStateResource)); + expect(service.resourceStateService.clearResource).toHaveBeenCalled(); }); }); }); @@ -509,12 +148,8 @@ export class DummyResourceService<B extends Resource, T extends Resource> extend > { constructor( protected config: ResourceServiceConfig<B>, - protected repository: ResourceRepository, + protected stateService: StateService, ) { - super(config, repository); - } - - doSave(resource: T, toSave: unknown): Observable<T> { - return of(resource); + super(config, stateService); } } diff --git a/alfa-client/libs/tech-shared/src/lib/resource/resource.service.ts b/alfa-client/libs/tech-shared/src/lib/resource/resource.service.ts index a69b290c8037e6c4797ae4afc19a695568f9d1c9..8e57b59cc664167c27cc07b0d5f10600c904ad16 100644 --- a/alfa-client/libs/tech-shared/src/lib/resource/resource.service.ts +++ b/alfa-client/libs/tech-shared/src/lib/resource/resource.service.ts @@ -1,175 +1,57 @@ -import { HttpErrorResponse } from '@angular/common/http'; -import { getUrl, hasLink, Resource, ResourceUri } from '@ngxp/rest'; -import { isEqual, isNull } from 'lodash-es'; -import { - BehaviorSubject, - catchError, - combineLatest, - filter, - first, - map, - Observable, - of, - startWith, - tap, - throwError, -} from 'rxjs'; -import { isUnprocessableEntity } from '../http.util'; -import { HttpError } from '../tech.model'; -import { isNotNull } from '../tech.util'; +import { Resource, ResourceUri } from '@ngxp/rest'; +import { Observable } from 'rxjs'; +import { SingleResourceStateService, StateService } from '../ngrx/state.service'; +import { ResourceLoader } from './resource.loader'; import { ResourceServiceConfig } from './resource.model'; -import { ResourceRepository } from './resource.repository'; -import { mapToFirst, mapToResource } from './resource.rxjs.operator'; -import { - createEmptyStateResource, - createErrorStateResource, - createStateResource, - isInvalidResourceCombination, - isLoadingRequired, - isStateResoureStable, - StateResource, -} from './resource.util'; +import { StateResource } from './resource.util'; /** * B = Type of baseresource * T = Type of the resource which is working on */ export abstract class ResourceService<B extends Resource, T extends Resource> { - readonly stateResource: BehaviorSubject<StateResource<T>> = new BehaviorSubject( - createEmptyStateResource(), - ); - - configResource: B = null; + resourceStateService: SingleResourceStateService<T>; + resourceLoader: ResourceLoader<B, T>; constructor( protected config: ResourceServiceConfig<B>, - protected repository: ResourceRepository, - ) {} - - public get(): Observable<StateResource<T>> { - return combineLatest([this.stateResource.asObservable(), this.getConfigResource()]).pipe( - tap(([stateResource, configResource]) => - this.handleResourceChanges(stateResource, configResource), - ), - filter( - ([stateResource]) => !isInvalidResourceCombination(stateResource, this.configResource), - ), - mapToFirst<T, B>(), - startWith(createEmptyStateResource<T>(true)), - ); - } - - private getConfigResource(): Observable<B> { - return this.config.resource.pipe( - filter( - (configStateResource: StateResource<B>) => - !configStateResource.loading && !configStateResource.reload, - ), - mapToResource<B>(), - ); - } - - handleResourceChanges(stateResource: StateResource<T>, configResource: B): void { - if (!isEqual(this.configResource, configResource)) { - this.handleConfigResourceChanges(stateResource, configResource); - } else if (this.shouldLoadResource(stateResource, configResource)) { - this.loadResource(configResource); - } + protected stateService: StateService, + ) { + this.initStateService(); + this.initResourceLoader(); } - handleConfigResourceChanges(stateResource: StateResource<T>, configResource: B) { - this.configResource = configResource; - if (isStateResoureStable(stateResource)) { - this.updateStateResourceByConfigResource(stateResource, configResource); - } - } - - updateStateResourceByConfigResource(stateResource: StateResource<T>, configResource: B): void { - if (this.shouldClearStateResource(stateResource, configResource)) { - this.clearResource(); - } else if (this.hasGetLink(configResource)) { - this.loadResource(configResource); - } - } - - shouldClearStateResource(stateResource: StateResource<T>, configResource: B): boolean { - return ( - (isNull(configResource) || this.hasNotGetLink(configResource)) && - !this.isStateResourceEmpty(stateResource) + private initStateService(): void { + this.resourceStateService = this.stateService.createSingleResourceService( + this.config.stateInfo, ); } - private hasNotGetLink(configResource: B): boolean { - return !this.hasGetLink(configResource); + private initResourceLoader(): void { + this.resourceLoader = new ResourceLoader<B, T>(this.config, this.resourceStateService); } - private isStateResourceEmpty(stateResource: StateResource<T>): boolean { - return isEqual(stateResource, createEmptyStateResource()); - } - - private clearResource(): void { - this.stateResource.next(createEmptyStateResource()); + public get(): Observable<StateResource<T>> { + return this.resourceLoader.get(); } - hasGetLink(configResource: B): boolean { - return isNotNull(configResource) && hasLink(configResource, this.config.getLinkRel); + public reloadResource(): void { + this.resourceStateService.reloadResource(); } - shouldLoadResource(stateResource: StateResource<T>, configResource: B): boolean { - return isNotNull(configResource) && isLoadingRequired(stateResource); + public existResource(): Observable<boolean> { + return this.resourceStateService.existsResource(); } - loadResource(configResource: B): void { - this.doLoadResource(getUrl(configResource, this.config.getLinkRel)); + public selectResource(): Observable<StateResource<T>> { + return this.resourceStateService.selectResource(); } public loadByResourceUri(resourceUri: ResourceUri): void { - this.doLoadResource(resourceUri); + this.resourceStateService.loadResource(resourceUri); } - doLoadResource(resourceUri: ResourceUri): void { - this.setStateResourceLoading(); - this.repository - .getResource(resourceUri) - .pipe(first()) - .subscribe((loadedResource: T) => this.updateStateResource(loadedResource)); - } - - setStateResourceLoading(): void { - this.stateResource.next({ ...createEmptyStateResource(true), reload: false }); - } - - updateStateResource(resource: T): void { - this.stateResource.next(createStateResource(resource)); - } - - public save(toSave: unknown): Observable<StateResource<T | HttpError>> { - const previousResource: T = this.stateResource.value.resource; - return this.doSave(previousResource, toSave).pipe( - tap((loadedResource: T) => this.stateResource.next(createStateResource(loadedResource))), - map(() => this.stateResource.value), - catchError((errorResponse: HttpErrorResponse) => this.handleError(errorResponse)), - ); - } - - handleError(errorResponse: HttpErrorResponse): Observable<StateResource<HttpError>> { - if (isUnprocessableEntity(errorResponse.status)) { - return of(createErrorStateResource((<any>errorResponse) as HttpError)); - } - return throwError(() => errorResponse); - } - - abstract doSave(resource: T, toSave: unknown): Observable<T>; - - public refresh(): void { - this.stateResource.next({ ...this.stateResource.value, reload: true }); - } - - public existResource(): Observable<boolean> { - return this.stateResource.asObservable().pipe(mapToResource<T>(), map(isNotNull)); - } - - public selectResource(): Observable<StateResource<T>> { - return this.stateResource.asObservable(); + public clearResource(): void { + this.resourceStateService.clearResource(); } } diff --git a/alfa-client/libs/tech-shared/src/lib/tech-shared.module.ts b/alfa-client/libs/tech-shared/src/lib/tech-shared.module.ts index ecb138d23695a7111038967cf2d80147cbac5206..7e4cdca79c9f17bf7f36e1e769d5de9ac15eb6fa 100644 --- a/alfa-client/libs/tech-shared/src/lib/tech-shared.module.ts +++ b/alfa-client/libs/tech-shared/src/lib/tech-shared.module.ts @@ -27,6 +27,8 @@ import { Injector, NgModule } from '@angular/core'; import { HttpBinaryFileInterceptor } from './interceptor/http-binary-file.interceptor'; import { HttpXsrfInterceptor } from './interceptor/http-xsrf.interceptor'; import { XhrInterceptor } from './interceptor/xhr.interceptor'; +import { EffectService } from './ngrx/effects.service'; +import { StateService } from './ngrx/state.service'; import { ConvertApiErrorToErrorMessagesPipe } from './pipe/convert-api-error-to-error-messages.pipe'; import { ConvertForDataTestPipe } from './pipe/convert-for-data-test.pipe'; import { ConvertToBooleanPipe } from './pipe/convert-to-boolean.pipe'; @@ -89,6 +91,8 @@ import { ToTrafficLightPipe } from './pipe/to-traffic-light.pipe'; ConvertApiErrorToErrorMessagesPipe, ], providers: [ + StateService, + EffectService, { provide: HTTP_INTERCEPTORS, useClass: XhrInterceptor, diff --git a/alfa-client/libs/tech-shared/test/marbles.ts b/alfa-client/libs/tech-shared/test/marbles.ts index 199c8726cb43b889816929635014861fc19d19df..2b785612e602a3133e05ca4e02b1bad63e97b5cb 100644 --- a/alfa-client/libs/tech-shared/test/marbles.ts +++ b/alfa-client/libs/tech-shared/test/marbles.ts @@ -1,5 +1,8 @@ import { ObservableWithSubscriptions, cold, hot } from 'jest-marbles'; +export const CLOSED_FRAME: string = '(a|)'; +export const SECOND_FRAME: string = '-a'; + export function singleHot(object: any, frame: string = 'a'): ObservableWithSubscriptions { return hot(frame, { a: object }); } @@ -11,3 +14,7 @@ export function singleCold(object: any, frame: string = 'a'): ObservableWithSubs export function singleColdCompleted(object: any, frame: string = 'a'): ObservableWithSubscriptions { return cold(`(${frame}|)`, { a: object }); } + +export function multiCold(object: any, frame: string = 'ab'): ObservableWithSubscriptions { + return cold(frame, object); +} diff --git a/alfa-client/libs/tech-shared/test/ngrx.ts b/alfa-client/libs/tech-shared/test/ngrx.ts index 526b345e5b8d10646ef4d1f62fd72d01837a3b90..b7b17b0a3409ccb9ff5098fab83d74ac125820fe 100644 --- a/alfa-client/libs/tech-shared/test/ngrx.ts +++ b/alfa-client/libs/tech-shared/test/ngrx.ts @@ -1,3 +1,64 @@ +import { createAction, createFeatureSelector, createSelector, props } from '@ngrx/store'; import { TypedAction } from '@ngrx/store/src/models'; +import { + LoadResourceFailureProps, + LoadResourceSuccessProps, + ResourceUriProps, + SingleResourceLoadActions, + TypedActionCreator, + TypedActionCreatorWithProps, +} from '../src/lib/ngrx/actions'; +import { SingleResourceState } from '../src/lib/ngrx/state.model'; +import { StateInfo } from '../src/lib/resource/resource.model'; export const DUMMY_ACTION: TypedAction<string> = { type: 'Dummy Action' }; + +export const FUNCTIONAL_FEATURE_KEY = 'FunctionalState'; +export const FUNCTIONAL_FEATURE_PATH_KEY = 'FunctionalResourcePath'; + +export interface SingleResourceParentState { + readonly [FUNCTIONAL_FEATURE_KEY]: SingleResourcePartialState; +} +export interface SingleResourcePartialState { + readonly [FUNCTIONAL_FEATURE_PATH_KEY]: SingleResourceState; +} + +export class StateInfoTestFactory { + public static readonly STATE_INFO_NAME: string = FUNCTIONAL_FEATURE_KEY; + public static readonly STATE_INFO_PATH: string = FUNCTIONAL_FEATURE_PATH_KEY; + + public static create(): StateInfo { + return { + name: this.STATE_INFO_NAME, + path: this.STATE_INFO_PATH, + }; + } +} + +export const dummySelector = createSelector( + createFeatureSelector(FUNCTIONAL_FEATURE_KEY), + (state: unknown) => state, +); + +export const dummyLoadAction: TypedActionCreatorWithProps<ResourceUriProps> = createAction( + 'DummyLoadAction', + props<ResourceUriProps>(), +); + +export const dummyLoadSuccessAction: TypedActionCreatorWithProps<LoadResourceSuccessProps> = + createAction('DummyLoadSuccessAction', props<LoadResourceSuccessProps>()); + +export const dummyLoadFailureAction: TypedActionCreatorWithProps<LoadResourceFailureProps> = + createAction('DummyLoadFailureAction', props<LoadResourceFailureProps>()); + +export const dummyClearAction: TypedActionCreator = createAction('DummyClearAction'); + +export const dummyReloadAction: TypedActionCreator = createAction('DummyReloadAction'); + +export const singleResourceActions: SingleResourceLoadActions = { + loadAction: dummyLoadAction, + loadSuccessAction: dummyLoadSuccessAction, + loadFailureAction: dummyLoadFailureAction, + clearAction: dummyClearAction, + reloadAction: dummyReloadAction, +};