From 0182979a689003888f7d029f16f26bba56795d4f Mon Sep 17 00:00:00 2001 From: Igor Terzic Date: Wed, 26 Jun 2019 04:46:07 -0700 Subject: [PATCH] RecordData errors --- bin/publish.js | 12 +- package.json | 2 +- packages/-build-infra/src/debug-macros.js | 12 +- packages/-build-infra/src/features.js | 29 +- packages/-ember-data/ember-cli-build.js | 5 + packages/-ember-data/package.json | 2 +- .../tests/helpers/watch-property.js | 4 +- packages/-ember-data/tests/index.html | 8 + .../record-data/record-data-errors-test.ts | 352 ++++++++++++++++++ .../record-data/record-data-test.ts | 164 ++++---- packages/-ember-data/tests/unit/model-test.js | 20 +- packages/-ember-data/tsconfig.json | 5 +- packages/adapter/addon/error.js | 136 +------ packages/adapter/addon/rest.js | 4 +- packages/adapter/tsconfig.json | 2 + .../addon/environment/global.js | 17 - .../addon/environment/index.js | 1 - packages/canary-features/addon/index.js | 38 +- packages/model/addon/-private/attr.js | 11 + packages/model/addon/-private/belongs-to.js | 8 +- packages/model/index.js | 2 +- .../addon/-private/embedded-records-mixin.js | 4 +- packages/serializer/addon/json-api.js | 8 +- packages/store/addon/-private/index.js | 2 + .../addon/-private/system/errors-utils.js | 139 +++++++ .../-private/system/model/internal-model.ts | 93 ++++- .../addon/-private/system/model/model.js | 75 +++- .../-private/system/model/record-data.ts | 123 +++--- .../system/record-arrays/record-array.js | 4 +- .../-private/system/relationships/ext.js | 4 +- packages/store/addon/-private/system/store.js | 26 +- .../addon/-private/system/store/finders.js | 20 +- .../system/store/record-data-store-wrapper.ts | 5 + .../ts-interfaces/record-data-json-api.ts | 9 + .../record-data-store-wrapper.ts | 26 +- .../-private/ts-interfaces/record-data.ts | 15 +- packages/store/index.js | 1 + 37 files changed, 1025 insertions(+), 363 deletions(-) create mode 100644 packages/-ember-data/tests/integration/record-data/record-data-errors-test.ts delete mode 100644 packages/canary-features/addon/environment/global.js delete mode 100644 packages/canary-features/addon/environment/index.js create mode 100644 packages/store/addon/-private/system/errors-utils.js diff --git a/bin/publish.js b/bin/publish.js index 5448d75b8a9..fe789e0ffd2 100644 --- a/bin/publish.js +++ b/bin/publish.js @@ -66,7 +66,9 @@ function getConfig() { } if (!['release', 'beta', 'canary', 'lts'].includes(mainOptions.channel)) { throw new Error( - `Incorrect usage of publish:\n\tpublish \n\nChannel must be one of release|beta|canary|lts. Received ${mainOptions.channel}` + `Incorrect usage of publish:\n\tpublish \n\nChannel must be one of release|beta|canary|lts. Received ${ + mainOptions.channel + }` ); } @@ -162,7 +164,9 @@ function assertGitIsClean() { if (options.force) { console.log( chalk.white( - `⚠️ ⚠️ ⚠️ Expected to publish npm tag ${options.distTag} from the git branch ${expectedChannelBranch}, but found ${foundBranch}` + `⚠️ ⚠️ ⚠️ Expected to publish npm tag ${ + options.distTag + } from the git branch ${expectedChannelBranch}, but found ${foundBranch}` ) + chalk.yellow('\n\tPassed option: ') + chalk.white('--force') + @@ -171,7 +175,9 @@ function assertGitIsClean() { } else { console.log( chalk.red( - `💥 Expected to publish npm tag ${options.distTag} from the git branch ${expectedChannelBranch}, but found ${foundBranch} 💥 \n\t` + `💥 Expected to publish npm tag ${ + options.distTag + } from the git branch ${expectedChannelBranch}, but found ${foundBranch} 💥 \n\t` ) + chalk.grey('Use ') + chalk.white('--force') + diff --git a/package.json b/package.json index 0d5e038e7eb..6ff3d3f59a7 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "test:node": "lerna run test:node", "test:production": "yarn workspace ember-data test:production", "test:try-one": "yarn workspace ember-data test:try-one", - "test:enabled-in-progress-features": "yarn workspace ember-data test --enable-in-progress", + "test:enabled-in-progress-features": "yarn workspace ember-data test:optional-features", "test-external:ember-m3": "./bin/test-external-partner-project.js ember-m3 https://github.com/hjdivad/ember-m3.git", "test-external:ember-data-change-tracker": "./bin/test-external-partner-project.js ember-data-change-tracker https://github.com/danielspaniel/ember-data-change-tracker.git", "test-external:model-fragments": "./bin/test-external-partner-project.js ember-data-model-fragments https://github.com/lytics/ember-data-model-fragments.git", diff --git a/packages/-build-infra/src/debug-macros.js b/packages/-build-infra/src/debug-macros.js index 6f553ae36fa..16abd9852cb 100644 --- a/packages/-build-infra/src/debug-macros.js +++ b/packages/-build-infra/src/debug-macros.js @@ -10,17 +10,7 @@ module.exports = function debugMacros(environment) { flags: [ { source: '@ember-data/canary-features', - flags: Object.assign( - // explicit list of additional exports within @ember-data/canary-features - // without adding this (with a null value) an error is thrown during - // the feature replacement process (e.g. XYZ is not a supported flag) - { - FEATURES: null, - DEFAULT_FEATURES: null, - isEnabled: null, - }, - FEATURES - ), + flags: FEATURES, }, ], }, diff --git a/packages/-build-infra/src/features.js b/packages/-build-infra/src/features.js index 8c63e351ab3..9b09713a2fd 100644 --- a/packages/-build-infra/src/features.js +++ b/packages/-build-infra/src/features.js @@ -1,12 +1,29 @@ 'use strict'; function getFeatures() { - const features = { SAMPLE_FEATURE_FLAG: null }; - let enableFeatures = process.env.EMBER_DATA_FEATURES; - // turn on all features when given the above environment variable - if (enableFeatures) { - for (let key in features) { - features[key] = true; + const features = { + SAMPLE_FEATURE_FLAG: null, + RECORD_DATA_ERRORS: null, + }; + + const FEATURE_OVERRIDES = process.env.EMBER_DATA_FEATURE_OVERRIDE; + if (FEATURE_OVERRIDES === 'ENABLE_ALL_OPTIONAL') { + // enable all features with a current value of `null` + for (let feature in features) { + let featureValue = features[feature]; + + if (featureValue === null) { + features[feature] = true; + } + } + } else if (FEATURE_OVERRIDES) { + // enable only the specific features listed in the environment + // variable (comma separated) + const forcedFeatures = FEATURE_OVERRIDES.split(','); + for (var i = 0; i < forcedFeatures.length; i++) { + let featureName = forcedFeatures[i]; + + features[featureName] = true; } } diff --git a/packages/-ember-data/ember-cli-build.js b/packages/-ember-data/ember-cli-build.js index aa2951398b8..a47da076b2a 100644 --- a/packages/-ember-data/ember-cli-build.js +++ b/packages/-ember-data/ember-cli-build.js @@ -4,6 +4,11 @@ const EmberAddon = require('ember-cli/lib/broccoli/ember-addon'); module.exports = function(defaults) { let app = new EmberAddon(defaults, { + babel: { + // this ensures that the same `@ember-data/canary-features` processing that the various + // ember-data addons do is done in the dummy app + plugins: [...require('@ember-data/-build-infra/src/debug-macros')()], + }, 'ember-cli-babel': { throwUnlessParallelizable: true, }, diff --git a/packages/-ember-data/package.json b/packages/-ember-data/package.json index 7bc7622e707..29a7de59812 100644 --- a/packages/-ember-data/package.json +++ b/packages/-ember-data/package.json @@ -14,7 +14,7 @@ "test": "ember test", "test:all": "ember try:each", "test:production": "ember test -e production", - "test:optional-features": "ember test -e test-optional-features", + "test:optional-features": "EMBER_DATA_FEATURE_OVERRIDE=ENABLE_ALL_OPTIONAL ember test", "test:try-one": "ember try:one", "prepublishOnly": "ember build --environment=production && ember ts:precompile", "postpublish": "ember ts:clean" diff --git a/packages/-ember-data/tests/helpers/watch-property.js b/packages/-ember-data/tests/helpers/watch-property.js index b9983b33fe2..8a210b9a4d0 100644 --- a/packages/-ember-data/tests/helpers/watch-property.js +++ b/packages/-ember-data/tests/helpers/watch-property.js @@ -98,7 +98,9 @@ QUnit.assert.watchedPropertyCounts = function assertWatchedPropertyCount( expectedCount = expectedCount[0]; } - assertionText += ` | Expected ${expectedCount} change notifications for ${propertyName} but recieved ${counter.count}`; + assertionText += ` | Expected ${expectedCount} change notifications for ${propertyName} but recieved ${ + counter.count + }`; if (counter === undefined) { throw new Error( diff --git a/packages/-ember-data/tests/index.html b/packages/-ember-data/tests/index.html index 5209b852321..fd15b0f6ab8 100644 --- a/packages/-ember-data/tests/index.html +++ b/packages/-ember-data/tests/index.html @@ -24,6 +24,14 @@ + + + diff --git a/packages/-ember-data/tests/integration/record-data/record-data-errors-test.ts b/packages/-ember-data/tests/integration/record-data/record-data-errors-test.ts new file mode 100644 index 00000000000..ae523fa460f --- /dev/null +++ b/packages/-ember-data/tests/integration/record-data/record-data-errors-test.ts @@ -0,0 +1,352 @@ +import { get } from '@ember/object'; +import { setupTest } from 'ember-qunit'; +import Model from 'ember-data/model'; +import Store from 'ember-data/store'; +import { module, test } from 'qunit'; +import { settled } from '@ember/test-helpers'; +import EmberObject from '@ember/object'; +import { attr, hasMany, belongsTo } from '@ember-data/model'; +import { InvalidError, ServerError } from '@ember-data/adapter/error'; +import { JsonApiValidationError } from '@ember-data/store/-private/ts-interfaces/record-data-json-api'; +import RecordData, { RecordIdentifier } from '@ember-data/store/-private/ts-interfaces/record-data'; +import { RECORD_DATA_ERRORS } from '@ember-data/canary-features'; + +class Person extends Model { + // TODO fix the typing for naked attrs + @attr('string', {}) + name; + + @attr('string', {}) + lastName; +} + +class TestRecordData implements RecordData { + commitWasRejected(recordIdentifier: RecordIdentifier, errors?: JsonApiValidationError[]): void {} + + // Use correct interface once imports have been fix + _storeWrapper: any; + + pushData(data, calculateChange?: boolean) {} + clientDidCreate() {} + + willCommit() {} + + unloadRecord() {} + rollbackAttributes() { + return []; + } + changedAttributes(): any {} + + hasChangedAttributes(): boolean { + return false; + } + + setDirtyAttribute(key: string, value: any) {} + + getAttr(key: string): string { + return 'test'; + } + + hasAttr(key: string): boolean { + return false; + } + + getHasMany(key: string) { + return {}; + } + + isRecordInUse(): boolean { + return true; + } + + isNew() { + return false; + } + + isDeleted() { + return false; + } + + addToHasMany(key: string, recordDatas: RecordData[], idx?: number) {} + removeFromHasMany(key: string, recordDatas: RecordData[]) {} + setDirtyHasMany(key: string, recordDatas: RecordData[]) {} + + getBelongsTo(key: string) { + return {}; + } + + setDirtyBelongsTo(name: string, recordData: RecordData | null) {} + + didCommit(data) {} + + isAttrDirty(key: string) { + return false; + } + removeFromInverseRelationships(isNew: boolean) {} + + _initRecordCreateOptions(options) { + return {}; + } +} + +let CustomStore = Store.extend({ + createRecordDataFor(modelName, id, clientId, storeWrapper) { + return new TestRecordData(); + }, +}); + +module('integration/record-data - Custom RecordData Errors', function(hooks) { + if (!RECORD_DATA_ERRORS) { + return; + } + + setupTest(hooks); + + let store; + + hooks.beforeEach(function() { + let { owner } = this; + + owner.register('model:person', Person); + owner.register('service:store', CustomStore); + }); + + test('Record Data invalid errors', async function(assert) { + assert.expect(2); + let called = 0; + let createCalled = 0; + const personHash = { + type: 'person', + id: '1', + attributes: { + name: 'Scumbag Dale', + }, + }; + let { owner } = this; + + class LifecycleRecordData extends TestRecordData { + commitWasRejected(recordIdentifier, errors) { + assert.equal(errors[0].detail, 'is a generally unsavoury character', 'received the error'); + assert.equal(errors[0].source.pointer, '/data/attributes/name', 'pointer is correct'); + } + } + + let TestStore = Store.extend({ + createRecordDataFor(modelName, id, clientId, storeWrapper) { + return new LifecycleRecordData(); + }, + }); + + let TestAdapter = EmberObject.extend({ + updateRecord() { + return Promise.reject( + new InvalidError([ + { + title: 'Invalid Attribute', + detail: 'is a generally unsavoury character', + source: { + pointer: '/data/attributes/name', + }, + }, + ]) + ); + }, + + createRecord() { + return Promise.resolve(); + }, + }); + + owner.register('service:store', TestStore); + owner.register('adapter:application', TestAdapter, { singleton: false }); + + store = owner.lookup('service:store'); + + store.push({ + data: [personHash], + }); + let person = store.peekRecord('person', '1'); + person.save().then(() => {}, err => {}); + }); + + test('Record Data adapter errors', async function(assert) { + assert.expect(1); + const personHash = { + type: 'person', + id: '1', + attributes: { + name: 'Scumbag Dale', + }, + }; + let { owner } = this; + + class LifecycleRecordData extends TestRecordData { + commitWasRejected(recordIdentifier, errors) { + assert.equal(errors, undefined, 'Did not pass adapter errors'); + } + } + + let TestStore = Store.extend({ + createRecordDataFor(modelName, id, clientId, storeWrapper) { + return new LifecycleRecordData(); + }, + }); + + let TestAdapter = EmberObject.extend({ + updateRecord() { + return Promise.reject(); + }, + }); + + owner.register('service:store', TestStore); + owner.register('adapter:application', TestAdapter, { singleton: false }); + + store = owner.lookup('service:store'); + + store.push({ + data: [personHash], + }); + let person = store.peekRecord('person', '1'); + await person.save().then(() => {}, err => {}); + }); + + test('Getting errors from Record Data shows up on the record', async function(assert) { + assert.expect(7); + let storeWrapper; + const personHash = { + type: 'person', + id: '1', + attributes: { + name: 'Scumbag Dale', + lastName: 'something', + }, + }; + let { owner } = this; + let errorsToReturn = [ + { + title: 'Invalid Attribute', + detail: '', + source: { + pointer: '/data/attributes/name', + }, + }, + ]; + + class LifecycleRecordData extends TestRecordData { + constructor(sw) { + super(); + storeWrapper = sw; + } + + getErrors(recordIdentifier: RecordIdentifier): JsonApiValidationError[] { + return errorsToReturn; + } + } + + let TestStore = Store.extend({ + createRecordDataFor(modelName, id, clientId, storeWrapper) { + return new LifecycleRecordData(storeWrapper); + }, + }); + + owner.register('service:store', TestStore); + store = owner.lookup('service:store'); + + store.push({ + data: [personHash], + }); + let person = store.peekRecord('person', '1'); + let nameError = person + .get('errors') + .errorsFor('name') + .get('firstObject'); + assert.equal(nameError.attribute, 'name', 'error shows up on name'); + assert.equal(person.get('isValid'), false, 'person is not valid'); + errorsToReturn = []; + storeWrapper.notifyErrorsChange('person', '1'); + assert.equal(person.get('isValid'), true, 'person is valid'); + assert.equal(person.get('errors').errorsFor('name').length, 0, 'no errors on name'); + errorsToReturn = [ + { + title: 'Invalid Attribute', + detail: '', + source: { + pointer: '/data/attributes/lastName', + }, + }, + ]; + storeWrapper.notifyErrorsChange('person', '1'); + assert.equal(person.get('isValid'), false, 'person is valid'); + assert.equal(person.get('errors').errorsFor('name').length, 0, 'no errors on name'); + let lastNameError = person + .get('errors') + .errorsFor('lastName') + .get('firstObject'); + assert.equal(lastNameError.attribute, 'lastName', 'error shows up on lastName'); + }); + + test('Record data which does not implement getErrors still works correctly with the default DS.Model', async function(assert) { + assert.expect(4); + let called = 0; + let createCalled = 0; + const personHash = { + type: 'person', + id: '1', + attributes: { + name: 'Scumbag Dale', + }, + }; + let { owner } = this; + + class LifecycleRecordData extends TestRecordData { + commitWasRejected(recordIdentifier, errors) { + assert.equal(errors[0].detail, 'is a generally unsavoury character', 'received the error'); + assert.equal(errors[0].source.pointer, '/data/attributes/name', 'pointer is correct'); + } + } + + let TestStore = Store.extend({ + createRecordDataFor(modelName, id, clientId, storeWrapper) { + return new LifecycleRecordData(); + }, + }); + + let TestAdapter = EmberObject.extend({ + updateRecord() { + return Promise.reject( + new InvalidError([ + { + title: 'Invalid Attribute', + detail: 'is a generally unsavoury character', + source: { + pointer: '/data/attributes/name', + }, + }, + ]) + ); + }, + + createRecord() { + return Promise.resolve(); + }, + }); + + owner.register('service:store', TestStore); + owner.register('adapter:application', TestAdapter, { singleton: false }); + + store = owner.lookup('service:store'); + + store.push({ + data: [personHash], + }); + let person = store.peekRecord('person', '1'); + await person.save().then(() => {}, err => {}); + + assert.equal(person.get('isValid'), false, 'rejecting the save invalidates the person'); + let nameError = person + .get('errors') + .errorsFor('name') + .get('firstObject'); + assert.equal(nameError.attribute, 'name', 'error shows up on name'); + }); +}); diff --git a/packages/-ember-data/tests/integration/record-data/record-data-test.ts b/packages/-ember-data/tests/integration/record-data/record-data-test.ts index 2ee45bbe34e..65617baaee1 100644 --- a/packages/-ember-data/tests/integration/record-data/record-data-test.ts +++ b/packages/-ember-data/tests/integration/record-data/record-data-test.ts @@ -6,6 +6,7 @@ import { module, test } from 'qunit'; import { settled } from '@ember/test-helpers'; import EmberObject from '@ember/object'; import { attr, hasMany, belongsTo } from '@ember-data/model'; +import { RECORD_DATA_ERRORS } from '@ember-data/canary-features'; class Person extends Model { // TODO fix the typing for naked attrs @@ -25,32 +26,31 @@ class House extends Model { tenants; } - // TODO: this should work // class TestRecordData implements RecordData class TestRecordData { // Use correct interface once imports have been fix _storeWrapper: any; - pushData(data, calculateChange?: boolean) { } - clientDidCreate() { } + pushData(data, calculateChange?: boolean) {} + clientDidCreate() {} - willCommit() { } + willCommit() {} - commitWasRejected() { } + commitWasRejected() {} - unloadRecord() { } - rollbackAttributes() { } - changedAttributes(): any { } + unloadRecord() {} + rollbackAttributes() {} + changedAttributes(): any {} hasChangedAttributes(): boolean { return false; } - setDirtyAttribute(key: string, value: any) { } + setDirtyAttribute(key: string, value: any) {} getAttr(key: string): string { - return "test"; + return 'test'; } hasAttr(key: string): boolean { @@ -61,68 +61,70 @@ class TestRecordData { return {}; } - addToHasMany(key: string, recordDatas: this[], idx?: number) { } - removeFromHasMany(key: string, recordDatas: this[]) { } - setDirtyHasMany(key: string, recordDatas: this[]) { } + addToHasMany(key: string, recordDatas: this[], idx?: number) {} + removeFromHasMany(key: string, recordDatas: this[]) {} + setDirtyHasMany(key: string, recordDatas: this[]) {} - getBelongsTo(key: string) { } + getBelongsTo(key: string) {} - setDirtyBelongsTo(name: string, recordData: this | null) { } + setDirtyBelongsTo(name: string, recordData: this | null) {} - didCommit(data) { } + didCommit(data) {} - isAttrDirty(key: string) { return false; } - removeFromInverseRelationships(isNew: boolean) { } + isAttrDirty(key: string) { + return false; + } + removeFromInverseRelationships(isNew: boolean) {} - _initRecordCreateOptions(options) { } + _initRecordCreateOptions(options) {} } let CustomStore = Store.extend({ createRecordDataFor(modelName, id, clientId, storeWrapper) { return new TestRecordData(); - } + }, }); let houseHash, davidHash, runspiredHash, igorHash; -module('integration/record-data - Custom RecordData Implementations', function (hooks) { +module('integration/record-data - Custom RecordData Implementations', function(hooks) { setupTest(hooks); let store; - hooks.beforeEach(function () { + hooks.beforeEach(function() { let { owner } = this; houseHash = { type: 'house', id: '1', attributes: { - name: 'Moomin' - } + name: 'Moomin', + }, }; davidHash = { type: 'person', id: '1', attributes: { - name: 'David' - } + name: 'David', + }, }; runspiredHash = { type: 'person', id: '2', attributes: { - name: 'Runspired' - } + name: 'Runspired', + }, }; igorHash = { type: 'person', id: '3', attributes: { - name: 'Igor' - } + name: 'Igor', + }, }; owner.register('model:person', Person); @@ -130,7 +132,7 @@ module('integration/record-data - Custom RecordData Implementations', function ( owner.register('service:store', CustomStore); }); - test("A RecordData implementation that has the required spec methods should not error out", async function (assert) { + test('A RecordData implementation that has the required spec methods should not error out', async function(assert) { let { owner } = this; store = owner.lookup('service:store'); @@ -173,7 +175,7 @@ module('integration/record-data - Custom RecordData Implementations', function ( assert.equal(get(all, 'length'), 3); }); - test("Record Data push, create and save lifecycle", async function (assert) { + test('Record Data push, create and save lifecycle', async function(assert) { assert.expect(17); let called = 0; let createCalled = 0; @@ -182,10 +184,10 @@ module('integration/record-data - Custom RecordData Implementations', function ( id: '1', attributes: { name: 'Scumbag Dale', - } - } + }, + }; let { owner } = this; - let calledPush = 0 + let calledPush = 0; let calledClientDidCreate = 0; let calledWillCommit = 0; let calledWasRejected = 0; @@ -223,11 +225,10 @@ module('integration/record-data - Custom RecordData Implementations', function ( } } - let TestStore = Store.extend({ createRecordDataFor(modelName, id, clientId, storeWrapper) { return new LifecycleRecordData(); - } + }, }); let TestAdapter = EmberObject.extend({ @@ -242,7 +243,7 @@ module('integration/record-data - Custom RecordData Implementations', function ( createRecord() { return Promise.resolve(); - } + }, }); owner.register('service:store', TestStore); @@ -251,7 +252,7 @@ module('integration/record-data - Custom RecordData Implementations', function ( store = owner.lookup('service:store'); store.push({ - data: [personHash] + data: [personHash], }); assert.equal(calledPush, 1, 'Called pushData'); @@ -309,18 +310,22 @@ module('integration/record-data - Custom RecordData Implementations', function ( assert.equal(calledPush, 0, 'Did not call pushData'); }); - test("Record Data attribute settting", async function (assert) { - assert.expect(11); + test('Record Data attribute settting', async function(assert) { + let expectedCount = 11; + if (RECORD_DATA_ERRORS) { + expectedCount = 12; + } + assert.expect(expectedCount); const personHash = { type: 'person', id: '1', attributes: { name: 'Scumbag Dale', - } - } + }, + }; let { owner } = this; - let calledGet = 0 + let calledGet = 0; class AttributeRecordData extends TestRecordData { changedAttributes(): any { @@ -339,7 +344,7 @@ module('integration/record-data - Custom RecordData Implementations', function ( getAttr(key: string): string { calledGet++; assert.equal(key, 'name', 'key passed to getAttr'); - return "new attribute"; + return 'new attribute'; } hasAttr(key: string): boolean { @@ -355,7 +360,7 @@ module('integration/record-data - Custom RecordData Implementations', function ( let TestStore = Store.extend({ createRecordDataFor(modelName, id, clientId, storeWrapper) { return new AttributeRecordData(); - } + }, }); owner.register('service:store', TestStore); @@ -363,7 +368,7 @@ module('integration/record-data - Custom RecordData Implementations', function ( store = owner.lookup('service:store'); store.push({ - data: [personHash] + data: [personHash], }); let person = store.peekRecord('person', '1'); @@ -371,11 +376,19 @@ module('integration/record-data - Custom RecordData Implementations', function ( person.set('name', 'new value'); person.notifyPropertyChange('name'); assert.equal(person.get('name'), 'new attribute'); - assert.equal(calledGet, 3, 'called getAttr after notifyPropertyChange'); - assert.deepEqual(person.changedAttributes(), { name: ['old', 'new'] }, 'changed attributes passes through RD value'); + let expectedTimesToCallGet = 3; + if (RECORD_DATA_ERRORS) { + expectedTimesToCallGet = 4; + } + assert.equal(calledGet, expectedTimesToCallGet, 'called getAttr after notifyPropertyChange'); + assert.deepEqual( + person.changedAttributes(), + { name: ['old', 'new'] }, + 'changed attributes passes through RD value' + ); }); - test("Record Data controls belongsTo notifications", async function (assert) { + test('Record Data controls belongsTo notifications', async function(assert) { assert.expect(6); let called = 0; let createCalled = 0; @@ -384,7 +397,6 @@ module('integration/record-data - Custom RecordData Implementations', function ( let belongsToReturnValue = { data: { id: '1', type: 'person' } }; class RelationshipRecordData extends TestRecordData { - constructor(storeWrapper) { super(); this._storeWrapper = storeWrapper; @@ -409,7 +421,7 @@ module('integration/record-data - Custom RecordData Implementations', function ( } else { return this._super(modelName, id, clientId, storeWrapper); } - } + }, }); owner.register('service:store', TestStore); @@ -417,11 +429,11 @@ module('integration/record-data - Custom RecordData Implementations', function ( store = owner.lookup('service:store'); store.push({ - data: [davidHash, runspiredHash] + data: [davidHash, runspiredHash], }); store.push({ - data: [houseHash] + data: [houseHash], }); let house = store.peekRecord('house', '1'); @@ -429,10 +441,14 @@ module('integration/record-data - Custom RecordData Implementations', function ( assert.equal(house.get('landlord.name'), 'David', 'belongsTo get correctly looked up'); house.set('landlord', runspired); - assert.equal(house.get('landlord.name'), 'David', 'belongsTo does not change if RD did not notify'); + assert.equal( + house.get('landlord.name'), + 'David', + 'belongsTo does not change if RD did not notify' + ); }); - test("Record Data custom belongsTo", async function (assert) { + test('Record Data custom belongsTo', async function(assert) { assert.expect(4); let { owner } = this; @@ -462,7 +478,7 @@ module('integration/record-data - Custom RecordData Implementations', function ( } else { return this._super(modelName, id, clientId, storeWrapper); } - } + }, }); owner.register('service:store', TestStore); @@ -470,14 +486,13 @@ module('integration/record-data - Custom RecordData Implementations', function ( store = owner.lookup('service:store'); store.push({ - data: [davidHash, runspiredHash, igorHash] + data: [davidHash, runspiredHash, igorHash], }); store.push({ - data: [houseHash] + data: [houseHash], }); - let house = store.peekRecord('house', '1'); assert.equal(house.get('landlord.name'), 'David', 'belongsTo get correctly looked up'); @@ -488,7 +503,7 @@ module('integration/record-data - Custom RecordData Implementations', function ( assert.equal(house.get('landlord.name'), 'Igor', 'RecordData sets the custom belongsTo value'); }); - test("Record Data controls hasMany notifications", async function (assert) { + test('Record Data controls hasMany notifications', async function(assert) { assert.expect(10); let called = 0; let createCalled = 0; @@ -544,7 +559,7 @@ module('integration/record-data - Custom RecordData Implementations', function ( } else { return this._super(modelName, id, clientId, storeWrapper); } - } + }, }); owner.register('service:store', TestStore); @@ -552,11 +567,11 @@ module('integration/record-data - Custom RecordData Implementations', function ( store = owner.lookup('service:store'); store.push({ - data: [davidHash, runspiredHash, igorHash] + data: [davidHash, runspiredHash, igorHash], }); store.push({ - data: [houseHash] + data: [houseHash], }); let house = store.peekRecord('house', '1'); @@ -571,13 +586,17 @@ module('integration/record-data - Custom RecordData Implementations', function ( assert.deepEqual(people.toArray(), [david], 'has many doesnt change if RD did not notify'); people.removeObject(david); - assert.deepEqual(people.toArray(), [david], 'hasMany removal doesnt apply the change unless notified'); + assert.deepEqual( + people.toArray(), + [david], + 'hasMany removal doesnt apply the change unless notified' + ); house.set('tenants', [igor]); assert.deepEqual(people.toArray(), [david], 'setDirtyHasMany doesnt apply unless notified'); }); - test("Record Data supports custom hasMany handling", async function (assert) { + test('Record Data supports custom hasMany handling', async function(assert) { assert.expect(10); let { owner } = this; @@ -606,7 +625,7 @@ module('integration/record-data - Custom RecordData Implementations', function ( assert.equal(recordDatas[0].id, '2', 'Passed correct RD to addToHasMany'); calledAddToHasMany++; - hasManyReturnValue = { data: [{ id: '3', type: 'person'} , { id: '2', type: 'person' }] }; + hasManyReturnValue = { data: [{ id: '3', type: 'person' }, { id: '2', type: 'person' }] }; this._storeWrapper.notifyHasManyChange('house', '1', null, 'tenants'); } @@ -618,14 +637,14 @@ module('integration/record-data - Custom RecordData Implementations', function ( assert.equal(key, 'tenants', 'Passed correct key to removeFromHasMany'); assert.equal(recordDatas[0].id, '2', 'Passed correct RD to removeFromHasMany'); calledRemoveFromHasMany++; - hasManyReturnValue = { data: [{ id: '1', type: 'person'}] }; + hasManyReturnValue = { data: [{ id: '1', type: 'person' }] }; this._storeWrapper.notifyHasManyChange('house', '1', null, 'tenants'); } setDirtyHasMany(key: string, recordDatas: any[]) { assert.equal(key, 'tenants', 'Passed correct key to addToHasMany'); assert.equal(recordDatas[0].id, '3', 'Passed correct RD to addToHasMany'); - hasManyReturnValue = { data: [{ id: '1', type: 'person'} , { id: '2', type: 'person' }] }; + hasManyReturnValue = { data: [{ id: '1', type: 'person' }, { id: '2', type: 'person' }] }; this._storeWrapper.notifyHasManyChange('house', '1', null, 'tenants'); } } @@ -637,7 +656,7 @@ module('integration/record-data - Custom RecordData Implementations', function ( } else { return this._super(modelName, id, clientId, storeWrapper); } - } + }, }); owner.register('service:store', TestStore); @@ -645,11 +664,11 @@ module('integration/record-data - Custom RecordData Implementations', function ( store = owner.lookup('service:store'); store.push({ - data: [davidHash, runspiredHash, igorHash] + data: [davidHash, runspiredHash, igorHash], }); store.push({ - data: [houseHash] + data: [houseHash], }); let house = store.peekRecord('house', '1'); @@ -672,5 +691,4 @@ module('integration/record-data - Custom RecordData Implementations', function ( // This is intentionally !== [igor] to test the custom RD implementation assert.deepEqual(people.toArray(), [david, runspired], 'setDirtyHasMany applies changes'); }); - }); diff --git a/packages/-ember-data/tests/unit/model-test.js b/packages/-ember-data/tests/unit/model-test.js index f842f47a25c..cd64c6ab9fe 100644 --- a/packages/-ember-data/tests/unit/model-test.js +++ b/packages/-ember-data/tests/unit/model-test.js @@ -1225,7 +1225,15 @@ module('unit/model - Model', function(hooks) { test('an invalid record becomes clean again if changed property is reset', async function(assert) { adapter.updateRecord = () => { - return reject(new InvalidError([{ name: 'not valid' }])); + return reject( + new InvalidError([ + { + source: { + pointer: '/data/attributes/name', + }, + }, + ]) + ); }; store.push({ @@ -1279,7 +1287,15 @@ module('unit/model - Model', function(hooks) { test('an invalid record stays dirty if only invalid property is reset', async function(assert) { adapter.updateRecord = () => { - return reject(new InvalidError([{ name: 'not valid' }])); + return reject( + new InvalidError([ + { + source: { + pointer: '/data/attributes/name', + }, + }, + ]) + ); }; store.push({ diff --git a/packages/-ember-data/tsconfig.json b/packages/-ember-data/tsconfig.json index aec3c076a4b..8cf7522af40 100644 --- a/packages/-ember-data/tsconfig.json +++ b/packages/-ember-data/tsconfig.json @@ -21,7 +21,10 @@ "ember-data/*": ["addon/*"], "ember-data/test-support": ["addon-test-support"], "ember-data/test-support/*": ["addon-test-support/*"], - "*": ["types/*"] + "@ember-data/store": ["../store/addon"], + "@ember-data/store/*": ["../store/addon/*"], + "@ember-data/adapter/error": ["../adapter/addon/error"], + "*": ["../store/types/*"] } }, "include": [ diff --git a/packages/adapter/addon/error.js b/packages/adapter/addon/error.js index 5908c1076c1..db3a86c81d3 100644 --- a/packages/adapter/addon/error.js +++ b/packages/adapter/addon/error.js @@ -1,12 +1,6 @@ -import { makeArray } from '@ember/array'; -import { isPresent } from '@ember/utils'; import EmberError from '@ember/error'; import { assert } from '@ember/debug'; -const SOURCE_POINTER_REGEXP = /^\/?data\/(attributes|relationships)\/(.*)/; -const SOURCE_POINTER_PRIMARY_REGEXP = /^\/?data/; -const PRIMARY_ATTRIBUTE_KEY = 'base'; - /** A `AdapterError` is used by an adapter to signal that an error occurred during a request to an external API. It indicates a generic error, and @@ -332,132 +326,4 @@ export const ServerError = extend( 'The adapter operation failed due to a server error' ); -/** - Convert an hash of errors into an array with errors in JSON-API format. - - ```javascript - import { errorsHashToArray } from '@ember-data/adapter/error'; - - let errors = { - base: 'Invalid attributes on saving this record', - name: 'Must be present', - age: ['Must be present', 'Must be a number'] - }; - - let errorsArray = errorsHashToArray(errors); - // [ - // { - // title: "Invalid Document", - // detail: "Invalid attributes on saving this record", - // source: { pointer: "/data" } - // }, - // { - // title: "Invalid Attribute", - // detail: "Must be present", - // source: { pointer: "/data/attributes/name" } - // }, - // { - // title: "Invalid Attribute", - // detail: "Must be present", - // source: { pointer: "/data/attributes/age" } - // }, - // { - // title: "Invalid Attribute", - // detail: "Must be a number", - // source: { pointer: "/data/attributes/age" } - // } - // ] - ``` - - @method errorsHashToArray - @public - @param {Object} errors hash with errors as properties - @return {Array} array of errors in JSON-API format -*/ -export function errorsHashToArray(errors) { - let out = []; - - if (isPresent(errors)) { - Object.keys(errors).forEach(key => { - let messages = makeArray(errors[key]); - for (let i = 0; i < messages.length; i++) { - let title = 'Invalid Attribute'; - let pointer = `/data/attributes/${key}`; - if (key === PRIMARY_ATTRIBUTE_KEY) { - title = 'Invalid Document'; - pointer = `/data`; - } - out.push({ - title: title, - detail: messages[i], - source: { - pointer: pointer, - }, - }); - } - }); - } - - return out; -} - -/** - Convert an array of errors in JSON-API format into an object. - - ```javascript - import { errorsArrayToHash } from '@ember-data/adapter/error'; - - let errorsArray = [ - { - title: 'Invalid Attribute', - detail: 'Must be present', - source: { pointer: '/data/attributes/name' } - }, - { - title: 'Invalid Attribute', - detail: 'Must be present', - source: { pointer: '/data/attributes/age' } - }, - { - title: 'Invalid Attribute', - detail: 'Must be a number', - source: { pointer: '/data/attributes/age' } - } - ]; - - let errors = errorsArrayToHash(errorsArray); - // { - // "name": ["Must be present"], - // "age": ["Must be present", "must be a number"] - // } - ``` - - @method errorsArrayToHash - @public - @param {Array} errors array of errors in JSON-API format - @return {Object} -*/ -export function errorsArrayToHash(errors) { - let out = {}; - - if (isPresent(errors)) { - errors.forEach(error => { - if (error.source && error.source.pointer) { - let key = error.source.pointer.match(SOURCE_POINTER_REGEXP); - - if (key) { - key = key[2]; - } else if (error.source.pointer.search(SOURCE_POINTER_PRIMARY_REGEXP) !== -1) { - key = PRIMARY_ATTRIBUTE_KEY; - } - - if (key) { - out[key] = out[key] || []; - out[key].push(error.detail || error.title); - } - } - }); - } - - return out; -} +export { errorsHashToArray, errorsArrayToHash } from '@ember-data/store/-private'; diff --git a/packages/adapter/addon/rest.js b/packages/adapter/addon/rest.js index 3c66854e354..3165c4e62c8 100644 --- a/packages/adapter/addon/rest.js +++ b/packages/adapter/addon/rest.js @@ -1253,7 +1253,9 @@ function ajaxSuccess(adapter, payload, requestData, responseData) { function ajaxError(adapter, payload, requestData, responseData) { if (DEBUG) { - let message = `The server returned an empty string for ${requestData.method} ${requestData.url}, which cannot be parsed into a valid JSON. Return either null or {}.`; + let message = `The server returned an empty string for ${requestData.method} ${ + requestData.url + }, which cannot be parsed into a valid JSON. Return either null or {}.`; let validJSONString = !(responseData.textStatus === 'parsererror' && payload === ''); warn(message, validJSONString, { id: 'ds.adapter.returned-empty-string-as-JSON', diff --git a/packages/adapter/tsconfig.json b/packages/adapter/tsconfig.json index e72b9a85523..72e5477cf94 100644 --- a/packages/adapter/tsconfig.json +++ b/packages/adapter/tsconfig.json @@ -23,6 +23,8 @@ "@ember-data/adapter/test-support/*": ["addon-test-support/*"], "ember-data": ["../-ember-data/addon"], "ember-data/*": ["../-ember-data/addon/*"], + "@ember-data/store": ["../store/addon"], + "@ember-data/store/*": ["../store/addon/*"], "*": ["types/*"] } }, diff --git a/packages/canary-features/addon/environment/global.js b/packages/canary-features/addon/environment/global.js deleted file mode 100644 index 0d8c8b47f43..00000000000 --- a/packages/canary-features/addon/environment/global.js +++ /dev/null @@ -1,17 +0,0 @@ -/* global global:false mainContext:false */ -// from lodash to catch fake globals -function checkGlobal(value) { - return value && value.Object === Object ? value : undefined; -} - -// element ids can ruin global miss checks -function checkElementIdShadowing(value) { - return value && value.nodeType === undefined ? value : undefined; -} - -// export real global -export default checkGlobal(checkElementIdShadowing(typeof global === 'object' && global)) || -checkGlobal(typeof self === 'object' && self) || -checkGlobal(typeof window === 'object' && window) || -(typeof mainContext !== 'undefined' && mainContext) || // set before strict mode in Ember loader/wrapper - new Function('return this')(); // eval outside of strict mode diff --git a/packages/canary-features/addon/environment/index.js b/packages/canary-features/addon/environment/index.js deleted file mode 100644 index 7afcc369ba1..00000000000 --- a/packages/canary-features/addon/environment/index.js +++ /dev/null @@ -1 +0,0 @@ -export * from './global'; diff --git a/packages/canary-features/addon/index.js b/packages/canary-features/addon/index.js index bbaa1b676b2..6316194d601 100644 --- a/packages/canary-features/addon/index.js +++ b/packages/canary-features/addon/index.js @@ -1,30 +1,24 @@ +/* globals EmberDataENV */ + import { assign } from '@ember/polyfills'; -import global from '@ember-data/canary-features/environment'; -export const ENV = { - FEATURES: {}, -}; -(EmberDataENV => { - if (typeof EmberDataENV !== 'object' || EmberDataENV === null) return; - for (let flag in EmberDataENV) { - if ( - !EmberDataENV.hasOwnProperty(flag) || - flag === 'EXTEND_PROTOTYPES' || - flag === 'EMBER_LOAD_HOOKS' - ) - continue; - let defaultValue = ENV[flag]; - if (defaultValue === true) { - ENV[flag] = EmberDataENV[flag] !== false; - } else if (defaultValue === false) { - ENV[flag] = EmberDataENV[flag] === true; - } - } -})(global.EmberDataENV || global.ENV); +const ENV = typeof EmberDataENV === 'object' && EmberDataENV !== null ? EmberDataENV : {}; +// TODO: Make this file the source of truth, currently this must match +// the contents of `packages/-build-infra/src/features.js` export const DEFAULT_FEATURES = { SAMPLE_FEATURE_FLAG: null, + RECORD_DATA_ERRORS: null, }; +function featureValue(value) { + if (ENV.ENABLE_OPTIONAL_FEATURES && value === null) { + return true; + } + + return value; +} + export const FEATURES = assign({}, DEFAULT_FEATURES, ENV.FEATURES); -export const SAMPLE_FEATURE_FLAG = FEATURES.SAMPLE_FEATURE_FLAG; +export const SAMPLE_FEATURE_FLAG = featureValue(FEATURES.SAMPLE_FEATURE_FLAG); +export const RECORD_DATA_ERRORS = featureValue(FEATURES.RECORD_DATA_ERRORS); diff --git a/packages/model/addon/-private/attr.js b/packages/model/addon/-private/attr.js index 85666cd88c6..0aea2024d32 100644 --- a/packages/model/addon/-private/attr.js +++ b/packages/model/addon/-private/attr.js @@ -2,6 +2,7 @@ import { computed } from '@ember/object'; import { assert } from '@ember/debug'; import { DEBUG } from '@glimmer/env'; import { recordDataFor } from '@ember-data/store/-private'; +import { RECORD_DATA_ERRORS } from '@ember-data/canary-features'; /** @module ember-data @@ -147,6 +148,16 @@ export default function attr(type, options) { ); } } + if (RECORD_DATA_ERRORS) { + let oldValue = this._internalModel._recordData.getAttr(key); + if (oldValue !== value) { + let errors = this.get('errors'); + if (errors.get(key)) { + errors.remove(key); + } + this._markInvalidRequestAsClean(); + } + } return this._internalModel.setDirtyAttribute(key, value); }, }).meta(meta); diff --git a/packages/model/addon/-private/belongs-to.js b/packages/model/addon/-private/belongs-to.js index 579f865c0bc..2ebcbf3851d 100644 --- a/packages/model/addon/-private/belongs-to.js +++ b/packages/model/addon/-private/belongs-to.js @@ -141,7 +141,9 @@ export default function belongsTo(modelName, options) { } if (opts.hasOwnProperty('serialize')) { warn( - `You provided a serialize option on the "${key}" property in the "${this._internalModel.modelName}" class, this belongs in the serializer. See Serializer and it's implementations https://emberjs.com/api/data/classes/DS.Serializer.html`, + `You provided a serialize option on the "${key}" property in the "${ + this._internalModel.modelName + }" class, this belongs in the serializer. See Serializer and it's implementations https://emberjs.com/api/data/classes/DS.Serializer.html`, false, { id: 'ds.model.serialize-option-in-belongs-to', @@ -151,7 +153,9 @@ export default function belongsTo(modelName, options) { if (opts.hasOwnProperty('embedded')) { warn( - `You provided an embedded option on the "${key}" property in the "${this._internalModel.modelName}" class, this belongs in the serializer. See EmbeddedRecordsMixin https://emberjs.com/api/data/classes/DS.EmbeddedRecordsMixin.html`, + `You provided an embedded option on the "${key}" property in the "${ + this._internalModel.modelName + }" class, this belongs in the serializer. See EmbeddedRecordsMixin https://emberjs.com/api/data/classes/DS.EmbeddedRecordsMixin.html`, false, { id: 'ds.model.embedded-option-in-belongs-to', diff --git a/packages/model/index.js b/packages/model/index.js index e6b2a672511..4350d808784 100644 --- a/packages/model/index.js +++ b/packages/model/index.js @@ -7,6 +7,6 @@ const addonBaseConfig = addonBuildConfigForDataPackage(name); module.exports = Object.assign(addonBaseConfig, { shouldRollupPrivate: true, externalDependenciesForPrivateModule() { - return ['@ember-data/store', '@ember-data/store/-private']; + return ['@ember-data/canary-features', '@ember-data/store', '@ember-data/store/-private']; }, }); diff --git a/packages/serializer/addon/-private/embedded-records-mixin.js b/packages/serializer/addon/-private/embedded-records-mixin.js index f08acea6e31..39a291ebefa 100644 --- a/packages/serializer/addon/-private/embedded-records-mixin.js +++ b/packages/serializer/addon/-private/embedded-records-mixin.js @@ -430,7 +430,9 @@ export default Mixin.create({ } warn( - `The embedded relationship '${serializedKey}' is undefined for '${snapshot.modelName}' with id '${snapshot.id}'. Please include it in your original payload.`, + `The embedded relationship '${serializedKey}' is undefined for '${ + snapshot.modelName + }' with id '${snapshot.id}'. Please include it in your original payload.`, typeOf(snapshot.hasMany(relationship.key)) !== 'undefined', { id: 'ds.serializer.embedded-relationship-undefined' } ); diff --git a/packages/serializer/addon/json-api.js b/packages/serializer/addon/json-api.js index bed695f7753..fbeaf16e35b 100644 --- a/packages/serializer/addon/json-api.js +++ b/packages/serializer/addon/json-api.js @@ -261,7 +261,9 @@ const JSONAPISerializer = JSONSerializer.extend({ resourceHash.attributes[key] !== undefined ) { assert( - `Your payload for '${modelClass.modelName}' contains '${key}', but your serializer is setup to look for '${attributeKey}'. This is most likely because Ember Data's JSON API serializer dasherizes attribute keys by default. You should subclass JSONAPISerializer and implement 'keyForAttribute(key) { return key; }' to prevent Ember Data from customizing your attribute keys.`, + `Your payload for '${ + modelClass.modelName + }' contains '${key}', but your serializer is setup to look for '${attributeKey}'. This is most likely because Ember Data's JSON API serializer dasherizes attribute keys by default. You should subclass JSONAPISerializer and implement 'keyForAttribute(key) { return key; }' to prevent Ember Data from customizing your attribute keys.`, false ); } @@ -326,7 +328,9 @@ const JSONAPISerializer = JSONSerializer.extend({ resourceHash.relationships[key] !== undefined ) { assert( - `Your payload for '${modelClass.modelName}' contains '${key}', but your serializer is setup to look for '${relationshipKey}'. This is most likely because Ember Data's JSON API serializer dasherizes relationship keys by default. You should subclass JSONAPISerializer and implement 'keyForRelationship(key) { return key; }' to prevent Ember Data from customizing your relationship keys.`, + `Your payload for '${ + modelClass.modelName + }' contains '${key}', but your serializer is setup to look for '${relationshipKey}'. This is most likely because Ember Data's JSON API serializer dasherizes relationship keys by default. You should subclass JSONAPISerializer and implement 'keyForRelationship(key) { return key; }' to prevent Ember Data from customizing your relationship keys.`, false ); } diff --git a/packages/store/addon/-private/index.js b/packages/store/addon/-private/index.js index d769e5e96f6..4ff5e854fd9 100644 --- a/packages/store/addon/-private/index.js +++ b/packages/store/addon/-private/index.js @@ -14,6 +14,8 @@ export { export { default as normalizeModelName } from './system/normalize-model-name'; export { default as coerceId } from './system/coerce-id'; +export { errorsHashToArray, errorsArrayToHash } from './system/errors-utils'; + // `ember-data-model-fragments` relies on `RootState` and `InternalModel` export { default as RootState } from './system/model/states'; export { default as InternalModel } from './system/model/internal-model'; diff --git a/packages/store/addon/-private/system/errors-utils.js b/packages/store/addon/-private/system/errors-utils.js new file mode 100644 index 00000000000..c5271ac1984 --- /dev/null +++ b/packages/store/addon/-private/system/errors-utils.js @@ -0,0 +1,139 @@ +import { makeArray } from '@ember/array'; +import { isPresent } from '@ember/utils'; + +const SOURCE_POINTER_REGEXP = /^\/?data\/(attributes|relationships)\/(.*)/; +const SOURCE_POINTER_PRIMARY_REGEXP = /^\/?data/; +const PRIMARY_ATTRIBUTE_KEY = 'base'; + +/** + Convert an hash of errors into an array with errors in JSON-API format. + ```javascript + import DS from 'ember-data'; + const { errorsHashToArray } = DS; + let errors = { + base: 'Invalid attributes on saving this record', + name: 'Must be present', + age: ['Must be present', 'Must be a number'] + }; + let errorsArray = errorsHashToArray(errors); + // [ + // { + // title: "Invalid Document", + // detail: "Invalid attributes on saving this record", + // source: { pointer: "/data" } + // }, + // { + // title: "Invalid Attribute", + // detail: "Must be present", + // source: { pointer: "/data/attributes/name" } + // }, + // { + // title: "Invalid Attribute", + // detail: "Must be present", + // source: { pointer: "/data/attributes/age" } + // }, + // { + // title: "Invalid Attribute", + // detail: "Must be a number", + // source: { pointer: "/data/attributes/age" } + // } + // ] + ``` + @method errorsHashToArray + @public + @namespace + @for DS + @param {Object} errors hash with errors as properties + @return {Array} array of errors in JSON-API format +*/ +export function errorsHashToArray(errors) { + let out = []; + + if (isPresent(errors)) { + Object.keys(errors).forEach(key => { + let messages = makeArray(errors[key]); + for (let i = 0; i < messages.length; i++) { + let title = 'Invalid Attribute'; + let pointer = `/data/attributes/${key}`; + if (key === PRIMARY_ATTRIBUTE_KEY) { + title = 'Invalid Document'; + pointer = `/data`; + } + out.push({ + title: title, + detail: messages[i], + source: { + pointer: pointer, + }, + }); + } + }); + } + + return out; +} + +/** + Convert an array of errors in JSON-API format into an object. + + ```javascript + import DS from 'ember-data'; + + const { errorsArrayToHash } = DS; + + let errorsArray = [ + { + title: 'Invalid Attribute', + detail: 'Must be present', + source: { pointer: '/data/attributes/name' } + }, + { + title: 'Invalid Attribute', + detail: 'Must be present', + source: { pointer: '/data/attributes/age' } + }, + { + title: 'Invalid Attribute', + detail: 'Must be a number', + source: { pointer: '/data/attributes/age' } + } + ]; + + let errors = errorsArrayToHash(errorsArray); + // { + // "name": ["Must be present"], + // "age": ["Must be present", "must be a number"] + // } + ``` + + @method errorsArrayToHash + @public + @namespace + @for DS + @param {Array} errors array of errors in JSON-API format + @return {Object} +*/ +export function errorsArrayToHash(errors) { + let out = {}; + + if (isPresent(errors)) { + errors.forEach(error => { + if (error.source && error.source.pointer) { + let key = error.source.pointer.match(SOURCE_POINTER_REGEXP); + + if (key) { + key = key[2]; + } else if (error.source.pointer.search(SOURCE_POINTER_PRIMARY_REGEXP) !== -1) { + key = PRIMARY_ATTRIBUTE_KEY; + } + + if (key) { + out[key] = out[key] || []; + out[key].push(error.detail || error.title); + } + } + }); + } + + return out; +} diff --git a/packages/store/addon/-private/system/model/internal-model.ts b/packages/store/addon/-private/system/model/internal-model.ts index 678d398680b..0d7010ceab8 100644 --- a/packages/store/addon/-private/system/model/internal-model.ts +++ b/packages/store/addon/-private/system/model/internal-model.ts @@ -14,13 +14,15 @@ import OrderedSet from '../ordered-set'; import ManyArray from '../many-array'; import { PromiseBelongsTo, PromiseManyArray } from '../promise-proxies'; import Store from '../store'; +import { errorsHashToArray, errorsArrayToHash } from '../errors-utils'; import { RecordReference, BelongsToReference, HasManyReference } from '../references'; import { default as recordDataFor, relationshipStateFor } from '../record-data-for'; import RecordData from '../../ts-interfaces/record-data'; -import { JsonApiResource } from '../../ts-interfaces/record-data-json-api'; +import { JsonApiResource, JsonApiValidationError } from '../../ts-interfaces/record-data-json-api'; import { Record } from '../../ts-interfaces/record'; import { Dict } from '../../types'; +import { RECORD_DATA_ERRORS } from '@ember-data/canary-features'; // move to TS hacks module that we can delete when this is no longer a necessary recast type ManyArray = InstanceType; @@ -105,13 +107,12 @@ export default class InternalModel { // we create a new ManyArray, but in the interim the retained version will be // updated if inverse internal models are unloaded. _retainedManyArrayCache: Dict = Object.create(null); - _relationshipPromisesCache: Dict> = Object.create(null); + _relationshipPromisesCache: Dict> = Object.create(null); _relationshipProxyCache: Dict = Object.create(null); currentState: any; error: any; constructor(modelName: string, id: string | null, store, data, clientId) { - this.id = id; this.store = store; this.modelName = modelName; @@ -246,7 +247,10 @@ export default class InternalModel { } isValid() { - return this.currentState.isValid; + if (RECORD_DATA_ERRORS) { + } else { + return this.currentState.isValid; + } } dirtyType() { @@ -630,7 +634,7 @@ export default class InternalModel { manyArray => handleCompletedRelationshipRequest(this, key, jsonApi._relationship, manyArray, null), e => handleCompletedRelationshipRequest(this, key, jsonApi._relationship, null, e) - ); + ) as RSVP.Promise; this._relationshipPromisesCache[key] = loadingPromise; return loadingPromise; } @@ -896,13 +900,25 @@ export default class InternalModel { @param {String} name @param {Object} context */ - send(name, context?) { + send(name, context?, fromModel?) { let currentState = this.currentState; if (!currentState[name]) { this._unhandledEvent(currentState, name, context); } + if (RECORD_DATA_ERRORS) { + if ( + fromModel && + name === 'becameInvalid' && + this._recordData.getErrors && + this._recordData.getErrors({}).length === 0 + ) { + // this is a very specific internal hack for backsupport for record.send('becameInvalid') + let jsonApiErrors = [{ title: 'Invalid Error', detail: '', source: { pointer: '/data' } }]; + this._recordData.commitWasRejected({}, jsonApiErrors); + } + } return currentState[name](this, context); } @@ -1230,9 +1246,17 @@ export default class InternalModel { } hasErrors() { - let errors = get(this.getRecord(), 'errors'); - - return errors.get('length') > 0; + if (RECORD_DATA_ERRORS) { + if (this._recordData.getErrors) { + return this._recordData.getErrors({}).length > 0; + } else { + let errors = get(this.getRecord(), 'errors'); + return errors.get('length') > 0; + } + } else { + let errors = get(this.getRecord(), 'errors'); + return errors.get('length') > 0; + } } // FOR USE DURING COMMIT PROCESS @@ -1241,18 +1265,55 @@ export default class InternalModel { @method adapterDidInvalidate @private */ - adapterDidInvalidate(errors) { - let attribute; + adapterDidInvalidate(parsedErrors, error) { + if (RECORD_DATA_ERRORS) { + let attribute; + if (error && parsedErrors) { + if (!this._recordData.getErrors) { + for (attribute in parsedErrors) { + if (parsedErrors.hasOwnProperty(attribute)) { + this.addErrorMessageToAttribute(attribute, parsedErrors[attribute]); + } + } + } - for (attribute in errors) { - if (errors.hasOwnProperty(attribute)) { - this.addErrorMessageToAttribute(attribute, errors[attribute]); + let jsonApiErrors: JsonApiValidationError[] = errorsHashToArray(parsedErrors); + this.send('becameInvalid'); + if (jsonApiErrors.length === 0) { + jsonApiErrors = [{ title: 'Invalid Error', detail: '', source: { pointer: '/data' } }]; + } + this._recordData.commitWasRejected({}, jsonApiErrors); + } else { + this.send('becameError'); + this._recordData.commitWasRejected({}); } + } else { + let attribute; + + for (attribute in parsedErrors) { + if (parsedErrors.hasOwnProperty(attribute)) { + this.addErrorMessageToAttribute(attribute, parsedErrors[attribute]); + } + } + + this.send('becameInvalid'); + + this._recordData.commitWasRejected(); } + } - this.send('becameInvalid'); + notifyErrorsChange() { + let invalidErrors; + if (this._recordData.getErrors) { + invalidErrors = this._recordData.getErrors({}) || []; + } else { + return; + } + this.notifyInvalidErrorsChange(invalidErrors); + } - this._recordData.commitWasRejected(); + notifyInvalidErrorsChange(jsonApiErrors: JsonApiValidationError[]) { + this.getRecord().invalidErrorsChanged(jsonApiErrors); } /* diff --git a/packages/store/addon/-private/system/model/model.js b/packages/store/addon/-private/system/model/model.js index f1c9eed50b8..31b917ce86f 100644 --- a/packages/store/addon/-private/system/model/model.js +++ b/packages/store/addon/-private/system/model/model.js @@ -5,6 +5,7 @@ import EmberObject, { computed, get } from '@ember/object'; import { DEBUG } from '@glimmer/env'; import { assert, warn, deprecate } from '@ember/debug'; import { PromiseObject } from '../promise-proxies'; +import { errorsArrayToHash } from '../errors-utils'; import Errors from '../model/errors'; import { relationshipsByNameDescriptor, @@ -16,6 +17,8 @@ import recordDataFor from '../record-data-for'; import Ember from 'ember'; import InternalModel from './internal-model'; import RootState from './states'; +import { RECORD_DATA_ERRORS } from '@ember-data/canary-features'; + const { changeProperties } = Ember; /** @@ -59,6 +62,11 @@ const retrieveFromCurrentState = computed('currentState', function(key) { return get(this._internalModel.currentState, key); }).readOnly(); +const isValidRecordData = computed('errors.length', function(key) { + return !(this.get('errors.length') > 0); +}).readOnly(); + +const isValid = RECORD_DATA_ERRORS ? isValidRecordData : retrieveFromCurrentState; /** The model class that all Ember Data records descend from. @@ -70,6 +78,17 @@ const retrieveFromCurrentState = computed('currentState', function(key) { @uses Ember.Evented */ const Model = EmberObject.extend(Evented, { + init() { + this._super(...arguments); + if (RECORD_DATA_ERRORS) { + this._invalidRequests = []; + } + }, + + _notifyNetworkChanges: function() { + this.notifyPropertyChange('isValid'); + }, + /** If this property is `true` the record is in the `empty` state. Empty is the first state all records enter after they have @@ -237,7 +256,15 @@ const Model = EmberObject.extend(Evented, { @type {Boolean} @readOnly */ - isValid: retrieveFromCurrentState, + isValid: isValid, + + _markInvalidRequestAsClean() { + if (RECORD_DATA_ERRORS) { + this._invalidRequests = []; + this._notifyNetworkChanges(); + } + }, + /** If the record is in the dirty state this property will report what kind of change has caused it to move into the dirty @@ -407,9 +434,44 @@ const Model = EmberObject.extend(Evented, { this.send('becameValid'); } ); + if (RECORD_DATA_ERRORS) { + let recordData = recordDataFor(this); + let jsonApiErrors; + if (recordData.getErrors) { + jsonApiErrors = recordData.getErrors(); + if (jsonApiErrors) { + let errorsHash = errorsArrayToHash(jsonApiErrors); + let errorKeys = Object.keys(errorsHash); + + for (let i = 0; i < errorKeys.length; i++) { + errors._add(errorKeys[i], errorsHash[errorKeys[i]]); + } + } + } + } return errors; }).readOnly(), + invalidErrorsChanged(jsonApiErrors) { + if (RECORD_DATA_ERRORS) { + this._clearErrorMessages(); + let errors = errorsArrayToHash(jsonApiErrors); + let errorKeys = Object.keys(errors); + + for (let i = 0; i < errorKeys.length; i++) { + this._addErrorMessageToAttribute(errorKeys[i], errors[errorKeys[i]]); + } + } + }, + + _addErrorMessageToAttribute(attribute, message) { + this.get('errors')._add(attribute, message); + }, + + _clearErrorMessages() { + this.get('errors')._clear(); + }, + /** This property holds the `AdapterError` object with which last adapter operation was rejected. @@ -524,7 +586,7 @@ const Model = EmberObject.extend(Evented, { @param {Object} context */ send(name, context) { - return this._internalModel.send(name, context); + return this._internalModel.send(name, context, true); }, /** @@ -714,6 +776,9 @@ const Model = EmberObject.extend(Evented, { */ rollbackAttributes() { this._internalModel.rollbackAttributes(); + if (RECORD_DATA_ERRORS) { + this._markInvalidRequestAsClean(); + } }, /* @@ -1457,7 +1522,11 @@ Model.reopenClass({ } assert( - `The ${inverseType.modelName}:${inverseName} relationship declares 'inverse: null', but it was resolved as the inverse for ${this.modelName}:${name}.`, + `The ${ + inverseType.modelName + }:${inverseName} relationship declares 'inverse: null', but it was resolved as the inverse for ${ + this.modelName + }:${name}.`, !inverseOptions || inverseOptions.inverse !== null ); diff --git a/packages/store/addon/-private/system/model/record-data.ts b/packages/store/addon/-private/system/model/record-data.ts index a1322565837..1c73133f96c 100644 --- a/packages/store/addon/-private/system/model/record-data.ts +++ b/packages/store/addon/-private/system/model/record-data.ts @@ -8,21 +8,25 @@ import coerceId from '../coerce-id'; import BelongsToRelationship from '../relationships/state/belongs-to'; import ManyRelationship from '../relationships/state/has-many'; import Relationship from '../relationships/state/relationship'; -import RecordData, { ChangedAttributesHash } from '../../ts-interfaces/record-data' -import { JsonApiResource, JsonApiResourceIdentity, JsonApiBelongsToRelationship, JsonApiHasManyRelationship, AttributesHash } from "../../ts-interfaces/record-data-json-api"; +import RecordData, { ChangedAttributesHash } from '../../ts-interfaces/record-data'; +import { + JsonApiResource, + JsonApiResourceIdentity, + JsonApiBelongsToRelationship, + JsonApiHasManyRelationship, + JsonApiValidationError, + AttributesHash, +} from '../../ts-interfaces/record-data-json-api'; import { RelationshipRecordData } from '../../ts-interfaces/relationship-record-data'; import { RecordDataStoreWrapper } from '../../ts-interfaces/record-data-store-wrapper'; +import { RECORD_DATA_ERRORS } from '@ember-data/canary-features'; let nextBfsId = 1; export default class RecordDataDefault implements RelationshipRecordData { - store: any; - modelName: string; + _errors?: JsonApiValidationError[]; __relationships: Relationships | null; - __implicitRelationships:{ [key: string]: Relationship } | null; - clientId: string; - id: string | null; - storeWrapper: RecordDataStoreWrapper; + __implicitRelationships: { [key: string]: Relationship } | null; isDestroyed: boolean; _isNew: boolean; _bfsId: number; @@ -31,13 +35,15 @@ export default class RecordDataDefault implements RelationshipRecordData { __data: any; _scheduledDestroy: any; - constructor(modelName: string, id: string | null, clientId: string, storeWrapper: RecordDataStoreWrapper, store:any) { - this.store = store; - this.modelName = modelName; + constructor( + public modelName: string, + public id: string | null, + public clientId: string, + public storeWrapper: RecordDataStoreWrapper, + public store: any + ) { this.__relationships = null; this.__implicitRelationships = null; - this.clientId = clientId; - this.id = id; this.storeWrapper = storeWrapper; this.isDestroyed = false; this._isNew = false; @@ -90,6 +96,28 @@ export default class RecordDataDefault implements RelationshipRecordData { return this.__attributes !== null && Object.keys(this.__attributes).length > 0; } + _clearErrors() { + if (RECORD_DATA_ERRORS) { + if (this._errors) { + this._errors = undefined; + this.storeWrapper.notifyErrorsChange(this.modelName, this.id, this.clientId); + } + } + } + + getErrors(): JsonApiValidationError[] { + assert( + 'Can not call getErrors unless the RECORD_DATA_ERRORS feature flag is on', + RECORD_DATA_ERRORS + ); + if (RECORD_DATA_ERRORS) { + let errors: JsonApiValidationError[] = this._errors || []; + return errors; + } else { + return []; + } + } + // this is a hack bc we don't have access to the state machine // and relationships need this info and @runspired didn't see // how to get it just yet from storeWrapper. @@ -101,6 +129,7 @@ export default class RecordDataDefault implements RelationshipRecordData { this.__attributes = null; this.__inFlightAttributes = null; this.__data = null; + this._errors = undefined; } _setupRelationships(data) { @@ -179,10 +208,10 @@ export default class RecordDataDefault implements RelationshipRecordData { /* Checks if the attributes which are considered as changed are still different to the state which is acknowledged by the server. - + This method is needed when data for the internal model is pushed and the pushed data might acknowledge dirty attributes as confirmed. - + @method updateChangedAttributes @private */ @@ -206,7 +235,7 @@ export default class RecordDataDefault implements RelationshipRecordData { /* Returns an object, whose keys are changed properties, and value is an [oldProp, newProp] array. - + @method changedAttributes @private */ @@ -243,6 +272,8 @@ export default class RecordDataDefault implements RelationshipRecordData { this._inFlightAttributes = null; + this._clearErrors(); + return dirtyKeys; } @@ -268,6 +299,8 @@ export default class RecordDataDefault implements RelationshipRecordData { this._inFlightAttributes = null; this._updateChangedAttributes(); + this._clearErrors(); + return changedKeys; } @@ -293,7 +326,7 @@ export default class RecordDataDefault implements RelationshipRecordData { this._relationships.get(key).removeRecordDatas(recordDatas); } - commitWasRejected() { + commitWasRejected(identifier?, errors?: JsonApiValidationError[]) { let keys = Object.keys(this._inFlightAttributes); if (keys.length > 0) { let attrs = this._attributes; @@ -304,6 +337,12 @@ export default class RecordDataDefault implements RelationshipRecordData { } } this._inFlightAttributes = null; + if (RECORD_DATA_ERRORS) { + if (errors) { + this._errors = errors; + } + this.storeWrapper.notifyErrorsChange(this.modelName, this.id, this.clientId); + } } getBelongsTo(key: string): JsonApiBelongsToRelationship { @@ -385,10 +424,10 @@ export default class RecordDataDefault implements RelationshipRecordData { /** Computes the set of internal models reachable from `this` across exactly one relationship. - + @return {Array} An array containing the internal models that `this` belongs to or has many. - + */ _directlyRelatedRecordDatas(): RecordData[] { let array = []; @@ -403,11 +442,11 @@ export default class RecordDataDefault implements RelationshipRecordData { /** Computes the set of internal models reachable from this internal model. - + Reachability is determined over the relationship graph (ie a graph where nodes are internal models and edges are belongs to or has many relationships). - + @return {Array} An array including `this` and all internal models reachable from `this`. */ @@ -487,26 +526,26 @@ export default class RecordDataDefault implements RelationshipRecordData { implicit relationships are relationship which have not been declared but the inverse side exists on another record somewhere For example if there was - + ```app/models/comment.js import Model, { attr } from '@ember-data/model'; - + export default Model.extend({ name: attr() }); ``` - + but there is also - + ```app/models/post.js import Model, { attr, hasMany } from '@ember-data/model'; - + export default Model.extend({ name: attr(), comments: hasMany('comment') }); ``` - + would have a implicit post relationship in order to be do things like remove ourselves from the post when we are deleted */ @@ -589,17 +628,17 @@ export default class RecordDataDefault implements RelationshipRecordData { } /* - - + + TODO IGOR AND DAVID this shouldn't be public This method should only be called by records in the `isNew()` state OR once the record has been deleted and that deletion has been persisted. - + It will remove this record from any associated relationships. - + If `isNew` is true (default false), it will also completely reset all relationships to an empty state as well. - + @method removeFromInverseRelationships @param {Boolean} isNew whether to unload from the `isNew` perspective @private @@ -643,15 +682,15 @@ export default class RecordDataDefault implements RelationshipRecordData { /* Ember Data has 3 buckets for storing the value of an attribute on an internalModel. - + `_data` holds all of the attributes that have been acknowledged by a backend via the adapter. When rollbackAttributes is called on a model all attributes will revert to the record's state in `_data`. - + `_attributes` holds any change the user has made to an attribute that has not been acknowledged by the adapter. Any values in `_attributes` are have priority over values in `_data`. - + `_inFlightAttributes`. When a record is being synced with the backend the values in `_attributes` are copied to `_inFlightAttributes`. This way if the backend acknowledges the @@ -659,26 +698,26 @@ export default class RecordDataDefault implements RelationshipRecordData { values from `_inFlightAttributes` to `_data`. Without having to worry about changes made to `_attributes` while the save was happenign. - - + + Changed keys builds a list of all of the values that may have been changed by the backend after a successful save. - + It does this by iterating over each key, value pair in the payload returned from the server after a save. If the `key` is found in `_attributes` then the user has a local changed to the attribute that has not been synced with the server and the key is not included in the list of changed keys. - - - + + + If the value, for a key differs from the value in what Ember Data believes to be the truth about the backend state (A merger of the `_data` and `_inFlightAttributes` objects where `_inFlightAttributes` has priority) then that means the backend has updated the value and the key is added to the list of changed keys. - + @method _changedKeys @private */ diff --git a/packages/store/addon/-private/system/record-arrays/record-array.js b/packages/store/addon/-private/system/record-arrays/record-array.js index 9c314aedcf2..509f5a0d2d0 100644 --- a/packages/store/addon/-private/system/record-arrays/record-array.js +++ b/packages/store/addon/-private/system/record-arrays/record-array.js @@ -82,7 +82,9 @@ export default ArrayProxy.extend(Evented, { replace() { throw new Error( - `The result of a server query (for all ${this.modelName} types) is immutable. To modify contents, use toArray()` + `The result of a server query (for all ${ + this.modelName + } types) is immutable. To modify contents, use toArray()` ); }, diff --git a/packages/store/addon/-private/system/relationships/ext.js b/packages/store/addon/-private/system/relationships/ext.js index 5733a26ca7c..d5482bdf740 100644 --- a/packages/store/addon/-private/system/relationships/ext.js +++ b/packages/store/addon/-private/system/relationships/ext.js @@ -34,7 +34,9 @@ export const relatedTypesDescriptor = computed(function() { let modelName = typeForRelationshipMeta(meta); assert( - `You specified a hasMany (${meta.type}) on ${parentModelName} but ${meta.type} was not found.`, + `You specified a hasMany (${meta.type}) on ${parentModelName} but ${ + meta.type + } was not found.`, modelName ); diff --git a/packages/store/addon/-private/system/store.js b/packages/store/addon/-private/system/store.js index a98d4119306..069a9d7de5e 100644 --- a/packages/store/addon/-private/system/store.js +++ b/packages/store/addon/-private/system/store.js @@ -45,6 +45,7 @@ import RecordArrayManager from './record-array-manager'; import InternalModel from './model/internal-model'; import RecordDataDefault from './model/record-data'; import edBackburner from './backburner'; +import { RECORD_DATA_ERRORS } from '@ember-data/canary-features'; const badIdFormatAssertion = '`id` passed to `findRecord()` has to be non-empty string or number'; const emberRun = emberRunLoop.backburner; @@ -1315,7 +1316,9 @@ const Store = Service.extend({ let adapter = this.adapterFor(internalModel.modelName); assert( - `You tried to load a hasMany relationship but you have no adapter (for ${internalModel.modelName})`, + `You tried to load a hasMany relationship but you have no adapter (for ${ + internalModel.modelName + })`, adapter ); assert( @@ -1415,7 +1418,9 @@ const Store = Service.extend({ let adapter = this.adapterFor(internalModel.modelName); assert( - `You tried to load a belongsTo relationship but you have no adapter (for ${internalModel.modelName})`, + `You tried to load a belongsTo relationship but you have no adapter (for ${ + internalModel.modelName + })`, adapter ); assert( @@ -2164,7 +2169,9 @@ const Store = Service.extend({ } if (!data) { assert( - `Your ${internalModel.modelName} record was saved to the server, but the response does not have an id and no id has been set client side. Records must have ids. Please update the server response to provide an id in the response or generate the id on the client side either before saving the record or while normalizing the response.`, + `Your ${ + internalModel.modelName + } record was saved to the server, but the response does not have an id and no id has been set client side. Records must have ids. Please update the server response to provide an id in the response or generate the id on the client side either before saving the record or while normalizing the response.`, internalModel.id ); } @@ -2184,11 +2191,15 @@ const Store = Service.extend({ @param {InternalModel} internalModel @param {Object} errors */ - recordWasInvalid(internalModel, errors) { + recordWasInvalid(internalModel, parsedErrors, error) { if (DEBUG) { assertDestroyingStore(this, 'recordWasInvalid'); } - internalModel.adapterDidInvalidate(errors); + if (RECORD_DATA_ERRORS) { + internalModel.adapterDidInvalidate(parsedErrors, error); + } else { + internalModel.adapterDidInvalidate(parsedErrors); + } }, /** @@ -3272,9 +3283,8 @@ function _commit(adapter, store, operation, snapshot) { }, function(error) { if (error instanceof InvalidError) { - let errors = serializer.extractErrors(store, modelClass, error, snapshot.id); - - store.recordWasInvalid(internalModel, errors); + let parsedErrors = serializer.extractErrors(store, modelClass, error, snapshot.id); + store.recordWasInvalid(internalModel, parsedErrors, error); } else { store.recordWasError(internalModel, error); } diff --git a/packages/store/addon/-private/system/store/finders.js b/packages/store/addon/-private/system/store/finders.js index c648eb50ad9..56f0266091d 100644 --- a/packages/store/addon/-private/system/store/finders.js +++ b/packages/store/addon/-private/system/store/finders.js @@ -50,7 +50,9 @@ export function _find(adapter, store, modelClass, id, internalModel, options) { ); warn( - `You requested a record of type '${modelName}' with id '${id}' but the adapter returned a payload with primary data having an id of '${payload.data.id}'. Use 'store.findRecord()' when the requested id is the same as the one returned by the adapter. In other cases use 'store.queryRecord()' instead https://emberjs.com/api/data/classes/DS.Store.html#method_queryRecord`, + `You requested a record of type '${modelName}' with id '${id}' but the adapter returned a payload with primary data having an id of '${ + payload.data.id + }'. Use 'store.findRecord()' when the requested id is the same as the one returned by the adapter. In other cases use 'store.queryRecord()' instead https://emberjs.com/api/data/classes/DS.Store.html#method_queryRecord`, coerceId(payload.data.id) === coerceId(id), { id: 'ds.store.findRecord.id-mismatch', @@ -188,7 +190,9 @@ function ensureRelationshipIsSetToParent( let message = [ `Encountered mismatched relationship: Ember Data expected ${path} in the payload from ${relationshipFetched} to include ${expected} but got ${got} instead.\n`, `The ${includedRecord} record loaded at ${prefix} in the payload specified ${other} as its ${quotedInverse}, but should have specified ${expectedModel} (the record the relationship is being loaded from) as its ${quotedInverse} instead.`, - `This could mean that the response for ${relationshipFetched} may have accidentally returned ${quotedType} records that aren't related to ${expectedModel} and could be related to a different ${parentInternalModel.modelName} record instead.`, + `This could mean that the response for ${relationshipFetched} may have accidentally returned ${quotedType} records that aren't related to ${expectedModel} and could be related to a different ${ + parentInternalModel.modelName + } record instead.`, `Ember Data has corrected the ${includedRecord} record's ${quotedInverse} relationship to ${expectedModel} so that ${relationshipFetched} will include ${includedRecord}.`, `Please update the response from the server or change your serializer to either ensure that the response for only includes ${quotedType} records that specify ${expectedModel} as their ${quotedInverse}, or omit the ${quotedInverse} relationship from the response.`, ].join('\n'); @@ -290,7 +294,9 @@ export function _findHasMany(adapter, store, internalModel, link, relationship, let snapshot = internalModel.createSnapshot(options); let modelClass = store.modelFor(relationship.type); let promise = adapter.findHasMany(store, snapshot, link, relationship); - let label = `DS: Handle Adapter#findHasMany of '${internalModel.modelName}' : '${relationship.type}'`; + let label = `DS: Handle Adapter#findHasMany of '${internalModel.modelName}' : '${ + relationship.type + }'`; promise = guardDestroyedStore(promise, store, label); promise = _guard(promise, _bind(_objectIsAlive, internalModel)); @@ -298,7 +304,9 @@ export function _findHasMany(adapter, store, internalModel, link, relationship, return promise.then( adapterPayload => { assert( - `You made a 'findHasMany' request for a ${internalModel.modelName}'s '${relationship.key}' relationship, using link '${link}' , but the adapter's response did not have any data`, + `You made a 'findHasMany' request for a ${internalModel.modelName}'s '${ + relationship.key + }' relationship, using link '${link}' , but the adapter's response did not have any data`, payloadIsNotBlank(adapterPayload) ); let serializer = serializerForAdapter(store, adapter, relationship.type); @@ -326,7 +334,9 @@ export function _findBelongsTo(adapter, store, internalModel, link, relationship let snapshot = internalModel.createSnapshot(options); let modelClass = store.modelFor(relationship.type); let promise = adapter.findBelongsTo(store, snapshot, link, relationship); - let label = `DS: Handle Adapter#findBelongsTo of ${internalModel.modelName} : ${relationship.type}`; + let label = `DS: Handle Adapter#findBelongsTo of ${internalModel.modelName} : ${ + relationship.type + }`; promise = guardDestroyedStore(promise, store, label); promise = _guard(promise, _bind(_objectIsAlive, internalModel)); diff --git a/packages/store/addon/-private/system/store/record-data-store-wrapper.ts b/packages/store/addon/-private/system/store/record-data-store-wrapper.ts index ae387fc5d2a..52afd4e13c6 100644 --- a/packages/store/addon/-private/system/store/record-data-store-wrapper.ts +++ b/packages/store/addon/-private/system/store/record-data-store-wrapper.ts @@ -27,6 +27,11 @@ export default class RecordDataStoreWrapper implements IRecordDataStoreWrapper { }); } + notifyErrorsChange(modelName: string, id: string | null, clientId: string | null) { + let internalModel = this.store._getInternalModelForId(modelName, id, clientId); + internalModel.notifyErrorsChange(); + } + _flushPendingManyArrayUpdates() { if (this._willUpdateManyArrays === false) { return; diff --git a/packages/store/addon/-private/ts-interfaces/record-data-json-api.ts b/packages/store/addon/-private/ts-interfaces/record-data-json-api.ts index 581afbc6c4a..77282c8d750 100644 --- a/packages/store/addon/-private/ts-interfaces/record-data-json-api.ts +++ b/packages/store/addon/-private/ts-interfaces/record-data-json-api.ts @@ -37,4 +37,13 @@ export interface JsonApiHasManyRelationship { // Private _relationship?: ManyRelationship; } + +export interface JsonApiValidationError { + title: string; + detail: string; + source: { + pointer: string; + } +} + export type JsonApiRelationship = JsonApiBelongsToRelationship | JsonApiHasManyRelationship; diff --git a/packages/store/addon/-private/ts-interfaces/record-data-store-wrapper.ts b/packages/store/addon/-private/ts-interfaces/record-data-store-wrapper.ts index e48337ddbba..266283ef33f 100644 --- a/packages/store/addon/-private/ts-interfaces/record-data-store-wrapper.ts +++ b/packages/store/addon/-private/ts-interfaces/record-data-store-wrapper.ts @@ -1,15 +1,31 @@ -import { RelationshipsSchema, AttributesSchema } from "./record-data-schemas"; +import { RelationshipsSchema, AttributesSchema } from './record-data-schemas'; export interface RecordDataStoreWrapper { relationshipsDefinitionFor(modelName: string): RelationshipsSchema; attributesDefinitionFor(modelName: string): AttributesSchema; setRecordId(modelName: string, id: string, clientId: string): void; disconnectRecord(modelName: string, id: string | null, clientId: string): void; isRecordInUse(modelName: string, id: string | null, clientId: string): boolean; - notifyPropertyChange(modelName: string, id: string | null, clientId: string | null, key: string): void; + notifyPropertyChange( + modelName: string, + id: string | null, + clientId: string | null, + key: string + ): void; // Needed For relationships - notifyHasManyChange(modelName: string, id: string | null, clientId: string | null, key: string): void; + notifyHasManyChange( + modelName: string, + id: string | null, + clientId: string | null, + key: string + ): void; recordDataFor(modelName: string, id: string, clientId?: string): unknown; - notifyBelongsToChange(modelName: string, id: string | null, clientId: string | null, key: string): void; + notifyBelongsToChange( + modelName: string, + id: string | null, + clientId: string | null, + key: string + ): void; inverseForRelationship(modelName: string, key: string): string; inverseIsAsyncForRelationship(modelName: string, key: string): boolean; -} \ No newline at end of file + notifyErrorsChange(modelName: string, id: string | null, clientId: string | null): void; +} diff --git a/packages/store/addon/-private/ts-interfaces/record-data.ts b/packages/store/addon/-private/ts-interfaces/record-data.ts index a6d8614a34c..d3b5203478c 100644 --- a/packages/store/addon/-private/ts-interfaces/record-data.ts +++ b/packages/store/addon/-private/ts-interfaces/record-data.ts @@ -2,17 +2,24 @@ import { JsonApiResource, JsonApiHasManyRelationship, JsonApiBelongsToRelationship, + JsonApiValidationError, } from './record-data-json-api'; export interface ChangedAttributesHash { [key: string]: [string, string]; } +export interface RecordIdentifier { + id?: string | null; + type?: string; + lid?: string; +} + export default interface RecordData { pushData(data: JsonApiResource, calculateChange?: boolean): void; clientDidCreate(): void; willCommit(): void; - commitWasRejected(): void; + commitWasRejected(recordIdentifier?: RecordIdentifier, errors?: JsonApiValidationError[]): void; unloadRecord(): void; rollbackAttributes(): string[]; changedAttributes(): ChangedAttributesHash; @@ -38,4 +45,10 @@ export default interface RecordData { isRecordInUse(): boolean; _initRecordCreateOptions(options: any): object; + + // new + + isNew(): boolean; + + getErrors?(recordIdentifier: RecordIdentifier): JsonApiValidationError[]; } diff --git a/packages/store/index.js b/packages/store/index.js index 66a1366bcbe..f61aa24a496 100644 --- a/packages/store/index.js +++ b/packages/store/index.js @@ -9,6 +9,7 @@ module.exports = Object.assign(addonBaseConfig, { externalDependenciesForPrivateModule() { return [ '@ember-data/adapter/error', + '@ember-data/canary-features', 'ember-inflector', '@ember/ordered-set', 'ember-data/-debug',