Repository: adopted-ember-addons/ember-changeset
Pull request #668: add #removeError and #removeErrors to Changeset interface
Author: login: jkeen
Labels: name: enhancement
Created: 2023-05-17T22:44:52Z | Merged: 2025-03-25T15:03:39Z

I'm using ember-changeset with ember-changeset-validations with some validations where the presence requirement of one field is dependent on the value of another. If the dependent field ends up with an error and then the field it depends on changes, the error should really be cleared. But it didn't seem like there was a clean way to do that without actually rolling back the property. This seems to have come up a number of times in issues and PRs.

> This seems to be something that works in the newer `validated-changeset` 4.x-5.x experiment, but is not something available in the classic BufferedChangeset. 

> https://github.com/poteto/ember-changeset/issues/361
> https://github.com/poteto/ember-changeset/issues/282

> There were some workarounds in the above threads, like effectively "clearing" an error by changeset.addError('email', null), but in my testing it still left the changeset with an isValid = false state.

This PR adds removeError and removeErrors to the Changeset interface, which was just added to validated-changeset 1.4.0


Diff:
diff --git a/README.md b/README.md
index 86f38d1c..7a9103da 100644
--- a/README.md
+++ b/README.md
@@ -387,6 +387,8 @@ export default class ValidatedForm extends Component {
   - [`validate`](#validate)
   - [`addError`](#adderror)
   - [`pushErrors`](#pusherrors)
+  - [`removeError`](#removeerror)
+  - [`removeErrors`](#removeerrors)
   - [`snapshot`](#snapshot)
   - [`restore`](#restore)
   - [`cast`](#cast)
@@ -894,6 +896,28 @@ This is compatible with `ember-changeset-validations`, and allows you to either
 
 **[⬆️ back to top](#api)**
 
+#### `removeError`
+
+Manually remove an error from the changeset.
+
+```js
+changeset.removeError('email');
+```
+Removes an error without having to rollback the property.
+
+**[⬆️ back to top](#api)**
+
+#### `removeErrors`
+
+Manually remove an error from the changeset.
+
+```js
+changeset.removeErrors()
+```
+Removes all the errors without having to rollback properties.
+
+**[⬆️ back to top](#api)**
+
 #### `snapshot`
 
 Creates a snapshot of the changeset's errors and changes. This can be used to `restore` the changeset at a later time.
diff --git a/ember-changeset/addon/index.js b/ember-changeset/addon/index.js
index 357a1ffc..8c4424b0 100644
--- a/ember-changeset/addon/index.js
+++ b/ember-changeset/addon/index.js
@@ -136,6 +136,28 @@ export class EmberChangeset extends BufferedChangeset {
     return error;
   }
 
+  /**
+   * Manually remove an error from the changeset.
+   *
+   * @method removeError
+   */
+  removeError(key) {
+    super.removeError(key);
+
+    notifyPropertyChange(this, key);
+    return this;
+  }
+
+  /**
+   * Manually clears the errors from the changeset
+   *
+   * @method removeError
+   */
+  removeErrors() {
+    super.removeErrors();
+    return this;
+  }
+
   /**
    * Manually push multiple errors to the changeset as an array.
    *
diff --git a/ember-changeset/package.json b/ember-changeset/package.json
index f3730dbc..40b4823d 100644
--- a/ember-changeset/package.json
+++ b/ember-changeset/package.json
@@ -37,7 +37,7 @@
     "@glimmer/tracking": "^1.1.2",
     "ember-auto-import": "^2.8.1",
     "ember-cli-babel": "^7.26.11",
-    "validated-changeset": "~1.3.4"
+    "validated-changeset": "~1.4.0"
   },
   "devDependencies": {
     "@babel/eslint-parser": "^7.25.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f81ec58b..164dc140 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -39,8 +39,8 @@ importers:
         specifier: ^7.26.11
         version: 7.26.11
       validated-changeset:
-        specifier: ~1.3.4
-        version: 1.3.4
+        specifier: ~1.4.0
+        version: 1.4.0
     devDependencies:
       '@babel/eslint-parser':
         specifier: ^7.25.1
@@ -277,8 +277,8 @@ importers:
         specifier: ^3.3.0
         version: 3.3.0
       validated-changeset:
-        specifier: ~1.3.4
-        version: 1.3.4
+        specifier: ~1.4.0
+        version: 1.4.0
       webpack:
         specifier: ^5.95.0
         version: 5.96.1
@@ -1486,9 +1486,6 @@ packages:
   '@types/symlink-or-copy@1.2.2':
     resolution: {integrity: sha512-MQ1AnmTLOncwEf9IVU+B2e4Hchrku5N67NkgcAHW0p3sdzPe0FNMANxEm6OJUzPniEQGkeT3OROLlCwZJLWFZA==}
 
-  '@types/ungap__structured-clone@0.3.3':
-    resolution: {integrity: sha512-RNmhIPwoip6K/zZOv3ypksTAqaqLEXvlNSXKyrC93xMSOAHZCR7PifW6xKZCwkbbnbM9dwB9X56PPoNTlNwEqw==}
-
   '@ungap/structured-clone@0.3.4':
     resolution: {integrity: sha512-TSVh8CpnwNAsPC5wXcIyh92Bv1gq6E9cNDeeLu7Z4h8V4/qWtXJp7y42qljRkqcpmsve1iozwv1wr+3BNdILCg==}
 
@@ -3838,10 +3835,6 @@ packages:
   humanize-ms@1.2.1:
     resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
 
-  i@0.3.7:
-    resolution: {integrity: sha512-FYz4wlXgkQwIPqhzC5TdNMLSE5+GS1IIDJZY/1ZiEPCT2S3COUVZeT5OW4BmW4r5LHLQuOosSwsvnroG9GR59Q==}
-    engines: {node: '>=0.4'}
-
   iconv-lite@0.4.24:
     resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
     engines: {node: '>=0.10.0'}
@@ -4783,85 +4776,6 @@ packages:
     resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
     engines: {node: '>=8'}
 
-  npm@8.19.4:
-    resolution: {integrity: sha512-3HANl8i9DKnUA89P4KEgVNN28EjSeDCmvEqbzOAuxCFDzdBZzjUl99zgnGpOUumvW5lvJo2HKcjrsc+tfyv1Hw==}
-    engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
-    hasBin: true
-    bundledDependencies:
-      - '@isaacs/string-locale-compare'
-      - '@npmcli/arborist'
-      - '@npmcli/ci-detect'
-      - '@npmcli/config'
-      - '@npmcli/fs'
-      - '@npmcli/map-workspaces'
-      - '@npmcli/package-json'
-      - '@npmcli/run-script'
-      - abbrev
-      - archy
-      - cacache
-      - chalk
-      - chownr
-      - cli-columns
-      - cli-table3
-      - columnify
-      - fastest-levenshtein
-      - fs-minipass
-      - glob
-      - graceful-fs
-      - hosted-git-info
-      - ini
-      - init-package-json
-      - is-cidr
-      - json-parse-even-better-errors
-      - libnpmaccess
-      - libnpmdiff
-      - libnpmexec
-      - libnpmfund
-      - libnpmhook
-      - libnpmorg
-      - libnpmpack
-      - libnpmpublish
-      - libnpmsearch
-      - libnpmteam
-      - libnpmversion
-      - make-fetch-happen
-      - minimatch
-      - minipass
-      - minipass-pipeline
-      - mkdirp
-      - mkdirp-infer-owner
-      - ms
-      - node-gyp
-      - nopt
-      - npm-audit-report
-      - npm-install-checks
-      - npm-package-arg
-      - npm-pick-manifest
-      - npm-profile
-      - npm-registry-fetch
-      - npm-user-validate
-      - npmlog
-      - opener
-      - p-map
-      - pacote
-      - parse-conflict-json
-      - proc-log
-      - qrcode-terminal
-      - read
-      - read-package-json
-      - read-package-json-fast
-      - readdir-scoped-modules
-      - rimraf
-      - semver
-      - ssri
-      - tar
-      - text-table
-      - tiny-relative-date
-      - treeverse
-      - validate-npm-package-name
-      - which
-      - write-file-atomic
-
   npmlog@6.0.2:
     resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==}
     engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
@@ -6320,8 +6234,8 @@ packages:
   validate-peer-dependencies@1.2.0:
     resolution: {integrity: sha512-nd2HUpKc6RWblPZQ2GDuI65sxJ2n/UqZwSBVtj64xlWjMx0m7ZB2m9b2JS3v1f+n9VWH/dd1CMhkHfP6pIdckA==}
 
-  validated-changeset@1.3.4:
-    resolution: {integrity: sha512-kodJCCnd3oA7sCtUkkl7olhB1MQUz2uTAYUnE38R4Dn8hIU4bZVWass8KPGt0fvu2WdkjFJOGpXDrT0isqNKqw==}
+  validated-changeset@1.4.0:
+    resolution: {integrity: sha512-4LaQnVjaOCXDAIm/RYqd/RA94lHa7uCEjBcm+puTZkRmvTCqRkLh3dYSQ10DH/a7JY0HOj0rop0TXx9pbBqZuw==}
 
   vary@1.1.2:
     resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
@@ -8173,8 +8087,6 @@ snapshots:
 
   '@types/symlink-or-copy@1.2.2': {}
 
-  '@types/ungap__structured-clone@0.3.3': {}
-
   '@ungap/structured-clone@0.3.4': {}
 
   '@ungap/structured-clone@1.2.0': {}
@@ -11505,8 +11417,6 @@ snapshots:
     dependencies:
       ms: 2.1.3
 
-  i@0.3.7: {}
-
   iconv-lite@0.4.24:
     dependencies:
       safer-buffer: 2.1.2
@@ -12439,8 +12349,6 @@ snapshots:
     dependencies:
       path-key: 3.1.1
 
-  npm@8.19.4: {}
-
   npmlog@6.0.2:
     dependencies:
       are-we-there-yet: 3.0.1
@@ -14083,12 +13991,9 @@ snapshots:
       resolve-package-path: 3.1.0
       semver: 7.6.3
 
-  validated-changeset@1.3.4:
+  validated-changeset@1.4.0:
     dependencies:
-      '@types/ungap__structured-clone': 0.3.3
       '@ungap/structured-clone': 0.3.4
-      i: 0.3.7
-      npm: 8.19.4
 
   vary@1.1.2: {}
 
diff --git a/test-app/package.json b/test-app/package.json
index 5f539073..6342bfab 100644
--- a/test-app/package.json
+++ b/test-app/package.json
@@ -71,7 +71,7 @@
     "stylelint-config-standard": "^34.0.0",
     "stylelint-prettier": "^4.1.0",
     "tracked-built-ins": "^3.3.0",
-    "validated-changeset": "~1.3.4",
+    "validated-changeset": "~1.4.0",
     "webpack": "^5.95.0",
     "yup": "^0.32.11"
   },
diff --git a/test-app/tests/unit/changeset-test.js b/test-app/tests/unit/changeset-test.js
index 76caa112..6d885eb0 100644
--- a/test-app/tests/unit/changeset-test.js
+++ b/test-app/tests/unit/changeset-test.js
@@ -3099,6 +3099,162 @@ module('Unit | Utility | changeset', function (hooks) {
     assert.ok(get(dummyChangeset, 'isValid'), 'should be valid');
   });
 
+  /**
+   * #removeError
+   */
+
+  test('#removeError does not reset field values', async function (assert) {
+    let dummyChangeset = Changeset(dummyModel);
+
+    assert.strictEqual(dummyChangeset.email, undefined);
+
+    dummyModel.setProperties({
+      email: 'test@example.com',
+      name: 'Jim Bob',
+    });
+
+    dummyChangeset.addError('email', {
+      value: 'test@example.com',
+      validation: 'Email already taken',
+    });
+
+    assert.true(dummyChangeset.isInvalid);
+    assert.strictEqual(
+      get(dummyChangeset, 'error.email.validation'),
+      'Email already taken',
+    );
+
+    dummyChangeset.removeError('email');
+    assert.true(dummyChangeset.isValid);
+    assert.strictEqual(dummyChangeset.email, 'test@example.com');
+    assert.strictEqual(dummyChangeset.name, 'Jim Bob');
+  });
+
+  test('#removeError removes an error from the changeset', async function (assert) {
+    let dummyChangeset = Changeset(dummyModel);
+    dummyChangeset.addError('email', {
+      value: 'jim@bob.com',
+      validation: 'Email already taken',
+    });
+
+    assert.true(dummyChangeset.isInvalid);
+    assert.strictEqual(
+      get(dummyChangeset, 'error.email.validation'),
+      'Email already taken',
+    );
+
+    dummyChangeset.removeError('email');
+    assert.true(dummyChangeset.isValid);
+
+    assert.strictEqual(
+      get(dummyChangeset, 'error.email.validation'),
+      undefined,
+    );
+  });
+
+  test('#removeError using an invalid key does not throw an error', async function (assert) {
+    let dummyChangeset = Changeset(dummyModel);
+    dummyChangeset.addError('email', {
+      value: 'jim@bob.com',
+      validation: 'Email already taken',
+    });
+
+    assert.true(dummyChangeset.isInvalid);
+    assert.strictEqual(
+      get(dummyChangeset, 'error.email.validation'),
+      'Email already taken',
+    );
+
+    dummyChangeset.removeError('email');
+    dummyChangeset.removeError('foo');
+    assert.true(dummyChangeset.isValid);
+
+    assert.strictEqual(
+      get(dummyChangeset, 'error.email.validation'),
+      undefined,
+    );
+  });
+
+  test('#removeError removing one error leaves the other', async function (assert) {
+    let dummyChangeset = Changeset(dummyModel);
+    dummyChangeset.addError('email', {
+      value: 'jim@bob.com',
+      validation: 'Email already taken',
+    });
+    dummyChangeset.addError('age', {
+      value: '0',
+      validation: 'Age is too low',
+    });
+
+    assert.true(dummyChangeset.isInvalid);
+    assert.strictEqual(
+      get(dummyChangeset, 'error.email.validation'),
+      'Email already taken',
+    );
+    assert.strictEqual(
+      get(dummyChangeset, 'error.age.validation'),
+      'Age is too low',
+    );
+
+    dummyChangeset.removeError('email');
+    assert.false(dummyChangeset.isValid);
+
+    assert.strictEqual(
+      get(dummyChangeset, 'error.email.validation'),
+      undefined,
+    );
+    assert.strictEqual(
+      get(dummyChangeset, 'error.age.validation'),
+      'Age is too low',
+    );
+  });
+
+  /**
+   * #removeErrors
+   */
+
+  test('#removeErrors removes all errors', async function (assert) {
+    let dummyChangeset = Changeset(dummyModel);
+    dummyChangeset.addError('email', {
+      value: 'jim@bob.com',
+      validation: 'Email already taken',
+    });
+
+    dummyChangeset.addError('age', {
+      value: '0',
+      validation: 'Age is too low',
+    });
+
+    assert.true(dummyChangeset.isInvalid);
+    assert.strictEqual(
+      get(dummyChangeset, 'error.email.validation'),
+      'Email already taken',
+    );
+    assert.strictEqual(
+      get(dummyChangeset, 'error.age.validation'),
+      'Age is too low',
+    );
+
+    dummyChangeset.removeErrors();
+    assert.true(dummyChangeset.isValid);
+
+    assert.strictEqual(
+      get(dummyChangeset, 'error.email.validation'),
+      undefined,
+    );
+
+    assert.strictEqual(get(dummyChangeset, 'error.age.validation'), undefined);
+    assert.propEqual(get(dummyChangeset, 'errors'), []);
+  });
+
+  test('#removeErrors succeeds even when there are no errors', async function (assert) {
+    let dummyChangeset = Changeset(dummyModel);
+    assert.false(dummyChangeset.isInvalid);
+    dummyChangeset.removeErrors();
+    assert.true(dummyChangeset.isValid);
+    assert.propEqual(get(dummyChangeset, 'errors'), []);
+  });
+
   /**
    * #pushErrors
    */


Reviews:
user: login: SergeAstapov
body: Thank you @jkeen for contribution! Do you mind to address couple of comments to polish this and then we can land this.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-23T14:17:47Z
user: login: jkeen
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-23T14:32:56Z
user: login: SergeAstapov
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T15:03:32Z

Review comments:
diff_hunk: @@ -136,6 +136,29 @@ export class EmberChangeset extends BufferedChangeset {
     return error;
   }
 
+  /**
+   * Manually remove an error from the changeset.
+   *
+   * @method removeError
+   */
+  removeError(key) {
+    super.removeError(key);
+
+    notifyPropertyChange(this, key);
+    // Return passed-in `error`.
path: ember-changeset/addon/index.js
user: login: SergeAstapov
body: Should we update this comment?
created_at: 2025-03-23T14:14:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3099,6 +3099,124 @@ module('Unit | Utility | changeset', function (hooks) {
     assert.ok(get(dummyChangeset, 'isValid'), 'should be valid');
   });
 
+  /**
+   * #removeError
+   */
+
+  test('#removeError removes an error from the changeset', async function (assert) {
path: test-app/tests/unit/changeset-test.js
user: login: SergeAstapov
body: @jkeen could you please either add new test or add an assertion to this one to that checks that field values does not get reset? I assume it’s an important aspect of this new method
created_at: 2025-03-23T14:16:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3099,6 +3099,124 @@ module('Unit | Utility | changeset', function (hooks) {
     assert.ok(get(dummyChangeset, 'isValid'), 'should be valid');
   });
 
+  /**
+   * #removeError
+   */
+
+  test('#removeError removes an error from the changeset', async function (assert) {
path: test-app/tests/unit/changeset-test.js
user: login: jkeen
body: Great point, added a new test to verify that @SergeAstapov 
created_at: 2025-03-23T14:32:55Z
author_association: CONTRIBUTOR

PR comments:
user: login: jkeen
created_at: 2025-03-22T14:31:32Z
body: @SergeAstapov The tests will pass on this branch now that `validated-changeset` 1.4.0 has been released. Think we could get this merged and released too? 
author_association: CONTRIBUTOR
user: login: jkeen
created_at: 2025-03-25T14:42:12Z
body: @SergeAstapov Anything else holding this up from being merged and a new release cut? I'd love to get my production app off my hacky pnpm patches
author_association: CONTRIBUTOR
user: login: SergeAstapov
created_at: 2025-03-25T15:05:10Z
body: @jkeen this is merged. we need to solve npm access/npm token issue before this could be released.
author_association: CONTRIBUTOR
user: login: jkeen
created_at: 2025-03-25T15:12:04Z
body: @SergeAstapov Oohhh is that why the releases have stalled? I saw that #689 hadn't been merged in a while and was wondering

It took me a year to get a token sorted for a ruby gem I took over maintenance for, so I feel your pain and appreciate your efforts!
author_association: CONTRIBUTOR
user: login: jkeen
created_at: 2025-03-28T14:52:45Z
body: @SergeAstapov Sorry to bug ya again about this, but did you get that NPM token worked out? 😃 
author_association: CONTRIBUTOR
user: login: ryan-nauman-cs
created_at: 2025-04-08T13:10:19Z
body: could a release be published with this? i just went down a rabbit hole of trying to use it before its available 😆 
author_association: NONE