diff --git a/src/frontend/app/core/entity-service.ts b/src/frontend/app/core/entity-service.ts index d35bbc838a..2d4ff761c4 100644 --- a/src/frontend/app/core/entity-service.ts +++ b/src/frontend/app/core/entity-service.ts @@ -70,11 +70,6 @@ export class EntityService { if (!validateRelations || validated || isEntityBlocked(entityInfo.entityRequestInfo)) { return; } - // If we're not an 'official' object, go forth and fetch again. This will populate all the required '__guid' fields. - if (!entityInfo.entity.metadata) { - this.actionDispatch(); - return; - } validated = true; store.dispatch(new ValidateEntitiesStart( action as ICFAction, diff --git a/src/frontend/app/store/effects/request.effects.ts b/src/frontend/app/store/effects/request.effects.ts index 5d10251b61..9d0f3ff55d 100644 --- a/src/frontend/app/store/effects/request.effects.ts +++ b/src/frontend/app/store/effects/request.effects.ts @@ -1,9 +1,8 @@ - -import { catchError, first, map, mergeMap, withLatestFrom } from 'rxjs/operators'; import { Injectable } from '@angular/core'; import { RequestMethod } from '@angular/http'; import { Actions, Effect } from '@ngrx/effects'; import { Store } from '@ngrx/store'; +import { catchError, first, map, mergeMap, withLatestFrom } from 'rxjs/operators'; import { LoggerService } from '../../core/logger.service'; import { UtilsService } from '../../core/utils.service'; @@ -16,6 +15,7 @@ import { } from '../actions/request.actions'; import { AppState } from '../app-state'; import { validateEntityRelations } from '../helpers/entity-relations'; +import { ValidationResult } from '../helpers/entity-relations.types'; import { getRequestTypeFromMethod } from '../reducers/api-request-reducer/request-helpers'; import { rootUpdatingKey } from '../reducers/api-request-reducer/types'; import { getAPIRequestDataState } from '../selectors/api.selectors'; @@ -27,6 +27,7 @@ import { WrapperRequestActionSuccess, } from '../types/request.types'; + @Injectable() export class RequestEffect { constructor( @@ -80,16 +81,19 @@ export class RequestEffect { return this.store.select(getAPIRequestDataState).pipe( withLatestFrom(this.store.select(getPaginationState)), first(), - map(([allEntities, allPagination]) => { + map(([allEntities, allPagination]): ValidationResult => { + // The apiResponse will be null if we're validating as part of the entity service, not during an api request + const entities = apiResponse ? apiResponse.response.entities : null; return apiAction.skipValidation ? { started: false, - completed: Promise.resolve([]) + completed: Promise.resolve([]), + apiResponse } : validateEntityRelations({ cfGuid: validateAction.action.endpointGuid, store: this.store, allEntities, allPagination, - newEntities: apiResponse ? apiResponse.response.entities : null, + apiResponse, action: validateAction.action, parentEntities: validateAction.validateEntities, populateMissing: true, @@ -108,7 +112,7 @@ export class RequestEffect { mergeMap(({ independentUpdates, validation }) => { return [new EntitiesPipelineCompleted( apiAction, - apiResponse, + validation.apiResponse, validateAction, validation, independentUpdates @@ -162,7 +166,7 @@ export class RequestEffect { if ( !apiAction.updatingKey && - ( apiAction.options.method === 'post' || apiAction.options.method === RequestMethod.Post || + (apiAction.options.method === 'post' || apiAction.options.method === RequestMethod.Post || apiAction.options.method === 'delete' || apiAction.options.method === RequestMethod.Delete) ) { if (apiAction.removeEntityOnDelete) { diff --git a/src/frontend/app/store/helpers/entity-relations.ts b/src/frontend/app/store/helpers/entity-relations.ts index 93ee7356f5..adfa0622bc 100644 --- a/src/frontend/app/store/helpers/entity-relations.ts +++ b/src/frontend/app/store/helpers/entity-relations.ts @@ -1,20 +1,20 @@ - -import { of as observableOf, Observable } from 'rxjs'; import { Action, Store } from '@ngrx/store'; import { denormalize } from 'normalizr'; -import { filter, first, map, mergeMap, pairwise, skipWhile, withLatestFrom } from 'rxjs/operators'; +import { Observable, of as observableOf } from 'rxjs'; +import { filter, first, map, mergeMap, pairwise, skipWhile, switchMap, withLatestFrom } from 'rxjs/operators'; import { isEntityBlocked } from '../../core/entity-service'; -import { pathGet } from '../../core/utils.service'; +import { pathGet, pathSet } from '../../core/utils.service'; import { SetInitialParams } from '../actions/pagination.actions'; -import { FetchRelationPaginatedAction, FetchRelationSingleAction } from '../actions/relation.actions'; +import { FetchRelationAction, FetchRelationPaginatedAction, FetchRelationSingleAction } from '../actions/relation.actions'; +import { APIResponse } from '../actions/request.actions'; import { AppState } from '../app-state'; import { ActionState, RequestInfoState } from '../reducers/api-request-reducer/types'; import { getAPIRequestDataState, selectEntity, selectRequestInfo } from '../selectors/api.selectors'; import { selectPaginationState } from '../selectors/pagination.selectors'; import { APIResource, NormalizedResponse } from '../types/api.types'; import { PaginatedAction, PaginationEntityState } from '../types/pagination.types'; -import { IRequestAction, UpdateCfAction, WrapperRequestActionSuccess } from '../types/request.types'; +import { IRequestAction, RequestEntityLocation, WrapperRequestActionSuccess } from '../types/request.types'; import { EntitySchema } from './entity-factory'; import { fetchEntityTree } from './entity-relations.tree'; import { @@ -27,6 +27,7 @@ import { } from './entity-relations.types'; import { pick } from './reducer.helper'; + class AppStoreLayout { [entityKey: string]: { [guid: string]: any; @@ -43,8 +44,9 @@ interface ValidateResultFetchingState { * @interface ValidateEntityResult */ interface ValidateEntityResult { - action: Action; + action: FetchRelationAction; fetchingState$?: Observable; + abortDispatch?: boolean; } class ValidateEntityRelationsConfig { @@ -78,7 +80,7 @@ class ValidateEntityRelationsConfig { * @type {AppStoreLayout} * @memberof ValidateEntityRelationsConfig */ - newEntities: AppStoreLayout; + newEntities?: AppStoreLayout; /** * The action that has fetched the entity/entities * @@ -99,6 +101,12 @@ class ValidateEntityRelationsConfig { * @memberof ValidateEntityRelationsConfig */ populateMissing = true; + /** + * If we're validating an api request we'll have the apiResponse, otherwise it's null and we're ad hoc validating an entity/list + * + * @memberof ValidateEntityRelationsConfig + */ + apiResponse: APIResponse; } class ValidateLoopConfig extends ValidateEntityRelationsConfig { @@ -190,7 +198,7 @@ function createEntityWatcher(store, paramAction, guid: string): Observable; @@ -208,17 +216,13 @@ function createActionsForExistingEntities(config: HandleRelationsConfig): Valida result: guids }; - const action = new WrapperRequestActionSuccess( + return new WrapperRequestActionSuccess( response, paramAction, 'fetch', childEntitiesAsArray.length, 1 ); - return { - action, - fetchingState$: childRelation.isArray ? createEntityWatcher(store, paramAction, guids[0]) : null - }; } /** @@ -281,19 +285,12 @@ function handleRelation(config: HandleRelationsConfig): ValidateEntityResult[] { let results = []; if (childEntities) { if (!childRelation.isArray) { - // We've already got the missing entity in the store, we just need to associate it with it's parent. We can do this via two actions - // 1) a pretend 'request finished' event which handles the request data side of things - const connectEntityWithParent = createActionsForExistingEntities(config); - // 2) a pretend update which changes the entity's request info state in order for the entity service to emit the updated entity. - // This won't be fired often (missing single entities are rare, as opposed to missing lists of entities + also should only occur once - // per entity) - const notifyParentListenersOfChange: ValidateEntityResult = { - action: new UpdateCfAction({ - ...config.action, - updatingKey: `AssociatedChildAt:${new Date()}` - }, false, '') + // We've already got the missing entity in the store or current response, we just need to associate it with it's parent + const connectEntityWithParent: ValidateEntityResult = { + action: createSingleAction(config), + abortDispatch: true // Don't need to make the request.. it's either in the store or as part of apiResource with be }; - results = [].concat(results, connectEntityWithParent, notifyParentListenersOfChange); + results = [].concat(results, connectEntityWithParent); } } else { if (populateMissing) { @@ -380,27 +377,91 @@ function validationLoop(config: ValidateLoopConfig): ValidateEntityResult[] { return results; } -function handleValidationLoopResults(store, results) { +function associateChildWithParent(store, action: FetchRelationAction, apiResponse: APIResponse): Observable { + let childValue; + // Fetch the child value to associate with parent. Will either be a guid or a list of guids + if (action.child.isArray) { + const paginationAction = action as FetchRelationPaginatedAction; + childValue = store.select(selectPaginationState(action.entityKey, paginationAction.paginationKey)).pipe( + first(), + map((paginationSate: PaginationEntityState) => paginationSate.ids[1] || []) + ); + } else { + const entityAction = action as FetchRelationSingleAction; + childValue = observableOf(entityAction.guid); + } + + return childValue.pipe( + map(value => { + if (!value) { + return true; + } + + if (apiResponse) { + // Part of an api call. Assign to apiResponse which is added to store later + apiResponse.response.entities[action.parentEntitySchema.key][action.parentGuid].entity[action.child.paramName] = value; + } else { + // Not part of an api call. We already have the entity in the store, so fire off event to link child with parent + const response = { + entities: { + [action.parentEntitySchema.key]: { + [action.parentGuid]: { + entity: { + [action.child.paramName]: value + } + } + } + }, + result: [action.parentGuid] + }; + const parentAction: IRequestAction = { + endpointGuid: action.endpointGuid, + entity: action.parentEntitySchema, + entityLocation: RequestEntityLocation.OBJECT, + guid: action.parentGuid, + entityKey: action.parentEntitySchema.key, + type: '[Entity] Associate with parent', + }; + // Add for easier debugging + parentAction['childEntityKey'] = action.child.entityKey; + + const successAction = new WrapperRequestActionSuccess(response, parentAction, 'fetch', 1, 1); + store.dispatch(successAction); + } + return true; + }) + ); +} + +function handleValidationLoopResults(store: Store, results: ValidateEntityResult[], apiResponse: APIResponse): ValidationResult { const paginationFinished = new Array>(); - results.forEach(newActions => { - store.dispatch(newActions.action); - if (newActions.fetchingState$) { - const obs = newActions.fetchingState$.pipe( - pairwise(), - map(([oldFetching, newFetching]) => { - return oldFetching.fetching === true && newFetching.fetching === false; - }), - skipWhile(completed => !completed), - first(), - ).toPromise(); - paginationFinished.push(obs); + results.forEach(request => { + // Fetch any missing data + if (!request.abortDispatch) { + store.dispatch(request.action); } + // Wait for the action to be completed + const obs = request.fetchingState$ ? request.fetchingState$.pipe( + pairwise(), + map(([oldFetching, newFetching]) => { + return oldFetching.fetching === true && newFetching.fetching === false; + }), + skipWhile(completed => !completed), + first()) : observableOf(true); + // Associate the missing parameter with it's parent + const associatedObs = obs.pipe( + switchMap(() => { + const action: FetchRelationAction = FetchRelationAction.is(request.action); + return action ? associateChildWithParent(store, action, apiResponse) : observableOf(true); + }), + ).toPromise(); + paginationFinished.push(associatedObs); }); - return { started: !!(paginationFinished.length), - completed: Promise.all(paginationFinished) + completed: Promise.all(paginationFinished), + apiResponse }; } @@ -416,6 +477,7 @@ function handleValidationLoopResults(store, results) { * @returns {ValidationResult} */ export function validateEntityRelations(config: ValidateEntityRelationsConfig): ValidationResult { + config.newEntities = config.apiResponse ? config.apiResponse.response.entities : null; const { action, populateMissing, newEntities, allEntities, store } = config; let { parentEntities } = config; @@ -441,7 +503,7 @@ export function validateEntityRelations(config: ValidateEntityRelationsConfig): parentRelation: entityTree.rootRelation, }); - return handleValidationLoopResults(store, results); + return handleValidationLoopResults(store, results, config.apiResponse); } export function listEntityRelations(action: EntityInlineParentAction) { @@ -513,6 +575,7 @@ export function populatePaginationFromParent(store: Store, action: Pag allEntities, allPagination: {}, newEntities: {}, + apiResponse: null, parentEntities: null, entities: entity.entity[paramName], childEntities: entity.entity[paramName], diff --git a/src/frontend/app/store/helpers/entity-relations.types.ts b/src/frontend/app/store/helpers/entity-relations.types.ts index 203e750eb8..8a730bdfa3 100644 --- a/src/frontend/app/store/helpers/entity-relations.types.ts +++ b/src/frontend/app/store/helpers/entity-relations.types.ts @@ -1,6 +1,7 @@ import { Action } from '@ngrx/store'; import { getPaginationKey } from '../actions/pagination.actions'; +import { APIResponse } from '../actions/request.actions'; import { IRequestAction } from '../types/request.types'; import { EntitySchema } from './entity-factory'; @@ -107,4 +108,13 @@ export class ValidationResult { * @memberof ValidationResult */ completed: Promise; + + /** + * The new apiResponse. For the case of validating api calls this might be updated to ensure parent entities are associated with missing + * children. + * + * @type {APIResponse} + * @memberof ValidationResult + */ + apiResponse?: APIResponse; } diff --git a/src/frontend/app/store/reducers/api-request-data-reducer/request-data-reducer.factory.ts b/src/frontend/app/store/reducers/api-request-data-reducer/request-data-reducer.factory.ts index 96b6e6240a..4dc9ad1c49 100644 --- a/src/frontend/app/store/reducers/api-request-data-reducer/request-data-reducer.factory.ts +++ b/src/frontend/app/store/reducers/api-request-data-reducer/request-data-reducer.factory.ts @@ -25,10 +25,7 @@ export function requestDataReducerFactory(entityList = [], actions: IRequestArra if (!success.apiAction.updatingKey && success.requestType === 'delete') { return deleteEntity(state, success.apiAction.entityKey, success.apiAction.guid); } else if (success.response) { - // Does the entity associated with the action have a parent property that requires the result to be stored with it? - // For example we have fetched a list of spaces that need to be stored in an organization's entity? - const entities = populateParentEntity(state, success) || success.response.entities; - return deepMergeState(state, entities); + return deepMergeState(state, success.response.entities); } return state; case RECURSIVE_ENTITY_SET_DELETED: @@ -81,62 +78,3 @@ function deleteEntity(state, entityKey, guid) { } return newState; } - -function populateParentEntity(state, successAction) { - if (!isEntityInlineChildAction(successAction.apiAction)) { - return; - } - - const action: EntityInlineChildAction = successAction.apiAction as EntityInlineChildAction; - const response = successAction.response; - const entityKey = successAction.apiAction.entityKey; - const entity = successAction.apiAction.entity; - const entities = pathGet(`entities.${entityKey}`, response) || {}; - if (!Object.values(entities)) { - return; - } - - // Create a new entity with the inline result. For instance an new organization containing a list of spaces - // First create the required consts - const parentGuid = action.parentGuid; - const parentEntityKey = action.parentEntitySchema.key; - // We need to find out the entity param name. For instance an org with spaces in will store them in a `spaces` property - const parentEntityTree = fetchEntityTree({ - type: '', - entity: action.parentEntitySchema, - entityKey: parentEntityKey, - includeRelations: [createEntityRelationKey(parentEntityKey, entityKey)], - populateMissing: null, - }); - const relationKey = entity.length ? entity[0].relationKey : entity.relationKey; - const childRelation = parentEntityTree.rootRelation.childRelations.find(rel => - relationKey ? rel.paramName === relationKey : rel.entityKey === entityKey); - const entityParamName = childRelation.paramName; - - let newParentEntity = pathGet(`${parentEntityKey}.${parentGuid}`, state); - if (!newParentEntity) { - // We haven't yet fetched the parent entity so create one to store this list in. This can be used to fetch the child list in the future. - // NOTE - This should not contain the metadata property as the lack thereof forces the entity to fail validation and to be fetched - // properly with all it's properties - newParentEntity = { - entity: { - guid: parentGuid, - } - }; - } - newParentEntity = { - ...newParentEntity, - entity: { - ...newParentEntity.entity, - [entityParamName]: childRelation.isArray ? successAction.response.result : successAction.response.result[0] - } - }; - - // Apply the new entity to the response which will me merged into the store's state - successAction.response.entities[parentEntityKey] = { - ...successAction.response.entities[parentEntityKey], - [parentGuid]: newParentEntity - }; - - return successAction.response.entities; -}