Skip to content

Commit

Permalink
feat: validated changeset (#649)
Browse files Browse the repository at this point in the history
* feat: validated changeset

* fix bug

* align execute with format of changes

* bump validated-changeset

* add validated form test

* Update README with yup example

* prettier

* update readme

* update README
  • Loading branch information
snewcomer authored Apr 18, 2022
1 parent aac0b2c commit 538b68a
Show file tree
Hide file tree
Showing 18 changed files with 2,290 additions and 212 deletions.
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,68 @@ Other available types include the following. Please put in a PR if you need more
import { ValidationResult, ValidatorMapFunc, ValidatorAction } from 'ember-changeset/types';
```

## Alternative Changeset

Enabled in 4.1.0

We now ship a ValidatedChangeset that is a proposed new API we would like to introduce and see if it jives with users. The goal of this new feature is to remove confusing APIs and externalize validations.

- ✂️ `save`
- ✂️ `cast`
- ✂️ `merge`
- `errors` are required to be added to the Changeset manually after `validate`
- `validate` takes a callback with the sum of changes. In user land you will call `changeset.validate((changes) => yupSchema.validate(changes))`

```js
import Component from '@glimmer/component';
import { ValidatedChangeset } from 'ember-changeset';
import { action, get } from '@ember/object';
import { object, string } from 'yup';

class Foo {
user = {
name: 'someone',
email: '[email protected]',
};
}

const FormSchema = object({
cid: string().required(),
user: object({
name: string().required(),
email: string().email(),
})
});

export default class ValidatedForm extends Component {
constructor() {
super(...arguments);

this.model = new Foo();
this.changeset = ValidatedChangeset(this.model);
}

@action
async setChangesetProperty(path, evt) {
this.changeset.set(path, evt.target.value);
try {
await this.changeset.validate((changes) => FormSchema.validate(changes));
this.changeset.removeError(path);
await this.model.save();
} catch (e) {
this.changeset.addError(e.path, { value: this.changeset.get(e.path), validation: e.message });
}
}

@action
async submitForm(changeset, event) {
event.preventDefault();

changeset.execute();
}
}
```

## API

- Properties
Expand Down
3 changes: 3 additions & 0 deletions addon/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { assert } from '@ember/debug';
import { dependentKeyCompat } from '@ember/object/compat';
import { BufferedChangeset } from 'validated-changeset';
import { Changeset as ValidatedChangeset } from './validated-changeset';
import ArrayProxy from '@ember/array/proxy';
import ObjectProxy from '@ember/object/proxy';
import { notifyPropertyChange } from '@ember/object';
Expand All @@ -10,6 +11,8 @@ import { tracked } from '@glimmer/tracking';
import { get as safeGet, set as safeSet } from '@ember/object';
import { macroCondition, dependencySatisfies, importSync } from '@embroider/macros';

export { ValidatedChangeset };

const CHANGES = '_changes';
const PREVIOUS_CONTENT = '_previousContent';
const CONTENT = '_content';
Expand Down
229 changes: 229 additions & 0 deletions addon/validated-changeset.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { assert } from '@ember/debug';
import { dependentKeyCompat } from '@ember/object/compat';
import { ValidationChangeset, getKeyValues } from 'validated-changeset';
import ArrayProxy from '@ember/array/proxy';
import ObjectProxy from '@ember/object/proxy';
import { notifyPropertyChange } from '@ember/object';
import mergeDeep from './utils/merge-deep';
import isObject from './utils/is-object';
import { tracked } from '@glimmer/tracking';
import { get as safeGet, set as safeSet } from '@ember/object';
import { macroCondition, dependencySatisfies, importSync } from '@embroider/macros';

const CHANGES = '_changes';
const PREVIOUS_CONTENT = '_previousContent';
const CONTENT = '_content';

export function buildOldValues(content, changes, getDeep) {
const obj = Object.create(null);

for (let change of changes) {
obj[change.key] = getDeep(content, change.key);
}

return obj;
}

function isProxy(o) {
return !!(o && (o instanceof ObjectProxy || o instanceof ArrayProxy));
}

function maybeUnwrapProxy(o) {
return isProxy(o) ? maybeUnwrapProxy(safeGet(o, 'content')) : o;
}

let Model;
if (macroCondition(dependencySatisfies('ember-data', '*'))) {
Model = importSync('@ember-data/model').default;
}

export class EmberValidationChangeset extends ValidationChangeset {
@tracked _changes;
@tracked _errors;
@tracked _content;

isObject = isObject;

maybeUnwrapProxy = maybeUnwrapProxy;

// DO NOT override setDeep. Ember.set does not work wth empty hash and nested
// key Ember.set({}, 'user.name', 'foo');
// override base class
// DO NOT override setDeep. Ember.set does not work with Ember.set({}, 'user.name', 'foo');
getDeep = safeGet;
mergeDeep = mergeDeep;

safeGet(obj, key) {
if (Model && obj.relationshipFor?.(key)?.meta?.kind == 'belongsTo') {
return obj.belongsTo(key).value();
}
return safeGet(obj, key);
}
safeSet(obj, key, value) {
return safeSet(obj, key, value);
}

/**
* @property isValid
* @type {Array}
*/
@dependentKeyCompat
get isValid() {
return super.isValid;
}

/**
* @property isInvalid
* @type {Boolean}
*/
@dependentKeyCompat
get isInvalid() {
return super.isInvalid;
}

/**
* @property isPristine
* @type {Boolean}
*/
@dependentKeyCompat
get isPristine() {
return super.isPristine;
}

/**
* @property isDirty
* @type {Boolean}
*/
@dependentKeyCompat
get isDirty() {
return super.isDirty;
}

get pendingData() {
let content = this[CONTENT];
let changes = this[CHANGES];

let pendingChanges = this.mergeDeep(Object.create(Object.getPrototypeOf(content)), content, { safeGet, safeSet });

return this.mergeDeep(pendingChanges, changes, { safeGet, safeSet });
}

/**
* Manually add an error to the changeset. If there is an existing
* error or change for `key`, it will be overwritten.
*
* @method addError
*/
addError(key, error) {
super.addError(key, error);

notifyPropertyChange(this, key);
// Return passed-in `error`.
return error;
}

/**
* Manually push multiple errors to the changeset as an array.
*
* @method pushErrors
*/
pushErrors(key, ...newErrors) {
const { value, validation } = super.pushErrors(key, ...newErrors);

notifyPropertyChange(this, key);

return { value, validation };
}

/**
* Sets property or error on the changeset.
* Returns value or error
*/
_setProperty({ key, value, oldValue }) {
super._setProperty({ key, value, oldValue });

notifyPropertyChange(this, key);
}

/**
* Notifies virtual properties set on the changeset of a change.
* You can specify which keys are notified by passing in an array.
*
* @private
* @param {Array} keys
* @return {Void}
*/
_notifyVirtualProperties(keys) {
keys = super._notifyVirtualProperties(keys);

(keys || []).forEach((key) => notifyPropertyChange(this, key));

return;
}

/**
* Deletes a key off an object and notifies observers.
*/
_deleteKey(objName, key = '') {
const result = super._deleteKey(objName, key);

notifyPropertyChange(this, key);

return result;
}

/**
* Executes the changeset if in a valid state.
*
* @method execute
*/
execute() {
let oldContent;
if (this.isValid && this.isDirty) {
let content = this[CONTENT];
let changes = this[CHANGES];

// keep old values in case of error and we want to rollback
oldContent = buildOldValues(content, getKeyValues(changes), this.getDeep);

// we want mutation on original object
// @tracked
this[CONTENT] = this.mergeDeep(content, changes, { safeGet, safeSet });
}

this[PREVIOUS_CONTENT] = oldContent;

return this;
}
}

/**
* Creates new changesets.
*/
export function changeset(obj) {
assert('Underlying object for changeset is missing', Boolean(obj));
assert('Array is not a valid type to pass as the first argument to `changeset`', !Array.isArray(obj));

const c = new EmberValidationChangeset(obj);
return c;
}

/**
* Creates new changesets.
* @function Changeset
*/
export function Changeset(obj) {
const c = changeset(obj);

return new Proxy(c, {
get(targetBuffer, key /*, receiver*/) {
const res = targetBuffer.get(key.toString());
return res;
},

set(targetBuffer, key, value /*, receiver*/) {
targetBuffer.set(key.toString(), value);
return true;
},
});
}
Loading

0 comments on commit 538b68a

Please sign in to comment.