@crnk/angular-ngrx
Version:
Angular helper library for ngrx-json-api and crnk:
395 lines • 19.3 kB
JavaScript
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