├── test-app ├── app │ ├── templates │ │ ├── index.hbs │ │ ├── validated.hbs │ │ └── application.hbs │ ├── services │ │ └── store.js │ ├── adapters │ │ └── application.js │ ├── serializers │ │ └── application.js │ ├── routes │ │ ├── index.js │ │ └── validated.js │ ├── styles │ │ └── app.css │ ├── models │ │ ├── dog.js │ │ ├── user.js │ │ ├── sync-user.js │ │ └── profile.js │ ├── router.ts │ ├── config │ │ └── environment.d.ts │ ├── app.ts │ ├── index.html │ ├── deprecation-workflow.ts │ └── components │ │ ├── validated-form.js │ │ ├── changeset-form.js │ │ ├── validated-form.hbs │ │ └── changeset-form.hbs ├── types │ └── global.d.ts ├── config │ ├── targets.js │ ├── optional-features.json │ ├── ember-cli-update.json │ ├── environment.js │ └── ember-try.js ├── tests │ ├── test-helper.ts │ ├── index.html │ ├── helpers │ │ └── index.ts │ ├── integration │ │ ├── helpers │ │ │ ├── changeset-get-test.js │ │ │ └── changeset-test.js │ │ ├── components │ │ │ ├── changeset-form-test.js │ │ │ └── validated-form-test.js │ │ └── main-test.js │ └── unit │ │ └── utils │ │ └── merge-deep-test.js ├── tsconfig.json ├── testem.js ├── ember-cli-build.js └── package.json ├── pnpm-workspace.yaml ├── .template-lintrc.cjs ├── addon-main.cjs ├── .gitignore ├── src ├── helpers │ ├── changeset-get.js │ ├── changeset-set.js │ └── changeset.js ├── utils │ ├── is-object.js │ └── merge-deep.js ├── types.d.ts ├── index.d.ts ├── validated-changeset.js └── index.js ├── .prettierignore ├── .npmrc ├── .prettierrc.cjs ├── .editorconfig ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── workflows │ ├── push-dist.yml │ ├── publish.yml │ ├── ci.yml │ └── plan-release.yml └── ISSUE_TEMPLATE.md ├── babel.config.json ├── unpublished-development-types └── index.d.ts ├── LICENSE.md ├── .release-plan.json ├── RELEASE.md ├── tsconfig.json ├── CONTRIBUTING.md ├── rollup.config.mjs ├── CODE_OF_CONDUCT.md ├── eslint.config.mjs ├── package.json ├── assets └── title.svg └── README.md /test-app/app/templates/index.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test-app/app/templates/validated.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - . 3 | - test-app 4 | -------------------------------------------------------------------------------- /test-app/types/global.d.ts: -------------------------------------------------------------------------------- 1 | import '@glint/environment-ember-loose'; 2 | -------------------------------------------------------------------------------- /test-app/app/services/store.js: -------------------------------------------------------------------------------- 1 | export { default } from 'ember-data/store'; 2 | -------------------------------------------------------------------------------- /test-app/app/templates/application.hbs: -------------------------------------------------------------------------------- 1 |

Basic Form

