json-modifiable
Advanced tools
Comparing version 1.2.0 to 2.0.0
@@ -1,2 +0,2 @@ | ||
var createJSONModifiable=function(){"use strict";const e=/~[01]/g,r=e=>"~1"===e?"/":"~",t=t=>{try{return(t=>e.test(t)?t.replace(e,r):t)(t).split("/").slice(1)}catch{throw new Error(`Invalid JSON Pointer ${t}`)}},o=e=>Array.isArray(e)?[...e]:e&&"object"==typeof e?{...e}:e,n=e=>e<1/0,s=(e,r)=>{const s=t(r),a=s.pop(),c=o(e),i=s.reduce(((e,r,t)=>{if(void 0===e[r]){const o=s[t+1]||a;return e[r]=n(o)||"-"===o?[]:{}}return e[r]=o(e[r])}),c);return{last:a,next:c,lastObject:i}},a=(e,r,t)=>{const{last:o,next:a,lastObject:c}=s(e,r);return c[o]===t?e:(n(o)&&Array.isArray(c)?c.splice(o,0,t):"-"===o?c.push(t):c[o]=t,a)},c=(e,r)=>t(r).reduce(((e={},r)=>e[r]),e),i=(e,r)=>{const{last:t,next:o,lastObject:n}=s(e,r);return"-"===t&&n.pop(),void 0===n[t]?e:(Array.isArray(n)?n.splice(t,1):delete n[t],o)},u=(e,{op:r,from:t,path:o,value:n})=>{switch(r){case"replace":case"add":return a(e,o,n);case"remove":return i(e,o);case"copy":if(!t)throw new Error("from is required for copy operation");return a(e,o,c(e,t));case"move":if(!t)throw new Error("from is required for move operation");return a(i(e,t),o,c(e,t));case"test":{const r=JSON.stringify(c(e,o));if(r!==JSON.stringify(n))throw new Error(`test operation error - test of ${o} for ${n} failed - received ${r}`);return e}default:throw new Error(`Operation ${r} not supported`)}};var l={resolver:c,patch:(e,r)=>{try{return r.reduce(u,e)}catch(r){if(/test operation error/.test(r.message))return e;throw r}}};function p(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var f={exports:{}};!function(e,r){Object.defineProperty(r,"__esModule",{value:!0}),r.default=void 0;const t=/\{\{\s*(.+?)\s*\}\}/g,o=(e,r)=>e[r],n=e=>e.join("."),s=(e,r,a={},c=[""])=>{if(!e||null===(null==r?void 0:r.pattern)||"number"==typeof e||"boolean"==typeof e)return()=>e;const i=(null==r?void 0:r.resolver)||o;let u;const l=n(c),p=a[l]||(a[l]=[]),f=e=>void 0===u||p.some((([r,t])=>i(e,r)!==t))?(p.splice(0,p.length),!1):u;if("string"==typeof e)return s=>f(s)||((e,r,n={},s)=>{let a,c;const{pattern:i=t,resolver:u=o}=n,l=e.replace(i,((t,o)=>(a=t===e,c=u(r,o),s(o,c),a?"":c)));return a?c:l})(e,s,r,((...e)=>{((e,r)=>{r(e.reduce(((e,t)=>(r(e),n([e,t])))))})(c,(r=>a[r].push(...e)))}));if(Array.isArray(e)){const t=e.map(((e,t)=>s(e,r,a,[...c,t])));return e=>f(e)||(u=t.reduce(((r,t,o)=>{const n=t(e);return n===r[o]?r:[...r,n]}),u||[]))}const d=Object.entries(e).reduce(((e,[t,o])=>({...e,[t]:s(o,r,a,[...c,t])})),{});return e=>f(e)||(u=Object.entries(d).reduce(((r,[t,o])=>{const n=o(e);return n===r[t]?r:{...r,[t]:n}}),u||{}))};var a=s;r.default=a,e.exports=r.default}(f,f.exports);var d=p(f.exports);const v=(e,r)=>{if(e.size!==r.size)return!1;for(const t of e)if(!r.has(t))return!1;return!0},h=(e,r)=>{const t=Object.keys(e).map((e=>t=>r.resolver(t,e)));let o,n,s=[];return e=d(e,r),a=>{const c=e(a),i=()=>n=((e,r,{validator:t,resolver:o})=>Object.entries(e).every((([e,n])=>t(n,o(r,e)))))(c,a,r);if(c!==o)return o=c,s=t.map((e=>e(a))),i();const u=t.map((e=>e(a)));return p=s,(l=u).length===p.length&&l.every(((e,r)=>p[r]===e))?n:(s=u,i());var l,p}},y=(e,r)=>(e=e.map((e=>(({when:e,then:r,otherwise:t},o)=>(e=e.map((e=>h(e,o))),r=d(r,o),t=d(t,o),o=>(e.some((e=>e(o)))?r:t)(o)))(e,r))),r=>e.map((e=>e(r))));return(e,r=[],{context:t,...o}={},n=new Map,s)=>{if(!(o={...l,...o}).validator)throw new Error("A validator is required");r=y(r,o),s=e;const a=new Map,c=(e,r)=>{const t=n.get(e);t&&t.forEach((e=>e(r)))},i=()=>{const n=r(t,o),i=new Set(n);var u,l;u=(e=>{for(const[r,t]of a)if(v(r,e))return t})(i)||(r=>r.reduce(((e,r)=>{try{return o.patch(e,r)}catch(r){return c("error",{type:"PatchError",err:r}),e}}),e))(n),l=i,s!==u&&(a.set(l,u),c("modified",s=u))};i();const u=(e,r)=>{let t=n.get(e);return t?t.add(r):n.set(e,t=new Set([r])),()=>n.get(e).delete(r)};return{on:u,subscribe:e=>u("modified",e),get:()=>s,set:r=>e===r||i(e=r,a.clear()),setRules:e=>i(r=y(r,o)),setContext:e=>i(t=e)}}}(); | ||
var jsonModifiable=function(e){"use strict";function r(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var t={exports:{}};!function(e,r){Object.defineProperty(r,"__esModule",{value:!0}),r.default=void 0;const t=/\{\{\s*(.+?)\s*\}\}/g,o=(e,r)=>e[r],n=(e,r=".")=>e.join(r),s=(e,r={},i={},a=[""])=>{const c=n(a,r.delimiter);if(!e||null===r.pattern||"number"==typeof e||"boolean"==typeof e||r.skip&&r.skip.test(c))return()=>e;const u=r.resolver||o;let l;const p=i[c]||(i[c]=[]),f=e=>void 0===l||p.some((([r,t])=>u(e,r)!==t))?(p.splice(0,p.length),!1):l;if("string"==typeof e)return s=>f(s)||((e,r,n,s)=>{let i,a;const{pattern:c=t,resolver:u=o}=n,l=e.replace(c,((t,o)=>(i=t===e,a=u(r,o),s(o,a),i?"":a)));return i?a:l})(e,s,r,((...e)=>{((e,r)=>{r(e.reduce(((e,t)=>(r(e),n([e,t])))))})(a,(r=>i[r].push(...e)))}));if(Array.isArray(e)){const t=e.map(((e,t)=>s(e,r,i,[...a,t])));return e=>f(e)||(l=t.reduce(((r,t,o)=>{const n=t(e);return n===r[o]?r:[...r,n]}),l||[]))}const d=Object.entries(e).reduce(((e,[t,o])=>({...e,[t]:s(o,r,i,[...a,t])})),{});return e=>f(e)||(l=Object.entries(d).reduce(((r,[t,o])=>{const n=o(e);return n===r[t]?r:{...r,[t]:n}}),l||{}))};var i=s;r.default=i,e.exports=r.default}(t,t.exports);var o=r(t.exports);const n=(e,r)=>{if(e.size!==r.size)return!1;for(const t of e)if(!r.has(t))return!1;return!0},s=(e,r)=>{const t=Object.keys(e).map((e=>t=>r.resolver(t,e)));let n,s,i=[];return e=o(e,r),o=>{let a;const c=()=>a||(a=t.map((e=>e(o)))),u=e(o);return u===n&&(l=c(),p=i,l.length===p.length&&l.every(((e,r)=>p[r]===e)))?s:(n=u,i=a||c(),s=((e,r,{validator:t,resolver:o})=>Object.entries(e).every((([e,n])=>t(n,o(r,e)))))(u,o,r));var l,p}},i=(e,r)=>(e=e.map((e=>(({when:e,then:r,otherwise:t,options:n={}},i)=>{const a={...i,...n};return e=e.map((e=>s(e,a))),r=o(r,a),t=o(t,a),o=>(e.some((e=>e(o)))?r:t)(o)})(e,r))),r=>e.map((e=>e(r)))),a=(e,r)=>e[r],c=(...e)=>Object.assign({},...e),u=(e,r,t=[],{context:o={},...s}={},u=new Map,l)=>{if(s={resolver:a,patch:c,...s},!r)throw new Error("A validator is required");if(!s.patch)throw new Error("A patch function is required");if(!s.resolver)throw new Error("A resolver function is required");t=i(t,{...s,validator:r}),l=e;const p=(e,r)=>{var t;return null!==(t=u.get(e))&&void 0!==t&&t.forEach((e=>e(r))),r},f=(e,r)=>{const t=u.get(e)||new Set;return u.set(e,t),t.add(r),()=>t.delete(r)},d=new Map,v=(r,i)=>{if(!i&&r===o)return;const a=t(o=r),c=new Set(a),u=(e=>{for(const[r,t]of d)if(n(r,e))return t})(c)||(r=>r.reduce(((e,r)=>{try{return s.patch(e,r)}catch(r){return p("error",{type:"PatchError",err:r}),e}}),e))(a);l===u||d.set(c,p("modified",l=u))};return v(o,!0),{on:f,subscribe:e=>f("modified",e),get:()=>l,setContext:v}},l=/~[01]/g,p=e=>"~1"===e?"/":"~",f=e=>l.test(e)?e.replace(l,p):e,d=e=>{try{return e.split("/").map(f).slice(1)}catch{throw new Error(`Invalid JSON Pointer ${e}`)}},v=e=>Array.isArray(e)?[...e]:e&&"object"==typeof e?{...e}:e,h=e=>e<1/0,y=(e,r)=>{const t=d(r),o=t.pop(),n=v(e),s=t.reduce(((e,r,n)=>{if(void 0===e[r]){const s=t[n+1]||o;return e[r]=h(s)||"-"===s?[]:{}}return e[r]=v(e[r])}),n);return{last:o,next:n,lastObject:s}},w=(e,r,t)=>{const{last:o,next:n,lastObject:s}=y(e,r);return s[o]===t?e:(h(o)&&Array.isArray(s)?s.splice(o,0,t):"-"===o?s.push(t):s[o]=t,n)},m=(e,r)=>d(r).reduce(((e={},r)=>e[r]),e),b=(e,r)=>{const{last:t,next:o,lastObject:n}=y(e,r);return"-"===t&&n.pop(),void 0===n[t]?e:(Array.isArray(n)?n.splice(t,1):delete n[t],o)},O=(e,{op:r,from:t,path:o,value:n})=>{switch(r){case"replace":case"add":return w(e,o,n);case"remove":return b(e,o);case"copy":if(!t)throw new Error("from is required for copy operation");return w(e,o,m(e,t));case"move":if(!t)throw new Error("from is required for move operation");return w(b(e,t),o,m(e,t));case"test":{const r=JSON.stringify(m(e,o));if(r!==JSON.stringify(n))throw new Error(`test operation error - test of ${o} for ${n} failed - received ${r}`);return e}default:throw new Error(`Operation ${r} not supported`)}},g=(e,r)=>{try{return r.reduce(O,e)}catch(r){if(/test operation error/.test(r.message))return e;throw r}};return e.engine=u,e.jsonEngine=(e,r,t,o={})=>u(e,r,t,{...o,patch:g,resolver:m}),Object.defineProperty(e,"__esModule",{value:!0}),e}({}); | ||
//# sourceMappingURL=bundle.min.js.map |
@@ -1,52 +0,2 @@ | ||
import { JSONPatchOperation } from './patch'; | ||
type Unsubscribe = () => void; | ||
type Subscriber<T> = (arg: T) => void; | ||
type Validator = (schema: any, subject: any) => boolean; | ||
type Resolver = (object: Record<string, unknown>, path: string) => any; | ||
type ErrorEvent = { | ||
type: 'ValidationError' | 'PatchError'; | ||
err: Error; | ||
}; | ||
interface JSONModifiable<T, C, Op> { | ||
get: () => T; | ||
set: (descriptor: T) => void; | ||
setRules: (rules: Rule<Op>[]) => void; | ||
setContext: (context: C) => void; | ||
subscribe: (subscriber: Subscriber<T>) => Unsubscribe; | ||
on: (event: 'modified', subscriber: Subscriber<T>) => Unsubscribe; | ||
on: (event: 'error', subscriber: Subscriber<ErrorEvent>) => Unsubscribe; | ||
} | ||
type Condition = { | ||
[key: string]: Record<string, unknown>; | ||
}; | ||
type Operation = unknown; | ||
type Rule<Op> = { | ||
when: Condition[]; | ||
then?: Op[]; | ||
otherwise?: Op[]; | ||
}; | ||
type Options<T, C, Op> = { | ||
validator: Validator; | ||
context?: C; | ||
pattern?: RegExp | null; | ||
resolver?: Resolver; | ||
patch?: (operations: Op[], record: T) => T; | ||
}; | ||
export default function createJSONModifiable< | ||
T, | ||
C = Record<string, unknown>, | ||
Op = JSONPatchOperation, | ||
>( | ||
descriptor: T, | ||
rules: Rule<Op>[], | ||
options: Options<T, C, Op>, | ||
): JSONModifiable<T, C, Op>; | ||
export * from './engine'; | ||
export * from './json'; |
@@ -6,78 +6,17 @@ "use strict"; | ||
}); | ||
exports.default = void 0; | ||
Object.defineProperty(exports, "engine", { | ||
enumerable: true, | ||
get: function () { | ||
return _engine.engine; | ||
} | ||
}); | ||
Object.defineProperty(exports, "jsonEngine", { | ||
enumerable: true, | ||
get: function () { | ||
return _json.jsonEngine; | ||
} | ||
}); | ||
var _options = _interopRequireDefault(require("./options")); | ||
var _engine = require("./engine"); | ||
var _rule = require("./rule"); | ||
var _utils = require("./utils"); | ||
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } | ||
var _default = (descriptor, rules = [], { | ||
context, | ||
...opts | ||
} = {}, subscribers = new Map(), modified) => { | ||
opts = { ..._options.default, | ||
...opts | ||
}; | ||
if (!opts.validator) throw new Error(`A validator is required`); | ||
rules = (0, _rule.createStatefulRules)(rules, opts); | ||
modified = descriptor; | ||
const cache = new Map(); | ||
const emit = (eventType, thing) => { | ||
const set = subscribers.get(eventType); | ||
set && set.forEach(s => s(thing)); | ||
}; | ||
const evaluate = ops => ops.reduce((acc, ops) => { | ||
try { | ||
return opts.patch(acc, ops); | ||
} catch (err) { | ||
emit('error', { | ||
type: 'PatchError', | ||
err | ||
}); | ||
return acc; | ||
} | ||
}, descriptor); | ||
const getCached = ops => { | ||
for (const [key, value] of cache) if ((0, _utils.compareSets)(key, ops)) return value; | ||
}; | ||
const notify = (next, key) => { | ||
if (modified === next) return; | ||
cache.set(key, next); | ||
modified = next; | ||
emit('modified', modified); | ||
}; | ||
const run = () => { | ||
const rulesToApply = rules(context, opts); | ||
const ops = new Set(rulesToApply); | ||
notify(getCached(ops) || evaluate(rulesToApply), ops); | ||
}; // run immediately | ||
run(); | ||
const on = (eventType, subscriber) => { | ||
let m = subscribers.get(eventType); | ||
m ? m.add(subscriber) : subscribers.set(eventType, m = new Set([subscriber])); | ||
return () => subscribers.get(eventType).delete(subscriber); | ||
}; | ||
return { | ||
on, | ||
subscribe: subscriber => on('modified', subscriber), | ||
get: () => modified, | ||
set: d => descriptor === d || run(descriptor = d, cache.clear()), | ||
setRules: r => run(rules = (0, _rule.createStatefulRules)(rules, opts)), | ||
setContext: ctx => run(context = ctx) | ||
}; | ||
}; | ||
exports.default = _default; | ||
module.exports = exports.default; | ||
var _json = require("./json"); |
@@ -6,3 +6,3 @@ "use strict"; | ||
}); | ||
exports.unset = exports.get = exports.set = void 0; | ||
exports.unset = exports.get = exports.set = exports.compile = void 0; | ||
const tilde = /~[01]/g; | ||
@@ -16,3 +16,3 @@ | ||
try { | ||
return decodePointer(pointer).split('/').slice(1); | ||
return pointer.split('/').map(decodePointer).slice(1); | ||
} catch { | ||
@@ -23,2 +23,4 @@ throw new Error(`Invalid JSON Pointer ${pointer}`); | ||
exports.compile = compile; | ||
const shallowClone = thing => Array.isArray(thing) ? [...thing] : thing && typeof thing === 'object' ? { ...thing | ||
@@ -25,0 +27,0 @@ } : thing; |
@@ -14,12 +14,6 @@ "use strict"; | ||
// a stateful rule only tries to be evaluated if dependencies have changed since it was last ran? | ||
// can we have an unlimited cache for this rule? | ||
// [reference of rule]: [dependencies,value] | ||
// unlimited caching so we don't have to re-evaluate? | ||
const evalMap = (map, context, { | ||
validator, | ||
resolver | ||
}) => Object.entries(map).every(([key, schema]) => { | ||
return validator(schema, resolver(context, key)); | ||
}); | ||
}) => Object.entries(map).every(([key, schema]) => validator(schema, resolver(context, key))); | ||
@@ -33,16 +27,15 @@ const createStatefulMap = (when, options) => { | ||
return context => { | ||
let nextDeps; | ||
const getNextDeps = () => nextDeps || (nextDeps = deps.map(d => d(context))); | ||
const nextWhen = when(context); | ||
const run = () => last = evalMap(nextWhen, context, options); | ||
if (nextWhen !== lastWhen) { | ||
if (nextWhen !== lastWhen || !(0, _utils.areArraysEqual)(getNextDeps(), lastDeps)) { | ||
lastWhen = nextWhen; | ||
lastDeps = deps.map(d => d(context)); | ||
return run(); | ||
lastDeps = nextDeps || getNextDeps(); | ||
return last = evalMap(nextWhen, context, options); | ||
} | ||
const nextDeps = deps.map(d => d(context)); | ||
if ((0, _utils.areArraysEqual)(nextDeps, lastDeps)) return last; | ||
lastDeps = nextDeps; | ||
return run(); | ||
return last; | ||
}; | ||
@@ -54,7 +47,11 @@ }; | ||
then, | ||
otherwise | ||
otherwise, | ||
options: ruleOpts = {} | ||
}, options) => { | ||
when = when.map(w => createStatefulMap(w, options)); | ||
then = (0, _interpolatable.default)(then, options); | ||
otherwise = (0, _interpolatable.default)(otherwise, options); | ||
const interpolatableOptions = { ...options, | ||
...ruleOpts | ||
}; | ||
when = when.map(w => createStatefulMap(w, interpolatableOptions)); | ||
then = (0, _interpolatable.default)(then, interpolatableOptions); | ||
otherwise = (0, _interpolatable.default)(otherwise, interpolatableOptions); | ||
return context => (when.some(w => w(context)) ? then : otherwise)(context); | ||
@@ -61,0 +58,0 @@ }; |
{ | ||
"name": "json-modifiable", | ||
"version": "1.2.0", | ||
"version": "2.0.0", | ||
"description": "A rules engine that dynamically modifies your objects using JSON standards", | ||
"main": "build/index.js", | ||
"browser": "build/bundle.min.js", | ||
"main": "build/bundle.min.js", | ||
"module": "build/index.js", | ||
"types": "build/index.d.ts", | ||
"author": "Adam Jenkins", | ||
"sideEffects": false, | ||
"license": "MIT", | ||
@@ -43,3 +44,2 @@ "repository": { | ||
"@types/jest": "^27.0.1", | ||
"@types/lodash": "^4.14.172", | ||
"@types/node": "^16.7.10", | ||
@@ -61,3 +61,7 @@ "@typescript-eslint/eslint-plugin": "^4.30.0", | ||
"jest": "^27.1.0", | ||
"json-pointer": "^0.6.1", | ||
"json-ptr": "^3.0.0", | ||
"jsonpointer": "^5.0.0", | ||
"prettier": "^2.3.2", | ||
"property-expr": "^2.0.4", | ||
"rimraf": "^3.0.2", | ||
@@ -72,4 +76,4 @@ "rollup": "^2.56.3", | ||
"dependencies": { | ||
"interpolatable": "^1.2.0" | ||
"interpolatable": "^1.3.2" | ||
} | ||
} |
518
README.md
@@ -8,8 +8,18 @@ # json-modifiable | ||
An incredibly tiny and configurable rules engine for applying arbitrary modifications to a descriptor based on context. Designed to work best with JSON standards ([json pointer](https://datatracker.ietf.org/doc/html/rfc6901), [json patch](http://jsonpatch.com/), and [json schema](https://json-schema.org/)) but can work with | ||
## What is this? | ||
1. [JSON Pointer](https://datatracker.ietf.org/doc/html/rfc6901) like-syntax - like [property-expr](https://www.npmjs.com/package/property-expr) or [lodash's get](https://lodash.com/docs/4.17.15#get) | ||
2. Schema validators like [joi](https://www.npmjs.com/package/joi) or [yup](https://www.npmjs.com/package/yup). | ||
3. A custom patch function that accepts a document and the instructions provided in your rules, so you can roll your own patch logic. | ||
An incredibly tiny and configurable rules engine for applying arbitrary modifications to a [descriptor](#descriptor) based on context. It's **highly configurable**, although you might find it easiest to write the [rules](#rules) using JSON standards -[json pointer](https://datatracker.ietf.org/doc/html/rfc6901), [json patch](http://jsonpatch.com/), and [json schema](https://json-schema.org/) | ||
## Why? | ||
Serializable logic that can be easily stored in a database and shared amongst multiple components of your application. | ||
## Features | ||
- Highly configurable - define your own JSON structures. This doc encourages your rules to be written using [json schema](https://json-schema.org/) but the [validator](#validator) allows you to write them however you choose. | ||
- Configurable and highly performant interpolations (using uses [interpolatable](https://github.com/akmjenkins/interpolatable)) to make highly reusable rules | ||
- Extremely lightweight (under 2kb minzipped) | ||
- Runs everywhere JavaScript does - Deno/Node/browsers | ||
## Installation | ||
@@ -28,13 +38,31 @@ | ||
<script> | ||
const descriptor = createJSONModifiable(...); | ||
const descriptor = jsonModifiable.engine(...) | ||
// or see JSON Engine | ||
const descriptor = jsonModifiable.jsonEngine(...) | ||
</script> | ||
``` | ||
## Usage | ||
## Concepts | ||
- [Descriptor](#descriptor) | ||
- [Context](#context) | ||
- [Validator](#validator) | ||
- [Rules](#rules) | ||
- [Conditions](#condition) | ||
- [Operation](#operation) | ||
- [Resolver](#resolver) | ||
- [Patch](#patch) | ||
- [Interpolation](#interpolation) | ||
### Descriptor | ||
```ts | ||
type Descriptor = Record<string,unknown> | ||
``` | ||
A descriptor is a plain-old JavaScript object (POJO) that should be "modified" - contain different properties/structures - in various [contexts](#context). The modifications are defined by [rules](#rules). | ||
```js | ||
import createJSONModifiable from 'json-modifiable'; | ||
const descriptor = createJSONModifiable( | ||
{ | ||
{ | ||
fieldId: 'lastName', | ||
@@ -47,124 +75,25 @@ path: 'user.lastName', | ||
hidden: true, | ||
validations: [['minLength', 2]], | ||
}, | ||
[ | ||
{ | ||
when: [ | ||
{ | ||
'/formData/firstName': { | ||
type: 'string', | ||
minLength: 1, | ||
}, | ||
}, | ||
], | ||
then: [ | ||
{ | ||
op: 'add', | ||
path: '/validations/-', | ||
value: 'required', | ||
}, | ||
], | ||
}, | ||
], | ||
{ validator }, | ||
); | ||
descriptor.get().validations.find((v) => v === 'required'); // not found | ||
descriptor.setContext({ formData: { firstName: 'fred' } }); | ||
descriptor.get().validations.find((v) => v === 'required'); // found! | ||
validations: [], | ||
} | ||
``` | ||
## What in the heck is this good for? | ||
### Context | ||
Definining easy to read and easy to apply business logic to things that need to behave differently in different contexts. One use case I've used this for is to quickly and easily perform complicated modifications to form field descriptors based on the state of the form (or some other current application context). | ||
```js | ||
const descriptor = { | ||
fieldId: 'lastName', | ||
path: 'user.lastName', | ||
label: 'Last Name', | ||
readOnly: false, | ||
placeholder: 'Enter Your First Name', | ||
type: 'text', | ||
hidden: true, | ||
validations: [['minLength', 2]], | ||
}; | ||
const rules = [ | ||
{ | ||
when: [ | ||
{ | ||
'/formData/firstName': { | ||
type: 'string', | ||
minLength: 1, | ||
}, | ||
}, | ||
], | ||
then: [ | ||
{ | ||
op: 'add', | ||
path: '/validations/-', | ||
value: 'required', | ||
}, | ||
{ | ||
op: 'replace', | ||
path: '/hidden', | ||
value: false, | ||
}, | ||
], | ||
}, | ||
]; | ||
```ts | ||
type Context = Record<string,unknown> | ||
``` | ||
This library internally has tiny implementations of [json patch](http://jsonpatch.com/) and [json pointer](https://datatracker.ietf.org/doc/html/rfc6901) that it uses as default options. It should be noted that the json pointer and json patch implementations can access/modify nested structures that don't currently exist in the descriptor **without throwing errors**. And the patch operations are a bit looser than the spec - `add` and `replace` are treated as synonyms and prescribed errors aren't thrown. Another very important difference with the embedded json-patch utility is that it **only patches the parts of the descriptor that are actually modified** - i.e. no `cloneDeep`. This allows it to work beautifully with libraries that rely (or make heavy use of) referential integrity/memoization (like React). | ||
Context is also a plain object. The context is used by the [validator](#validator) to evaluate the [conditions](#condition) of [rules](#rules) | ||
```js | ||
const DynamicFormField = ({ context }) => { | ||
const refDescriptor = useRef(createJSONModifiable(descriptor, rules, { context })) | ||
const [currentDescriptor, setCurrentDescriptor] = useState(descriptor.current.get()); | ||
useEffect(() => { | ||
return refDescriptor.current.subscribe(setCurrentDescriptor) | ||
},[]) | ||
useEffect(() => { | ||
refDescriptor.current.setContext(context); | ||
},[context]) | ||
return (/* some JSX*/) | ||
{ | ||
formData: { | ||
firstName: 'Joe', | ||
lastName: 'Smith' | ||
} | ||
} | ||
``` | ||
Think outside the box here, what if you didn't have rules for individual field descriptors, but what if you entire form was just modifiable descriptors and the rules governing the entire form were encoded as a bunch of JSON patch operations? Because of the referential integrity of the patches, `memo`-ed components still work and things are still lightening fast. | ||
### Validator | ||
```js | ||
const myForm = { | ||
firstName: { | ||
label: 'First Name', | ||
placeholder: 'Enter your first name', | ||
}, | ||
}; | ||
const formRules = [ | ||
{ | ||
when: { | ||
'/formData/firstName': { | ||
type: 'string', | ||
pattern: '^A', | ||
}, | ||
}, | ||
then: [ | ||
{ | ||
op: 'replace', | ||
path: '/firstName/placeholder', | ||
value: 'Hey {{/formData/firstName}}, my first name starts with A too!', | ||
}, | ||
], | ||
}, | ||
]; | ||
``` | ||
## Validator | ||
```ts | ||
@@ -174,3 +103,3 @@ type Validator = (schema: any, subject: any) => boolean; | ||
A validator is the only dependency that must be user supplied. It accepts a schema and an subject to evaluate and it synchronously returns a boolean. Because of the extensive performance optimizations going on inside the engine to keep it blazing fast **it's important to note the validator MUST BE A PURE FUNCTION** | ||
A validator is the only dependency that must be user supplied. It accepts a [condition](#condition) and an subject to evaluate and it must **synchronously** return a boolean. Because of the extensive performance optimizations going on inside the engine to keep it blazing fast **it's important to note the validator MUST BE A PURE FUNCTION** | ||
@@ -180,11 +109,12 @@ Here's a great one, and the one used in all our tests: | ||
```js | ||
import { engine } from 'json-modifiable'; | ||
import Ajv from 'ajv'; | ||
const ajv = new Ajv(); | ||
const validator = (schema, subject) => ajv.validate(schema, subject); | ||
const validator = ajv.validate.bind(ajv); | ||
const modifiable = createJSONModifiable(myDescriptor, rules, { validator }); | ||
const modifiable = engine(myDescriptor, validator, rules); | ||
``` | ||
You can see that by supplying a different validator, you don't even have to use JSON schema (though we recommend it) in your modifiable rules. | ||
You should be able to see that by supplying a different validator, you can write rules however you want, not just using JSON Schema. | ||
@@ -194,94 +124,149 @@ ## Rules | ||
```ts | ||
type Rule = { | ||
export type Rule<Operation> = { | ||
when: Condition[]; | ||
then?: Operation[]; | ||
otherwise: Operation[]; | ||
then?: Operation; | ||
otherwise?: Operation; | ||
}; | ||
``` | ||
A rule looks like `when`, `then`, `otherwise` where only one of the `then` or `otherwise` needs to be defined. A when is made up on an array of objects whose keys are pointers to entities in `context` and whose values are schemas that will be passed to the `validator` function. | ||
A rule is an object whose `when` property is an array of [conditions](#condition) and contains a `then` and/or `otherwise` clause. The [validator](#validator) will evaluate the conditions and, if any of them are true, will apply the `then` operation (if supplied) via the [patch function](#patch). If none of them are true, the `otherwise` operation (if supplied) will be applied. | ||
The `when` is always an array of `Condition`s. `Condition`s are plain objects whose keys are `path`s and values are `schemas`. | ||
#### Condition | ||
```ts | ||
type Condition = { | ||
[key: string]: Record<string, any>; | ||
}; | ||
type Condition<Schema> = Record<string, Schema>; | ||
``` | ||
// e.g. | ||
const condition = { | ||
'/formData/firstName': { | ||
A condition is a plain object whose keys are resolved to values (by means of the [resolver](#resolver) function) and whose values are passed to the [validator](#validator) function with the resolved values. | ||
The default resolver maps the keys of conditions to the values of context directly: | ||
```js | ||
// context | ||
{ | ||
firstName: 'joe' | ||
} | ||
// condition | ||
{ | ||
firstName: { | ||
type: 'string', | ||
minLength: 2, | ||
}, | ||
}; | ||
pattern: '^j' | ||
} | ||
} | ||
``` | ||
If **any** of the `Condition`s in a `Rule` are true, then the operations in the `then` clause are applied. If none of them are true then the operations in the `otherwise` clause are applied. If a rule is false but no `otherwise` clause is specified, then no patches will be applied. The same goes for if a rule is true but doesn't have a `then` clause. | ||
is given to the validator like this: | ||
## Operations | ||
```js | ||
validator({ type: 'string', pattern: '^j'}, 'joe'); | ||
``` | ||
THe `then` and `otherwise` must be arrays or `Operation`s. The default implementation requires them to be [JSON patch](http://jsonpatch.com/) operations. The array of operations in a `then` or `otherwise` will be passed to the `patch` function and the document to apply them to. | ||
#### Operation | ||
An operation is simply the value encoded in the `then` or `otherwise` of a [rule](#rule). After the engine has run, the modified descriptor is computed by reducing all collected operations via the [patch](#patch) function in the order they were supplied in rules. They can be absolutely anything that the patch function can understand. The default patch function (literally `Object.assign`) expects the operation to be `Partial<Descriptor>` like this: | ||
```js | ||
const descriptor = { | ||
fieldName: 'lastName', | ||
label: 'Last Name', | ||
} | ||
const rule = { | ||
when: { | ||
firstName: { | ||
type: 'string', | ||
minLength: 1 | ||
} | ||
}, | ||
then: { | ||
validations: ['required'] | ||
} | ||
} | ||
// resultant descriptor when firstName is not empty | ||
{ | ||
fieldName: 'lastName', | ||
label: 'Last Name', | ||
validations: ['required'] | ||
} | ||
``` | ||
### Resolver | ||
```ts | ||
type PatchFunction = <T>(descriptor: T, operations: Operation[]) => T; | ||
export type Resolver<Context> = (object: Context, path: string) => any; | ||
``` | ||
It's important to know that rules run in the order they have been defined. So your patch operations will be operating on the last modified descriptor. | ||
A resolver resolves the keys of [conditions](#condition) to values. It is passed the key and the context being evaluated. The resultant value can be any value that will subsequently get passed to the [validator](#validator). The default resolver simply maps the key of the condition to the key in context: | ||
## API | ||
```js | ||
const resolver = (context, ket) => context[key] | ||
``` | ||
```ts | ||
createJSONModifiable<T,C = unknown,Op = JSONPatchOperation>( | ||
descriptor: T, | ||
rules: Rule<Op>[], | ||
options: Options<T,C,Op> | ||
): JSONModifiable<T,C> | ||
But the resolver function can be anything you choose. Some other ideas are: | ||
type Rule<Op> = { | ||
when: Condition[]; | ||
then?: Op[]; | ||
otherwise?: Op[]; | ||
}; | ||
- [json pointer](https://datatracker.ietf.org/doc/html/rfc6901) | ||
type Condition = { | ||
[key: string]: Record<string, unknown>; | ||
}; | ||
```js | ||
// context | ||
{ | ||
formData: { | ||
address: { | ||
street: '123 Fake Street' | ||
} | ||
} | ||
} | ||
// condition | ||
{ | ||
'/formData/address/street': { | ||
type: 'string' | ||
} | ||
} | ||
type Validator = (schema: any, subject: any) => boolean; | ||
type Resolver = (object: Record<string, unknown>, path: string) => any; | ||
// for example: | ||
import { get } from 'json-pointer'; | ||
const resolver = get; | ||
``` | ||
type Options<T, C, Op> = { | ||
// a validator is required | ||
validator: Validator; | ||
context?: C; | ||
pattern?: RegExp | null; | ||
resolver?: Resolver; | ||
patch?: (operations: Op[], record: T) => T; | ||
}; | ||
- [lodash get](https://lodash.com/docs/#get) | ||
type Unsubscribe = () => void; | ||
type Subscriber<T> = (arg: T) => void; | ||
```js | ||
// context | ||
{ | ||
formData: { | ||
address: { | ||
street: '123 Fake Street' | ||
} | ||
} | ||
} | ||
interface JSONModifiable<T, C, Op> { | ||
get: () => T; | ||
set: (descriptor: T) => void; | ||
setRules: (rules: Rule<Op>[]) => void; | ||
setContext: (context: C) => void; | ||
subscribe: (subscriber: Subscriber<T>) => Unsubscribe; | ||
on: (event: 'modified', subscriber: Subscriber<T>) => Unsubscribe; | ||
on: (event: 'error', subscriber: Subscriber<ErrorEvent>) => Unsubscribe; | ||
// condition | ||
{ | ||
'formData.address.street': { | ||
type: 'string' | ||
} | ||
} | ||
// for example: | ||
import { get } from 'lodash'; | ||
const resolver = get; | ||
``` | ||
## Interpolation | ||
### Patch | ||
You can interpolate values from context into your rules and patches using the `pattern` regexp. By default it uses [handlebars](https://handlebarsjs.com/)-style - e.g. `{{thingToInerpolate}}` | ||
The patch function does the work of applying the instructions encoded in the [operations](#operations) to the descriptor to end up with the final "modified" descriptor for any given context. | ||
Note the `resolver` accepts, by default, a [json pointer](https://datatracker.ietf.org/doc/html/rfc6901) is used for to evaluate what's being interpolated. So, by default, all interpolation patterns will look like this: `{{/path/to/thing/in/context}}` | ||
**NOTE:** The descriptor itself **should never be mutated**. `json-modifiable` leaves it up to the user to ensure the patch function is non-mutating. The default patch function is a simple shallow-clone `Object.assign`: | ||
Also useful to know is that you can interpolate more than strings, you can interpolate objects or even arrays. | ||
```js | ||
const patch = Object.assign | ||
``` | ||
### Interpolation | ||
`json-modifiable` uses [interpolatable](https://github.com/akmjenkins/interpolatable) to offer allow interpolation of values into rules/patches. See the [docs](https://github.com/akmjenkins/interpolatable) for how it works. The resolver function passed to `json-modifiable` will be the same one passed to interpolatable. By default it's just an accessor, but you could also use a resolver that works with [json pointer](https://datatracker.ietf.org/doc/html/rfc6901): | ||
Given the rule and the following context: | ||
@@ -340,3 +325,3 @@ | ||
### About interpolation performance | ||
#### About interpolation performance | ||
@@ -346,3 +331,3 @@ **TLDR** in performance critical environments where you aren't using interpolation, pass `null` for the `pattern` option: | ||
```js | ||
const modifiable = createJSONModifiable( | ||
const modifiable = engine( | ||
myDescriptor, | ||
@@ -357,6 +342,167 @@ rules, | ||
## Basic Usage | ||
`json-modifiable` relies on a [validator](#validator) function that evaluates the [condition](#condition) of [rules](#rules) and applies patches | ||
This package uses [interpolatable](https://github.com/akmjenkins/interpolatable) to perform blazing fast interpolations on deeply nested data structures. Interpolatable ensures the expensive operation of traversing nested objects/arrays only happens when absolutely necessary. However, if you don't use interpolation in your rules, your objects will still be traversed the first time your rules are loaded into the engine. If you are operating in a performance critical environment and/or your rules are very large, you can simply pass `null` for the pattern option to skip this initial traversal as well. In the future, it's possible that the interpolation pattern can be defined per rule for even finer-grained control. | ||
```js | ||
import { engine } from 'json-modifiable'; | ||
const descriptor = engine( | ||
{ | ||
fieldId: 'lastName', | ||
path: 'user.lastName', | ||
label: 'Last Name', | ||
readOnly: false, | ||
placeholder: 'Enter Your First Name', | ||
type: 'text', | ||
hidden: true, | ||
validations: [], | ||
}, | ||
validator, | ||
[ | ||
{ | ||
when: [ | ||
{ | ||
'firstName': { | ||
type: 'string', | ||
minLength: 1 | ||
} | ||
}, | ||
], | ||
then: { | ||
validations: ['required'] | ||
} | ||
}, | ||
// ... more rules | ||
], | ||
); | ||
descriptor.get().validations.find((v) => v === 'required'); // not found | ||
descriptor.setContext({ formData: { firstName: 'fred' } }); | ||
descriptor.get().validations.find((v) => v === 'required'); // found! | ||
``` | ||
## What in the heck is this good for? | ||
Definining easy to read and easy to apply business logic to things that need to behave differently in different contexts. One use case I've used this for is to quickly and easily perform complicated modifications to form field descriptors based on the state of the form (or some other current application context). | ||
```js | ||
const descriptor = { | ||
fieldId: 'lastName', | ||
path: 'user.lastName', | ||
label: 'Last Name', | ||
readOnly: false, | ||
placeholder: 'Enter Your First Name', | ||
type: 'text', | ||
hidden: true, | ||
validations: [], | ||
}; | ||
const rules = [ | ||
{ | ||
when: [ | ||
{ | ||
'/formData/firstName': { | ||
type: 'string', | ||
minLength: 1, | ||
}, | ||
}, | ||
], | ||
then: { | ||
validations: ['required'], | ||
hidden: false | ||
} | ||
}, | ||
]; | ||
``` | ||
### JSON Engine | ||
This library also exports a function `jsonEngine` which is a thin wrapper over the engine using [json patch](http://jsonpatch.com/) as the patch function and [json pointer](https://datatracker.ietf.org/doc/html/rfc6901) as the default resolver. You can then write modifiable rules like this: | ||
```ts | ||
const myRule: JSONPatchRule<SomeJSONSchema> = { | ||
when: [ | ||
{ | ||
'/contextPath': { | ||
type: 'string', | ||
const: '1', | ||
}, | ||
}, | ||
], | ||
then: [ | ||
{ | ||
op: 'remove', | ||
path: '/validations/0', | ||
}, | ||
{ | ||
op: 'replace', | ||
path: '/someNewKey', | ||
value: { newThing: 'fred' }, | ||
}, | ||
], | ||
otherwise: [ | ||
{ | ||
op: 'remove', | ||
path: '/validations', | ||
}, | ||
], | ||
}; | ||
``` | ||
This library internally has tiny, (largely) spec compliant implementations of [json patch](http://jsonpatch.com/) and [json pointer](https://datatracker.ietf.org/doc/html/rfc6901) that it uses as the default options for [json engine](#json-engine). | ||
The very important difference with the embedded json-patch utility is that it **only patches the parts of the descriptor that are actually modified** - i.e. no `cloneDeep`. This allows it to work beautifully with libraries that rely on (or make heavy use of) referential integrity/memoization (like React). | ||
```js | ||
const DynamicFormField = ({ context }) => { | ||
const refDescriptor = useRef(engine(descriptor, rules, { context })) | ||
const [currentDescriptor, setCurrentDescriptor] = useState(descriptor.current.get()); | ||
const [context,setContext] = useState({}) | ||
useEffect(() => { | ||
return refDescriptor.current.subscribe(setCurrentDescriptor) | ||
},[]) | ||
useEffect(() => { | ||
refDescriptor.current.setContext(context); | ||
},[context]) | ||
return (/* some JSX */) | ||
} | ||
``` | ||
Think outside the box here, what if you didn't have rules for individual field descriptors, but what if you entire form was just modifiable descriptors and the rules governing the entire form were encoded as a bunch of JSON patch operations? Because of the referential integrity of the patches, `memo`-ed components still work and things are still lightening fast. | ||
```js | ||
const myForm = { | ||
firstName: { | ||
label: 'First Name', | ||
placeholder: 'Enter your first name', | ||
}, | ||
}; | ||
const formRules = [ | ||
{ | ||
when: { | ||
'/formData/firstName': { | ||
type: 'string', | ||
pattern: '^A', | ||
}, | ||
}, | ||
then: [ | ||
{ | ||
op: 'replace', | ||
path: '/firstName/placeholder', | ||
value: 'Hey {{/formData/firstName}}, my first name starts with A too!', | ||
}, | ||
], | ||
}, | ||
]; | ||
``` | ||
## Other Cool Stuff | ||
@@ -363,0 +509,0 @@ |
Sorry, the diff of this file is not supported yet
49185
16
383
510
35
Updatedinterpolatable@^1.3.2