UNPKG

@crnk/angular-ngrx

Version:

Angular helper library for ngrx-json-api and crnk:

395 lines 19.3 kB
import { Observable } from "rxjs/Observable"; import { Subject } from "rxjs/Subject"; import { Subscription } from "rxjs/Subscription"; import * as _ from "lodash"; import "rxjs/add/operator/zip"; import "rxjs/add/operator/do"; import "rxjs/add/operator/debounceTime"; import "rxjs/add/operator/distinct"; import "rxjs/add/operator/switch"; import "rxjs/add/operator/finally"; import "rxjs/add/operator/share"; import { NgForm } from "@angular/forms"; import { getNgrxJsonApiZone, NGRX_JSON_API_DEFAULT_ZONE, NgrxJsonApiService, NgrxJsonApiStore, NgrxJsonApiStoreData, NgrxJsonApiZoneService, Resource, ResourceError, ResourceIdentifier, StoreResource } from 'ngrx-json-api'; import { Store } from "@ngrx/store"; import { getNgrxJsonApiStore$, getStoreData$ } from './crnk.binding.utils'; import { ReplaySubject } from "rxjs/ReplaySubject"; /** * Binding between ngrx-jsonapi and angular forms. It serves two purposes: * * <ul> * <li>Updates the JSON API store when forFormElement controls changes their values.</li> * <li>Updates the validation state of forFormElement controls in case of JSON API errors. JSON API errors that cannot be * mapped to a forFormElement control are hold in the errors property * </li> * <ul> * * The binding between resources in the store and forFormElement controls happens trough the naming of the forFormElement * controls. Two naming patterns are supported: * * <ul> * <li>basic binding for all forFormElement controls that start with "attributes." or "relationships.". A forFormElement * control with label "attributes.title" is mapped to the "title" attribute of the JSON API resource in the store. The id of the * resource is obtained from the FormBindingConfig.resource$. * </li> * <li>(not yet supported) advanced binding with the naming pattern * "resource.{type}.{id}.{attributes/relationships}.{label}". * It allows to edit multiple resources in the same forFormElement. * </li> * <ul> * * Similarly, JSON API errors are mapped back to forFormElement controls trougth the source pointer of the error. If such a * mapping is not found, the error is added to the errors attribute of this class. Usually applications show such errors above * all fields in the config. * * You may also have a look at the CrnkExpressionModule. Its ExpressionDirective provides an alternative to NgModel * that binds both a value and sets the label of forFormElement control with a single (type-safe) attribute. */ var /** * Binding between ngrx-jsonapi and angular forms. It serves two purposes: * * <ul> * <li>Updates the JSON API store when forFormElement controls changes their values.</li> * <li>Updates the validation state of forFormElement controls in case of JSON API errors. JSON API errors that cannot be * mapped to a forFormElement control are hold in the errors property * </li> * <ul> * * The binding between resources in the store and forFormElement controls happens trough the naming of the forFormElement * controls. Two naming patterns are supported: * * <ul> * <li>basic binding for all forFormElement controls that start with "attributes." or "relationships.". A forFormElement * control with label "attributes.title" is mapped to the "title" attribute of the JSON API resource in the store. The id of the * resource is obtained from the FormBindingConfig.resource$. * </li> * <li>(not yet supported) advanced binding with the naming pattern * "resource.{type}.{id}.{attributes/relationships}.{label}". * It allows to edit multiple resources in the same forFormElement. * </li> * <ul> * * Similarly, JSON API errors are mapped back to forFormElement controls trougth the source pointer of the error. If such a * mapping is not found, the error is added to the errors attribute of this class. Usually applications show such errors above * all fields in the config. * * You may also have a look at the CrnkExpressionModule. Its ExpressionDirective provides an alternative to NgModel * that binds both a value and sets the label of forFormElement control with a single (type-safe) attribute. */ FormBinding = /** @class */ (function () { function FormBinding(ngrxJsonApiService, config, store) { var _this = this; this.config = config; this.store = store; /** * Contains all errors that cannot be assigned to a forFormElement control. Usually such errors are shown on top above * all controls. */ this.unmappedErrors = []; /** * the forFormElement also sends out valueChanges upon initialization, we do not want that and filter them out with this flag */ this.wasDirty = false; /** * id of the main resource to be edited. */ this.primaryResourceId = null; /** * list of resources edited by this binding. May include related resources next to the primary one. */ this.editResourceIds = {}; /** * Subscription to forFormElement changes. Gets automatically cancelled if there are no subscriptions anymore to * resource$. */ this.formSubscription = null; this.storeSubscription = null; this.formControlsInitialized = false; this._storeDataSnapshot = null; this.validSubject = new ReplaySubject(1); this.dirtySubject = new ReplaySubject(1); var zoneId = config.zoneId || NGRX_JSON_API_DEFAULT_ZONE; this.zone = ngrxJsonApiService.getZone(zoneId); this.dirtySubject.next(false); this.validSubject.next(true); this.dirty = this.dirtySubject.asObservable().distinctUntilChanged(); this.valid = this.validSubject.asObservable().distinctUntilChanged(); if (this.config.form === null) { throw new Error('no forFormElement provided'); } if (this.config.queryId === null) { throw new Error('no queryId provided'); } // we make use of share() to keep the this.config.resource$ subscription // as long as there is at least subscriber on this.resource$. this.resource$ = this.zone.selectOneResults(this.config.queryId, true) .filter(function (it) { return !_.isEmpty(it); }) // ignore deletions .filter(function (it) { return !it.loading; }) .map(function (it) { return it.data; }) .filter(function (it) { return !_.isEmpty(it); }) // ignore deletions .distinctUntilChanged(function (a, b) { return _.isEqual(a, b); }) .do(function () { return _this.checkSubscriptions(); }) .do(function (resource) { return _this.primaryResourceId = { type: resource.type, id: resource.id }; }) .withLatestFrom(this.store, function (resource, state) { var jsonapiState = getNgrxJsonApiZone(state, zoneId); _this._storeDataSnapshot = jsonapiState.data; _this.mapResourceToControlErrors(jsonapiState.data); _this.updateDirtyState(jsonapiState.data); return resource; }) .finally(function () { return _this.cancelSubscriptions; }) .share(); } FormBinding.prototype.cancelSubscriptions = function () { if (this.formSubscription !== null) { this.formSubscription.unsubscribe(); this.formSubscription = null; } if (this.storeSubscription !== null) { this.storeSubscription.unsubscribe(); this.storeSubscription = null; } }; FormBinding.prototype.checkSubscriptions = function () { var _this = this; if (this.formSubscription === null) { // update store from value changes, for more information see // https://embed.plnkr.co/9aNuw6DG9VM4X8vUtkAa?show=app%2Fapp.components.ts,preview var formChanges$ = this.config.form.statusChanges .do(function (valid) { return _this.validSubject.next(valid === 'VALID'); }) .filter(function (valid) { return valid === 'VALID'; }) .do(function () { // it may take a moment for a form with all controls to initialize and register. // there seems no proper Angular lifecycle for this to check(???). Till no // control is found, we perform the mapping also here. // // geting notified about new control would be great... if (!_this.formControlsInitialized) { _this.mapResourceToControlErrors(_this._storeDataSnapshot); } }) .withLatestFrom(this.config.form.valueChanges, function (valid, values) { return values; }) .filter(function (it) { return _this.config.form.dirty || _this.wasDirty; }) .debounceTime(20) .distinctUntilChanged(function (a, b) { return _.isEqual(a, b); }) .do(function (it) { return _this.wasDirty = true; }); this.formSubscription = formChanges$.subscribe(function (formValues) { return _this.updateStoreFromFormValues(formValues); }); } if (this.storeSubscription != null && this.config.mapNonResultResources) { this.storeSubscription = this.store .let(getNgrxJsonApiStore$) .let(getStoreData$) .subscribe(function (data) { _this.mapResourceToControlErrors(data); }); } }; FormBinding.prototype.mapResourceToControlErrors = function (data) { var form = this.config.form; if (this.primaryResourceId) { var primaryResource = data[this.primaryResourceId.type][this.primaryResourceId.id]; var newUnmappedErrors = []; for (var _i = 0, _a = primaryResource.errors; _i < _a.length; _i++) { var resourceError = _a[_i]; var mapped = false; if (resourceError.source && resourceError.source.pointer) { var path = this.toPath(resourceError.source.pointer); var formName = this.toResourceFormName(primaryResource, path); if (form.controls[formName] || form.controls[path]) { mapped = true; } } if (!mapped) { newUnmappedErrors.push(resourceError); } } this.unmappedErrors = newUnmappedErrors; } }; FormBinding.prototype.updateDirtyState = function (data) { function isDirty(resourceId) { var resource = data[resourceId.type][resourceId.id]; return resource && resource.state !== 'IN_SYNC'; } var newDirty = isDirty(this.primaryResourceId); for (var _i = 0, _a = _.values(this.editResourceIds); _i < _a.length; _i++) { var editedResourceId = _a[_i]; newDirty = newDirty || isDirty(editedResourceId); } this.dirtySubject.next(newDirty); }; FormBinding.prototype.toResourceFormName = function (resource, basicFormName) { return '//' + resource.type + '//' + resource.id + '//' + basicFormName; }; FormBinding.prototype.toPath = function (sourcePointer) { var formName = sourcePointer.replace(new RegExp('/', 'g'), '.'); if (formName.startsWith('.')) { formName = formName.substring(1); } if (formName.endsWith('.')) { formName = formName.substring(0, formName.length - 1); } if (formName.startsWith('data.')) { formName = formName.substring(5); } return formName; }; FormBinding.prototype.save = function () { this.zone.apply(); }; FormBinding.prototype.delete = function () { this.zone.deleteResource({ resourceId: this.primaryResourceId, toRemote: true }); }; /** * computes type, id and field path from formName. */ /** * computes type, id and field path from formName. */ FormBinding.prototype.parseResourceFieldRef = /** * computes type, id and field path from formName. */ function (formName) { if (formName.startsWith('//')) { var _a = formName.substring(2).split('//'), type = _a[0], id = _a[1], path = _a[2]; return { resourceId: { type: type, id: id }, path: path }; } else { return { resourceId: { type: this.primaryResourceId.type, id: this.primaryResourceId.id }, path: formName }; } }; FormBinding.prototype.updateStoreFromFormValues = function (values) { var patchedResourceMap = {}; for (var _i = 0, _a = Object.keys(values); _i < _a.length; _i++) { var formName = _a[_i]; var value = values[formName]; var formRef = this.parseResourceFieldRef(formName); if (formRef.path.startsWith('attributes.') || formRef.path.startsWith('relationships.')) { var key = formRef.resourceId.type + '_' + formRef.resourceId.id; var storeTypeSnapshot = this._storeDataSnapshot[formRef.resourceId.type]; var storeResourceSnapshot = storeTypeSnapshot ? storeTypeSnapshot[formRef.resourceId.id] : undefined; var storeValueSnapshot = storeResourceSnapshot ? _.get(storeResourceSnapshot, formRef.path) : undefined; if (!_.isEqual(storeValueSnapshot, value)) { var patchedResource = patchedResourceMap[key]; if (!patchedResource) { patchedResource = { id: formRef.resourceId.id, type: formRef.resourceId.type, attributes: {} }; patchedResourceMap[key] = patchedResource; var resourceKey = formRef.resourceId.id + "@" + formRef.resourceId.type; if (!this.editResourceIds[resourceKey]) { this.editResourceIds[resourceKey] = formRef.resourceId; } } _.set(patchedResource, formRef.path, value); } } } var patchedResources = _.values(patchedResourceMap); for (var _b = 0, patchedResources_1 = patchedResources; _b < patchedResources_1.length; _b++) { var patchedResource = patchedResources_1[_b]; var cleanedPatchedResource = this.clearPrimeNgMarkers(patchedResource); this.zone.patchResource({ resource: cleanedPatchedResource }); } }; /** * Prime NG has to annoying habit of adding _$visited. Cleaned up here. Needs to be further investigated * and preferably avoided. * * FIXME move to HTTP layer or fix PrimeNG, preferably the later. */ /** * Prime NG has to annoying habit of adding _$visited. Cleaned up here. Needs to be further investigated * and preferably avoided. * * FIXME move to HTTP layer or fix PrimeNG, preferably the later. */ FormBinding.prototype.clearPrimeNgMarkers = /** * Prime NG has to annoying habit of adding _$visited. Cleaned up here. Needs to be further investigated * and preferably avoided. * * FIXME move to HTTP layer or fix PrimeNG, preferably the later. */ function (resource) { var cleanedResource = _.cloneDeep(resource); if (cleanedResource.attributes) { for (var _i = 0, _a = Object.keys(cleanedResource.attributes); _i < _a.length; _i++) { var attributeName = _a[_i]; var value = cleanedResource.attributes[attributeName]; if (_.isObject(value)) { delete value['_$visited']; } } } if (cleanedResource.relationships) { for (var _b = 0, _c = Object.keys(cleanedResource.relationships); _b < _c.length; _b++) { var relationshipName = _c[_b]; var relationship = cleanedResource.relationships[relationshipName]; if (relationship.data) { var dependencyIds = relationship.data instanceof Array ? relationship.data : [relationship.data]; for (var _d = 0, dependencyIds_1 = dependencyIds; _d < dependencyIds_1.length; _d++) { var dependencyId = dependencyIds_1[_d]; delete dependencyId['_$visited']; } } } } return cleanedResource; }; return FormBinding; }()); /** * Binding between ngrx-jsonapi and angular forms. It serves two purposes: * * <ul> * <li>Updates the JSON API store when forFormElement controls changes their values.</li> * <li>Updates the validation state of forFormElement controls in case of JSON API errors. JSON API errors that cannot be * mapped to a forFormElement control are hold in the errors property * </li> * <ul> * * The binding between resources in the store and forFormElement controls happens trough the naming of the forFormElement * controls. Two naming patterns are supported: * * <ul> * <li>basic binding for all forFormElement controls that start with "attributes." or "relationships.". A forFormElement * control with label "attributes.title" is mapped to the "title" attribute of the JSON API resource in the store. The id of the * resource is obtained from the FormBindingConfig.resource$. * </li> * <li>(not yet supported) advanced binding with the naming pattern * "resource.{type}.{id}.{attributes/relationships}.{label}". * It allows to edit multiple resources in the same forFormElement. * </li> * <ul> * * Similarly, JSON API errors are mapped back to forFormElement controls trougth the source pointer of the error. If such a * mapping is not found, the error is added to the errors attribute of this class. Usually applications show such errors above * all fields in the config. * * You may also have a look at the CrnkExpressionModule. Its ExpressionDirective provides an alternative to NgModel * that binds both a value and sets the label of forFormElement control with a single (type-safe) attribute. */ export { FormBinding }; //# sourceMappingURL=crnk.binding.form.js.map