2 | 3 | {{outlet}} -------------------------------------------------------------------------------- /test-app/app/adapters/application.js: -------------------------------------------------------------------------------- 1 | export { default } from '@ember-data/adapter/json-api'; 2 | -------------------------------------------------------------------------------- /.template-lintrc.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | extends: 'recommended', 5 | }; 6 | -------------------------------------------------------------------------------- /test-app/app/serializers/application.js: -------------------------------------------------------------------------------- 1 | export { default } from '@ember-data/serializer/json-api'; 2 | -------------------------------------------------------------------------------- /test-app/app/routes/index.js: -------------------------------------------------------------------------------- 1 | import Route from '@ember/routing/route'; 2 | 3 | export default class IndexRoute extends Route {} 4 | -------------------------------------------------------------------------------- /test-app/app/routes/validated.js: -------------------------------------------------------------------------------- 1 | import Route from '@ember/routing/route'; 2 | 3 | export default class ValidatedRoute extends Route {} 4 | -------------------------------------------------------------------------------- /addon-main.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { addonV1Shim } = require('@embroider/addon-shim'); 4 | module.exports = addonV1Shim(__dirname); 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | dist/ 3 | declarations/ 4 | 5 | # npm/pnpm/yarn pack output 6 | *.tgz 7 | 8 | # deps & caches 9 | node_modules/ 10 | .eslintcache 11 | .prettiercache 12 | -------------------------------------------------------------------------------- /test-app/app/styles/app.css: -------------------------------------------------------------------------------- 1 | /* Ember supports plain CSS out of the box. More info: https://cli.emberjs.com/release/advanced-use/stylesheets/ */ 2 | 3 | #ember-testing-container { 4 | font-size: 2em; 5 | } 6 | -------------------------------------------------------------------------------- /src/helpers/changeset-get.js: -------------------------------------------------------------------------------- 1 | import { helper } from '@ember/component/helper'; 2 | 3 | export function changesetGet([changeset, fieldPath]) { 4 | return changeset.get(fieldPath); 5 | } 6 | 7 | export default helper(changesetGet); 8 | -------------------------------------------------------------------------------- /test-app/config/targets.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const browsers = [ 4 | 'last 1 Chrome versions', 5 | 'last 1 Firefox versions', 6 | 'last 1 Safari versions', 7 | ]; 8 | 9 | module.exports = { 10 | browsers, 11 | }; 12 | -------------------------------------------------------------------------------- /test-app/config/optional-features.json: -------------------------------------------------------------------------------- 1 | { 2 | "application-template-wrapper": false, 3 | "default-async-observers": true, 4 | "jquery-integration": false, 5 | "template-only-glimmer-components": true, 6 | "no-implicit-route-model": true 7 | } 8 | -------------------------------------------------------------------------------- /test-app/app/models/dog.js: -------------------------------------------------------------------------------- 1 | import Model, { attr, belongsTo } from '@ember-data/model'; 2 | 3 | export default class Dog extends Model { 4 | @attr('string', { defaultValue: 'rough collie' }) breed; 5 | @belongsTo('user', { async: true, inverse: 'dogs' }) user; 6 | } 7 | -------------------------------------------------------------------------------- /test-app/app/models/user.js: -------------------------------------------------------------------------------- 1 | import Model, { belongsTo, hasMany } from '@ember-data/model'; 2 | 3 | export default class User extends Model { 4 | @belongsTo('profile', { async: true, inverse: null }) profile; 5 | @hasMany('dog', { async: true, inverse: 'user' }) dogs; 6 | } 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # unconventional js 2 | /blueprints/*/files/ 3 | 4 | # compiled output 5 | /dist/ 6 | /declarations/ 7 | 8 | # misc 9 | /coverage/ 10 | /pnpm-lock.yaml 11 | 12 | # it's now edit with release-plan, so no manual formatting/validating needed 13 | /CHANGELOG.md 14 | -------------------------------------------------------------------------------- /test-app/app/models/sync-user.js: -------------------------------------------------------------------------------- 1 | import Model, { belongsTo, hasMany } from '@ember-data/model'; 2 | 3 | export default class SyncUser extends Model { 4 | @belongsTo('profile', { async: false, inverse: null }) profile; 5 | @hasMany('dog', { async: false, inverse: null }) dogs; 6 | } 7 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # Docs: https://pnpm.io/npmrc 2 | # https://github.com/emberjs/rfcs/pull/907 3 | 4 | # we don't want addons to be bad citizens of the ecosystem 5 | auto-install-peers=false 6 | 7 | # we want true isolation, 8 | # if a dependency is not declared, we want an error 9 | resolve-peers-from-workspace-root=false 10 | -------------------------------------------------------------------------------- /test-app/app/models/profile.js: -------------------------------------------------------------------------------- 1 | import Model, { attr, belongsTo } from '@ember-data/model'; 2 | 3 | export default class Profile extends Model { 4 | @attr('string', { defaultValue: 'Bob' }) firstName; 5 | @attr('string', { defaultValue: 'Ross' }) lastName; 6 | 7 | @belongsTo('dog', { async: true, inverse: null }) pet; 8 | } 9 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | plugins: ['prettier-plugin-ember-template-tag'], 5 | overrides: [ 6 | { 7 | files: '*.{js,gjs,ts,gts,mjs,mts,cjs,cts}', 8 | options: { 9 | singleQuote: true, 10 | templateSingleQuote: false, 11 | }, 12 | }, 13 | ], 14 | }; 15 | -------------------------------------------------------------------------------- /test-app/app/router.ts: -------------------------------------------------------------------------------- 1 | import EmberRouter from '@ember/routing/router'; 2 | import config from 'test-app/config/environment'; 3 | 4 | export default class Router extends EmberRouter { 5 | location = config.locationType; 6 | rootURL = config.rootURL; 7 | } 8 | 9 | Router.map(function () { 10 | this.route('validated'); 11 | }); 12 | -------------------------------------------------------------------------------- /src/utils/is-object.js: -------------------------------------------------------------------------------- 1 | import { isArray } from '@ember/array'; 2 | 3 | /** 4 | * Employ Ember strategies for isObject detection 5 | * @method isObject 6 | */ 7 | export default function isObject(val) { 8 | return ( 9 | val !== null && 10 | typeof val === 'object' && 11 | !(val instanceof Date || val instanceof RegExp) && 12 | !isArray(val) 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /test-app/app/config/environment.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Type declarations for 3 | * import config from 'test-app/config/environment' 4 | */ 5 | declare const config: { 6 | environment: string; 7 | modulePrefix: string; 8 | podModulePrefix: string; 9 | locationType: 'history' | 'hash' | 'none'; 10 | rootURL: string; 11 | APP: Record; 12 | }; 13 | 14 | export default config; 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.hbs] 16 | insert_final_newline = false 17 | 18 | [*.{diff,md}] 19 | trim_trailing_whitespace = false 20 | -------------------------------------------------------------------------------- /src/helpers/changeset-set.js: -------------------------------------------------------------------------------- 1 | import { helper } from '@ember/component/helper'; 2 | import { isChangeset } from 'validated-changeset'; 3 | 4 | /** 5 | * This is a drop in replacement for the `mut` helper 6 | * 7 | * @method changesetSet 8 | * @param params 9 | */ 10 | export function changesetSet([obj, path]) { 11 | if (isChangeset(obj)) { 12 | return (value) => { 13 | return obj.set(path, value); 14 | }; 15 | } 16 | } 17 | 18 | export default helper(changesetSet); 19 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | Closes # . 12 | 13 | ## Changes proposed in this pull request 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | BufferedChangeset, 3 | Changeset, 4 | ValidatedChangeset, 5 | } from 'validated-changeset/dist'; 6 | 7 | import type { 8 | ValidationResult, 9 | ValidatorMapFunc, 10 | ValidatorMap, 11 | ValidatorAction, 12 | Snapshot, 13 | } from 'validated-changeset/dist/types'; 14 | 15 | export type { 16 | BufferedChangeset, 17 | Changeset, 18 | ValidatedChangeset, 19 | ValidationResult, 20 | ValidatorMap, 21 | ValidatorMapFunc, 22 | ValidatorAction, 23 | Snapshot, 24 | }; 25 | -------------------------------------------------------------------------------- /test-app/tests/test-helper.ts: -------------------------------------------------------------------------------- 1 | import Application from 'test-app/app'; 2 | import config from 'test-app/config/environment'; 3 | import * as QUnit from 'qunit'; 4 | import { setApplication } from '@ember/test-helpers'; 5 | import { setup } from 'qunit-dom'; 6 | import { loadTests } from 'ember-qunit/test-loader'; 7 | import { start, setupEmberOnerrorValidation } from 'ember-qunit'; 8 | 9 | setApplication(Application.create(config.APP)); 10 | 11 | setup(QUnit.assert); 12 | setupEmberOnerrorValidation(); 13 | loadTests(); 14 | start(); 15 | -------------------------------------------------------------------------------- /test-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/ember", 3 | "glint": { 4 | "environment": ["ember-loose", "ember-template-imports"] 5 | }, 6 | "compilerOptions": { 7 | // The combination of `baseUrl` with `paths` allows Ember's classic package 8 | // layout, which is not resolvable with the Node resolution algorithm, to 9 | // work with TypeScript. 10 | "baseUrl": ".", 11 | "paths": { 12 | "test-app/tests/*": ["tests/*"], 13 | "test-app/*": ["app/*"], 14 | "*": ["types/*"] 15 | }, 16 | "types": ["ember-source/types"] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | [ 4 | "@babel/plugin-transform-typescript", 5 | { 6 | "allExtensions": true, 7 | "onlyRemoveTypeImports": true, 8 | "allowDeclareFields": true 9 | } 10 | ], 11 | "@embroider/addon-dev/template-colocation-plugin", 12 | [ 13 | "babel-plugin-ember-template-compilation", 14 | { 15 | "targetFormat": "hbs", 16 | "transforms": [] 17 | } 18 | ], 19 | [ 20 | "module:decorator-transforms", 21 | { "runtime": { "import": "decorator-transforms/runtime" } } 22 | ] 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /test-app/app/app.ts: -------------------------------------------------------------------------------- 1 | import Application from '@ember/application'; 2 | import Resolver from 'ember-resolver'; 3 | import loadInitializers from 'ember-load-initializers'; 4 | import config from 'test-app/config/environment'; 5 | import { importSync, isDevelopingApp, macroCondition } from '@embroider/macros'; 6 | 7 | if (macroCondition(isDevelopingApp())) { 8 | importSync('./deprecation-workflow'); 9 | } 10 | 11 | export default class App extends Application { 12 | modulePrefix = config.modulePrefix; 13 | podModulePrefix = config.podModulePrefix; 14 | Resolver = Resolver; 15 | } 16 | 17 | loadInitializers(App, config.modulePrefix); 18 | -------------------------------------------------------------------------------- /test-app/config/ember-cli-update.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": "1.0.0", 3 | "packages": [ 4 | { 5 | "name": "ember-cli", 6 | "version": "6.3.1", 7 | "blueprints": [ 8 | { 9 | "name": "app", 10 | "outputRepo": "https://github.com/ember-cli/ember-new-output", 11 | "codemodsSource": "ember-app-codemods-manifest@1", 12 | "isBaseBlueprint": true, 13 | "options": [ 14 | "--no-welcome", 15 | "--pnpm", 16 | "--ci-provider=github", 17 | "--typescript" 18 | ] 19 | } 20 | ] 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /test-app/testem.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | test_page: 'tests/index.html?hidepassed', 5 | disable_watching: true, 6 | launch_in_ci: ['Chrome'], 7 | launch_in_dev: ['Chrome'], 8 | browser_start_timeout: 120, 9 | browser_args: { 10 | Chrome: { 11 | ci: [ 12 | // --no-sandbox is needed when running Chrome inside a container 13 | process.env.CI ? '--no-sandbox' : null, 14 | '--headless', 15 | '--disable-dev-shm-usage', 16 | '--disable-software-rasterizer', 17 | '--mute-audio', 18 | '--remote-debugging-port=0', 19 | '--window-size=1440,900', 20 | ].filter(Boolean), 21 | }, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /unpublished-development-types/index.d.ts: -------------------------------------------------------------------------------- 1 | // Add any types here that you need for local development only. 2 | // These will *not* be published as part of your addon, so be careful that your published code does not rely on them! 3 | 4 | import '@glint/environment-ember-loose'; 5 | import '@glint/environment-ember-template-imports'; 6 | 7 | // Uncomment if you need to support consuming projects in loose mode 8 | // 9 | // declare module '@glint/environment-ember-loose/registry' { 10 | // export default interface Registry { 11 | // // Add any registry entries from other addons here that your addon itself uses (in non-strict mode templates) 12 | // // See https://typed-ember.gitbook.io/glint/using-glint/ember/using-addons 13 | // } 14 | // } 15 | -------------------------------------------------------------------------------- /test-app/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | TestApp 6 | 7 | 8 | 9 | {{content-for "head"}} 10 | 11 | 12 | 13 | 14 | {{content-for "head-footer"}} 15 | 16 | 17 | {{content-for "body"}} 18 | 19 | 20 | 21 | 22 | {{content-for "body-footer"}} 23 | 24 | 25 | -------------------------------------------------------------------------------- /test-app/app/deprecation-workflow.ts: -------------------------------------------------------------------------------- 1 | import setupDeprecationWorkflow from 'ember-cli-deprecation-workflow'; 2 | 3 | /** 4 | * Docs: https://github.com/ember-cli/ember-cli-deprecation-workflow 5 | */ 6 | setupDeprecationWorkflow({ 7 | /** 8 | false by default, but if a developer / team wants to be more aggressive about being proactive with 9 | handling their deprecations, this should be set to "true" 10 | */ 11 | throwOnUnhandled: false, 12 | workflow: [ 13 | /* ... handlers ... */ 14 | /* to generate this list, run your app for a while (or run the test suite), 15 | * and then run in the browser console: 16 | * 17 | * deprecationWorkflow.flushDeprecations() 18 | * 19 | * And copy the handlers here 20 | */ 21 | /* example: */ 22 | /* { handler: 'silence', matchId: 'template-action' }, */ 23 | ], 24 | }); 25 | -------------------------------------------------------------------------------- /test-app/ember-cli-build.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const EmberApp = require('ember-cli/lib/broccoli/ember-app'); 4 | const { maybeEmbroider } = require('@embroider/test-setup'); 5 | 6 | module.exports = function (defaults) { 7 | const app = new EmberApp(defaults, { 8 | autoImport: { 9 | watchDependencies: ['ember-changeset'], 10 | }, 11 | emberData: { 12 | deprecations: { 13 | // New projects can safely leave this deprecation disabled. 14 | // If upgrading, to opt-into the deprecated behavior, set this to true and then follow: 15 | // https://deprecations.emberjs.com/id/ember-data-deprecate-store-extends-ember-object 16 | // before upgrading to Ember Data 6.0 17 | DEPRECATE_STORE_EXTENDS_EMBER_OBJECT: false, 18 | }, 19 | }, 20 | 'ember-cli-babel': { enableTypeScriptTransform: true }, 21 | 22 | // Add options here 23 | }); 24 | 25 | return maybeEmbroider(app); 26 | }; 27 | -------------------------------------------------------------------------------- /src/helpers/changeset.js: -------------------------------------------------------------------------------- 1 | import { helper } from '@ember/component/helper'; 2 | import { Changeset } from '../index.js'; 3 | import { 4 | lookupValidator, 5 | isChangeset, 6 | isPromise, 7 | isObject, 8 | } from 'validated-changeset'; 9 | 10 | export function changeset([obj, validations], options = {}) { 11 | if (!obj) { 12 | // route transitions may trigger this 13 | return; 14 | } 15 | 16 | if (isChangeset(obj)) { 17 | return obj; 18 | } 19 | 20 | if (isObject(validations)) { 21 | if (isPromise(obj)) { 22 | return obj.then((resolved) => 23 | Changeset(resolved, lookupValidator(validations), validations, options), 24 | ); 25 | } 26 | 27 | return Changeset(obj, lookupValidator(validations), validations, options); 28 | } 29 | 30 | if (isPromise(obj)) { 31 | return Promise.resolve(obj).then((resolved) => 32 | Changeset(resolved, validations, {}, options), 33 | ); 34 | } 35 | 36 | return Changeset(obj, validations, {}, options); 37 | } 38 | 39 | export default helper(changeset); 40 | -------------------------------------------------------------------------------- /.github/workflows/push-dist.yml: -------------------------------------------------------------------------------- 1 | # Because this library needs to be built, 2 | # we can't easily point package.json files at the git repo for easy cross-repo testing. 3 | # 4 | # This workflow brings back that capability by placing the compiled assets on a "dist" branch 5 | # (configurable via the "branch" option below) 6 | name: Push dist 7 | 8 | on: 9 | push: 10 | branches: 11 | - main 12 | - master 13 | 14 | jobs: 15 | push-dist: 16 | name: Push dist 17 | runs-on: ubuntu-latest 18 | timeout-minutes: 10 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: pnpm/action-setup@v4 23 | - name: Install Node 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: 18 27 | cache: pnpm 28 | - name: Install Dependencies 29 | run: pnpm install --frozen-lockfile 30 | - uses: kategengler/put-built-npm-package-contents-on-branch@v2.0.0 31 | with: 32 | branch: dist 33 | token: ${{ secrets.GITHUB_TOKEN }} 34 | working-directory: "test-app" 35 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Lauren Elizabeth Tan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 15 | ## Version 16 | 17 | 18 | 19 | ## Test Case 20 | 21 | 22 | 23 | ## Steps to reproduce 24 | 25 | 26 | 27 | ## Expected Behavior 28 | 29 | 30 | 31 | ## Actual Behavior 32 | 33 | 34 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # For every push to the primary branch with .release-plan.json modified, 2 | # runs release-plan. 3 | 4 | name: Publish Stable 5 | 6 | on: 7 | workflow_dispatch: 8 | push: 9 | branches: 10 | - main 11 | - master 12 | paths: 13 | - ".release-plan.json" 14 | 15 | concurrency: 16 | group: publish-${{ github.head_ref || github.ref }} 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | publish: 21 | name: "NPM Publish" 22 | runs-on: ubuntu-latest 23 | permissions: 24 | contents: write 25 | pull-requests: write 26 | id-token: write 27 | attestations: write 28 | 29 | steps: 30 | - uses: actions/checkout@v4 31 | - uses: pnpm/action-setup@v4 32 | - uses: actions/setup-node@v4 33 | with: 34 | node-version: 18 35 | # This creates an .npmrc that reads the NODE_AUTH_TOKEN environment variable 36 | registry-url: "https://registry.npmjs.org" 37 | cache: pnpm 38 | - run: pnpm install --frozen-lockfile 39 | - name: Publish to NPM 40 | run: NPM_CONFIG_PROVENANCE=true pnpm release-plan publish 41 | env: 42 | GITHUB_AUTH: ${{ secrets.GITHUB_TOKEN }} 43 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 44 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | import { BufferedChangeset } from 'validated-changeset'; 2 | import type { HelperLike } from '@glint/template'; 3 | 4 | type BufferedChangesetConstructorParameters = ConstructorParameters< 5 | typeof BufferedChangeset 6 | >; 7 | 8 | type Config = BufferedChangesetConstructorParameters[3] & { 9 | changeset?: typeof EmberChangeset; 10 | }; 11 | 12 | type changesetFunctionsParameters = [ 13 | BufferedChangesetConstructorParameters[0], 14 | BufferedChangesetConstructorParameters[1]?, 15 | BufferedChangesetConstructorParameters[2]?, 16 | Config?, 17 | ]; 18 | 19 | export class EmberChangeset extends BufferedChangeset {} 20 | export function changeset( 21 | ...args: changesetFunctionsParameters 22 | ): EmberChangeset; 23 | export function Changeset( 24 | ...args: changesetFunctionsParameters 25 | ): EmberChangeset; 26 | 27 | type changesetGet = HelperLike<{ 28 | Args: { 29 | Positional: [ 30 | changeset: BufferedChangeset | EmberChangeset, 31 | fieldPath: string, 32 | ]; 33 | }; 34 | Return: unknown; 35 | }>; 36 | 37 | type changesetSet = HelperLike<{ 38 | Args: { 39 | Positional: [ 40 | changeset: BufferedChangeset | EmberChangeset, 41 | fieldPath: string, 42 | ]; 43 | }; 44 | Return: (value: unknown) => void; 45 | }>; 46 | -------------------------------------------------------------------------------- /test-app/tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | TestApp Tests 6 | 7 | 8 | 9 | {{content-for "head"}} {{content-for "test-head"}} 10 | 11 | 12 | 13 | 14 | 15 | {{content-for "head-footer"}} {{content-for "test-head-footer"}} 16 | 17 | 18 | {{content-for "body"}} {{content-for "test-body"}} 19 | 20 |
21 |
22 |
23 |
24 |
25 |
26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | {{content-for "body-footer"}} {{content-for "test-body-footer"}} 34 | 35 | 36 | -------------------------------------------------------------------------------- /test-app/config/environment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function (environment) { 4 | const ENV = { 5 | modulePrefix: 'test-app', 6 | environment, 7 | rootURL: '/', 8 | locationType: 'history', 9 | EmberENV: { 10 | EXTEND_PROTOTYPES: false, 11 | FEATURES: { 12 | // Here you can enable experimental features on an ember canary build 13 | // e.g. EMBER_NATIVE_DECORATOR_SUPPORT: true 14 | }, 15 | }, 16 | 17 | APP: { 18 | // Here you can pass flags/options to your application instance 19 | // when it is created 20 | }, 21 | }; 22 | 23 | if (environment === 'development') { 24 | // ENV.APP.LOG_RESOLVER = true; 25 | // ENV.APP.LOG_ACTIVE_GENERATION = true; 26 | // ENV.APP.LOG_TRANSITIONS = true; 27 | // ENV.APP.LOG_TRANSITIONS_INTERNAL = true; 28 | // ENV.APP.LOG_VIEW_LOOKUPS = true; 29 | } 30 | 31 | if (environment === 'test') { 32 | // Testem prefers this... 33 | ENV.locationType = 'none'; 34 | 35 | // keep test console output quieter 36 | ENV.APP.LOG_ACTIVE_GENERATION = false; 37 | ENV.APP.LOG_VIEW_LOOKUPS = false; 38 | 39 | ENV.APP.rootElement = '#ember-testing'; 40 | ENV.APP.autoboot = false; 41 | } 42 | 43 | if (environment === 'production') { 44 | // here you can enable a production-specific feature 45 | } 46 | 47 | return ENV; 48 | }; 49 | -------------------------------------------------------------------------------- /test-app/tests/helpers/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | setupApplicationTest as upstreamSetupApplicationTest, 3 | setupRenderingTest as upstreamSetupRenderingTest, 4 | setupTest as upstreamSetupTest, 5 | type SetupTestOptions, 6 | } from 'ember-qunit'; 7 | 8 | // This file exists to provide wrappers around ember-qunit's 9 | // test setup functions. This way, you can easily extend the setup that is 10 | // needed per test type. 11 | 12 | function setupApplicationTest(hooks: NestedHooks, options?: SetupTestOptions) { 13 | upstreamSetupApplicationTest(hooks, options); 14 | 15 | // Additional setup for application tests can be done here. 16 | // 17 | // For example, if you need an authenticated session for each 18 | // application test, you could do: 19 | // 20 | // hooks.beforeEach(async function () { 21 | // await authenticateSession(); // ember-simple-auth 22 | // }); 23 | // 24 | // This is also a good place to call test setup functions coming 25 | // from other addons: 26 | // 27 | // setupIntl(hooks, 'en-us'); // ember-intl 28 | // setupMirage(hooks); // ember-cli-mirage 29 | } 30 | 31 | function setupRenderingTest(hooks: NestedHooks, options?: SetupTestOptions) { 32 | upstreamSetupRenderingTest(hooks, options); 33 | 34 | // Additional setup for rendering tests can be done here. 35 | } 36 | 37 | function setupTest(hooks: NestedHooks, options?: SetupTestOptions) { 38 | upstreamSetupTest(hooks, options); 39 | 40 | // Additional setup for unit tests can be done here. 41 | } 42 | 43 | export { setupApplicationTest, setupRenderingTest, setupTest }; 44 | -------------------------------------------------------------------------------- /.release-plan.json: -------------------------------------------------------------------------------- 1 | { 2 | "solution": { 3 | "ember-changeset": { 4 | "impact": "major", 5 | "oldVersion": "4.2.0", 6 | "newVersion": "5.0.0", 7 | "tagName": "latest", 8 | "constraints": [ 9 | { 10 | "impact": "major", 11 | "reason": "Appears in changelog section :boom: Breaking Change" 12 | }, 13 | { 14 | "impact": "minor", 15 | "reason": "Appears in changelog section :rocket: Enhancement" 16 | }, 17 | { 18 | "impact": "patch", 19 | "reason": "Appears in changelog section :house: Internal" 20 | } 21 | ], 22 | "pkgJSONPath": "./package.json" 23 | } 24 | }, 25 | "description": "## Release (2025-04-27)\n\n* ember-changeset 5.0.0 (major)\n\n#### :boom: Breaking Change\n* `ember-changeset`\n * [#706](https://github.com/adopted-ember-addons/ember-changeset/pull/706) Drop support for Ember.js versions below 4.8 ([@SergeAstapov](https://github.com/SergeAstapov))\n\n#### :rocket: Enhancement\n* `ember-changeset`\n * [#705](https://github.com/adopted-ember-addons/ember-changeset/pull/705) Convert addon to v2 format ([@SergeAstapov](https://github.com/SergeAstapov))\n\n#### :house: Internal\n* `ember-changeset`\n * [#703](https://github.com/adopted-ember-addons/ember-changeset/pull/703) Bump pnpm to v10 ([@SergeAstapov](https://github.com/SergeAstapov))\n * [#701](https://github.com/adopted-ember-addons/ember-changeset/pull/701) Bump release-plan to v0.16 ([@SergeAstapov](https://github.com/SergeAstapov))\n\n#### Committers: 1\n- Sergey Astapov ([@SergeAstapov](https://github.com/SergeAstapov))\n" 26 | } 27 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release Process 2 | 3 | Releases in this repo are mostly automated using [release-plan](https://github.com/embroider-build/release-plan/). Once you label all your PRs correctly (see below) you will have an automatically generated PR that updates your CHANGELOG.md file and a `.release-plan.json` that is used to prepare the release once the PR is merged. 4 | 5 | ## Preparation 6 | 7 | Since the majority of the actual release process is automated, the remaining tasks before releasing are: 8 | 9 | - correctly labeling **all** pull requests that have been merged since the last release 10 | - updating pull request titles so they make sense to our users 11 | 12 | Some great information on why this is important can be found at [keepachangelog.com](https://keepachangelog.com/en/1.1.0/), but the overall 13 | guiding principle here is that changelogs are for humans, not machines. 14 | 15 | When reviewing merged PR's the labels to be used are: 16 | 17 | - breaking - Used when the PR is considered a breaking change. 18 | - enhancement - Used when the PR adds a new feature or enhancement. 19 | - bug - Used when the PR fixes a bug included in a previous release. 20 | - documentation - Used when the PR adds or updates documentation. 21 | - internal - Internal changes or things that don't fit in any other category. 22 | 23 | **Note:** `release-plan` requires that **all** PRs are labeled. If a PR doesn't fit in a category it's fine to label it as `internal` 24 | 25 | ## Release 26 | 27 | Once the prep work is completed, the actual release is straight forward: you just need to merge the open [Plan Release](https://github.com/adopted-ember-addons/ember-changeset/pulls?q=is%3Apr+is%3Aopen+%22Prepare+Release%22+in%3Atitle) PR 28 | -------------------------------------------------------------------------------- /test-app/app/components/validated-form.js: -------------------------------------------------------------------------------- 1 | import Component from '@glimmer/component'; 2 | import { action } from '@ember/object'; 3 | import { tracked } from '@glimmer/tracking'; 4 | import { ValidatedChangeset } from 'ember-changeset'; 5 | 6 | import { object, string } from 'yup'; 7 | 8 | const FormSchema = object({ 9 | cid: string().required(), 10 | user: object({ 11 | name: string().required(), 12 | email: string().email(), 13 | }), 14 | }); 15 | 16 | class Address { 17 | constructor(args) { 18 | Object.assign(this, args); 19 | } 20 | street = '123'; 21 | city = 'Yurtville'; 22 | } 23 | 24 | class Foo { 25 | user = { 26 | name: 'someone', 27 | email: 'something@gmail.com', 28 | }; 29 | 30 | addresses = [new Address(), new Address({ city: 'Woods' })]; 31 | 32 | cid = '1'; 33 | 34 | @tracked 35 | growth = 0; 36 | 37 | save() { 38 | return Promise.resolve(); 39 | } 40 | 41 | // notifications = { 42 | // email: false, 43 | // sms: true, 44 | // }; 45 | 46 | // get doubleGrowth() { 47 | // return this.growth * 2; 48 | // } 49 | } 50 | 51 | export default class ValidatedForm extends Component { 52 | constructor() { 53 | super(...arguments); 54 | 55 | this.model = new Foo(); 56 | this.changeset = ValidatedChangeset(this.model); 57 | } 58 | 59 | @action 60 | async setChangesetProperty(path, evt) { 61 | this.changeset.set(path, evt.target.value); 62 | try { 63 | await this.changeset.validate((changes) => { 64 | return FormSchema.validate(changes); 65 | }); 66 | this.changeset.removeError(path); 67 | 68 | await this.model.save(); 69 | } catch (e) { 70 | this.changeset.addError(e.path, { 71 | value: this.changeset.get(e.path), 72 | validation: e.message, 73 | }); 74 | } 75 | } 76 | 77 | @action 78 | async submitForm(changeset, event) { 79 | event.preventDefault(); 80 | 81 | changeset.execute(); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/ember/tsconfig.json", 3 | "include": [ 4 | "src/**/*", 5 | "types/**/*", 6 | "unpublished-development-types/**/*", 7 | "index.d.ts" 8 | ], 9 | "glint": { 10 | "environment": ["ember-loose", "ember-template-imports"] 11 | }, 12 | "compilerOptions": { 13 | "allowJs": true, 14 | "declarationDir": "declarations", 15 | /** 16 | https://www.typescriptlang.org/tsconfig#noEmit 17 | 18 | We want to emit declarations, so this option must be set to `false`. 19 | @tsconfig/ember sets this to `true`, which is incompatible with our need to set `emitDeclarationOnly`. 20 | @tsconfig/ember is more optimized for apps, which wouldn't emit anything, only type check. 21 | */ 22 | "noEmit": false, 23 | /** 24 | https://www.typescriptlang.org/tsconfig#emitDeclarationOnly 25 | We want to only emit declarations as we use Rollup to emit JavaScript. 26 | */ 27 | "emitDeclarationOnly": true, 28 | 29 | /** 30 | https://www.typescriptlang.org/tsconfig#noEmitOnError 31 | Do not block emit on TS errors. 32 | */ 33 | "noEmitOnError": false, 34 | 35 | /** 36 | https://www.typescriptlang.org/tsconfig#rootDir 37 | "Default: The longest common path of all non-declaration input files." 38 | 39 | Because we want our declarations' structure to match our rollup output, 40 | we need this "rootDir" to match the "srcDir" in the rollup.config.mjs. 41 | 42 | This way, we can have simpler `package.json#exports` that matches 43 | imports to files on disk 44 | */ 45 | "rootDir": "./src", 46 | 47 | /** 48 | https://www.typescriptlang.org/tsconfig#allowImportingTsExtensions 49 | 50 | We want our tooling to know how to resolve our custom files so the appropriate plugins 51 | can do the proper transformations on those files. 52 | */ 53 | "allowImportingTsExtensions": true, 54 | 55 | "types": ["ember-source/types"] 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /test-app/app/components/changeset-form.js: -------------------------------------------------------------------------------- 1 | import Component from '@glimmer/component'; 2 | import { later } from '@ember/runloop'; 3 | import { action, get } from '@ember/object'; 4 | import { tracked } from '@glimmer/tracking'; 5 | import { Changeset } from 'ember-changeset'; 6 | 7 | function validate() { 8 | return 'not good'; 9 | } 10 | 11 | let dummyValidations = { 12 | user: { 13 | name(value) { 14 | return !!value; 15 | }, 16 | email(value) { 17 | let ok = value && value.includes('@'); 18 | return new Promise((resolve) => 19 | // eslint-disable-next-line ember/no-runloop 20 | later( 21 | this, 22 | () => { 23 | resolve(ok); 24 | }, 25 | 400, 26 | ), 27 | ); 28 | }, 29 | }, 30 | }; 31 | 32 | function dummyValidator({ key, newValue, oldValue, changes, content }) { 33 | let validatorFn = get(dummyValidations, key); 34 | 35 | if (typeof validatorFn === 'function') { 36 | return validatorFn(newValue, oldValue, changes, content); 37 | } 38 | } 39 | 40 | class Address { 41 | constructor(args) { 42 | Object.assign(this, args); 43 | } 44 | street = '123'; 45 | city = 'Yurtville'; 46 | } 47 | 48 | class Foo { 49 | user = { 50 | aliases: ['someone'], 51 | name: 'someone', 52 | email: 'something', 53 | }; 54 | 55 | addresses = [new Address(), new Address({ city: 'Woods' })]; 56 | 57 | cid = '1'; 58 | 59 | @tracked 60 | growth = 0; 61 | 62 | notifications = { 63 | email: false, 64 | sms: true, 65 | }; 66 | 67 | get doubleGrowth() { 68 | return this.growth * 2; 69 | } 70 | } 71 | 72 | export default class ChangesetForm extends Component { 73 | constructor() { 74 | super(...arguments); 75 | 76 | this.model = new Foo(); 77 | this.changeset = Changeset(this.model, dummyValidator); 78 | } 79 | 80 | updateAttrOnInput(changesetSet, event) { 81 | changesetSet(event.target.value); 82 | } 83 | 84 | updateAttrOnChange(changesetSet, event) { 85 | changesetSet(event.target.checked); 86 | } 87 | 88 | get validateOnRender() { 89 | let cs = Changeset({}, null, { title: validate }); 90 | cs.validate(); 91 | return cs; 92 | } 93 | 94 | @action 95 | async submitForm(changeset, event) { 96 | event.preventDefault(); 97 | 98 | await changeset.validate(); 99 | 100 | changeset.save(); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /test-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-app", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "Small description for test-app goes here", 6 | "repository": "", 7 | "license": "MIT", 8 | "author": "", 9 | "directories": { 10 | "doc": "doc", 11 | "test": "tests" 12 | }, 13 | "scripts": { 14 | "build": "ember build --environment=production", 15 | "start": "ember serve", 16 | "test": "concurrently 'pnpm:test:*'", 17 | "test:ember": "ember test", 18 | "test:ember-compatibility": "ember try:each" 19 | }, 20 | "devDependencies": { 21 | "@babel/core": "^7.26.10", 22 | "@babel/plugin-proposal-decorators": "^7.25.9", 23 | "@ember/optional-features": "^2.2.0", 24 | "@ember/string": "^3.1.1", 25 | "@ember/test-helpers": "^5.1.0", 26 | "@embroider/compat": "^3.4.8", 27 | "@embroider/core": "^3.4.8", 28 | "@embroider/macros": "^1.16.13", 29 | "@embroider/test-setup": "^4.0.0", 30 | "@embroider/webpack": "^4.0.0", 31 | "@glimmer/component": "^1.1.2", 32 | "@glimmer/tracking": "^1.1.2", 33 | "@glint/environment-ember-loose": "^1.5.2", 34 | "@glint/environment-ember-template-imports": "^1.5.2", 35 | "@glint/template": "^1.5.2", 36 | "@tsconfig/ember": "^3.0.10", 37 | "@types/qunit": "^2.19.12", 38 | "@types/rsvp": "^4.0.9", 39 | "broccoli-asset-rev": "^3.0.0", 40 | "concurrently": "^9.1.2", 41 | "ember-auto-import": "^2.10.0", 42 | "ember-changeset": "workspace:*", 43 | "ember-cli": "~6.3.1", 44 | "ember-cli-babel": "^8.2.0", 45 | "ember-cli-deprecation-workflow": "^3.3.0", 46 | "ember-cli-htmlbars": "^6.3.0", 47 | "ember-cli-inject-live-reload": "^2.1.0", 48 | "ember-cli-sri": "^2.1.1", 49 | "ember-cli-terser": "^4.0.2", 50 | "ember-data": "~4.12.8", 51 | "ember-load-initializers": "^3.0.1", 52 | "ember-qunit": "^9.0.1", 53 | "ember-try": "^4.0.0", 54 | "ember-resolver": "^13.1.0", 55 | "ember-source": "~6.3.0", 56 | "ember-template-imports": "^4.3.0", 57 | "loader.js": "^4.7.0", 58 | "qunit": "^2.24.1", 59 | "qunit-dom": "^3.4.0", 60 | "scenario-tester": "^2.1.2", 61 | "typescript": "^5.8.2", 62 | "webpack": "^5.99.5", 63 | "yup": "^1.6.1" 64 | }, 65 | "dependenciesMeta": { 66 | "ember-changeset": { 67 | "injected": true 68 | } 69 | }, 70 | "engines": { 71 | "node": ">= 18" 72 | }, 73 | "volta": { 74 | "extends": "../package.json" 75 | }, 76 | "ember": { 77 | "edition": "octane" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | ## Improve documentation 4 | 5 | We are always looking to improve our documentation. If at some moment you are 6 | reading the documentation and something is not clear, or you can't find what you 7 | are looking for, then please open an issue with the repository. This gives us a 8 | chance to answer your question and to improve the documentation if needed. 9 | 10 | Pull requests correcting spelling or grammar mistakes are always welcome. 11 | 12 | ## Found a bug? 13 | 14 | Please try to answer at least the following questions when reporting a bug: 15 | 16 | - Which version of the project did you use when you noticed the bug? 17 | - How do you reproduce the error condition? 18 | - What happened that you think is a bug? 19 | - What should it do instead? 20 | 21 | It would really help the maintainers if you could provide a reduced test case 22 | that reproduces the error condition. 23 | 24 | ## Have a feature request? 25 | 26 | Please provide some thoughful commentary and code samples on what this feature 27 | should do and why it should be added (your use case). The minimal questions you 28 | should answer when submitting a feature request should be: 29 | 30 | - What will it allow you to do that you can't do today? 31 | - Why do you need this feature and how will it benefit other users? 32 | - Are there any drawbacks to this feature? 33 | 34 | ## Submitting a pull-request? 35 | 36 | Here are some things that will increase the chance that your pull-request will 37 | get accepted: 38 | 39 | - Did you confirm this fix/feature is something that is needed? 40 | - Did you write tests, preferably in a test driven style? 41 | - Did you add documentation for the changes you made? 42 | 43 | If your pull-request addresses an issue then please add the corresponding 44 | issue's number to the description of your pull-request. 45 | 46 | ## Installation 47 | 48 | - `git clone git@github.com:adopted-ember-addons/ember-changeset.git` 49 | - `cd ember-changeset` 50 | - `pnpm install` 51 | 52 | ## Linting 53 | 54 | - `pnpm lint` 55 | - `pnpm lint:fix` 56 | 57 | ## Running tests 58 | 59 | - `pnpm test` – Runs the test suite on the current Ember version 60 | - `pnpm test:ember --server` – Runs the test suite in "watch mode" 61 | - `pnpm test:ember-compatibility` – Runs the test suite against multiple Ember versions 62 | 63 | ## Running the dummy application 64 | 65 | - `pnpm start` 66 | - Visit the dummy application at [http://localhost:4200](http://localhost:4200). 67 | 68 | For more information on using ember-cli, visit [https://cli.emberjs.com/release/](https://cli.emberjs.com/release/). 69 | -------------------------------------------------------------------------------- /test-app/app/components/validated-form.hbs: -------------------------------------------------------------------------------- 1 | {{! template-lint-disable require-input-label }} 2 |

Validated Changeset isValid · {{this.changeset.isValid}}

3 |

Validated Changeset isPristine · {{this.changeset.isPristine}}

4 |

Validated Changeset cid: 5 | {{this.changeset.cid}}

6 |

Validated Changeset Email: 7 | {{changeset-get 8 | this.changeset 9 | "user.email" 10 | }}

11 |

Validated Changeset Name: 12 | {{changeset-get 13 | this.changeset 14 | "user.name" 15 | }}

16 |
17 |

Model Name: 18 | {{this.model.user.name}}

19 |

Model Email: 20 | {{this.model.user.email}}

21 |

Model cid: {{this.model.cid}}

22 | 23 | {{#each this.model.addresses as |address idx|}} 24 |

{{address.street}} {{address.city}}

25 | {{/each}} 26 | 27 |
28 |
29 | 30 | 36 |
37 | 38 |
39 | 40 | 46 |
47 | 48 |
49 | 50 | 56 |
57 | 58 |
59 |

Addresses

60 | {{#each this.changeset.addresses as |address index|}} 61 | 66 | 71 | {{/each}} 72 |
73 | 74 |
75 | 80 |
81 | 82 | 87 | 88 |
-------------------------------------------------------------------------------- /test-app/config/ember-try.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // @todo uncomment when https://github.com/emberjs/ember.js/issues/20777 resolved 4 | // const getChannelURL = require('ember-source-channel-url'); 5 | async function getChannelURL(channelType) { 6 | let HOST = 7 | process.env.EMBER_SOURCE_CHANNEL_URL_HOST || 'https://s3.amazonaws.com'; 8 | let PATH = 'builds.emberjs.com'; 9 | 10 | const response = await fetch(`${HOST}/${PATH}/${channelType}.json`); 11 | const result = await response.json(); 12 | 13 | return result.version 14 | .replace('.canary', '') 15 | .replace('.beta', '') 16 | .replace('-release', ''); 17 | } 18 | const { embroiderSafe, embroiderOptimized } = require('@embroider/test-setup'); 19 | 20 | module.exports = async function () { 21 | return { 22 | packageManager: 'pnpm', 23 | scenarios: [ 24 | { 25 | name: 'ember-lts-4.8', 26 | npm: { 27 | devDependencies: { 28 | 'ember-data': '~4.8.0', 29 | 'ember-resolver': '^8.0.0', 30 | 'ember-source': '~4.8.0', 31 | }, 32 | }, 33 | }, 34 | { 35 | name: 'ember-lts-4.12', 36 | npm: { 37 | devDependencies: { 38 | 'ember-data': '~4.12.0', 39 | 'ember-source': '~4.12.0', 40 | }, 41 | }, 42 | }, 43 | { 44 | name: 'ember-lts-5.4', 45 | npm: { 46 | devDependencies: { 47 | 'ember-source': '~5.4.0', 48 | }, 49 | }, 50 | }, 51 | { 52 | name: 'ember-lts-5.8', 53 | npm: { 54 | devDependencies: { 55 | 'ember-source': '~5.8.0', 56 | }, 57 | }, 58 | }, 59 | { 60 | name: 'ember-lts-5.12', 61 | npm: { 62 | devDependencies: { 63 | 'ember-source': '~5.12.0', 64 | }, 65 | }, 66 | }, 67 | { 68 | name: 'ember-release', 69 | npm: { 70 | devDependencies: { 71 | 'ember-source': await getChannelURL('release'), 72 | }, 73 | }, 74 | }, 75 | { 76 | name: 'ember-beta', 77 | npm: { 78 | devDependencies: { 79 | 'ember-source': await getChannelURL('beta'), 80 | '@ember/test-helpers': '^4.0.0', 81 | }, 82 | }, 83 | }, 84 | { 85 | name: 'ember-canary', 86 | npm: { 87 | devDependencies: { 88 | 'ember-source': await getChannelURL('canary'), 89 | '@ember/test-helpers': '^4.0.0', 90 | }, 91 | }, 92 | }, 93 | embroiderSafe(), 94 | embroiderOptimized(), 95 | ], 96 | }; 97 | }; 98 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | pull_request: {} 9 | 10 | concurrency: 11 | group: ci-${{ github.head_ref || github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | test: 16 | name: "Tests" 17 | runs-on: ubuntu-latest 18 | timeout-minutes: 10 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: pnpm/action-setup@v4 23 | - name: Install Node 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: 18 27 | cache: pnpm 28 | - name: Install Dependencies 29 | run: pnpm install --frozen-lockfile 30 | - name: Second Install for proper injected 31 | run: pnpm install --frozen-lockfile 32 | - name: Lint 33 | run: pnpm lint 34 | - name: Run Tests 35 | run: pnpm test:ember 36 | working-directory: test-app 37 | 38 | floating: 39 | name: "Floating Dependencies" 40 | runs-on: ubuntu-latest 41 | timeout-minutes: 10 42 | 43 | steps: 44 | - uses: actions/checkout@v4 45 | - uses: pnpm/action-setup@v4 46 | - uses: actions/setup-node@v4 47 | with: 48 | node-version: 18 49 | cache: pnpm 50 | - name: Install Dependencies 51 | run: pnpm install --no-lockfile 52 | - name: Second Install for proper injected 53 | run: pnpm install --no-lockfile 54 | - name: Run Tests 55 | run: pnpm test:ember 56 | 57 | try-scenarios: 58 | name: ${{ matrix.try-scenario }} 59 | runs-on: ubuntu-latest 60 | needs: "test" 61 | timeout-minutes: 10 62 | 63 | strategy: 64 | fail-fast: false 65 | matrix: 66 | try-scenario: 67 | - ember-lts-4.8 68 | - ember-lts-4.12 69 | - ember-lts-5.4 70 | - ember-lts-5.8 71 | - ember-lts-5.12 72 | - ember-release 73 | - ember-beta 74 | - ember-canary 75 | # @todo uncomment once Ember Data bumped to => 5.3 76 | # - embroider-safe 77 | # - embroider-optimized 78 | 79 | steps: 80 | - uses: actions/checkout@v4 81 | - uses: pnpm/action-setup@v4 82 | - name: Install Node 83 | uses: actions/setup-node@v4 84 | with: 85 | node-version: 18 86 | cache: pnpm 87 | - name: Install Dependencies 88 | run: pnpm install --frozen-lockfile 89 | - name: Second Install for proper injected 90 | run: pnpm install --frozen-lockfile 91 | - name: Run Tests 92 | run: ./node_modules/.bin/ember try:one ${{ matrix.try-scenario }} --skip-cleanup 93 | working-directory: test-app 94 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import { babel } from '@rollup/plugin-babel'; 2 | import { Addon } from '@embroider/addon-dev/rollup'; 3 | import copy from 'rollup-plugin-copy'; 4 | 5 | const addon = new Addon({ 6 | srcDir: 'src', 7 | destDir: 'dist', 8 | }); 9 | 10 | export default { 11 | // This provides defaults that work well alongside `publicEntrypoints` below. 12 | // You can augment this if you need to. 13 | output: addon.output(), 14 | 15 | plugins: [ 16 | // These are the modules that users should be able to import from your 17 | // addon. Anything not listed here may get optimized away. 18 | // By default all your JavaScript modules (**/*.js) will be importable. 19 | // But you are encouraged to tweak this to only cover the modules that make 20 | // up your addon's public API. Also make sure your package.json#exports 21 | // is aligned to the config here. 22 | // See https://github.com/embroider-build/embroider/blob/main/docs/v2-faq.md#how-can-i-define-the-public-exports-of-my-addon 23 | addon.publicEntrypoints(['**/*.js', 'index.js', 'template-registry.js']), 24 | 25 | // These are the modules that should get reexported into the traditional 26 | // "app" tree. Things in here should also be in publicEntrypoints above, but 27 | // not everything in publicEntrypoints necessarily needs to go here. 28 | addon.appReexports(['helpers/**/*.js']), 29 | 30 | // Follow the V2 Addon rules about dependencies. Your code can import from 31 | // `dependencies` and `peerDependencies` as well as standard Ember-provided 32 | // package names. 33 | addon.dependencies(), 34 | 35 | // This babel config should *not* apply presets or compile away ES modules. 36 | // It exists only to provide development niceties for you, like automatic 37 | // template colocation. 38 | // 39 | // By default, this will load the actual babel config from the file 40 | // babel.config.json. 41 | babel({ 42 | extensions: ['.js', '.gjs', '.ts', '.gts'], 43 | babelHelpers: 'bundled', 44 | }), 45 | 46 | // Ensure that standalone .hbs files are properly integrated as Javascript. 47 | addon.hbs(), 48 | 49 | // Ensure that .gjs files are properly integrated as Javascript 50 | addon.gjs(), 51 | 52 | // @todo uncomment once converted to TypeScript 53 | // // Emit .d.ts declaration files 54 | // addon.declarations('declarations'), 55 | 56 | // addons are allowed to contain imports of .css files, which we want rollup 57 | // to leave alone and keep in the published output. 58 | addon.keepAssets(['**/*.css']), 59 | 60 | // Remove leftover build artifacts when starting a new build. 61 | addon.clean(), 62 | 63 | // Copy Readme and License into published package 64 | copy({ 65 | targets: [ 66 | { src: 'src/index.d.ts', dest: 'declarations' }, 67 | { src: 'src/types.d.ts', dest: 'declarations' }, 68 | ], 69 | }), 70 | ], 71 | }; 72 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at arr@sugarpirate.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Debugging: 3 | * https://eslint.org/docs/latest/use/configure/debug 4 | * ---------------------------------------------------- 5 | * 6 | * Print a file's calculated configuration 7 | * 8 | * npx eslint --print-config path/to/file.js 9 | * 10 | * Inspecting the config 11 | * 12 | * npx eslint --inspect-config 13 | * 14 | */ 15 | import babelParser from '@babel/eslint-parser'; 16 | import js from '@eslint/js'; 17 | import prettier from 'eslint-config-prettier'; 18 | import ember from 'eslint-plugin-ember/recommended'; 19 | import importPlugin from 'eslint-plugin-import'; 20 | import qunit from 'eslint-plugin-qunit'; 21 | import n from 'eslint-plugin-n'; 22 | import globals from 'globals'; 23 | import ts from 'typescript-eslint'; 24 | 25 | const parserOptions = { 26 | esm: { 27 | js: { 28 | ecmaFeatures: { modules: true }, 29 | ecmaVersion: 'latest', 30 | }, 31 | ts: { 32 | projectService: true, 33 | project: true, 34 | tsconfigRootDir: import.meta.dirname, 35 | }, 36 | }, 37 | }; 38 | 39 | export default ts.config( 40 | js.configs.recommended, 41 | ember.configs.base, 42 | ember.configs.gjs, 43 | ember.configs.gts, 44 | prettier, 45 | /** 46 | * Ignores must be in their own object 47 | * https://eslint.org/docs/latest/use/configure/ignore 48 | */ 49 | { 50 | ignores: [ 51 | 'declarations/', 52 | 'dist/', 53 | 'node_modules/', 54 | 'coverage/', 55 | 'test-app/dist', 56 | '!**/.*', 57 | ], 58 | }, 59 | /** 60 | * https://eslint.org/docs/latest/use/configure/configuration-files#configuring-linter-options 61 | */ 62 | { 63 | linterOptions: { 64 | reportUnusedDisableDirectives: 'error', 65 | }, 66 | }, 67 | { 68 | files: ['**/*.js'], 69 | languageOptions: { 70 | parser: babelParser, 71 | }, 72 | }, 73 | { 74 | files: ['**/*.{js,gjs}'], 75 | languageOptions: { 76 | parserOptions: parserOptions.esm.js, 77 | globals: { 78 | ...globals.browser, 79 | }, 80 | }, 81 | }, 82 | { 83 | files: ['**/*.{ts,gts}'], 84 | languageOptions: { 85 | parser: ember.parser, 86 | parserOptions: parserOptions.esm.ts, 87 | }, 88 | extends: [...ts.configs.recommendedTypeChecked, ember.configs.gts], 89 | }, 90 | { 91 | files: ['src/**/*'], 92 | plugins: { 93 | import: importPlugin, 94 | }, 95 | rules: { 96 | // require relative imports use full extensions 97 | 'import/extensions': ['error', 'always', { ignorePackages: true }], 98 | }, 99 | }, 100 | { 101 | rules: { 102 | 'ember/no-get': 'off', 103 | }, 104 | }, 105 | { 106 | files: ['tests/**/*-test.{js,gjs,ts,gts}'], 107 | plugins: { 108 | qunit, 109 | }, 110 | }, 111 | /** 112 | * CJS node files 113 | */ 114 | { 115 | files: [ 116 | '**/*.cjs', 117 | 'test-app/config/**/*.js', 118 | 'test-app/ember-cli-build.js', 119 | 'test-app/scenarios.js', 120 | 'test-app/testem.js', 121 | ], 122 | plugins: { 123 | n, 124 | }, 125 | 126 | languageOptions: { 127 | sourceType: 'script', 128 | ecmaVersion: 'latest', 129 | globals: { 130 | ...globals.node, 131 | }, 132 | }, 133 | }, 134 | /** 135 | * ESM node files 136 | */ 137 | { 138 | files: ['**/*.mjs'], 139 | plugins: { 140 | n, 141 | }, 142 | 143 | languageOptions: { 144 | sourceType: 'module', 145 | ecmaVersion: 'latest', 146 | parserOptions: parserOptions.esm.js, 147 | globals: { 148 | ...globals.node, 149 | }, 150 | }, 151 | }, 152 | ); 153 | -------------------------------------------------------------------------------- /.github/workflows/plan-release.yml: -------------------------------------------------------------------------------- 1 | name: Plan Release 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | pull_request_target: # This workflow has permissions on the repo, do NOT run code from PRs in this workflow. See https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ 9 | types: 10 | - labeled 11 | - unlabeled 12 | 13 | concurrency: 14 | group: plan-release # only the latest one of these should ever be running 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | is-this-a-release: 19 | name: "Is this a release?" 20 | runs-on: ubuntu-latest 21 | outputs: 22 | command: ${{ steps.check-release.outputs.command }} 23 | 24 | steps: 25 | - uses: actions/checkout@v4 26 | with: 27 | fetch-depth: 2 28 | ref: "master" 29 | # This will only cause the `is-this-a-release` job to have a "command" of `release` 30 | # when the .release-plan.json file was changed on the last commit. 31 | - id: check-release 32 | run: if git diff --name-only HEAD HEAD~1 | grep -w -q ".release-plan.json"; then echo "command=release"; fi >> $GITHUB_OUTPUT 33 | 34 | create-prepare-release-pr: 35 | name: Create Prepare Release PR 36 | runs-on: ubuntu-latest 37 | timeout-minutes: 5 38 | needs: is-this-a-release 39 | permissions: 40 | contents: write 41 | issues: read 42 | pull-requests: write 43 | # only run on push event or workflow dispatch if plan wasn't updated (don't create a release plan when we're releasing) 44 | # only run on labeled event if the PR has already been merged 45 | if: ((github.event_name == 'push' || github.event_name == 'workflow_dispatch') && needs.is-this-a-release.outputs.command != 'release') || (github.event_name == 'pull_request_target' && github.event.pull_request.merged == true) 46 | 47 | steps: 48 | - uses: actions/checkout@v4 49 | # We need to download lots of history so that 50 | # github-changelog can discover what's changed since the last release 51 | with: 52 | fetch-depth: 0 53 | ref: "master" 54 | - uses: pnpm/action-setup@v4 55 | - uses: actions/setup-node@v4 56 | with: 57 | node-version: 18 58 | cache: pnpm 59 | - run: pnpm install --frozen-lockfile 60 | - name: "Generate Explanation and Prep Changelogs" 61 | id: explanation 62 | run: | 63 | set +e 64 | pnpm release-plan prepare 2> >(tee -a release-plan-stderr.txt >&2) 65 | 66 | if [ $? -ne 0 ]; then 67 | release_plan_output=$(cat release-plan-stderr.txt) 68 | else 69 | release_plan_output=$(jq .description .release-plan.json -r) 70 | rm release-plan-stderr.txt 71 | 72 | if [ $(jq '.solution | length' .release-plan.json) -eq 1 ]; then 73 | new_version=$(jq -r '.solution[].newVersion' .release-plan.json) 74 | echo "new_version=v$new_version" >> $GITHUB_OUTPUT 75 | fi 76 | fi 77 | echo 'text<> $GITHUB_OUTPUT 78 | echo "$release_plan_output" >> $GITHUB_OUTPUT 79 | echo 'EOF' >> $GITHUB_OUTPUT 80 | env: 81 | GITHUB_AUTH: ${{ secrets.GITHUB_TOKEN }} 82 | 83 | - uses: peter-evans/create-pull-request@v7 84 | with: 85 | commit-message: "Prepare Release ${{ steps.explanation.outputs.new_version}} using 'release-plan'" 86 | labels: "internal" 87 | branch: release-preview 88 | title: Prepare Release ${{ steps.explanation.outputs.new_version }} 89 | body: | 90 | This PR is a preview of the release that [release-plan](https://github.com/embroider-build/release-plan) has prepared. To release you should just merge this PR 👍 91 | 92 | ----------------------------------------- 93 | 94 | ${{ steps.explanation.outputs.text }} 95 | -------------------------------------------------------------------------------- /test-app/app/components/changeset-form.hbs: -------------------------------------------------------------------------------- 1 | {{! template-lint-disable require-input-label }} 2 |

Changeset isPristine · {{this.changeset.isPristine}}

3 |

Changeset cid: 4 | {{this.changeset.cid}}

5 |

Changeset Email: 6 | {{changeset-get 7 | this.changeset 8 | "user.email" 9 | }}

10 |

Changeset Name: 11 | {{changeset-get 12 | this.changeset 13 | "user.name" 14 | }}

15 |

Changeset notifications email: 16 | {{changeset-get 17 | this.changeset 18 | "notifications.email" 19 | }}

20 |

Changeset notifications sms: 21 | {{changeset-get 22 | this.changeset 23 | "notifications.sms" 24 | }}

25 |
26 |

Model Name: 27 | {{this.model.user.name}}

28 |

Model Email: 29 | {{this.model.user.email}}

30 |

Model cid: {{this.model.cid}}

31 |

Model notifications email: 32 | {{this.model.notifications.email}}

35 |

Model notifications sms: 36 | {{this.model.notifications.sms}}

39 |

Doubled: {{this.model.doubleGrowth}}

40 | 41 | {{#each this.validateOnRender.errors as |e|}} 42 |

Validate On Render for {{e.key}}: {{e.value}}

43 | {{/each}} 44 | 45 | {{#each this.model.addresses as |address idx|}} 46 |

{{address.street}} {{address.city}}

47 | {{/each}} 48 | 49 |
50 |
51 | 52 | 58 |
59 | 60 |
61 | 62 | 71 |
72 | 73 |
74 | 75 | 84 |
85 | 86 |
87 | 88 | 97 | 98 | 107 |
108 | 109 |
110 |

Addresses

111 | {{#each this.changeset.addresses as |address index|}} 112 | 117 | 122 | {{/each}} 123 |
124 | 125 |
126 | 134 |
135 | 136 | 141 | 142 |
-------------------------------------------------------------------------------- /test-app/tests/integration/helpers/changeset-get-test.js: -------------------------------------------------------------------------------- 1 | import { fillIn, find, render, settled } from '@ember/test-helpers'; 2 | import { setupRenderingTest } from 'ember-qunit'; 3 | import hbs from 'htmlbars-inline-precompile'; 4 | import { module, test } from 'qunit'; 5 | import { Changeset } from 'ember-changeset'; 6 | 7 | module('Integration | Helper | changeset-get', function (hooks) { 8 | setupRenderingTest(hooks); 9 | 10 | let model; 11 | 12 | hooks.beforeEach(function () { 13 | model = { 14 | name: { 15 | first: 'Bob', 16 | last: 'Loblaw', 17 | }, 18 | email: 'bob@lob.law', 19 | url: 'http://bobloblawslawblog.com', 20 | }; 21 | 22 | this.changeset = Changeset(model); 23 | this.fieldName = 'name.first'; 24 | }); 25 | 26 | test('it retrieves the current value using {{get}}', async function (assert) { 27 | this.updateName = (changeset, evt) => { 28 | changeset.set('name.first', evt.target.value); 29 | }; 30 | await render(hbs` 31 | 32 | 38 |

{{this.changeset.name.first}}

39 |
    40 | {{#each this.changeset.changes as |change|}} 41 |
  • {{change.key}}: {{change.value}}
  • 42 | {{/each}} 43 |
44 | `); 45 | 46 | await fillIn(find('input'), 'Robert'); 47 | 48 | assert.dom('#test-el').hasText('Robert'); 49 | assert.dom('input').hasValue('Robert'); 50 | 51 | await this.changeset.rollback(); 52 | 53 | assert.dom('#test-el').hasText('Robert'); 54 | assert.dom('input').hasValue('Robert'); 55 | }); 56 | 57 | test('it succeeds in retrieving the current value using {{get}}', async function (assert) { 58 | this.updateName = (changeset, evt) => { 59 | changeset.set('name.first', evt.target.value); 60 | }; 61 | await render(hbs` 62 | 63 | 69 |

{{get this.changeset this.fieldName}}

70 |
    71 | {{#each (get this.changeset "changes") as |change index|}} 72 |
  • {{change.key}}: {{change.value}}
  • 73 | {{/each}} 74 |
75 | `); 76 | 77 | const input = find('input'); 78 | const testEl = find('#test-el'); 79 | 80 | await fillIn(input, 'Robert'); 81 | 82 | assert.dom(testEl).hasText('Robert'); 83 | let list = find('#change-0'); 84 | assert.dom(list).hasText('name.first: Robert'); 85 | assert.strictEqual(input.value, 'Robert'); 86 | 87 | this.changeset.rollback(); 88 | 89 | await settled(); 90 | assert.dom(testEl).hasText('Bob'); 91 | list = find('#change-0'); 92 | assert.notOk(list, 'no changes'); 93 | assert.strictEqual(input.value, 'Bob'); 94 | }); 95 | }); 96 | 97 | module('Integration | Helper | changeset-get relationships', function (hooks) { 98 | setupRenderingTest(hooks); 99 | 100 | hooks.beforeEach(function () { 101 | this.store = this.owner.lookup('service:store'); 102 | 103 | this.createUser = (userType, withDogs) => { 104 | let profile = this.store.createRecord('profile'); 105 | let user = this.store.createRecord(userType, { profile }); 106 | 107 | if (withDogs) { 108 | for (let i = 0; i < 2; i++) { 109 | user.get('dogs').addObject(this.store.createRecord('dog')); 110 | } 111 | } 112 | return user; 113 | }; 114 | 115 | this.createUserWithNullBelongsTo = (userType) => { 116 | let user = this.store.createRecord(userType, { profile: null }); 117 | return user; 118 | }; 119 | }); 120 | 121 | test('it renders belongsTo property', async function (assert) { 122 | let user = this.createUser('user', false); 123 | this.changeset = Changeset(user); 124 | 125 | await render(hbs` 126 |

{{changeset-get this.changeset "profile.firstName"}}

127 | `); 128 | 129 | assert.dom('#test-el').hasText('Bob'); 130 | }); 131 | 132 | test('it does not fail with a null belongsTo property', async function (assert) { 133 | let user = this.createUserWithNullBelongsTo('user'); 134 | this.changeset = Changeset(user); 135 | 136 | await render(hbs` 137 |

{{changeset-get this.changeset "profile.firstName"}}

138 | `); 139 | 140 | assert.dom('#test-el').hasText(''); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ember-changeset", 3 | "version": "5.0.0", 4 | "description": "Changesets for Ember", 5 | "keywords": [ 6 | "ember-addon", 7 | "changeset" 8 | ], 9 | "homepage": "https://github.com/adopted-ember-addons/ember-changeset", 10 | "bugs": "https://github.com/adopted-ember-addons/ember-changeset/issues", 11 | "repository": "https://github.com/adopted-ember-addons/ember-changeset", 12 | "license": "MIT", 13 | "author": "Lauren Tan ", 14 | "contributors": [ 15 | { 16 | "name": "Scott Newcomer", 17 | "url": "https://github.com/snewcomer" 18 | } 19 | ], 20 | "exports": { 21 | ".": { 22 | "types": "./declarations/index.d.ts", 23 | "default": "./dist/index.js" 24 | }, 25 | "./validators": { 26 | "types": "./declarations/validators/index.d.ts", 27 | "default": "./dist/validators/index.js" 28 | }, 29 | "./*": { 30 | "types": "./declarations/*.d.ts", 31 | "default": "./dist/*.js" 32 | }, 33 | "./addon-main.js": "./addon-main.cjs" 34 | }, 35 | "typesVersions": { 36 | "*": { 37 | "*": [ 38 | "declarations/*" 39 | ] 40 | } 41 | }, 42 | "files": [ 43 | "addon-main.cjs", 44 | "declarations", 45 | "dist" 46 | ], 47 | "scripts": { 48 | "build": "rollup --config", 49 | "format": "prettier . --cache --write", 50 | "lint": "concurrently \"pnpm:lint:*(!fix)\" --names \"lint:\" --prefixColors auto", 51 | "lint:fix": "concurrently \"pnpm:lint:*:fix\" --names \"fix:\" --prefixColors auto && pnpm run format", 52 | "lint:format": "prettier . --cache --check", 53 | "lint:hbs": "ember-template-lint . --no-error-on-unmatched-pattern", 54 | "lint:hbs:fix": "ember-template-lint . --fix --no-error-on-unmatched-pattern", 55 | "lint:js": "eslint . --cache", 56 | "lint:js:fix": "eslint . --fix", 57 | "lint:types": "glint", 58 | "prepare": "rollup --config", 59 | "prepublishOnly": "rollup --config", 60 | "start": "concurrently 'pnpm:start:*' --restart-after 5000 --prefix-colors cyan,white,yellow", 61 | "start:build": "rollup --config --watch", 62 | "start:test-app": "pnpm --filter=test-app start", 63 | "test": "concurrently \"pnpm:lint:*(!fix)\" \"pnpm:test:*\"", 64 | "test:ember": "pnpm run --filter=test-app test:ember", 65 | "test:ember-compatibility": "pnpm --filter=test-app test:ember-compatibility" 66 | }, 67 | "dependencies": { 68 | "@embroider/addon-shim": "^1.9.0", 69 | "@embroider/macros": "^1.0.0", 70 | "decorator-transforms": "^2.2.2", 71 | "validated-changeset": "~1.4.1" 72 | }, 73 | "devDependencies": { 74 | "@babel/core": "^7.26.10", 75 | "@babel/eslint-parser": "^7.27.0", 76 | "@babel/plugin-transform-typescript": "^7.27.0", 77 | "@babel/runtime": "^7.27.0", 78 | "@ember-data/model": "~4.12.8", 79 | "@ember/string": "^3.1.1", 80 | "@embroider/addon-dev": "^7.1.3", 81 | "@eslint/js": "^9.23.0", 82 | "@glimmer/tracking": "^1.1.2", 83 | "@glint/core": "^1.5.2", 84 | "@glint/environment-ember-loose": "^1.5.2", 85 | "@glint/environment-ember-template-imports": "^1.5.2", 86 | "@glint/template": "^1.5.2", 87 | "@rollup/plugin-babel": "^6.0.4", 88 | "@tsconfig/ember": "^3.0.8", 89 | "babel-plugin-ember-template-compilation": "^2.2.5", 90 | "concurrently": "^9.0.1", 91 | "ember-data": "~4.12.8", 92 | "ember-source": "^6.3.0", 93 | "ember-template-lint": "^7.0.1", 94 | "eslint": "^9.23.0", 95 | "eslint-config-prettier": "^10.1.1", 96 | "eslint-plugin-ember": "^12.5.0", 97 | "eslint-plugin-import": "^2.31.0", 98 | "eslint-plugin-n": "^17.17.0", 99 | "eslint-plugin-qunit": "^8.1.2", 100 | "globals": "^16.0.0", 101 | "prettier": "^3.5.3", 102 | "prettier-plugin-ember-template-tag": "^2.0.4", 103 | "release-plan": "^0.16.0", 104 | "rollup": "^4.37.0", 105 | "rollup-plugin-copy": "^3.5.0", 106 | "typescript": "~5.8.2", 107 | "typescript-eslint": "^8.28.0", 108 | "webpack": "^5.99.5" 109 | }, 110 | "peerDependencies": { 111 | "@ember-data/model": "*", 112 | "ember-data": "*", 113 | "ember-source": ">=4.8.0" 114 | }, 115 | "peerDependenciesMeta": { 116 | "@ember-data/model": { 117 | "optional": true 118 | }, 119 | "ember-data": { 120 | "optional": true 121 | } 122 | }, 123 | "packageManager": "pnpm@10.7.0", 124 | "pnpm": { 125 | "overrides": { 126 | "@embroider/macros": "1.16.13" 127 | }, 128 | "onlyBuiltDependencies": [ 129 | "core-js" 130 | ] 131 | }, 132 | "volta": { 133 | "node": "22.14.0", 134 | "pnpm": "10.9.0" 135 | }, 136 | "ember": { 137 | "edition": "octane" 138 | }, 139 | "ember-addon": { 140 | "version": 2, 141 | "type": "addon", 142 | "main": "addon-main.cjs", 143 | "app-js": { 144 | "./helpers/changeset-get.js": "./dist/_app_/helpers/changeset-get.js", 145 | "./helpers/changeset-set.js": "./dist/_app_/helpers/changeset-set.js", 146 | "./helpers/changeset.js": "./dist/_app_/helpers/changeset.js" 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /test-app/tests/unit/utils/merge-deep-test.js: -------------------------------------------------------------------------------- 1 | import mergeDeep from 'ember-changeset/utils/merge-deep'; 2 | import { Change } from 'validated-changeset'; 3 | import { module, test } from 'qunit'; 4 | import { setupTest } from 'ember-qunit'; 5 | import { get, set } from '@ember/object'; 6 | 7 | module('Unit | Utility | merge deep', (hooks) => { 8 | setupTest(hooks); 9 | 10 | test('it returns merged objects', async function (assert) { 11 | let objA = { other: 'Ivan' }; 12 | let objB = { foo: new Change('bar'), zoo: 'doo' }; 13 | let value = mergeDeep(objA, objB); 14 | 15 | assert.deepEqual( 16 | value, 17 | { other: 'Ivan', foo: 'bar', zoo: 'doo' }, 18 | 'merges both values', 19 | ); 20 | }); 21 | 22 | test('works with arrays', function (assert) { 23 | const objA = { employees: ['Ivan', 'Jan'] }; 24 | const objB = { 25 | employees: { 0: new Change('Jull'), 1: new Change('Olafur') }, 26 | }; 27 | const value = mergeDeep(objA, objB); 28 | 29 | assert.deepEqual(value, { employees: ['Jull', 'Olafur'] }); 30 | }); 31 | 32 | test('it unsets', async function (assert) { 33 | let objA = { other: 'Ivan' }; 34 | let objB = { other: new Change(null) }; 35 | let value = mergeDeep(objA, objB); 36 | 37 | assert.deepEqual(value, { other: null }, 'unsets value'); 38 | }); 39 | 40 | test('it works with Ember.get and Ember.set', async function (assert) { 41 | let objA = { other: 'Ivan' }; 42 | let objB = { other: new Change(null) }; 43 | let value = mergeDeep(objA, objB, { safeGet: get, safeSet: set }); 44 | 45 | assert.deepEqual(value, { other: null }, 'unsets value'); 46 | }); 47 | 48 | test('it works with deeper nested objects', async function (assert) { 49 | let objA = { company: { employees: ['Ivan', 'Jan'] } }; 50 | let objB = { company: { employees: new Change(['Jull', 'Olafur']) } }; 51 | let value = mergeDeep(objA, objB); 52 | 53 | assert.deepEqual( 54 | value, 55 | { company: { employees: ['Jull', 'Olafur'] } }, 56 | 'has right employees', 57 | ); 58 | }); 59 | 60 | test('it works with source null', async function (assert) { 61 | let objA = { company: { employees: null } }; 62 | let objB = { company: { employees: new Change(['Jull', 'Olafur']) } }; 63 | let value = mergeDeep(objA, objB); 64 | 65 | assert.deepEqual( 66 | value, 67 | { company: { employees: ['Jull', 'Olafur'] } }, 68 | 'has right employees', 69 | ); 70 | 71 | objB = { company: { employees: null } }; 72 | value = mergeDeep(objA, objB); 73 | 74 | assert.deepEqual( 75 | value, 76 | { company: { employees: null } }, 77 | 'has right employees', 78 | ); 79 | }); 80 | 81 | test('it works with unsafe properties', async function (assert) { 82 | class A { 83 | _boo = 'bo'; 84 | 85 | get boo() { 86 | return this._boo; 87 | } 88 | set boo(value) { 89 | this._boo = value; 90 | } 91 | 92 | foo = { baz: 'ba' }; 93 | } 94 | 95 | class B extends A { 96 | other = 'Ivan'; 97 | } 98 | 99 | const objA = new B(); 100 | const objB = { boo: new Change('doo'), foo: { baz: new Change('bar') } }; 101 | 102 | const value = mergeDeep(objA, objB, { safeGet: get, safeSet: set }); 103 | 104 | assert.strictEqual(value.boo, 'doo', 'unsafe plain property is merged'); 105 | assert.strictEqual(value.other, 'Ivan', 'safe property is not touched'); 106 | assert.deepEqual( 107 | value.foo, 108 | { baz: 'bar' }, 109 | 'unsafe object property is merged', 110 | ); 111 | }); 112 | 113 | test('it merges with content as ember data object', async function (assert) { 114 | this.store = this.owner.lookup('service:store'); 115 | 116 | this.createUser = (userType, withDogs) => { 117 | let profile = this.store.createRecord('profile'); 118 | let user = this.store.createRecord(userType, { profile }); 119 | 120 | if (withDogs) { 121 | for (let i = 0; i < 2; i++) { 122 | user.get('dogs').addObject(this.store.createRecord('dog')); 123 | } 124 | } 125 | return user; 126 | }; 127 | 128 | let user = this.createUser('user', false); 129 | let user2 = { profile: { firstName: new Change('Joejoe') } }; 130 | mergeDeep(user, user2, { safeGet: get, safeSet: set }); 131 | 132 | assert.strictEqual( 133 | user.get('profile.firstName'), 134 | 'Joejoe', 135 | 'has first name', 136 | ); 137 | }); 138 | 139 | test('it does not work with ember-data objects', async function (assert) { 140 | assert.expect(1); 141 | 142 | this.store = this.owner.lookup('service:store'); 143 | 144 | this.createUser = (userType, withDogs) => { 145 | let profile = this.store.createRecord('profile'); 146 | let user = this.store.createRecord(userType, { profile }); 147 | 148 | if (withDogs) { 149 | for (let i = 0; i < 2; i++) { 150 | user.get('dogs').addObject(this.store.createRecord('dog')); 151 | } 152 | } 153 | return user; 154 | }; 155 | 156 | let user = this.createUser('user', false); 157 | let user2 = this.createUser('user', true); 158 | try { 159 | mergeDeep(user, user2, { safeGet: get, safeSet: set }); 160 | } catch ({ message }) { 161 | assert.strictEqual( 162 | message, 163 | 'Unable to `mergeDeep` with your data. Are you trying to merge two ember-data objects? Please file an issue with ember-changeset.', 164 | 'throws message', 165 | ); 166 | } 167 | }); 168 | }); 169 | -------------------------------------------------------------------------------- /src/validated-changeset.js: -------------------------------------------------------------------------------- 1 | import { assert } from '@ember/debug'; 2 | import { dependentKeyCompat } from '@ember/object/compat'; 3 | import { ValidationChangeset, getKeyValues } from 'validated-changeset'; 4 | import ArrayProxy from '@ember/array/proxy'; 5 | import ObjectProxy from '@ember/object/proxy'; 6 | import { notifyPropertyChange } from '@ember/object'; 7 | import mergeDeep from './utils/merge-deep.js'; 8 | import isObject from './utils/is-object.js'; 9 | import { tracked } from '@glimmer/tracking'; 10 | import { get as safeGet, set as safeSet } from '@ember/object'; 11 | import { 12 | macroCondition, 13 | dependencySatisfies, 14 | importSync, 15 | } from '@embroider/macros'; 16 | 17 | const CHANGES = '_changes'; 18 | const PREVIOUS_CONTENT = '_previousContent'; 19 | const CONTENT = '_content'; 20 | 21 | export function buildOldValues(content, changes, getDeep) { 22 | const obj = Object.create(null); 23 | 24 | for (let change of changes) { 25 | obj[change.key] = getDeep(content, change.key); 26 | } 27 | 28 | return obj; 29 | } 30 | 31 | function isProxy(o) { 32 | return !!(o && (o instanceof ObjectProxy || o instanceof ArrayProxy)); 33 | } 34 | 35 | function maybeUnwrapProxy(o) { 36 | return isProxy(o) ? maybeUnwrapProxy(safeGet(o, 'content')) : o; 37 | } 38 | 39 | let Model; 40 | if (macroCondition(dependencySatisfies('ember-data', '*'))) { 41 | Model = importSync('@ember-data/model').default; 42 | } 43 | 44 | export class EmberValidationChangeset extends ValidationChangeset { 45 | @tracked _changes; 46 | @tracked _errors; 47 | @tracked _content; 48 | 49 | isObject = isObject; 50 | 51 | maybeUnwrapProxy = maybeUnwrapProxy; 52 | 53 | // DO NOT override setDeep. Ember.set does not work wth empty hash and nested 54 | // key Ember.set({}, 'user.name', 'foo'); 55 | // override base class 56 | // DO NOT override setDeep. Ember.set does not work with Ember.set({}, 'user.name', 'foo'); 57 | getDeep = safeGet; 58 | mergeDeep = mergeDeep; 59 | 60 | safeGet(obj, key) { 61 | if (Model && obj.relationshipFor?.(key)?.meta?.kind == 'belongsTo') { 62 | return obj.belongsTo(key).value(); 63 | } 64 | return safeGet(obj, key); 65 | } 66 | safeSet(obj, key, value) { 67 | return safeSet(obj, key, value); 68 | } 69 | 70 | /** 71 | * @property isValid 72 | * @type {Array} 73 | */ 74 | @dependentKeyCompat 75 | get isValid() { 76 | return super.isValid; 77 | } 78 | 79 | /** 80 | * @property isInvalid 81 | * @type {Boolean} 82 | */ 83 | @dependentKeyCompat 84 | get isInvalid() { 85 | return super.isInvalid; 86 | } 87 | 88 | /** 89 | * @property isPristine 90 | * @type {Boolean} 91 | */ 92 | @dependentKeyCompat 93 | get isPristine() { 94 | return super.isPristine; 95 | } 96 | 97 | /** 98 | * @property isDirty 99 | * @type {Boolean} 100 | */ 101 | @dependentKeyCompat 102 | get isDirty() { 103 | return super.isDirty; 104 | } 105 | 106 | get pendingData() { 107 | let content = this[CONTENT]; 108 | let changes = this[CHANGES]; 109 | 110 | let pendingChanges = this.mergeDeep( 111 | Object.create(Object.getPrototypeOf(content)), 112 | content, 113 | { safeGet, safeSet }, 114 | ); 115 | 116 | return this.mergeDeep(pendingChanges, changes, { safeGet, safeSet }); 117 | } 118 | 119 | /** 120 | * Manually add an error to the changeset. If there is an existing 121 | * error or change for `key`, it will be overwritten. 122 | * 123 | * @method addError 124 | */ 125 | addError(key, error) { 126 | super.addError(key, error); 127 | 128 | notifyPropertyChange(this, key); 129 | // Return passed-in `error`. 130 | return error; 131 | } 132 | 133 | /** 134 | * Manually push multiple errors to the changeset as an array. 135 | * 136 | * @method pushErrors 137 | */ 138 | pushErrors(key, ...newErrors) { 139 | const { value, validation } = super.pushErrors(key, ...newErrors); 140 | 141 | notifyPropertyChange(this, key); 142 | 143 | return { value, validation }; 144 | } 145 | 146 | /** 147 | * Sets property or error on the changeset. 148 | * Returns value or error 149 | */ 150 | _setProperty({ key, value, oldValue }) { 151 | super._setProperty({ key, value, oldValue }); 152 | 153 | notifyPropertyChange(this, key); 154 | } 155 | 156 | /** 157 | * Notifies virtual properties set on the changeset of a change. 158 | * You can specify which keys are notified by passing in an array. 159 | * 160 | * @private 161 | * @param {Array} keys 162 | * @return {Void} 163 | */ 164 | _notifyVirtualProperties(keys) { 165 | keys = super._notifyVirtualProperties(keys); 166 | 167 | (keys || []).forEach((key) => notifyPropertyChange(this, key)); 168 | 169 | return; 170 | } 171 | 172 | /** 173 | * Deletes a key off an object and notifies observers. 174 | */ 175 | _deleteKey(objName, key = '') { 176 | const result = super._deleteKey(objName, key); 177 | 178 | notifyPropertyChange(this, key); 179 | 180 | return result; 181 | } 182 | 183 | /** 184 | * Executes the changeset if in a valid state. 185 | * 186 | * @method execute 187 | */ 188 | execute() { 189 | let oldContent; 190 | if (this.isValid && this.isDirty) { 191 | let content = this[CONTENT]; 192 | let changes = this[CHANGES]; 193 | 194 | // keep old values in case of error and we want to rollback 195 | oldContent = buildOldValues(content, getKeyValues(changes), this.getDeep); 196 | 197 | // we want mutation on original object 198 | // @tracked 199 | this[CONTENT] = this.mergeDeep(content, changes, { safeGet, safeSet }); 200 | } 201 | 202 | this[PREVIOUS_CONTENT] = oldContent; 203 | 204 | return this; 205 | } 206 | } 207 | 208 | /** 209 | * Creates new changesets. 210 | */ 211 | export function changeset(obj) { 212 | assert('Underlying object for changeset is missing', Boolean(obj)); 213 | assert( 214 | 'Array is not a valid type to pass as the first argument to `changeset`', 215 | !Array.isArray(obj), 216 | ); 217 | 218 | const c = new EmberValidationChangeset(obj); 219 | return c; 220 | } 221 | 222 | /** 223 | * Creates new changesets. 224 | * @function Changeset 225 | */ 226 | export function Changeset(obj) { 227 | const c = changeset(obj); 228 | 229 | return new Proxy(c, { 230 | get(targetBuffer, key /*, receiver*/) { 231 | const res = targetBuffer.get(key.toString()); 232 | return res; 233 | }, 234 | 235 | set(targetBuffer, key, value /*, receiver*/) { 236 | targetBuffer.set(key.toString(), value); 237 | return true; 238 | }, 239 | }); 240 | } 241 | -------------------------------------------------------------------------------- /test-app/tests/integration/components/changeset-form-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { setupRenderingTest } from 'ember-qunit'; 3 | import { click, find, fillIn, render } from '@ember/test-helpers'; 4 | import hbs from 'htmlbars-inline-precompile'; 5 | 6 | module('Integration | Component | changeset-form', function (hooks) { 7 | setupRenderingTest(hooks); 8 | 9 | test('it renders form', async function (assert) { 10 | await render(hbs``); 11 | 12 | // start filling in some data to test states of form 13 | await fillIn('[data-test-user-email]', 'foo'); 14 | await fillIn('[data-test-cid]', 2); 15 | 16 | assert.dom('[data-test-cid]').hasValue('2', 'has cid input value'); 17 | assert 18 | .dom('[data-test-user-email]') 19 | .hasValue('foo', 'has email input value'); 20 | assert 21 | .dom('[data-test-user-name]') 22 | .hasValue('someone', 'has name input value'); 23 | 24 | assert.true( 25 | find('[data-test-submit]').disabled, 26 | 'button disabled due to invalid email', 27 | ); 28 | 29 | assert 30 | .dom('[data-test-model-user-email]') 31 | .hasText('something', 'has old email still b/c input not valid'); 32 | 33 | await fillIn('[data-test-user-email]', 'foo@gmail.com'); 34 | 35 | assert.false( 36 | find('[data-test-submit]').disabled, 37 | 'button not disabled after valid email', 38 | ); 39 | 40 | // submit - now enabled with valid email 41 | await click('[data-test-submit]'); 42 | 43 | // toggle submit valid state 44 | await fillIn('[data-test-user-email]', 'bar '); 45 | 46 | assert.true( 47 | find('[data-test-submit]').disabled, 48 | 'button disabled due to new invalid email', 49 | ); 50 | 51 | await fillIn('[data-test-user-email]', 'foo@gmail.com'); 52 | 53 | assert.false( 54 | find('[data-test-submit]').disabled, 55 | 'button not disabled b/c valid email was input-ed', 56 | ); 57 | 58 | await fillIn('[data-test-user-name]', 'makers'); 59 | 60 | // submit - still enabled 61 | assert.false(find('[data-test-submit]').disabled, 'button not disabled'); 62 | 63 | assert.dom('[data-test-model-cid]').hasText('2', 'has cid after submit'); 64 | assert 65 | .dom('[data-test-model-user-email]') 66 | .hasText('foo@gmail.com', 'has email after submit'); 67 | assert 68 | .dom('[data-test-changeset-user-email]') 69 | .hasText('foo@gmail.com', 'changeset has email after submit'); 70 | assert.dom('[data-test-cid]').hasValue('2', 'still has cid input value'); 71 | assert 72 | .dom('[data-test-user-email]') 73 | .hasValue('foo@gmail.com', 'still has email input value'); 74 | assert 75 | .dom('[data-test-user-name]') 76 | .hasValue('makers', 'still has name input value'); 77 | 78 | await fillIn('[data-test-user-name]', 'bar'); 79 | 80 | assert 81 | .dom('[data-test-changeset-user-name]') 82 | .hasText('bar', 'has user name after fill in'); 83 | assert 84 | .dom('[data-test-changeset-user-email]') 85 | .hasText( 86 | 'foo@gmail.com', 87 | 'has correct email even when changing related properties', 88 | ); 89 | assert 90 | .dom('[data-test-model-user-email]') 91 | .hasText( 92 | 'foo@gmail.com', 93 | 'has correct email even when changing related properties', 94 | ); 95 | assert 96 | .dom('[data-test-changeset-cid]') 97 | .hasText('2', 'has correct cid even when changing related properties'); 98 | assert 99 | .dom('[data-test-model-cid]') 100 | .hasText('2', 'has correct cid even when changing related properties'); 101 | assert.dom('[data-test-cid]').hasValue('2', 'has cid input value'); 102 | assert 103 | .dom('[data-test-user-email]') 104 | .hasValue('foo@gmail.com', 'has email input value in final state'); 105 | assert 106 | .dom('[data-test-user-name]') 107 | .hasValue('bar', 'has name input value in final state'); 108 | }); 109 | 110 | test('it correctly toggle boolean values', async function (assert) { 111 | await render(hbs``); 112 | 113 | assert 114 | .dom('[data-test-changeset-notifications-email]') 115 | .hasText('false', 'has initial value'); 116 | await click('[data-test-notifications-email]'); 117 | assert 118 | .dom('[data-test-changeset-notifications-email]') 119 | .hasText('true', 'has updated value'); 120 | await click('[data-test-notifications-email]'); 121 | assert 122 | .dom('[data-test-changeset-notifications-email]') 123 | .hasText('false', 'has original value again'); 124 | 125 | assert 126 | .dom('[data-test-changeset-notifications-sms]') 127 | .hasText('true', 'has initial value'); 128 | await click('[data-test-notifications-sms]'); 129 | assert 130 | .dom('[data-test-changeset-notifications-sms]') 131 | .hasText('false', 'has updated value'); 132 | await click('[data-test-notifications-sms]'); 133 | assert 134 | .dom('[data-test-changeset-notifications-sms]') 135 | .hasText('true', 'has original value again'); 136 | }); 137 | 138 | test('it handles array of addresses', async function (assert) { 139 | await render(hbs``); 140 | 141 | assert 142 | .dom('[data-test-address="0"]') 143 | .hasText('123 Yurtville', 'address 1 model value'); 144 | assert 145 | .dom('[data-test-address="1"]') 146 | .hasText('123 Woods', 'address 2 model value'); 147 | 148 | assert 149 | .dom('[data-test-address-street="0"]') 150 | .hasValue('123', 'street 1 initial value'); 151 | assert 152 | .dom('[data-test-address-city="0"]') 153 | .hasValue('Yurtville', 'city 1 initial value'); 154 | assert 155 | .dom('[data-test-address-street="1"]') 156 | .hasValue('123', 'street 2 initial value'); 157 | assert 158 | .dom('[data-test-address-city="1"]') 159 | .hasValue('Woods', 'city 2 initial value'); 160 | 161 | await fillIn('[data-test-address-street="0"]', '456'); 162 | 163 | assert 164 | .dom('[data-test-address="0"]') 165 | .hasText('123 Yurtville', 'address 1 model keeps value'); 166 | assert 167 | .dom('[data-test-address="1"]') 168 | .hasText('123 Woods', 'address 2 model keeps value'); 169 | 170 | assert 171 | .dom('[data-test-address-street="0"]') 172 | .hasValue('456', 'street 1 new value'); 173 | assert 174 | .dom('[data-test-address-city="0"]') 175 | .hasValue('Yurtville', 'city 1 initial value'); 176 | assert 177 | .dom('[data-test-address-street="1"]') 178 | .hasValue('123', 'street 2 initial value'); 179 | assert 180 | .dom('[data-test-address-city="1"]') 181 | .hasValue('Woods', 'city 2 initial value'); 182 | }); 183 | }); 184 | -------------------------------------------------------------------------------- /src/utils/merge-deep.js: -------------------------------------------------------------------------------- 1 | import { 2 | isChange, 3 | getChangeValue, 4 | normalizeObject, 5 | isArrayObject, 6 | objectToArray, 7 | arrayToObject, 8 | } from 'validated-changeset'; 9 | 10 | function isMergeableObject(value) { 11 | return isNonNullObject(value) && !isSpecial(value); 12 | } 13 | 14 | function isNonNullObject(value) { 15 | return !!value && typeof value === 'object' && value !== null; 16 | } 17 | 18 | function isSpecial(value) { 19 | let stringValue = Object.prototype.toString.call(value); 20 | 21 | return stringValue === '[object RegExp]' || stringValue === '[object Date]'; 22 | } 23 | 24 | // Reconsider when enumerable symbols are removed - https://github.com/emberjs/ember.js/commit/ef0e277533b3eab01e58d68b79d7e37d8b11ee34 25 | // function getEnumerableOwnPropertySymbols(target) { 26 | // return Object.getOwnPropertySymbols 27 | // ? Object.getOwnPropertySymbols(target).filter(symbol => { 28 | // return Object.prototype.propertyIsEnumerable.call(target, symbol) 29 | // }) 30 | // : []; 31 | // } 32 | 33 | function getKeys(target) { 34 | return Object.keys(target); 35 | // .concat(getEnumerableOwnPropertySymbols(target)) 36 | } 37 | 38 | function propertyIsOnObject(object, property) { 39 | try { 40 | return property in object; 41 | } catch { 42 | return false; 43 | } 44 | } 45 | 46 | // Ember Data models don't respond as expected to foo.hasOwnProperty, so we do a special check 47 | function hasEmberDataProperty(target, key, options) { 48 | let fields = options.safeGet(target, 'constructor.fields'); 49 | 50 | return fields instanceof Map && fields.has(key); 51 | } 52 | 53 | // Protects from prototype poisoning and unexpected merging up the prototype chain. 54 | function propertyIsUnsafe(target, key, options) { 55 | if (hasEmberDataProperty(target, key, options)) { 56 | return false; 57 | } 58 | 59 | return ( 60 | propertyIsOnObject(target, key) && // Properties are safe to merge if they don't exist in the target yet, 61 | !( 62 | ( 63 | Object.prototype.hasOwnProperty.call(target, key) && 64 | Object.prototype.propertyIsEnumerable.call(target, key) 65 | ) // unsafe if they exist up the prototype chain, 66 | ) 67 | ); // and also unsafe if they're nonenumerable. 68 | } 69 | 70 | /** 71 | * DFS - traverse depth first until find object with `value`. Then go back up tree and try on next key 72 | * Need to exhaust all possible avenues. 73 | * 74 | * @method buildPathToValue 75 | */ 76 | function buildPathToValue(source, options, kv, possibleKeys) { 77 | Object.keys(source).forEach((key) => { 78 | let possible = source[key]; 79 | if (possible && isChange(possible)) { 80 | kv[[...possibleKeys, key].join('.')] = getChangeValue(possible); 81 | return; 82 | } 83 | 84 | if (possible && typeof possible === 'object') { 85 | buildPathToValue(possible, options, kv, [...possibleKeys, key]); 86 | } 87 | }); 88 | 89 | return kv; 90 | } 91 | 92 | /** 93 | * `source` will always have a leaf key `value` with the property we want to set 94 | * 95 | * @method mergeTargetAndSource 96 | */ 97 | function mergeTargetAndSource(target, source, options) { 98 | getKeys(source).forEach((key) => { 99 | // proto poisoning. So can set by nested key path 'person.name' 100 | if (propertyIsUnsafe(target, key, options)) { 101 | // if safeSet, we will find keys leading up to value and set 102 | if (options.safeSet) { 103 | const kv = buildPathToValue(source, options, {}, []); 104 | // each key will be a path nested to the value `person.name.other` 105 | if (Object.keys(kv).length > 0) { 106 | // we found some keys! 107 | for (key in kv) { 108 | const val = kv[key]; 109 | options.safeSet(target, key, val); 110 | } 111 | } 112 | } 113 | 114 | return; 115 | } 116 | 117 | // else safe key on object 118 | if ( 119 | propertyIsOnObject(target, key) && 120 | isMergeableObject(source[key]) && 121 | !isChange(source[key]) 122 | ) { 123 | options.safeSet( 124 | target, 125 | key, 126 | mergeDeep( 127 | options.safeGet(target, key), 128 | options.safeGet(source, key), 129 | options, 130 | ), 131 | ); 132 | } else { 133 | let next = source[key]; 134 | if (isChange(next)) { 135 | return options.safeSet(target, key, getChangeValue(next)); 136 | } 137 | 138 | // if just some normal leaf value, then set 139 | return options.safeSet(target, key, normalizeObject(next)); 140 | } 141 | }); 142 | 143 | return target; 144 | } 145 | 146 | /** 147 | * goal is to mutate target with source's properties, ensuring we dont encounter 148 | * pitfalls of { ..., ... } spread syntax overwriting keys on objects that we merged 149 | * 150 | * This is also adjusted for Ember peculiarities. Specifically `options.safeSet` will allows us 151 | * to handle properties on Proxy objects (that aren't the target's own property) 152 | * 153 | * @method mergeDeep 154 | */ 155 | export default function mergeDeep(target, source, options = {}) { 156 | options.safeGet = 157 | options.safeGet || 158 | function (obj, key) { 159 | return obj[key]; 160 | }; 161 | options.safeSet = 162 | options.safeSet || 163 | function (obj, key, value) { 164 | return (obj[key] = value); 165 | }; 166 | let sourceIsArray = Array.isArray(source); 167 | let targetIsArray = Array.isArray(target); 168 | let sourceAndTargetTypesMatch = sourceIsArray === targetIsArray; 169 | 170 | if (!sourceAndTargetTypesMatch) { 171 | let sourceIsArrayLike = isArrayObject(source); 172 | 173 | if (targetIsArray && sourceIsArrayLike) { 174 | return objectToArray( 175 | mergeTargetAndSource(arrayToObject(target), source, options), 176 | ); 177 | } 178 | 179 | return source; 180 | } else if (sourceIsArray) { 181 | return source; 182 | } else if (target === null || target === undefined) { 183 | /** 184 | * If the target was set to null or undefined, we always want to return the source. 185 | * There is nothing to merge. 186 | * 187 | * Without this explicit check, typeof null === typeof {any object-like thing} 188 | * which means that mergeTargetAndSource will be called, and you can't merge with null 189 | */ 190 | return source; 191 | } else { 192 | try { 193 | return mergeTargetAndSource(target, source, options); 194 | } catch (e) { 195 | // this is very unlikely to be hit but lets throw an error otherwise 196 | throw new Error( 197 | 'Unable to `mergeDeep` with your data. Are you trying to merge two ember-data objects? Please file an issue with ember-changeset.', 198 | { cause: e }, 199 | ); 200 | } 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /test-app/tests/integration/components/validated-form-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { setupRenderingTest } from 'ember-qunit'; 3 | import { click, find, fillIn, render } from '@ember/test-helpers'; 4 | import hbs from 'htmlbars-inline-precompile'; 5 | 6 | module('Integration | Component | validated-form', function (hooks) { 7 | setupRenderingTest(hooks); 8 | 9 | test('it renders form', async function (assert) { 10 | await render(hbs``); 11 | 12 | // start filling in some data to test states of form 13 | await fillIn('[data-test-user-email]', 'foo'); 14 | await fillIn('[data-test-cid]', 2); 15 | 16 | assert.dom('[data-test-cid]').hasValue('2', 'has cid input value'); 17 | assert 18 | .dom('[data-test-user-email]') 19 | .hasValue('foo', 'has email input value'); 20 | assert 21 | .dom('[data-test-user-name]') 22 | .hasValue('someone', 'has name input value'); 23 | 24 | assert.true( 25 | find('[data-test-submit]').disabled, 26 | 'button disabled due to invalid email', 27 | ); 28 | 29 | assert 30 | .dom('[data-test-model-user-email]') 31 | .hasText( 32 | 'something@gmail.com', 33 | 'has old email still b/c input not valid', 34 | ); 35 | 36 | await fillIn('[data-test-user-email]', 'foo@gmail.com'); 37 | 38 | assert.false( 39 | find('[data-test-submit]').disabled, 40 | 'button not disabled after valid email', 41 | ); 42 | 43 | // submit - now enabled with valid email 44 | await click('[data-test-submit]'); 45 | 46 | // toggle submit valid state 47 | await fillIn('[data-test-user-email]', 'bar '); 48 | 49 | assert.true( 50 | find('[data-test-submit]').disabled, 51 | 'button disabled due to new invalid email', 52 | ); 53 | 54 | await fillIn('[data-test-user-email]', 'foo@gmail.com'); 55 | 56 | assert.false( 57 | find('[data-test-submit]').disabled, 58 | 'button not disabled b/c valid email was input-ed', 59 | ); 60 | 61 | await fillIn('[data-test-user-name]', 'makers'); 62 | 63 | // submit - still enabled 64 | assert.false(find('[data-test-submit]').disabled, 'button not disabled'); 65 | 66 | assert.dom('[data-test-model-cid]').hasText('2', 'has cid after submit'); 67 | assert 68 | .dom('[data-test-model-user-email]') 69 | .hasText('foo@gmail.com', 'has email after submit'); 70 | assert 71 | .dom('[data-test-changeset-user-email]') 72 | .hasText('foo@gmail.com', 'changeset has email after submit'); 73 | assert.dom('[data-test-cid]').hasValue('2', 'still has cid input value'); 74 | assert 75 | .dom('[data-test-user-email]') 76 | .hasValue('foo@gmail.com', 'still has email input value'); 77 | assert 78 | .dom('[data-test-user-name]') 79 | .hasValue('makers', 'still has name input value'); 80 | 81 | await fillIn('[data-test-user-name]', 'bar'); 82 | 83 | assert 84 | .dom('[data-test-changeset-user-name]') 85 | .hasText('bar', 'has user name after fill in'); 86 | assert 87 | .dom('[data-test-changeset-user-email]') 88 | .hasText( 89 | 'foo@gmail.com', 90 | 'has correct email even when changing related properties', 91 | ); 92 | assert 93 | .dom('[data-test-model-user-email]') 94 | .hasText( 95 | 'foo@gmail.com', 96 | 'has correct email even when changing related properties', 97 | ); 98 | assert 99 | .dom('[data-test-changeset-cid]') 100 | .hasText('2', 'has correct cid even when changing related properties'); 101 | assert 102 | .dom('[data-test-model-cid]') 103 | .hasText('2', 'has correct cid even when changing related properties'); 104 | assert.dom('[data-test-cid]').hasValue('2', 'has cid input value'); 105 | assert 106 | .dom('[data-test-user-email]') 107 | .hasValue('foo@gmail.com', 'has email input value in final state'); 108 | assert 109 | .dom('[data-test-user-name]') 110 | .hasValue('bar', 'has name input value in final state'); 111 | }); 112 | 113 | test('it correctly toggle boolean values', async function (assert) { 114 | await render(hbs``); 115 | 116 | assert 117 | .dom('[data-test-changeset-notifications-email]') 118 | .hasText('false', 'has initial value'); 119 | await click('[data-test-notifications-email]'); 120 | assert 121 | .dom('[data-test-changeset-notifications-email]') 122 | .hasText('true', 'has updated value'); 123 | await click('[data-test-notifications-email]'); 124 | assert 125 | .dom('[data-test-changeset-notifications-email]') 126 | .hasText('false', 'has original value again'); 127 | 128 | assert 129 | .dom('[data-test-changeset-notifications-sms]') 130 | .hasText('true', 'has initial value'); 131 | await click('[data-test-notifications-sms]'); 132 | assert 133 | .dom('[data-test-changeset-notifications-sms]') 134 | .hasText('false', 'has updated value'); 135 | await click('[data-test-notifications-sms]'); 136 | assert 137 | .dom('[data-test-changeset-notifications-sms]') 138 | .hasText('true', 'has original value again'); 139 | }); 140 | 141 | test('it handles array of addresses', async function (assert) { 142 | await render(hbs``); 143 | 144 | assert 145 | .dom('[data-test-address="0"]') 146 | .hasText('123 Yurtville', 'address 1 model value'); 147 | assert 148 | .dom('[data-test-address="1"]') 149 | .hasText('123 Woods', 'address 2 model value'); 150 | 151 | assert 152 | .dom('[data-test-address-street="0"]') 153 | .hasValue('123', 'street 1 initial value'); 154 | assert 155 | .dom('[data-test-address-city="0"]') 156 | .hasValue('Yurtville', 'city 1 initial value'); 157 | assert 158 | .dom('[data-test-address-street="1"]') 159 | .hasValue('123', 'street 2 initial value'); 160 | assert 161 | .dom('[data-test-address-city="1"]') 162 | .hasValue('Woods', 'city 2 initial value'); 163 | 164 | await fillIn('[data-test-address-street="0"]', '456'); 165 | 166 | assert 167 | .dom('[data-test-address="0"]') 168 | .hasText('123 Yurtville', 'address 1 model keeps value'); 169 | assert 170 | .dom('[data-test-address="1"]') 171 | .hasText('123 Woods', 'address 2 model keeps value'); 172 | 173 | assert 174 | .dom('[data-test-address-street="0"]') 175 | .hasValue('456', 'street 1 new value'); 176 | assert 177 | .dom('[data-test-address-city="0"]') 178 | .hasValue('Yurtville', 'city 1 initial value'); 179 | assert 180 | .dom('[data-test-address-street="1"]') 181 | .hasValue('123', 'street 2 initial value'); 182 | assert 183 | .dom('[data-test-address-city="1"]') 184 | .hasValue('Woods', 'city 2 initial value'); 185 | }); 186 | }); 187 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { assert } from '@ember/debug'; 2 | import { dependentKeyCompat } from '@ember/object/compat'; 3 | import { BufferedChangeset } from 'validated-changeset'; 4 | import { Changeset as ValidatedChangeset } from './validated-changeset.js'; 5 | import ArrayProxy from '@ember/array/proxy'; 6 | import ObjectProxy from '@ember/object/proxy'; 7 | import { notifyPropertyChange } from '@ember/object'; 8 | import mergeDeep from './utils/merge-deep.js'; 9 | import isObject from './utils/is-object.js'; 10 | import { tracked } from '@glimmer/tracking'; 11 | import { get as safeGet, set as safeSet } from '@ember/object'; 12 | import { 13 | macroCondition, 14 | dependencySatisfies, 15 | importSync, 16 | } from '@embroider/macros'; 17 | 18 | export { ValidatedChangeset }; 19 | export { default as changesetGet } from './helpers/changeset-get.js'; 20 | export { default as changesetSet } from './helpers/changeset-set.js'; 21 | 22 | const CHANGES = '_changes'; 23 | const PREVIOUS_CONTENT = '_previousContent'; 24 | const CONTENT = '_content'; 25 | const defaultValidatorFn = () => true; 26 | 27 | export function buildOldValues(content, changes, getDeep) { 28 | const obj = Object.create(null); 29 | 30 | for (let change of changes) { 31 | obj[change.key] = getDeep(content, change.key); 32 | } 33 | 34 | return obj; 35 | } 36 | 37 | function isProxy(o) { 38 | return !!(o && (o instanceof ObjectProxy || o instanceof ArrayProxy)); 39 | } 40 | 41 | function maybeUnwrapProxy(o) { 42 | return isProxy(o) ? maybeUnwrapProxy(safeGet(o, 'content')) : o; 43 | } 44 | 45 | let Model; 46 | if (macroCondition(dependencySatisfies('ember-data', '*'))) { 47 | Model = importSync('@ember-data/model').default; 48 | } 49 | 50 | export class EmberChangeset extends BufferedChangeset { 51 | @tracked _changes; 52 | @tracked _errors; 53 | @tracked _content; 54 | 55 | isObject = isObject; 56 | 57 | maybeUnwrapProxy = maybeUnwrapProxy; 58 | 59 | // DO NOT override setDeep. Ember.set does not work wth empty hash and nested 60 | // key Ember.set({}, 'user.name', 'foo'); 61 | // override base class 62 | // DO NOT override setDeep. Ember.set does not work with Ember.set({}, 'user.name', 'foo'); 63 | getDeep = safeGet; 64 | mergeDeep = mergeDeep; 65 | 66 | safeGet(obj, key) { 67 | if (Model && obj.relationshipFor?.(key)?.meta?.kind == 'belongsTo') { 68 | return obj.belongsTo(key).value(); 69 | } 70 | return safeGet(obj, key); 71 | } 72 | safeSet(obj, key, value) { 73 | return safeSet(obj, key, value); 74 | } 75 | 76 | /** 77 | * @property isValid 78 | * @type {Array} 79 | */ 80 | @dependentKeyCompat 81 | get isValid() { 82 | return super.isValid; 83 | } 84 | 85 | /** 86 | * @property isInvalid 87 | * @type {Boolean} 88 | */ 89 | @dependentKeyCompat 90 | get isInvalid() { 91 | return super.isInvalid; 92 | } 93 | 94 | /** 95 | * @property isPristine 96 | * @type {Boolean} 97 | */ 98 | @dependentKeyCompat 99 | get isPristine() { 100 | return super.isPristine; 101 | } 102 | 103 | /** 104 | * @property isDirty 105 | * @type {Boolean} 106 | */ 107 | @dependentKeyCompat 108 | get isDirty() { 109 | return super.isDirty; 110 | } 111 | 112 | get pendingData() { 113 | let content = this[CONTENT]; 114 | let changes = this[CHANGES]; 115 | 116 | let pendingChanges = this.mergeDeep( 117 | Object.create(Object.getPrototypeOf(content)), 118 | content, 119 | { safeGet, safeSet }, 120 | ); 121 | 122 | return this.mergeDeep(pendingChanges, changes, { safeGet, safeSet }); 123 | } 124 | 125 | /** 126 | * Manually add an error to the changeset. If there is an existing 127 | * error or change for `key`, it will be overwritten. 128 | * 129 | * @method addError 130 | */ 131 | addError(key, error) { 132 | super.addError(key, error); 133 | 134 | notifyPropertyChange(this, key); 135 | // Return passed-in `error`. 136 | return error; 137 | } 138 | 139 | /** 140 | * Manually remove an error from the changeset. 141 | * 142 | * @method removeError 143 | */ 144 | removeError(key) { 145 | super.removeError(key); 146 | 147 | notifyPropertyChange(this, key); 148 | return this; 149 | } 150 | 151 | /** 152 | * Manually clears the errors from the changeset 153 | * 154 | * @method removeError 155 | */ 156 | removeErrors() { 157 | super.removeErrors(); 158 | return this; 159 | } 160 | 161 | /** 162 | * Manually push multiple errors to the changeset as an array. 163 | * 164 | * @method pushErrors 165 | */ 166 | pushErrors(key, ...newErrors) { 167 | const { value, validation } = super.pushErrors(key, ...newErrors); 168 | 169 | notifyPropertyChange(this, key); 170 | 171 | return { value, validation }; 172 | } 173 | 174 | /** 175 | * Sets property or error on the changeset. 176 | * Returns value or error 177 | */ 178 | _setProperty({ key, value, oldValue }) { 179 | super._setProperty({ key, value, oldValue }); 180 | 181 | notifyPropertyChange(this, key); 182 | } 183 | 184 | /** 185 | * Notifies virtual properties set on the changeset of a change. 186 | * You can specify which keys are notified by passing in an array. 187 | * 188 | * @private 189 | * @param {Array} keys 190 | * @return {Void} 191 | */ 192 | _notifyVirtualProperties(keys) { 193 | keys = super._notifyVirtualProperties(keys); 194 | 195 | (keys || []).forEach((key) => notifyPropertyChange(this, key)); 196 | 197 | return; 198 | } 199 | 200 | /** 201 | * Deletes a key off an object and notifies observers. 202 | */ 203 | _deleteKey(objName, key = '') { 204 | const result = super._deleteKey(objName, key); 205 | 206 | notifyPropertyChange(this, key); 207 | 208 | return result; 209 | } 210 | 211 | /** 212 | * Executes the changeset if in a valid state. 213 | * 214 | * @method execute 215 | */ 216 | execute() { 217 | let oldContent; 218 | if (this.isValid && this.isDirty) { 219 | let content = this[CONTENT]; 220 | let changes = this[CHANGES]; 221 | 222 | // keep old values in case of error and we want to rollback 223 | oldContent = buildOldValues(content, this.changes, this.getDeep); 224 | 225 | // we want mutation on original object 226 | // @tracked 227 | this[CONTENT] = this.mergeDeep(content, changes, { safeGet, safeSet }); 228 | } 229 | 230 | this[PREVIOUS_CONTENT] = oldContent; 231 | 232 | return this; 233 | } 234 | } 235 | 236 | /** 237 | * Creates new changesets. 238 | */ 239 | export function changeset( 240 | obj, 241 | validateFn = defaultValidatorFn, 242 | validationMap = {}, 243 | options = {}, 244 | ) { 245 | assert('Underlying object for changeset is missing', Boolean(obj)); 246 | assert( 247 | 'Array is not a valid type to pass as the first argument to `changeset`', 248 | !Array.isArray(obj), 249 | ); 250 | 251 | if (options.changeset) { 252 | return new options.changeset(obj, validateFn, validationMap, options); 253 | } 254 | 255 | const c = new EmberChangeset(obj, validateFn, validationMap, options); 256 | return c; 257 | } 258 | 259 | /** 260 | * Creates new changesets. 261 | * @function Changeset 262 | */ 263 | export function Changeset( 264 | obj, 265 | validateFn = defaultValidatorFn, 266 | validationMap = {}, 267 | options = {}, 268 | ) { 269 | const c = changeset(obj, validateFn, validationMap, options); 270 | 271 | return new Proxy(c, { 272 | get(targetBuffer, key /*, receiver*/) { 273 | const res = targetBuffer.get(key.toString()); 274 | return res; 275 | }, 276 | 277 | set(targetBuffer, key, value /*, receiver*/) { 278 | targetBuffer.set(key.toString(), value); 279 | return true; 280 | }, 281 | }); 282 | } 283 | 284 | export default class ChangesetKlass { 285 | /** 286 | * Changeset factory 287 | * TODO: deprecate in favor of factory function 288 | * 289 | * @class ChangesetKlass 290 | * @constructor 291 | */ 292 | constructor( 293 | obj, 294 | validateFn = defaultValidatorFn, 295 | validationMap = {}, 296 | options = {}, 297 | ) { 298 | const c = changeset(obj, validateFn, validationMap, options); 299 | 300 | return new Proxy(c, { 301 | get(targetBuffer, key /*, receiver*/) { 302 | const res = targetBuffer.get(key.toString()); 303 | return res; 304 | }, 305 | 306 | set(targetBuffer, key, value /*, receiver*/) { 307 | targetBuffer.set(key.toString(), value); 308 | return true; 309 | }, 310 | }); 311 | } 312 | } 313 | -------------------------------------------------------------------------------- /assets/title.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test-app/tests/integration/main-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { setupTest } from 'ember-qunit'; 3 | import { set } from '@ember/object'; 4 | import { settled } from '@ember/test-helpers'; 5 | import { Changeset } from 'ember-changeset'; 6 | import { isEmpty } from '@ember/utils'; 7 | 8 | module('Integration | main', function (hooks) { 9 | setupTest(hooks); 10 | 11 | hooks.beforeEach(function () { 12 | this.store = this.owner.lookup('service:store'); 13 | 14 | this.createUser = (userType, withDogs) => { 15 | let profile = this.store.createRecord('profile'); 16 | let user = this.store.createRecord(userType, { profile }); 17 | 18 | if (withDogs) { 19 | for (let i = 0; i < 2; i++) { 20 | user.get('dogs').addObject(this.store.createRecord('dog')); 21 | } 22 | } 23 | return user; 24 | }; 25 | }); 26 | 27 | test('works for top level properties', async function (assert) { 28 | let profile = this.store.createRecord('profile', { 29 | firstName: 'Terry', 30 | lastName: 'Bubblewinkles', 31 | nickname: 't', 32 | }); 33 | let changeset = Changeset(profile); 34 | 35 | assert.false(changeset.isDirty, 'isDirty false'); 36 | changeset.firstName = 'RGB'; 37 | assert.true(changeset.isDirty, 'isDirty true'); 38 | assert.strictEqual(changeset.firstName, 'RGB', 'firstName after set'); 39 | assert.strictEqual( 40 | profile.firstName, 41 | 'Terry', 42 | 'modal has original firstName', 43 | ); 44 | 45 | changeset.execute(); 46 | 47 | assert.true(changeset.isDirty, 'isDirty true'); 48 | assert.strictEqual(changeset.firstName, 'RGB', 'firstName after set'); 49 | assert.strictEqual( 50 | profile.firstName, 51 | 'RGB', 52 | 'original modal has new firstName', 53 | ); 54 | }); 55 | 56 | async function testBasicBelongsTo(assert, userType) { 57 | let user = this.createUser(userType, false); 58 | let changeset = Changeset(user); 59 | 60 | assert.equal( 61 | changeset.get('profile.firstName'), 62 | user.get('profile.firstName'), 63 | ); 64 | assert.equal( 65 | changeset.get('profile.lastName'), 66 | user.get('profile.lastName'), 67 | ); 68 | 69 | assert.equal(changeset.isDirty, false, 'is not dirty'); 70 | 71 | changeset.get('profile').set('firstName', 'Grace'); 72 | changeset.set('profile.nickname', 'g'); 73 | set(changeset, 'profile.lastName', 'Hopper'); 74 | 75 | assert.equal( 76 | changeset.get('profile.firstName'), 77 | 'Grace', 78 | 'has firstName after set', 79 | ); 80 | assert.equal( 81 | changeset.get('profile.nickname'), 82 | 'g', 83 | 'has nickname after set', 84 | ); 85 | assert.equal( 86 | changeset.get('profile.lastName'), 87 | 'Hopper', 88 | 'Ember.set does work', 89 | ); 90 | 91 | assert.equal(changeset.isDirty, true, 'is dirty'); 92 | 93 | changeset.execute(); 94 | 95 | assert.equal( 96 | user.get('profile.firstName'), 97 | 'Grace', 98 | 'firstName after execute', 99 | ); 100 | assert.equal( 101 | user.get('profile.lastName'), 102 | 'Hopper', 103 | 'lastName after execute', 104 | ); 105 | assert.equal(user.get('profile.nickname'), 'g', 'nickname after execute'); 106 | 107 | assert.equal(changeset.isDirty, true, 'is dirty'); 108 | 109 | let profile = this.store.createRecord('profile', { 110 | firstName: 'Terry', 111 | lastName: 'Bubblewinkles', 112 | nickname: 't', 113 | }); 114 | 115 | changeset.set('profile', profile); 116 | 117 | assert.equal( 118 | changeset.get('profile').get('firstName'), 119 | 'Terry', 120 | 'firstName after set', 121 | ); 122 | assert.equal( 123 | changeset.get('profile').get('lastName'), 124 | 'Bubblewinkles', 125 | 'lastName after set', 126 | ); 127 | assert.equal( 128 | changeset.get('profile.firstName'), 129 | 'Terry', 130 | 'firstName after set nested', 131 | ); 132 | assert.equal( 133 | changeset.profile.firstName, 134 | 'Terry', 135 | 'firstName after set nested', 136 | ); 137 | assert.equal( 138 | changeset.get('profile.lastName'), 139 | 'Bubblewinkles', 140 | 'lastName after set nested', 141 | ); 142 | assert.equal( 143 | changeset.profile.lastName, 144 | 'Bubblewinkles', 145 | 'lastName after set nested', 146 | ); 147 | assert.equal( 148 | changeset.get('profile.nickname'), 149 | 't', 150 | 'nickname after set nested', 151 | ); 152 | assert.equal(changeset.profile.nickname, 't', 'nickname after set nested'); 153 | 154 | assert.equal(changeset.isDirty, true, 'is dirty'); 155 | 156 | changeset.execute(); 157 | 158 | assert.equal(user.get('profile.firstName'), 'Terry'); 159 | assert.equal(user.get('profile.lastName'), 'Bubblewinkles'); 160 | 161 | assert.equal(changeset.isDirty, true, 'is dirty'); 162 | 163 | changeset.set('profile', null); 164 | assert.equal(changeset.get('profile'), null, 'changeset profile is null'); 165 | 166 | assert.equal(changeset.isDirty, true, 'is dirty'); 167 | 168 | changeset.execute(); 169 | 170 | assert.equal( 171 | changeset.get('profile'), 172 | null, 173 | 'changeset profile relationship is still null', 174 | ); 175 | assert.equal( 176 | user.get('profile.firstName'), 177 | null, 178 | 'underlying user profile firstName is null', 179 | ); 180 | 181 | assert.equal(changeset.isDirty, true, 'is dirty'); 182 | } 183 | 184 | test('it works for belongsTo', async function (assert) { 185 | assert.expect(28); 186 | 187 | await testBasicBelongsTo.call(this, assert, 'user'); 188 | }); 189 | 190 | test('it works for sync belongsTo', async function (assert) { 191 | assert.expect(28); 192 | 193 | await testBasicBelongsTo.call(this, assert, 'sync-user'); 194 | }); 195 | 196 | test('can call prepare with belongsTo', async function (assert) { 197 | let user = this.createUser('sync-user', false); 198 | let changeset = Changeset(user); 199 | let profile = this.store.createRecord('profile', { 200 | firstName: 'Terry', 201 | lastName: 'Bubblewinkles', 202 | nickname: 't', 203 | }); 204 | 205 | changeset.set('profile', profile); 206 | changeset.prepare((changes) => { 207 | let modified = {}; 208 | 209 | for (let key in changes) { 210 | modified[key] = changes[key]; 211 | } 212 | 213 | return modified; 214 | }); 215 | 216 | assert.strictEqual( 217 | changeset.get('profile').get('firstName'), 218 | 'Terry', 219 | 'firstName after set', 220 | ); 221 | }); 222 | 223 | async function testSaveUser(assert, userType) { 224 | assert.expect(1); 225 | 226 | let profile = this.store.createRecord('profile'); 227 | let save = () => { 228 | assert.ok(true, 'user save was called'); 229 | }; 230 | 231 | let user = this.store.createRecord(userType, { profile, save }); 232 | let changeset = Changeset(user); 233 | 234 | changeset.set('profile.firstName', 'Grace'); 235 | changeset.save(); 236 | } 237 | 238 | test('can save user', async function (assert) { 239 | assert.expect(2); 240 | 241 | await testSaveUser.call(this, assert, 'user'); 242 | }); 243 | 244 | test('can save sync user', async function (assert) { 245 | assert.expect(2); 246 | 247 | await testSaveUser.call(this, assert, 'sync-user'); 248 | }); 249 | 250 | test('can save ember data model with multiple attributes', async function (assert) { 251 | assert.expect(1); 252 | 253 | let save = () => { 254 | assert.ok(true, 'profile save was called'); 255 | }; 256 | let profile = this.store.createRecord('profile', { save }); 257 | let pet = this.store.createRecord('dog'); 258 | let changeset = Changeset(profile); 259 | 260 | changeset.set('firstName', 'bo'); 261 | changeset.set('lastName', 'jackson'); 262 | changeset.set('pet', pet); 263 | 264 | changeset.save(); 265 | }); 266 | 267 | async function testBelongsToViaChangeset(assert, userType) { 268 | let profile = this.store.createRecord('profile'); 269 | let user = this.store.createRecord(userType, { profile }); 270 | 271 | let changeset = Changeset(user); 272 | 273 | assert.equal(changeset.isDirty, false, 'changeset is not dirty'); 274 | 275 | changeset.set('profile.firstName', 'Grace'); 276 | profile = changeset.get('profile'); 277 | let profileChangeset = Changeset(profile); 278 | 279 | assert.equal( 280 | profileChangeset.get('firstName'), 281 | 'Grace', 282 | 'profileChangeset profile firstName is set', 283 | ); 284 | assert.equal( 285 | profileChangeset.isDirty, 286 | false, 287 | 'profile changeset is not dirty', 288 | ); 289 | assert.equal( 290 | changeset.get('profile.firstName'), 291 | 'Grace', 292 | 'changeset profile firstName is set', 293 | ); 294 | assert.equal(changeset.isDirty, true, 'changeset is dirty'); 295 | 296 | profileChangeset.execute(); 297 | 298 | assert.equal(profile.firstName, 'Grace', 'profile still has first name'); 299 | assert.equal( 300 | profileChangeset.isDirty, 301 | false, 302 | 'profile changeset is not dirty', 303 | ); 304 | assert.equal( 305 | user.get('profile.firstName'), 306 | 'Bob', 307 | 'user still has profile has first name', 308 | ); 309 | assert.equal(changeset.isDirty, true, 'changeset is dirty'); 310 | 311 | changeset.execute(); 312 | 313 | assert.equal(profile.firstName, 'Grace', 'profile has first name'); 314 | assert.equal( 315 | profileChangeset.isDirty, 316 | false, 317 | 'profile changeset is not dirty', 318 | ); 319 | assert.equal( 320 | user.get('profile.firstName'), 321 | 'Grace', 322 | 'user now has profile has first name', 323 | ); 324 | assert.equal(changeset.isDirty, true, 'changeset is dirty'); 325 | } 326 | 327 | test('can work with belongsTo via changeset', async function (assert) { 328 | assert.expect(13); 329 | 330 | await testBelongsToViaChangeset.call(this, assert, 'user'); 331 | }); 332 | 333 | test('can work with sync belongsTo via changeset', async function (assert) { 334 | assert.expect(13); 335 | 336 | await testBelongsToViaChangeset.call(this, assert, 'sync-user'); 337 | }); 338 | 339 | async function testHasMany(assert, userType) { 340 | let user = this.createUser(userType, true); 341 | let changeset = Changeset(user); 342 | let newDog = this.store.createRecord('dog', { breed: 'Münsterländer' }); 343 | 344 | assert.equal(changeset.isDirty, false, 'is not dirty'); 345 | assert.deepEqual(changeset.changes, [], 'has no changes'); 346 | 347 | let dogs = changeset.get('dogs'); 348 | dogs.pushObjects([newDog]); 349 | 350 | assert.equal(changeset.isDirty, false, 'is not dirty b/c no set'); 351 | assert.deepEqual(changeset.changes, [], 'has no changes'); 352 | 353 | dogs = changeset.get('dogs'); 354 | assert.equal( 355 | dogs.objectAt(0).get('breed'), 356 | 'rough collie', 357 | 'has first breed', 358 | ); 359 | assert.equal( 360 | dogs.objectAt(1).get('breed'), 361 | 'rough collie', 362 | 'has second breed', 363 | ); 364 | assert.equal( 365 | dogs.objectAt(2).get('breed'), 366 | 'Münsterländer', 367 | 'has third breed', 368 | ); 369 | 370 | assert.equal(changeset.isDirty, false, 'is not dirty before execute'); 371 | assert.deepEqual(changeset.changes, [], 'has no changes before execute'); 372 | 373 | changeset.execute(); 374 | 375 | dogs = user.get('dogs'); 376 | assert.equal( 377 | dogs.objectAt(0).get('breed'), 378 | 'rough collie', 379 | 'has first breed', 380 | ); 381 | assert.equal( 382 | dogs.objectAt(1).get('breed'), 383 | 'rough collie', 384 | 'has second breed', 385 | ); 386 | assert.equal( 387 | dogs.objectAt(2).get('breed'), 388 | 'Münsterländer', 389 | 'has third breed', 390 | ); 391 | 392 | assert.equal(changeset.isDirty, false, 'is not dirty after execute'); 393 | assert.deepEqual(changeset.changes, [], 'has no changes'); 394 | 395 | changeset.set('dogs', []); 396 | 397 | assert.equal(changeset.isDirty, true, 'is dirty'); 398 | assert.deepEqual( 399 | changeset.changes, 400 | [{ key: 'dogs', value: [] }], 401 | 'has changes', 402 | ); 403 | 404 | changeset.set('dogs', [newDog]); 405 | 406 | assert.equal(changeset.isDirty, true, 'is dirty'); 407 | assert.deepEqual( 408 | changeset.changes, 409 | [{ key: 'dogs', value: [newDog] }], 410 | 'has changes', 411 | ); 412 | 413 | changeset.execute(); 414 | await settled(); 415 | 416 | dogs = user.get('dogs'); 417 | assert.equal(dogs.length, 1, 'dogs removed'); 418 | 419 | assert.equal(changeset.isDirty, true, 'is still dirty'); 420 | assert.deepEqual( 421 | changeset.changes, 422 | [{ key: 'dogs', value: [newDog] }], 423 | 'has changes', 424 | ); 425 | 426 | changeset.rollback(); 427 | assert.equal(changeset.isDirty, false, 'is not dirty'); 428 | } 429 | 430 | test('it works for hasMany / firstObject', async function (assert) { 431 | assert.expect(22); 432 | 433 | await testHasMany.call(this, assert, 'user'); 434 | }); 435 | 436 | test('it works for sync hasMany / firstObject', async function (assert) { 437 | assert.expect(22); 438 | 439 | await testHasMany.call(this, assert, 'sync-user'); 440 | }); 441 | 442 | async function testRollbackHasMany(assert, userType) { 443 | let user = this.createUser(userType, true); 444 | 445 | let changeset = Changeset(user); 446 | let newDog = this.store.createRecord('dog', { breed: 'Münsterländer' }); 447 | changeset.set('dogs', [...changeset.get('dogs').toArray(), newDog]); 448 | 449 | let dogs = changeset.get('dogs'); 450 | assert.equal(dogs.length, 3, 'changeset has 3 dogs'); 451 | 452 | changeset.rollback(); 453 | 454 | dogs = changeset.get('dogs'); 455 | assert.equal(dogs.length, 2, 'changeset has 2 dogs'); 456 | } 457 | 458 | test('it can rollback hasMany', async function (assert) { 459 | assert.expect(2); 460 | 461 | await testRollbackHasMany.call(this, assert, 'user'); 462 | }); 463 | 464 | test('it can rollback sync hasMany', async function (assert) { 465 | assert.expect(2); 466 | 467 | await testRollbackHasMany.call(this, assert, 'sync-user'); 468 | }); 469 | 470 | async function testInitiallyEmptyRelationships(assert, userType) { 471 | let profile = this.store.createRecord('profile'); 472 | let user = this.store.createRecord(userType); 473 | 474 | let changeset = Changeset(user); 475 | 476 | changeset.set('profile', profile); 477 | const dogs = [ 478 | this.store.createRecord('dog'), 479 | this.store.createRecord('dog', { breed: 'Münsterländer' }), 480 | ]; 481 | 482 | changeset.set('dogs', dogs); 483 | 484 | changeset.execute(); 485 | await settled(); 486 | 487 | assert.equal( 488 | user.get('profile.firstName'), 489 | 'Bob', 490 | 'Profile is set on user', 491 | ); 492 | assert.equal(user.get('dogs.firstObject.breed'), 'rough collie'); 493 | assert.equal(user.get('dogs.lastObject.breed'), 'Münsterländer'); 494 | } 495 | 496 | test('it sets relationships which were empty initially', async function (assert) { 497 | assert.expect(3); 498 | 499 | await testInitiallyEmptyRelationships.call(this, assert, 'user'); 500 | }); 501 | 502 | test('it sets sync relationships which were empty initially', async function (assert) { 503 | assert.expect(3); 504 | 505 | await testInitiallyEmptyRelationships.call(this, assert, 'sync-user'); 506 | }); 507 | 508 | async function testBelongsToPresenceValidation(assert, userType) { 509 | let user = this.store.createRecord(userType); 510 | function userValidator({ key, newValue }) { 511 | if (key === 'profile') { 512 | return isEmpty(newValue) ? 'Cannot be blank' : true; 513 | } 514 | return true; 515 | } 516 | let userChangeset = Changeset(user, userValidator); 517 | // The following simulates rendering of the current value 518 | // and triggers the ObjectTreeNode logic in validated-changeset. 519 | userChangeset.profile; 520 | await userChangeset.validate('profile'); 521 | 522 | assert.deepEqual(userChangeset.error.profile.validation, 'Cannot be blank'); 523 | } 524 | 525 | test('#error for empty belongsTo', async function (assert) { 526 | assert.expect(1); 527 | 528 | await testBelongsToPresenceValidation.call(this, assert, 'user'); 529 | }); 530 | 531 | test('#error for empty sync belongsTo', async function (assert) { 532 | assert.expect(1); 533 | 534 | await testBelongsToPresenceValidation.call(this, assert, 'sync-user'); 535 | }); 536 | }); 537 | -------------------------------------------------------------------------------- /test-app/tests/integration/helpers/changeset-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { setupRenderingTest } from 'ember-qunit'; 3 | import { typeOf, isPresent } from '@ember/utils'; 4 | import { set } from '@ember/object'; 5 | import { Changeset } from 'ember-changeset'; 6 | import { lookupValidator } from 'validated-changeset'; 7 | import hbs from 'htmlbars-inline-precompile'; 8 | import { 9 | render, 10 | find, 11 | fillIn, 12 | click, 13 | blur, 14 | triggerEvent, 15 | } from '@ember/test-helpers'; 16 | 17 | module('Integration | Helper | changeset', function (hooks) { 18 | setupRenderingTest(hooks); 19 | 20 | hooks.beforeEach(function () { 21 | this.updateAttr = (changeset, attr, event) => { 22 | changeset.set(attr, event.target.value); 23 | }; 24 | }); 25 | 26 | test('it validates changes', async function (assert) { 27 | let validations = { 28 | firstName(value) { 29 | return (isPresent(value) && value.length > 3) || 'too short'; 30 | }, 31 | lastName(value) { 32 | return (isPresent(value) && value.length > 3) || 'too short'; 33 | }, 34 | }; 35 | this.dummyModel = { firstName: 'Jim', lastName: 'Bob' }; 36 | this.validate = ({ key, newValue }) => { 37 | let validatorFn = validations[key]; 38 | 39 | if (typeOf(validatorFn) === 'function') { 40 | return validatorFn(newValue); 41 | } 42 | }; 43 | this.submit = (changeset) => changeset.validate(); 44 | this.reset = (changeset) => changeset.rollback(); 45 | await render(hbs` 46 | {{#let (changeset this.dummyModel this.validate) as |changesetObj|}} 47 | {{#if changesetObj.isInvalid}} 48 |

There were one or more errors in your form.

49 | {{/if}} 50 | 51 | 52 | 53 | 54 | {{/let}} 55 | `); 56 | 57 | await fillIn('#first-name', 'A'); 58 | await click('#submit-btn'); 59 | assert.dom('#errors-paragraph').exists('should be invalid'); 60 | 61 | await fillIn('#first-name', 'Billy'); 62 | await click('#submit-btn'); 63 | assert.dom('#errors-paragraph').doesNotExist('should be valid'); 64 | }); 65 | 66 | test('it accepts validation map', async function (assert) { 67 | let validations = { 68 | firstName(key, newValue) { 69 | return (isPresent(newValue) && newValue.length > 3) || 'too short'; 70 | }, 71 | lastName(key, newValue) { 72 | return (isPresent(newValue) && newValue.length > 3) || 'too short'; 73 | }, 74 | }; 75 | this.dummyModel = { firstName: 'Jim', lastName: 'Bobbie' }; 76 | this.validations = validations; 77 | this.submit = (changeset) => changeset.validate(); 78 | this.reset = (changeset) => changeset.rollback(); 79 | await render(hbs` 80 | {{#let (changeset this.dummyModel this.validations) as |changesetObj|}} 81 | {{#if changesetObj.isInvalid}} 82 |

There were one or more errors in your form.

83 | {{/if}} 84 | 85 | 86 | 87 | 88 | {{/let}} 89 | `); 90 | 91 | await fillIn('#first-name', 'A'); 92 | await click('#submit-btn'); 93 | assert.dom('#errors-paragraph').exists('should be invalid'); 94 | 95 | await fillIn('#first-name', 'Billy'); 96 | await click('#submit-btn'); 97 | assert.dom('#errors-paragraph').doesNotExist('should be valid'); 98 | }); 99 | 100 | test('it accepts validation map with multiple validations', async function (assert) { 101 | function validateLength() { 102 | return (key, newValue) => 103 | (isPresent(newValue) && newValue.length > 3) || 'too short'; 104 | } 105 | function validateStartsUppercase() { 106 | return (key, newValue) => 107 | (isPresent(newValue) && 108 | newValue.charCodeAt(0) > 65 && 109 | newValue.charCodeAt(0) < 90) || 110 | 'not upper case'; 111 | } 112 | let validations = { 113 | firstName: [validateLength(), validateStartsUppercase()], 114 | }; 115 | this.dummyModel = { firstName: 'Jim', lastName: 'Bobbie' }; 116 | this.validations = validations; 117 | this.submit = (changeset) => changeset.validate(); 118 | this.reset = (changeset) => changeset.rollback(); 119 | await render(hbs` 120 | {{#let (changeset this.dummyModel this.validations) as |changesetObj|}} 121 | {{#if changesetObj.isInvalid}} 122 |

There were one or more errors in your form.

123 | {{/if}} 124 | 125 | 126 | 127 | 128 | {{/let}} 129 | `); 130 | 131 | await fillIn('#first-name', 'A'); 132 | await click('#submit-btn'); 133 | assert.dom('#errors-paragraph').exists('should be invalid'); 134 | 135 | await fillIn('#first-name', 'Billy'); 136 | await click('#submit-btn'); 137 | assert.dom('#errors-paragraph').doesNotExist('should be valid'); 138 | }); 139 | 140 | test('it accepts validation map with multiple validations with promises', async function (assert) { 141 | function validateLength() { 142 | return (key, newValue) => 143 | (isPresent(newValue) && Promise.resolve(newValue.length > 3)) || 144 | 'too short'; 145 | } 146 | function validateStartsUppercase() { 147 | return (key, newValue) => 148 | (isPresent(newValue) && 149 | newValue.charCodeAt(0) > 65 && 150 | newValue.charCodeAt(0) < 90) || 151 | Promise.resolve('not upper case'); 152 | } 153 | let validations = { 154 | firstName: [validateLength(), validateStartsUppercase()], 155 | }; 156 | this.dummyModel = { firstName: 'Jim', lastName: 'Bobbie' }; 157 | this.validations = validations; 158 | this.submit = (changeset) => changeset.validate(); 159 | this.reset = (changeset) => changeset.rollback(); 160 | await render(hbs` 161 | {{#let (changeset this.dummyModel this.validations) as |changesetObj|}} 162 | {{#if changesetObj.isInvalid}} 163 |

There were one or more errors in your form.

164 | {{/if}} 165 | 166 | 167 | 168 | 169 | {{/let}} 170 | `); 171 | 172 | await fillIn('#first-name', 'A'); 173 | await click('#submit-btn'); 174 | assert.dom('#errors-paragraph').exists('should be invalid'); 175 | 176 | await fillIn('#first-name', 'Billy'); 177 | await click('#submit-btn'); 178 | assert.dom('#errors-paragraph').doesNotExist('should be valid'); 179 | }); 180 | 181 | test('it rollsback changes', async function (assert) { 182 | this.dummyModel = { firstName: 'Jim' }; 183 | this.reset = (changeset) => changeset.rollback(); 184 | await render(hbs` 185 | {{#let (changeset this.dummyModel) as |changesetObj|}} 186 | 187 | 188 | {{/let}} 189 | `); 190 | 191 | assert.dom('#first-name').hasValue('Jim', 'precondition'); 192 | await fillIn('#first-name', 'foo'); 193 | assert.dom('#first-name').hasValue('foo', 'should update input'); 194 | await click('#reset-btn'); 195 | assert.dom('#first-name').hasValue('Jim', 'should rollback'); 196 | }); 197 | 198 | test('it can be used with 1 argument', async function (assert) { 199 | this.dummyModel = { firstName: 'Jim', lastName: 'Bob' }; 200 | this.submit = (changeset) => changeset.validate(); 201 | this.reset = (changeset) => changeset.rollback(); 202 | await render(hbs` 203 | {{#let (changeset this.dummyModel) as |changesetObj|}} 204 | {{#if changesetObj.isInvalid}} 205 |

There were one or more errors in your form.

206 | {{/if}} 207 | 208 | 209 | 210 | 211 | {{/let}} 212 | `); 213 | 214 | await click('#submit-btn'); 215 | assert.dom('#errors-paragraph').doesNotExist('should be valid'); 216 | }); 217 | 218 | test('it updates when set without a validator', async function (assert) { 219 | this.dummyModel = { firstName: 'Jim', lastName: 'Bob' }; 220 | await render(hbs` 221 | {{#let (changeset this.dummyModel) as |changesetObj|}} 222 |

{{changesetObj.firstName}} {{changesetObj.lastName}}

223 | 229 | 230 | {{/let}} 231 | `); 232 | 233 | assert.dom('h1').hasText('Jim Bob', 'precondition'); 234 | await fillIn('#first-name', 'foo'); 235 | await fillIn('#last-name', 'bar'); 236 | assert.dom('h1').hasText('foo bar', 'should update observable value'); 237 | }); 238 | 239 | test('it updates when set with a validator', async function (assert) { 240 | this.dummyModel = { firstName: 'Jim', lastName: 'Bob' }; 241 | this.validate = () => true; 242 | this.updateFirstName = (changeset, evt) => { 243 | changeset.firstName = evt.target.value; 244 | }; 245 | await render(hbs` 246 | {{#let (changeset this.dummyModel this.this.validate) as |changesetObj|}} 247 |

{{changesetObj.firstName}} {{changesetObj.lastName}}

248 | 254 | 255 | {{/let}} 256 | `); 257 | 258 | assert.dom('h1').hasText('Jim Bob', 'precondition'); 259 | await fillIn('#first-name', 'foo'); 260 | await fillIn('#last-name', 'bar'); 261 | assert.dom('h1').hasText('foo bar', 'should update observable value'); 262 | }); 263 | 264 | test('a passed down nested object updates when set without a validator', async function (assert) { 265 | let data = { person: { firstName: 'Jim', lastName: 'Bob' } }; 266 | let changeset = Changeset(data); 267 | this.changeset = changeset; 268 | 269 | await render(hbs` 270 |

{{this.changeset.person.firstName}} {{this.changeset.person.lastName}}

271 | 275 | 278 | `); 279 | 280 | assert.dom('h1').hasText('Jim Bob', 'precondition'); 281 | assert.strictEqual( 282 | changeset.get('person.firstName'), 283 | 'Jim', 284 | 'precondition firstName', 285 | ); 286 | assert.strictEqual( 287 | changeset.get('person.lastName'), 288 | 'Bob', 289 | 'precondition lastName', 290 | ); 291 | await fillIn('#first-name', 'foo'); 292 | await fillIn('#last-name', 'bar'); 293 | assert.strictEqual( 294 | changeset.get('person.firstName'), 295 | 'foo', 296 | 'should update observable value', 297 | ); 298 | assert.strictEqual( 299 | changeset.get('person.lastName'), 300 | 'bar', 301 | 'should update observable value lastName', 302 | ); 303 | assert.strictEqual( 304 | changeset.get('person').firstName, 305 | 'foo', 306 | 'should work with top level key', 307 | ); 308 | assert.strictEqual( 309 | changeset.get('person').lastName, 310 | 'bar', 311 | 'should work with top level key last name', 312 | ); 313 | assert.strictEqual( 314 | changeset.person.firstName, 315 | 'foo', 316 | 'should work with top level key', 317 | ); 318 | assert.strictEqual( 319 | changeset.get('_content').person.firstName, 320 | 'Jim', 321 | "keeps value on model as execute hasn't been called", 322 | ); 323 | assert.dom('h1').hasText('foo bar', 'should update observable value'); 324 | }); 325 | 326 | test('nested object updates when set without a validator', async function (assert) { 327 | let data = { person: { firstName: 'Jim', lastName: 'Bob' } }; 328 | let changeset = Changeset(data); 329 | this.changeset = changeset; 330 | 331 | await render(hbs` 332 |

{{this.changeset.person.firstName}} {{this.changeset.person.lastName}}

333 | 337 | 341 | `); 342 | 343 | assert.dom('h1').hasText('Jim Bob', 'precondition'); 344 | await fillIn('#first-name', 'foo'); 345 | await fillIn('#last-name', 'bar'); 346 | assert.dom('h1').hasText('foo bar', 'should update observable value'); 347 | }); 348 | 349 | test('nested key error clears after entering valid input', async function (assert) { 350 | let data = { person: { firstName: 'Jim' } }; 351 | let validator = ({ newValue }) => 352 | isPresent(newValue) || 'need a first name'; 353 | let c = Changeset(data, validator); 354 | this.c = c; 355 | 356 | await render(hbs` 357 |

{{this.c.person.firstName}}

358 | 363 | {{this.c.error.person.firstName.validation}} 364 | `); 365 | 366 | assert.dom('h1').hasText('Jim', 'precondition'); 367 | await fillIn('#first-name', 'foo'); 368 | await fillIn('#first-name', ''); 369 | 370 | let actual = find('#first-name-error').textContent.trim(); 371 | let expectedResult = 'need a first name'; 372 | assert.strictEqual(actual, expectedResult, 'shows error message'); 373 | 374 | await fillIn('#first-name', 'foo'); 375 | 376 | actual = find('#first-name-error').textContent.trim(); 377 | expectedResult = ''; 378 | assert.strictEqual(actual, expectedResult, 'hides error message'); 379 | }); 380 | 381 | test('nested object updates when set with async validator', async function (assert) { 382 | let data = { person: { firstName: 'Jim' } }; 383 | let validator = () => Promise.resolve(true); 384 | let c = Changeset(data, validator); 385 | this.c = c; 386 | 387 | await render(hbs` 388 |

{{this.c.person.firstName}}

389 | 394 | {{this.c.error.person.firstName.validation}} 395 | `); 396 | assert.dom('h1').hasText('Jim', 'precondition'); 397 | await fillIn('#first-name', 'John'); 398 | assert.dom('h1').hasText('John', 'should update observable value'); 399 | }); 400 | 401 | test('deeply nested key error clears after entering valid input', async function (assert) { 402 | assert.expect(3); 403 | 404 | let data = { person: { name: { parts: { first: 'Jim' } } } }; 405 | let validator = ({ newValue }) => 406 | isPresent(newValue) || 'need a first name'; 407 | let c = Changeset(data, validator); 408 | this.c = c; 409 | this.mutValue = (path, evt) => (this.c[path] = evt.target.value); 410 | 411 | await render(hbs` 412 |

{{this.c.person.name.parts.first}}

413 | 418 | {{this.c.error.person.name.parts.first.validation}} 419 | `); 420 | 421 | assert.dom('h1').hasText('Jim', 'precondition'); 422 | await fillIn('#first-name', ''); 423 | 424 | { 425 | let actual = find('#first-name-error').textContent.trim(); 426 | let expectedResult = 'need a first name'; 427 | assert.strictEqual(actual, expectedResult, 'shows error message'); 428 | } 429 | 430 | await fillIn('#first-name', 'foo'); 431 | 432 | { 433 | let actual = find('#first-name-error').textContent.trim(); 434 | let expectedResult = ''; 435 | assert.strictEqual(actual, expectedResult, 'hides error message'); 436 | } 437 | }); 438 | 439 | test('a rollback propagates binding to deeply nested changesets', async function (assert) { 440 | let data = { person: { firstName: 'Jim', lastName: 'Bob' } }; 441 | let changeset = Changeset(data); 442 | this.changeset = changeset; 443 | this.reset = () => changeset.rollback(); 444 | this.mutValue = (path, evt) => (this.changeset[path] = evt.target.value); 445 | await render(hbs` 446 |

{{this.changeset.person.firstName}} {{this.changeset.person.lastName}}

447 | 451 | 455 | 456 | `); 457 | 458 | assert.dom('h1').hasText('Jim Bob', 'precondition'); 459 | await fillIn('#first-name', 'foo'); 460 | await fillIn('#last-name', 'bar'); 461 | assert.dom('h1').hasText('foo bar', 'should update observable value'); 462 | await click('#reset-btn'); 463 | assert.dom('h1').hasText('Jim Bob', 'should rollback values'); 464 | }); 465 | 466 | test('it does not rollback when validating', async function (assert) { 467 | let dummyValidations = { 468 | even(k, value) { 469 | return value % 2 === 0 || 'must be even'; 470 | }, 471 | odd(k, value) { 472 | return value % 2 !== 0 || 'must be odd'; 473 | }, 474 | }; 475 | let changeset = Changeset( 476 | { even: 4, odd: 4 }, 477 | lookupValidator(dummyValidations), 478 | dummyValidations, 479 | ); 480 | this.addEven = (changeset, evt) => { 481 | changeset.even = evt.target.value; 482 | }; 483 | this.addOdd = (changeset, evt) => { 484 | changeset.odd = evt.target.value; 485 | }; 486 | this.changeset = changeset; 487 | this.validateProperty = (changeset, property) => 488 | changeset.validate(property); 489 | await render(hbs` 490 |
491 | 492 | 498 | {{#if this.changeset.error.even}} 499 | {{this.changeset.error.even.validation}} 500 | {{/if}} 501 | {{this.changeset.even}} 502 |
503 | 504 |
505 | 506 | 512 | {{#if this.changeset.error.odd}} 513 | {{this.changeset.error.odd.validation}} 514 | {{/if}} 515 | {{this.changeset.odd}} 516 |
517 | `); 518 | 519 | await fillIn('#even', '9'); 520 | await triggerEvent('#odd', 'blur'); 521 | assert 522 | .dom('small.even') 523 | .hasText('must be even', 'should display error message'); 524 | assert 525 | .dom('small.odd') 526 | .hasText('must be odd', 'should display error message'); 527 | assert.dom('#even').hasValue('9', 'should not rollback'); 528 | assert.dom('code.even').hasText('9', 'should not rollback'); 529 | assert.dom('#odd').hasValue('4', 'should not rollback'); 530 | await blur('#even'); 531 | // there is a scenario where going from valid to invalid would cause values to 532 | // go out of sync 533 | await fillIn('#odd', '10'); 534 | await blur('#odd'); 535 | assert 536 | .dom('small.even') 537 | .hasText('must be even', 'should display error message'); 538 | assert 539 | .dom('small.odd') 540 | .hasText('must be odd', 'should display error message'); 541 | assert.dom('#odd').hasValue('10', 'should not rollback'); 542 | assert.dom('code.odd').hasText('10', 'should not rollback'); 543 | }); 544 | 545 | test('it handles when changeset is already set', async function (assert) { 546 | class Moment { 547 | constructor(date) { 548 | this.date = date; 549 | } 550 | } 551 | let d = new Date('2015'); 552 | let momentInstance = new Moment(d); 553 | this.dummyModel = { startDate: momentInstance }; 554 | await render(hbs` 555 | {{#let (changeset this.dummyModel) as |changesetObj|}} 556 |

{{changesetObj.startDate.date}}

557 | {{/let}} 558 | `); 559 | 560 | assert.dom('h1').hasText(d.toString(), 'should update observable value'); 561 | }); 562 | 563 | test('it handles when is plain object passed to helper', async function (assert) { 564 | let d = new Date('2015'); 565 | this.d = d; 566 | await render(hbs` 567 | {{#let (changeset (hash date=this.d)) as |changesetObj|}} 568 |

{{changesetObj.date}}

569 | {{/let}} 570 | `); 571 | 572 | assert.dom('h1').hasText(d.toString(), 'should update observable value'); 573 | }); 574 | 575 | test('it handles models that are promises', async function (assert) { 576 | this.dummyModel = Promise.resolve({ firstName: 'Jim', lastName: 'Bob' }); 577 | 578 | this.updateAttr = (changeset, attr, event) => { 579 | set(changeset, attr, event.target.value); 580 | }; 581 | 582 | // @todo this test does not await until promise resolved 583 | // and actually mutates props on the Promise object, not on the changeset 584 | await render(hbs` 585 | {{#let (changeset this.dummyModel) as |changesetObj|}} 586 |

{{changesetObj.firstName}} {{changesetObj.lastName}}

587 | 592 | 593 | 598 | {{/let}} 599 | `); 600 | 601 | await fillIn('#first-name', 'foo'); 602 | await blur('#first-name'); 603 | await fillIn('#last-name', 'bar'); 604 | await blur('#last-name'); 605 | assert.dom('h1').hasText('foo bar', 'should update observable value'); 606 | }); 607 | 608 | test('it skips validation when skipValidate is passed as an option', async function (assert) { 609 | this.dummyModel = { firstName: 'Jim', lastName: 'Bob' }; 610 | this.validate = () => false; 611 | await render(hbs` 612 | {{#let (changeset this.dummyModel this.validate skipValidate=true) as |changesetObj|}} 613 |

{{changesetObj.firstName}} {{changesetObj.lastName}}

614 | 615 | 616 | {{#if changesetObj.isInvalid}} 617 |

There were one or more errors in your form.

618 | {{/if}} 619 | {{/let}} 620 | `); 621 | 622 | assert.dom('h1').hasText('Jim Bob', 'precondition'); 623 | await fillIn('#first-name', 'J'); 624 | await blur('#first-name'); 625 | await fillIn('#last-name', 'B'); 626 | await blur('#last-name'); 627 | assert.dom('h1').hasText('J B', 'should update observable value'); 628 | assert.dom('#error-paragraph').doesNotExist('should skip validation'); 629 | }); 630 | 631 | test('it validates changes with changesetKeys', async function (assert) { 632 | let validations = { 633 | firstName(value) { 634 | return (isPresent(value) && value.length > 3) || 'too short'; 635 | }, 636 | }; 637 | this.dummyModel = { firstName: 'Jimm', lastName: 'Bob' }; 638 | this.validate = ({ key, newValue }) => { 639 | let validatorFn = validations[key]; 640 | 641 | if (typeOf(validatorFn) === 'function') { 642 | return validatorFn(newValue); 643 | } 644 | }; 645 | this.submit = (changeset) => changeset.validate(); 646 | this.reset = (changeset) => changeset.rollback(); 647 | this.changesetKeys = ['lastName']; 648 | await render(hbs` 649 | {{#let (changeset this.dummyModel this.validate changesetKeys=this.changesetKeys) as |changesetObj|}} 650 | {{#if changesetObj.isDirty}} 651 |

There were one or more errors in your form.

652 | {{/if}} 653 | 654 | 655 | {{/let}} 656 | `); 657 | 658 | await fillIn('#first-name', 'A'); 659 | await click('#submit-btn'); 660 | assert.dom('#errors-paragraph').doesNotExist('should not be invalid'); 661 | }); 662 | }); 663 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |



ember-changeset


2 | 3 | Download count all time 4 | GitHub Actions Build Status 5 | npm version 6 | Ember Observer Score 7 | 8 | ## Compatibility 9 | 10 | - Ember.js v4.8 or above 11 | - Embroider or ember-auto-import v2 12 | 13 | ## Installation 14 | 15 | ``` 16 | ember install ember-changeset 17 | ``` 18 | 19 | > [Watch a free video intro presented by EmberScreencasts](https://www.emberscreencasts.com/posts/168-introduction-to-ember-changeset) 20 | > 21 | > 22 | 23 | ## Updates 24 | 25 | We have released `v3.0.0`. See the CHANGELOG [here](https://github.com/adopted-ember-addons/ember-changeset/blob/master/CHANGELOG.md). 26 | This requires Ember >= 3.13 as the use of `@tracked` will help us monitor and propagate changes to the UI layer. 27 | If your app is < 3.13 or you need to support IE11, then you can install the 2.0 series `ember install ember-changeset@v2.2.4`. 28 | 29 | Support for IE11 was dropped with the `v3.0.0` release given our ubiquitous use of Proxy. 30 | 31 | The base library for this addon is [validated-changeset](https://github.com/adopted-ember-addons/validated-changeset/). 32 | As a result, this functionality is available outside of Ember as well! 33 | 34 | ## Philosophy 35 | 36 | The idea behind a changeset is simple: it represents a set of valid changes to be applied onto any Object (`Ember.Object`, `DS.Model`, POJOs, etc). 37 | Each change is tested against an optional validation, and if valid, the change is stored and applied when executed. 38 | 39 | Assuming a Data Down, Actions Up (DDAU) approach, a changeset is more appropriate compared to implicit 2 way bindings. 40 | Other validation libraries only validate a property _after_ it is set on an Object, which means that your Object can enter an invalid state. 41 | 42 | `ember-changeset` only allows valid changes to be set, so your Objects will never become invalid (assuming you have 100% validation coverage). 43 | Additionally, this addon is designed to be un-opinionated about your choice of form and/or validation library, so you can easily integrate it into an existing solution. 44 | 45 | The simplest way to incorporate validations is to use [`ember-changeset-validations`](https://github.com/adopted-ember-addons/ember-changeset-validations/), a companion addon to this one. 46 | It has a simple mental model, and there are no Observers or CPs involved – just pure functions. 47 | 48 | See also the [plugins](#plugins) section for addons that extend `ember-changeset`. 49 | 50 | #### tl;dr 51 | 52 | ```js 53 | import { Changeset } from "ember-changeset"; 54 | 55 | let dummyValidations = { 56 | firstName(newValue) { 57 | return !!newValue; 58 | }, 59 | }; 60 | 61 | function validatorFn({ key, newValue, oldValue, changes, content }) { 62 | let validator = get(dummyValidations, key); 63 | 64 | if (typeof validator === "function") { 65 | return validator(newValue, oldValue, changes, content); 66 | } 67 | } 68 | 69 | let changeset = Changeset(user, validatorFn); 70 | user.firstName; // "Michael" 71 | user.lastName; // "Bolton" 72 | 73 | changeset.set("firstName", "Jim"); 74 | changeset.set("lastName", "B"); 75 | changeset.isInvalid; // true 76 | changeset.get("errors"); // [{ key: 'lastName', validation: 'too short', value: 'B' }] 77 | changeset.set("lastName", "Bob"); 78 | changeset.isValid; // true 79 | 80 | user.firstName; // "Michael" 81 | user.lastName; // "Bolton" 82 | 83 | changeset.save(); // sets and saves valid changes on the user 84 | user.firstName; // "Jim" 85 | user.lastName; // "Bob" 86 | ``` 87 | 88 | ## Usage 89 | 90 | First, create a new `Changeset` using the `changeset` helper or through JavaScript via a factory function: 91 | 92 | ```hbs 93 | {{! application/template.hbs}} 94 | {{#let (changeset this.model this.validate) as |changesetObj|}} 95 | 100 | {{/let}} 101 | ``` 102 | 103 | ```js 104 | import Component from '@glimmer/component'; 105 | import { cached } from '@glimmer/tracking'; 106 | import { Changeset } from 'ember-changeset'; 107 | 108 | export default FormComponent extends Component { 109 | @cached 110 | get changeset() { 111 | let validator = this.validate; 112 | return Changeset(this.model, validator); 113 | } 114 | } 115 | ``` 116 | 117 | The helper receives any Object (including `DS.Model`, `Ember.Object`, or even POJOs) and an optional `validator` action. 118 | If a `validator` is passed into the helper, the changeset will attempt to call that function when a value changes. 119 | 120 | ```js 121 | // application/controller.js 122 | import Controller from "@ember/controller"; 123 | import { action } from "@ember/object"; 124 | 125 | export default class FormController extends Controller { 126 | @action 127 | submit(changeset) { 128 | return changeset.save(); 129 | } 130 | 131 | @action 132 | rollback(changeset) { 133 | return changeset.rollback(); 134 | } 135 | 136 | @action 137 | setChangesetProperty(changeset, path, evt) { 138 | return changeset.set(path, evt.target.value); 139 | } 140 | 141 | @action 142 | validate({ key, newValue, oldValue, changes, content }) { 143 | // lookup a validator function on your favorite validation library 144 | // and return a Boolean 145 | } 146 | } 147 | ``` 148 | 149 | Then, in your favorite form library, simply pass in the `changeset` in place of the original model. 150 | 151 | ```hbs 152 | {{! dummy-form/template.hbs}} 153 |
154 | 158 | 162 | 163 | 164 | 165 |
166 | ``` 167 | 168 | In the above example, when the input changes, only the changeset's internal values are updated. 169 | When the submit button is clicked, the changes are only executed if _all changes_ are valid. 170 | 171 | On rollback, all changes are dropped and the underlying Object is left untouched. 172 | 173 | ## Extending the base ember-changeset class 174 | 175 | ```js 176 | import { EmberChangeset, Changeset } from "ember-changeset"; 177 | 178 | class MyChangeset extends EmberChangeset { 179 | save() { 180 | super.save(...arguments); 181 | // do stuff 182 | } 183 | } 184 | 185 | let changeset = Changeset(user, validatorFn, validationMap, { 186 | changeset: MyChangeset, 187 | }); 188 | ``` 189 | 190 | ## Changeset template helpers 191 | 192 | `ember-changeset` overrides `set` and `get` in order to handle deeply nested setters. 193 | `mut` is simply an alias for `Ember.set(changeset, ...)`, thus we provide a `changeset-set` template helper if you are dealing with nested setters. 194 | 195 | `changeset-get` is necessary for nested getters to easily retrieve leaf keys without error. 196 | Ember's templating layer will ask us for the first key it comes across as it traverses down the object (`user.firstName`). 197 | We keep track of the changes, but to also keep track of unchanged values and properly merge them in the changeset is difficult. 198 | If you are only accessing keys in an object that is only one level deep, you do not need this helper. 199 | 200 | ```hbs 201 |
202 | 208 |
209 | ``` 210 | 211 | ### Template Tag support 212 | 213 | For usage in [Template Tag Format](https://guides.emberjs.com/release/components/template-tag-format/), 214 | this addon provides `changesetGet` and `changesetSet` named exports: 215 | 216 | ```gjs 217 | import { changesetGet, changesetSet } from "ember-changeset"; 218 | 219 | 229 | ``` 230 | 231 | ## Limiting which keys dirty the changeset 232 | 233 | In order to limit the changes made to your changeset and it's associated `isDirty` state, you can pass in a list of `changesetKeys`. 234 | 235 | ```js 236 | let changeset = Changeset(model, validatorFn, validationMap, { 237 | changesetKeys: ["name"], 238 | }); 239 | ``` 240 | 241 | ## Disabling Automatic Validation 242 | 243 | The default behavior of `Changeset` is to automatically validate a field when it is set. 244 | Automatic validation can be disabled by passing `skipValidate` as an option when creating a changeset. 245 | 246 | ```js 247 | let changeset = Changeset(model, validatorFn, validationMap, { 248 | skipValidate: true, 249 | }); 250 | ``` 251 | 252 | ```hbs 253 | {{#let 254 | (changeset this.model this.validate skipValidate=true) 255 | as |changesetObj| 256 | }} 257 | ... 258 | {{/let}} 259 | ``` 260 | 261 | Be sure to call `validate()` on the `changeset` before saving or committing changes. 262 | 263 | ## TypesScript 264 | 265 | ```ts 266 | import Component from "@glimmer/component"; 267 | import type { BufferedChangeset } from "ember-changeset/types"; 268 | import { Changeset } from "ember-changeset"; 269 | 270 | interface Args { 271 | user: { 272 | name: string; 273 | age: number; 274 | }; 275 | } 276 | 277 | export default class Foo extends Component { 278 | changeset: BufferedChangeset; 279 | 280 | constructor(owner, args) { 281 | super(owner, args); 282 | this.changeset = Changeset(args.user); 283 | } 284 | } 285 | ``` 286 | 287 | Other available types include the following. Please put in a PR if you need more types or access directly in `validated-changeset`! 288 | 289 | ```js 290 | import type { ValidationResult, ValidatorMapFunc, ValidatorAction } from 'ember-changeset/types'; 291 | ``` 292 | 293 | This project ships [Glint](https://github.com/typed-ember/glint) types, 294 | which allow you when using TypeScript to get strict type checking in your templates. 295 | 296 | Unless you are using [strict mode](http://emberjs.github.io/rfcs/0496-handlebars-strict-mode.html) templates 297 | (via [first class component templates](http://emberjs.github.io/rfcs/0779-first-class-component-templates.html)), 298 | Glint needs a [Template Registry](https://typed-ember.gitbook.io/glint/using-glint/ember/template-registry) 299 | that contains entries for the template helper provided by this addon. 300 | To add these registry entries automatically to your app, you just need to import `ember-changeset/template-registry` 301 | from somewhere in your app. When using Glint already, you will likely have a file like 302 | `types/glint.d.ts` where you already import glint types, so just add the import there: 303 | 304 | ```ts 305 | import "@glint/environment-ember-loose"; 306 | import type ChangesetRegistry from "ember-changeset/template-registry"; 307 | declare module "@glint/environment-ember-loose/registry" { 308 | export default interface Registry 309 | extends ChangesetRegistry /* other addon registries */ { 310 | // local entries 311 | } 312 | } 313 | ``` 314 | 315 | ## Alternative Changeset 316 | 317 | Enabled in 4.1.0. Experimental and subject to changes until 5.0. 318 | 319 | We now ship a `ValidatedChangeset` that is a proposed new API we would like to introduce and see if it jives with users. 320 | The goal of this new feature is to remove confusing APIs and externalize validations. 321 | 322 | - ✂️ `save` 323 | - ✂️ `cast` 324 | - ✂️ `merge` 325 | - `errors` are required to be added to the Changeset manually after `validate` 326 | - `validate` takes a callback with the sum of changes and original content to be applied to your externalized validation. In user land you will call `changeset.validate((changes) => yupSchema.validate(changes))` 327 | 328 | ```js 329 | import Component from "@glimmer/component"; 330 | import { ValidatedChangeset } from "ember-changeset"; 331 | import { action, get } from "@ember/object"; 332 | import { object, string } from "yup"; 333 | 334 | class Foo { 335 | user = { 336 | name: "someone", 337 | email: "something@gmail.com", 338 | }; 339 | } 340 | 341 | const FormSchema = object({ 342 | cid: string().required(), 343 | user: object({ 344 | name: string().required(), 345 | email: string().email(), 346 | }), 347 | }); 348 | 349 | export default class ValidatedForm extends Component { 350 | constructor() { 351 | super(...arguments); 352 | 353 | this.model = new Foo(); 354 | this.changeset = ValidatedChangeset(this.model); 355 | } 356 | 357 | @action 358 | async setChangesetProperty(path, evt) { 359 | this.changeset.set(path, evt.target.value); 360 | try { 361 | await this.changeset.validate((changes) => FormSchema.validate(changes)); 362 | this.changeset.removeError(path); 363 | } catch (e) { 364 | this.changeset.addError(e.path, { 365 | value: this.changeset.get(e.path), 366 | validation: e.message, 367 | }); 368 | } 369 | } 370 | 371 | @action 372 | async submitForm(changeset, event) { 373 | event.preventDefault(); 374 | 375 | changeset.execute(); 376 | await this.model.save(); 377 | } 378 | } 379 | ``` 380 | 381 | ## API 382 | 383 | - Properties 384 | - [`error`](#error) 385 | - [`change`](#change) 386 | - [`errors`](#errors) 387 | - [`changes`](#changes) 388 | - [`data`](#data) 389 | - [`pendingData`](#pendingData) 390 | - [`isValid`](#isvalid) 391 | - [`isInvalid`](#isinvalid) 392 | - [`isPristine`](#ispristine) 393 | - [`isDirty`](#isdirty) 394 | - Methods 395 | - [`get`](#get) 396 | - [`set`](#set) 397 | - [`prepare`](#prepare) 398 | - [`execute`](#execute) 399 | - [`unexecute`](#unexecute) 400 | - [`save`](#save) 401 | - [`merge`](#merge) 402 | - [`rollback`](#rollback) 403 | - [`rollbackInvalid`](#rollbackinvalid) 404 | - [`rollbackProperty`](#rollbackproperty) 405 | - [`validate`](#validate) 406 | - [`addError`](#adderror) 407 | - [`pushErrors`](#pusherrors) 408 | - [`removeError`](#removeerror) 409 | - [`removeErrors`](#removeerrors) 410 | - [`snapshot`](#snapshot) 411 | - [`restore`](#restore) 412 | - [`cast`](#cast) 413 | - [`isValidating`](#isvalidating) 414 | - Events 415 | - [`beforeValidation`](#beforevalidation) 416 | - [`afterValidation`](#aftervalidation) 417 | - [`afterRollback`](#afterrollback) 418 | 419 | #### `error` 420 | 421 | Returns the error object. 422 | 423 | ```js 424 | { 425 | firstName: { 426 | value: 'Jim', 427 | validation: 'First name must be greater than 7 characters' 428 | } 429 | } 430 | ``` 431 | 432 | Note that keys can be arbitrarily nested: 433 | 434 | ```js 435 | { 436 | address: { 437 | zipCode: { 438 | value: '123', 439 | validation: 'Zip code must have 5 digits' 440 | } 441 | } 442 | } 443 | ``` 444 | 445 | You can use this property to locate a single error: 446 | 447 | ```hbs 448 | {{#if this.changeset.error.firstName}} 449 |

{{this.changeset.error.firstName.validation}}

450 | {{/if}} 451 | 452 | {{#if this.changeset.error.address.zipCode}} 453 |

{{this.changeset.error.address.zipCode.validation}}

454 | {{/if}} 455 | ``` 456 | 457 | **[⬆️ back to top](#api)** 458 | 459 | #### `change` 460 | 461 | Returns the change object. 462 | 463 | ```js 464 | { 465 | firstName: "Jim"; 466 | } 467 | ``` 468 | 469 | Note that keys can be arbitrarily nested: 470 | 471 | ```js 472 | { 473 | address: { 474 | zipCode: "10001"; 475 | } 476 | } 477 | ``` 478 | 479 | You can use this property to locate a single change: 480 | 481 | ```hbs 482 | {{this.changeset.change.firstName}} 483 | {{this.changeset.change.address.zipCode}} 484 | ``` 485 | 486 | **[⬆️ back to top](#api)** 487 | 488 | #### `errors` 489 | 490 | Returns an array of errors. If your `validate` function returns a non-boolean value, it is added here as the `validation` property. 491 | 492 | ```js 493 | [ 494 | { 495 | key: "firstName", 496 | value: "Jim", 497 | validation: "First name must be greater than 7 characters", 498 | }, 499 | { 500 | key: "address.zipCode", 501 | value: "123", 502 | validation: "Zip code must have 5 digits", 503 | }, 504 | ]; 505 | ``` 506 | 507 | You can use this property to render a list of errors: 508 | 509 | ```hbs 510 | {{#if this.changeset.isInvalid}} 511 |

There were errors in your form:

512 |
    513 | {{#each this.changeset.errors as |error|}} 514 |
  • {{error.key}}: {{error.validation}}
  • 515 | {{/each}} 516 |
517 | {{/if}} 518 | ``` 519 | 520 | **[⬆️ back to top](#api)** 521 | 522 | #### `changes` 523 | 524 | Returns an array of changes to be executed. Only valid changes will be stored on this property. 525 | 526 | ```js 527 | [ 528 | { 529 | key: "firstName", 530 | value: "Jim", 531 | }, 532 | { 533 | key: "address.zipCode", 534 | value: 10001, 535 | }, 536 | ]; 537 | ``` 538 | 539 | You can use this property to render a list of changes: 540 | 541 | ```hbs 542 |
    543 | {{#each this.changeset.changes as |change|}} 544 |
  • {{change.key}}: {{change.value}}
  • 545 | {{/each}} 546 |
547 | ``` 548 | 549 | **[⬆️ back to top](#api)** 550 | 551 | #### `data` 552 | 553 | Returns the Object that was wrapped in the changeset. 554 | 555 | ```js 556 | let user = { name: "Bobby", age: 21, address: { zipCode: "10001" } }; 557 | let changeset = Changeset(user); 558 | 559 | changeset.get("data"); // user 560 | ``` 561 | 562 | **[⬆️ back to top](#api)** 563 | 564 | #### `pendingData` 565 | 566 | Returns object with changes applied to original data without mutating original data object. 567 | Unlike `execute()`, `pendingData` shows resulting object even if validation failed. Original data or changeset won't be modified. 568 | 569 | Note: Currently, it only works with POJOs. Refer to [`execute`](#execute) for a way to apply changes onto ember-data models. 570 | 571 | ```js 572 | let user = { name: "Bobby", age: 21, address: { zipCode: "10001" } }; 573 | let changeset = Changeset(user); 574 | 575 | changeset.set("name", "Zoe"); 576 | 577 | changeset.get("pendingData"); // { name: 'Zoe', age: 21, address: { zipCode: '10001' } } 578 | ``` 579 | 580 | **[⬆️ back to top](#api)** 581 | 582 | #### `isValid` 583 | 584 | Returns a Boolean value of the changeset's validity. 585 | 586 | ```js 587 | changeset.isValid; // true 588 | ``` 589 | 590 | You can use this property in the template: 591 | 592 | ```hbs 593 | {{#if this.changeset.isValid}} 594 |

Good job!

595 | {{/if}} 596 | ``` 597 | 598 | **[⬆️ back to top](#api)** 599 | 600 | #### `isInvalid` 601 | 602 | Returns a Boolean value of the changeset's (in)validity. 603 | 604 | ```js 605 | changeset.isInvalid; // true 606 | ``` 607 | 608 | You can use this property in the template: 609 | 610 | ```hbs 611 | {{#if this.changeset.isInvalid}} 612 |

There were one or more errors in your form

613 | {{/if}} 614 | ``` 615 | 616 | **[⬆️ back to top](#api)** 617 | 618 | #### `isPristine` 619 | 620 | Returns a Boolean value of the changeset's state. A pristine changeset is one with no changes. 621 | 622 | ```js 623 | changeset.isPristine; // true 624 | ``` 625 | 626 | If changes present on the changeset are equal to the content's, this will return `true`. However, note that key/value pairs in the list of changes must all be present and equal on the content, but not necessarily vice versa: 627 | 628 | ```js 629 | let user = { name: "Bobby", age: 21, address: { zipCode: "10001" } }; 630 | 631 | changeset.set("name", "Bobby"); 632 | changeset.isPristine; // true 633 | 634 | changeset.set("address.zipCode", "10001"); 635 | changeset.isPristine; // true 636 | 637 | changeset.set("foo", "bar"); 638 | changeset.isPristine; // false 639 | ``` 640 | 641 | **[⬆️ back to top](#api)** 642 | 643 | #### `isDirty` 644 | 645 | Returns a Boolean value of the changeset's state. A dirty changeset is one with changes. 646 | 647 | ```js 648 | changeset.isDirty; // true 649 | ``` 650 | 651 | **[⬆️ back to top](#api)** 652 | 653 | #### `get` 654 | 655 | Exactly the same semantics as `Ember.get`. This proxies first to the error value, the changed value, and finally to the underlying Object. 656 | 657 | ```js 658 | changeset.get("firstName"); // "Jim" 659 | changeset.set("firstName", "Billy"); // "Billy" 660 | changeset.get("firstName"); // "Billy" 661 | 662 | changeset.get("address.zipCode"); // "10001" 663 | changeset.set("address.zipCode", "94016"); // "94016" 664 | changeset.get("address.zipCode"); // "94016" 665 | ``` 666 | 667 | You can use and bind this property in the template: 668 | 669 | ```hbs 670 | 671 | ``` 672 | 673 | Note that using `Ember.get` **will not necessarily work if you're expecting an Object**. On the other hand, using `changeset.get` will work just fine: 674 | 675 | ```js 676 | get(changeset, "momentObj").format("dddd"); // will error, format is undefined 677 | changeset.get("momentObj").format("dddd"); // => "Friday" 678 | ``` 679 | 680 | This is because `Changeset` wraps an Object with `Ember.ObjectProxy` internally, and overrides `Ember.Object.get` to hide this implementation detail. 681 | 682 | Because an Object is wrapped with `Ember.ObjectProxy`, the following (although more verbose) will also work: 683 | 684 | ```js 685 | get(changeset, "momentObj.content").format("dddd"); // => "Friday" 686 | ``` 687 | 688 | **[⬆️ back to top](#api)** 689 | 690 | #### `set` 691 | 692 | Exactly the same semantics as `Ember.set`. This stores the change on the changeset. It is recommended to use `changeset.set(...)` instead of `Ember.set(changeset, ...)`. `Ember.set` will set the property for nested keys on the underlying model. 693 | 694 | ```js 695 | changeset.set("firstName", "Milton"); // "Milton" 696 | changeset.set("address.zipCode", "10001"); // "10001" 697 | ``` 698 | 699 | You can use and bind this property in the template: 700 | 701 | ```hbs 702 | 703 | 704 | ``` 705 | 706 | Any updates on this value will only store the change on the changeset, even with 2 way binding. 707 | 708 | **[⬆️ back to top](#api)** 709 | 710 | #### `prepare` 711 | 712 | Provides a function to run before emitting changes to the model. The callback function must return a hash in the same shape: 713 | 714 | ```js 715 | changeset.prepare((changes) => { 716 | // changes = { firstName: "Jim", lastName: "Bob", 'address.zipCode': "07030" }; 717 | let modified = {}; 718 | 719 | for (let key in changes) { 720 | let newKey = key.split(".").map(underscore).join("."); 721 | modified[newKey] = changes[key]; 722 | } 723 | 724 | // don't forget to return, the original changes object is not mutated 725 | // modified = { first_name: "Jim", last_name: "Bob", 'address.zip_code': "07030" }; 726 | return modified; 727 | }); // returns changeset 728 | ``` 729 | 730 | The callback function is **not validated** – if you modify a value, it is your responsibility to ensure that it is valid. 731 | 732 | Returns the changeset. 733 | 734 | **[⬆️ back to top](#api)** 735 | 736 | #### `execute` 737 | 738 | Applies the valid changes to the underlying Object. 739 | 740 | ```js 741 | changeset.execute(); // returns changeset 742 | ``` 743 | 744 | Note that executing the changeset will not remove the internal list of changes - instead, you should do so explicitly with `rollback` or `save` if that is desired. 745 | 746 | **[⬆️ back to top](#api)** 747 | 748 | #### `unexecute` 749 | 750 | Undo changes made to underlying Object for changeset. This is often useful if you want to remove changes from underlying Object if `save` fails. 751 | 752 | ```js 753 | changeset.save().catch(() => { 754 | // save applies changes to the underlying Object via this.execute(). This may be undesired for your use case. 755 | dummyChangeset.unexecute(); 756 | }); 757 | ``` 758 | 759 | **[⬆️ back to top](#api)** 760 | 761 | #### `save` 762 | 763 | Executes changes, then proxies to the underlying Object's `save` method, if one exists. If it does, the method can either return a `Promise` or a non-`Promise` value. Either way, the changeset's `save` method will return 764 | a promise. 765 | 766 | ```js 767 | changeset.save(); // returns Promise 768 | ``` 769 | 770 | The `save` method will also remove the internal list of changes if the `save` is successful. 771 | 772 | **[⬆️ back to top](#api)** 773 | 774 | #### `merge` 775 | 776 | Merges 2 changesets and returns a new changeset with the same underlying content and validator as the origin. Both changesets must point to the same underlying object. For example: 777 | 778 | ```js 779 | let changesetA = Changeset(user, validatorFn); 780 | let changesetB = Changeset(user, validatorFn); 781 | 782 | changesetA.set("firstName", "Jim"); 783 | changesetA.set("address.zipCode", "94016"); 784 | 785 | changesetB.set("firstName", "Jimmy"); 786 | changesetB.set("lastName", "Fallon"); 787 | changesetB.set("address.zipCode", "10112"); 788 | 789 | let changesetC = changesetA.merge(changesetB); 790 | changesetC.execute(); 791 | 792 | user.get("firstName"); // "Jimmy" 793 | user.get("lastName"); // "Fallon" 794 | user.get("address.zipCode"); // "10112" 795 | ``` 796 | 797 | **[⬆️ back to top](#api)** 798 | 799 | #### `rollback` 800 | 801 | Rolls back all unsaved changes and resets all errors. 802 | 803 | ```js 804 | changeset.rollback(); // returns changeset 805 | ``` 806 | 807 | **[⬆️ back to top](#api)** 808 | 809 | #### `rollbackInvalid` 810 | 811 | Rolls back all invalid unsaved changes and resets all errors. Valid changes will be kept on the changeset. 812 | 813 | ```js 814 | changeset.rollbackInvalid(); // returns changeset 815 | ``` 816 | 817 | **[⬆️ back to top](#api)** 818 | 819 | #### `rollbackProperty` 820 | 821 | Rolls back unsaved changes for the specified property only. All other changes will be kept on the changeset. 822 | 823 | ```js 824 | // user = { firstName: "Jim", lastName: "Bob" }; 825 | let changeset = Changeset(user); 826 | changeset.set("firstName", "Jimmy"); 827 | changeset.set("lastName", "Fallon"); 828 | changeset.rollbackProperty("lastName"); // returns changeset 829 | changeset.execute(); 830 | user.get("firstName"); // "Jimmy" 831 | user.get("lastName"); // "Bob" 832 | ``` 833 | 834 | **[⬆️ back to top](#api)** 835 | 836 | #### `validate` 837 | 838 | Validates all, single or multiple fields on the changeset. This will also validate the property on the underlying object, and is a useful method if you require the changeset to validate immediately on render. 839 | 840 | **Note:** This method requires a validation map to be passed in when the changeset is first instantiated. 841 | 842 | ```js 843 | user.set("lastName", "B"); 844 | user.set("address.zipCode", "123"); 845 | 846 | let validationMap = { 847 | lastName: validateLength({ min: 8 }), 848 | 849 | // specify nested keys with pojo's 850 | address: { 851 | zipCode: validateLength({ is: 5 }), 852 | }, 853 | }; 854 | 855 | let changeset = Changeset(user, validatorFn, validationMap); 856 | changeset.get("isValid"); // true 857 | 858 | // validate single field; returns Promise 859 | changeset.validate("lastName"); 860 | changeset.validate("address.zipCode"); 861 | // multiple keys 862 | changeset.validate("lastName", "address.zipCode"); 863 | 864 | // validate all fields; returns Promise 865 | changeset.validate().then(() => { 866 | changeset.get("isInvalid"); // true 867 | 868 | // [{ key: 'lastName', validation: 'too short', value: 'B' }, 869 | // { key: 'address.zipCode', validation: 'too short', value: '123' }] 870 | changeset.get("errors"); 871 | }); 872 | ``` 873 | 874 | **[⬆️ back to top](#api)** 875 | 876 | #### `addError` 877 | 878 | Manually add an error to the changeset. 879 | 880 | ```js 881 | changeset.addError("email", { 882 | value: "jim@bob.com", 883 | validation: "Email already taken", 884 | }); 885 | 886 | changeset.addError("address.zip", { 887 | value: "123", 888 | validation: "Must be 5 digits", 889 | }); 890 | 891 | // shortcut 892 | changeset.addError("email", "Email already taken"); 893 | changeset.addError("address.zip", "Must be 5 digits"); 894 | ``` 895 | 896 | Adding an error manually does not require any special setup. The error will be cleared if the value for the `key` is subsequently set to a valid value. Adding an error will overwrite any existing error or change for `key`. 897 | 898 | If using the shortcut method, the value in the changeset will be used as the value for the error. 899 | 900 | **[⬆️ back to top](#api)** 901 | 902 | #### `pushErrors` 903 | 904 | Manually push errors to the changeset. 905 | 906 | ```js 907 | changeset.pushErrors( 908 | "age", 909 | "Too short", 910 | "Not a valid number", 911 | "Must be greater than 18", 912 | ); 913 | changeset.pushErrors( 914 | "dogYears.age", 915 | "Too short", 916 | "Not a valid number", 917 | "Must be greater than 2.5", 918 | ); 919 | ``` 920 | 921 | This is compatible with `ember-changeset-validations`, and allows you to either add a new error with multiple validation messages or push to an existing array of validation messages. 922 | 923 | **[⬆️ back to top](#api)** 924 | 925 | #### `removeError` 926 | 927 | Manually remove an error from the changeset. 928 | 929 | ```js 930 | changeset.removeError("email"); 931 | ``` 932 | 933 | Removes an error without having to rollback the property. 934 | 935 | **[⬆️ back to top](#api)** 936 | 937 | #### `removeErrors` 938 | 939 | Manually remove an error from the changeset. 940 | 941 | ```js 942 | changeset.removeErrors(); 943 | ``` 944 | 945 | Removes all the errors without having to rollback properties. 946 | 947 | **[⬆️ back to top](#api)** 948 | 949 | #### `snapshot` 950 | 951 | Creates a snapshot of the changeset's errors and changes. This can be used to `restore` the changeset at a later time. 952 | 953 | ```js 954 | let snapshot = changeset.snapshot(); // snapshot 955 | ``` 956 | 957 | **[⬆️ back to top](#api)** 958 | 959 | #### `restore` 960 | 961 | Restores a snapshot of changes and errors to the changeset. This overrides existing changes and errors. 962 | 963 | ```js 964 | let user = { name: "Adam", address: { country: "United States" } }; 965 | let changeset = Changeset(user, validatorFn); 966 | 967 | changeset.set("name", "Jim Bob"); 968 | changeset.set("address.country", "North Korea"); 969 | let snapshot = changeset.snapshot(); 970 | 971 | changeset.set("name", "Poteto"); 972 | changeset.set("address.country", "Australia"); 973 | 974 | changeset.restore(snapshot); 975 | changeset.get("name"); // "Jim Bob" 976 | changeset.get("address.country"); // "North Korea" 977 | ``` 978 | 979 | **[⬆️ back to top](#api)** 980 | 981 | #### `cast` 982 | 983 | Unlike `Ecto.Changeset.cast`, `cast` will take an array of allowed keys and remove unwanted keys off of the changeset. 984 | 985 | ```js 986 | let allowed = ["name", "password", "address.country"]; 987 | let changeset = Changeset(user, validatorFn); 988 | 989 | changeset.set("name", "Jim Bob"); 990 | changeset.set("address.country", "United States"); 991 | 992 | changeset.set("unwantedProp", "foo"); 993 | changeset.set("address.unwantedProp", 123); 994 | changeset.get("unwantedProp"); // "foo" 995 | changeset.get("address.unwantedProp"); // 123 996 | 997 | changeset.cast(allowed); // returns changeset 998 | changeset.get("unwantedProp"); // undefined 999 | changeset.get("address.country"); // "United States" 1000 | changeset.get("another.unwantedProp"); // undefined 1001 | ``` 1002 | 1003 | For example, this method can be used to only allow specified changes through prior to saving. 1004 | This is especially useful if you also setup a `schema` object for your model (using Ember Data), which can then be exported and used as a list of allowed keys: 1005 | 1006 | ```js 1007 | // models/user.js 1008 | export const schema = { 1009 | name: attr("string"), 1010 | password: attr("string"), 1011 | }; 1012 | 1013 | export default Model.extend(schema); 1014 | ``` 1015 | 1016 | ```js 1017 | // controllers/foo.js 1018 | import { action } from '@ember/object'; 1019 | import { schema } from '../models/user'; 1020 | const { keys } = Object; 1021 | 1022 | export default FooController extends Controller { 1023 | // ... 1024 | 1025 | @action 1026 | save(changeset) { 1027 | return changeset.cast(keys(schema)).save(); 1028 | } 1029 | } 1030 | ``` 1031 | 1032 | **[⬆️ back to top](#api)** 1033 | 1034 | #### `isValidating` 1035 | 1036 | Checks to see if async validator for a given key has not resolved. If no key is provided it will check to see if any async validator is running. 1037 | 1038 | ```js 1039 | changeset.set("lastName", "Appleseed"); 1040 | changeset.set("firstName", "Johnny"); 1041 | changeset.set("address.city", "Anchorage"); 1042 | changeset.validate(); 1043 | 1044 | changeset.isValidating(); // true if any async validation is still running 1045 | changeset.isValidating("lastName"); // true if lastName validation is async and still running 1046 | changeset.isValidating("address.city"); // true if address.city validation is async and still running 1047 | 1048 | changeset.validate().then(() => { 1049 | changeset.isValidating(); // false since validations are complete 1050 | }); 1051 | ``` 1052 | 1053 | **[⬆️ back to top](#api)** 1054 | 1055 | #### `beforeValidation` 1056 | 1057 | This event is triggered after isValidating is set to true for a key, but before the validation is complete. 1058 | 1059 | ```js 1060 | changeset.on("beforeValidation", (key) => { 1061 | console.log(`${key} is validating...`); 1062 | }); 1063 | changeset.validate(); 1064 | changeset.isValidating(); // true 1065 | // console output: lastName is validating... 1066 | // console output: address.city is validating... 1067 | ``` 1068 | 1069 | **[⬆️ back to top](#api)** 1070 | 1071 | #### `afterValidation` 1072 | 1073 | This event is triggered after async validations are complete and isValidating is set to false for a key. 1074 | 1075 | ```js 1076 | changeset.on("afterValidation", (key) => { 1077 | console.log(`${key} has completed validating`); 1078 | }); 1079 | changeset.validate().then(() => { 1080 | changeset.isValidating(); // false 1081 | // console output: lastName has completed validating 1082 | // console output: address.city has completed validating 1083 | }); 1084 | ``` 1085 | 1086 | **[⬆️ back to top](#api)** 1087 | 1088 | #### `afterRollback` 1089 | 1090 | This event is triggered after a rollback of the changeset. 1091 | This can be used for [some advanced use cases](https://github.com/offirgolan/ember-changeset-cp-validations/issues/25#issuecomment-375855834) 1092 | where it is necessary to separately track all changes that are made to the changeset. 1093 | 1094 | ```js 1095 | changeset.on("afterRollback", () => { 1096 | console.log("changeset has rolled back"); 1097 | }); 1098 | changeset.rollback(); 1099 | // console output: changeset has rolled back 1100 | ``` 1101 | 1102 | **[⬆️ back to top](#api)** 1103 | 1104 | ## Validation signature 1105 | 1106 | To use with your favorite validation library, you should create a custom `validator` action to be passed into the changeset: 1107 | 1108 | ```js 1109 | // application/controller.js 1110 | import Controller from "@ember/controller"; 1111 | import { action } from "@ember/object"; 1112 | 1113 | export default class FormController extends Controller { 1114 | @action 1115 | validate({ key, newValue, oldValue, changes, content }) { 1116 | // lookup a validator function on your favorite validation library 1117 | // should return a Boolean 1118 | } 1119 | } 1120 | ``` 1121 | 1122 | ```hbs 1123 | {{! application/template.hbs}} 1124 | 1125 | ``` 1126 | 1127 | Your action will receive a single POJO containing the `key`, `newValue`, `oldValue`, a one way reference to `changes`, and the original object `content`. 1128 | 1129 | ## Handling Server Errors 1130 | 1131 | When you run `changeset.save()`, under the hood this executes the changeset, and then runs the save method on your original content object, passing its return value back to you. You are then free to use this result to add additional errors to the changeset via the `addError` method, if applicable. 1132 | 1133 | For example, if you are using an Ember Data model in your route, saving the changeset will save the model. If the save rejects, Ember Data will add errors to the model for you. To copy the model errors over to your changeset, add a handler like this: 1134 | 1135 | ```js 1136 | changeset 1137 | .save() 1138 | .then(() => { 1139 | /* ... */ 1140 | }) 1141 | .catch(() => { 1142 | get(this, "model.errors").forEach(({ attribute, message }) => { 1143 | changeset.addError(attribute, message); 1144 | }); 1145 | }); 1146 | ``` 1147 | 1148 | ## Detecting Changesets 1149 | 1150 | If you're uncertain whether or not you're dealing with a `Changeset`, you can use the `isChangeset` util. 1151 | 1152 | ```js 1153 | import { isChangeset } from "validated-changeset"; 1154 | 1155 | if (isChangeset(model)) { 1156 | model.execute(); 1157 | // other changeset-specific code... 1158 | } 1159 | ``` 1160 | 1161 | ## Plugins 1162 | 1163 | - [`ember-changeset-validations`](https://github.com/adopted-ember-addons/ember-changeset-validations) - Pure, functional validations without CPs or Observers 1164 | - [`ember-changeset-cp-validations`](https://github.com/offirgolan/ember-changeset-cp-validations) - Work with `ember-cp-validations` 1165 | - [`ember-changeset-hofs`](https://github.com/nucleartide/ember-changeset-hofs) - Higher-order validation functions 1166 | - [`ember-bootstrap-changeset-validations`](https://github.com/kaliber5/ember-bootstrap-changeset-validations) - Adds support for changeset validations to `ember-bootstrap` 1167 | 1168 | ## Tips and Tricks 1169 | 1170 | - General Input Helper with ember-concurrency 1171 | 1172 | ```js 1173 | export default Component.extend({ 1174 | classNameBindings: ["hasError:validated-input--error"], 1175 | 1176 | _checkValidity: task(function* (changeset, valuePath, value) { 1177 | yield timeout(150); 1178 | 1179 | let snapshot = changeset.snapshot(); 1180 | 1181 | // valuePath is the property on the changeset, e.g. firstName 1182 | changeset.set(valuePath, value); 1183 | 1184 | if (!changeset.get(`error.${valuePath}`)) { 1185 | set(this, "hasError", false); 1186 | } else { 1187 | // if error, restore changeset so don't show error in template immediately' 1188 | // i.e. wait for onblur action to validate and show error in template 1189 | changeset.restore(snapshot); 1190 | } 1191 | }).restartable(), 1192 | 1193 | actions: { 1194 | /** 1195 | * @method validateProperty 1196 | * @param {Object} changeset 1197 | * @param {String} valuePath 1198 | * @param {Object} e 1199 | */ 1200 | validateProperty(changeset, valuePath, e) { 1201 | changeset.set(valuePath, e.target.value); 1202 | 1203 | if (changeset.get(`error.${valuePath}`)) { 1204 | set(this, "hasError", true); 1205 | } else { 1206 | set(this, "hasError", false); 1207 | } 1208 | }, 1209 | 1210 | /** 1211 | * @method checkValidity 1212 | * @param {Object} changeset 1213 | * @param {Event} e 1214 | */ 1215 | checkValidity(changeset, e) { 1216 | get(this, "_checkValidity").perform( 1217 | changeset, 1218 | this.valuePath, 1219 | e.target.value, 1220 | ); 1221 | }, 1222 | }, 1223 | }); 1224 | ``` 1225 | 1226 | ```hbs 1227 | 1235 | ``` 1236 | 1237 | ## Contributors 1238 | 1239 | We're grateful to these wonderful contributors who've contributed to `ember-changeset`: 1240 | 1241 | [//]: contributor-faces 1242 | 1243 | 1244 | 1245 | 1246 | 1247 | 1248 | 1249 | 1250 | 1251 | 1252 | 1253 | 1254 | 1255 | 1256 | 1257 | 1258 | 1259 | 1260 | 1261 | 1262 | 1263 | 1264 | 1265 | 1266 | 1267 | 1268 | 1269 | 1270 | 1271 | 1272 | 1273 | [//]: contributor-faces 1274 | 1275 | ## Contributing 1276 | 1277 | See the [Contributing](CONTRIBUTING.md) guide for details. 1278 | 1279 | ## License 1280 | 1281 | This project is licensed under the [MIT License](LICENSE.md). 1282 | --------------------------------------------------------------------------------