diff --git a/Makefile b/Makefile deleted file mode 100644 index c8e1dd5..0000000 --- a/Makefile +++ /dev/null @@ -1,24 +0,0 @@ -BIN = ./node_modules/.bin -SRC = $(wildcard src/* src/*/*) -TEST = $(wildcard test/* test/*/*) - -build: index.js - -index.js: src/index.js $(SRC) - $(BIN)/rollup $< -c > $@ - -test.js: test/index.js $(TEST) - $(BIN)/rollup $< -c > $@ - -test: test-node test-browser - -test-node: test.js index.js - node $< - -test-browser: test.js index.js - $(BIN)/browserify $< --debug | $(BIN)/tape-run - -clean: - rm -rf index.js test.js - -.PHONY: build clean test test-node test-browser diff --git a/README.md b/README.md index 113724d..c3e135a 100644 --- a/README.md +++ b/README.md @@ -15,37 +15,62 @@ npm i form-blueprint --save ## Usage ```js -import * as bpTools from "form-blueprint"; +import createBlueprint, { Blueprint, Schema, Field, defaultSchema } from "form-blueprint"; ``` -### validate() +### createBlueprint() ```text -bpTools.validate( blueprint ) +createBlueprint( blueprint [, schema ] ) ``` -Validates a blueprint object. An error is thrown if the value is invalid. +Creates a new blueprint object with a schema. If a schema is not provided, the builtin default schema is used. -### merge() + +### Blueprint#root + +```text +blueprint.root +``` + +The blueprint's root field object. + +### Blueprint#schema + +```text +blueprint.schema +``` + +The blueprint's schema object. + +### Blueprint#getField() + +```text +blueprint.getField( key ) +``` + +Get a field in a blueprint by key. The key can be complex to get deep values (eg. `a[0].b.c`). + +### Blueprint#transform() ```text -bpTools.merge( blueprint1, blueprint2 [, ... blueprintN ] ) +blueprint.transform( [ value ] ) ``` -Merges blueprint several objects together into a new blueprint. The last blueprint wins when there are section and option conflicts. +Transforms a value according to the blueprint and schema rules. Value does not need to be provided, in which case default values are returned. -### defaults() +### Blueprint#normalize() ```text -bpTools.defaults( blueprint ) +blueprint.normalize() ``` -Returns an object of default blueprint values. +Normlaize a blueprint using schema rules. This generally doesn't need to be called as this run when a blueprint is created. -### applyDefaults() +### Blueprint#join() ```text -bpTools.applyDefaults( blueprint, object ) +blueprint.join( blueprint1 [, blueprint2 [, ... ] ] ) ``` -Applies a blueprint's default values to an object. The default values are only set if the object's value is undefined or an empty string. +Joins one or more blueprints together into a single blueprint. The blueprint that join is called on is considered the master blueprint. The master's schema is used to join and upon conflicts, the master's copy will always win. diff --git a/package.json b/package.json index 7464c61..66aede9 100644 --- a/package.json +++ b/package.json @@ -10,28 +10,36 @@ "main": "index.js", "scripts": { "lint": "eslint src/ test/", - "build": "make clean && make", - "test": "make test", + "clean": "shx rm -rf index.js test.js", + "build": "npm-run-all clean build-lib", + "build-lib": "rollup src/index.js -c > index.js", + "build-test": "rollup test/index.js -c > test.js", + "test": "npm-run-all build-test test-node test-browser", + "test-node": "node test.js", + "test-browser": "browserify test.js --debug | tape-run", "prepublish": "npm run build", "autorelease": "autorelease pre && npm publish && autorelease post" }, "dependencies": { - "lodash": "^4.8.2" + "immutable": "^3.8.1", + "lodash.topath": "^4.5.2" }, "devDependencies": { "autorelease": "^1.6.0", "autorelease-github": "^1.1.1", "autorelease-travis": "^1.2.1", - "babel-eslint": "^6.0.2", + "babel-eslint": "^7.2.1", "babel-plugin-transform-object-rest-spread": "^6.6.5", - "babel-preset-es2015-rollup": "^1.1.1", - "browserify": "^13.0.0", + "babel-preset-es2015-rollup": "^3.0.0", + "browserify": "^14.1.0", "eslint": "^3.0.0", - "rollup": "^0.34.0", + "npm-run-all": "^4.0.2", + "rollup": "^0.41.6", "rollup-plugin-babel": "^2.4.0", "rollup-plugin-json": "^2.0.0", + "shx": "^0.2.2", "tape": "^4.5.1", - "tape-run": "^2.1.3", + "tape-run": "^3.0.0", "uglify-js": "^2.6.2" }, "keywords": [], diff --git a/src/blueprint.js b/src/blueprint.js new file mode 100644 index 0000000..93dc888 --- /dev/null +++ b/src/blueprint.js @@ -0,0 +1,57 @@ +import { Record } from "immutable"; +import toPath from "lodash.topath"; +import Schema from "./schema"; +import Field from "./field"; + +const DEFAULTS = { + root: null, + schema: null +}; + +export default class Blueprint extends Record(DEFAULTS) { + static create(props = {}) { + if (Blueprint.isBlueprint(props)) { + return props; + } + + props.root = Field.create(props.root); + props.schema = Schema.create(props.schema); + + return new Blueprint(props).normalize(); + } + + static isBlueprint(b) { + return b instanceof Blueprint; + } + + static kind() { + return "blueprint"; + } + + getField(key) { + const path = toPath(key); + let field = this.root; + + while (path.length && field) { + field = field.getChildField(path.shift()); + } + + return field; + } + + transform(value) { + return this.schema.transform(value, this.root); + } + + normalize() { + return this.merge({ + root: this.schema.normalize(this.root) + }); + } + + join(...blueprints) { + const fields = [this].concat(blueprints).map(b => b.root); + const root = this.schema.join(...fields); + return this.merge({ root }); + } +} diff --git a/src/field.js b/src/field.js new file mode 100644 index 0000000..e1e3431 --- /dev/null +++ b/src/field.js @@ -0,0 +1,58 @@ +import { Record, Map, List } from "immutable"; + +const DEFAULTS = { + key: null, + type: null, + children: null, + props: null +}; + +export default class Field extends Record(DEFAULTS) { + static create(field = {}) { + if (Field.isField(field)) { + return field; + } + + if (typeof field !== "object" || field == null) { + throw new Error("Expecting object for field."); + } + + let { + key=null, + type=null, + children, + ...props + } = field; + + children = Field.createList(children); + props = Map.isMap(props) ? props : Map(props); + + return new Field({ key, type, props, children }); + } + + static createList(fields) { + if (List.isList(fields)) return fields; + + if (Array.isArray(fields)) { + fields = fields.map(Field.create); + } else if (typeof fields === "object" && fields != null) { + fields = Object.keys(fields).map(key => { + return Field.create({ ...fields[key], key }); + }); + } + + return List(fields); + } + + static isField(b) { + return b instanceof Field; + } + + static kind() { + return "field"; + } + + getChildField(key) { + return this.children.find(o => o.key === key); + } +} diff --git a/src/index.js b/src/index.js index 57c18fe..1009e4b 100644 --- a/src/index.js +++ b/src/index.js @@ -1,94 +1,16 @@ -import {assign,omit,has,clone,reduce,assignWith} from "lodash"; - -// throw an error if blueprint is not valid -export function validate(bp) { - if (typeof bp !== "object" || bp == null) { - throw new Error("Expecting object for blueprint."); - } - - for (let section_key in bp) { - if (!has(bp, section_key)) continue; - let section = bp[section_key]; - if (!validSection(section)) { - throw new Error(`Invalid blueprint section '${section_key}'.`); - } - - let options = section.options; - for (let option_key in options) { - if (!has(options, option_key)) continue; - if (!validOption(options[option_key])) { - throw new Error(`Invalid blueprint option '${section_key}.${option_key}'.`); - } - } - } -} - -// returns boolean for if section object is valid -export function validSection(section) { - return (typeof section === "object" && section != null) && - (typeof section.options === "object" && section.options != null); -} - -// returns boolean for if section option object is valid -export function validOption(option) { - return (typeof option === "object" && option != null) && - (typeof option.type === "string" && option.type); -} - -// merge several blueprints together -export function merge(...args) { - return args.reduce((m, obj) => { - if (!obj) return m; - - for (let section_key in obj) { - if (!has(obj, section_key)) continue; - let section = obj[section_key]; - if (!validSection(section)) continue; - - if (!has(m, section_key)) m[section_key] = { options: {} }; - assign(m[section_key], omit(section, "options")); - let options = section.options; - - for (let option_key in options) { - if (!has(options, option_key)) continue; - let option = options[option_key]; - if (!validOption(option)) continue; - - m[section_key].options[option_key] = clone(option); - } - } - - return m; - },{}); -} - -// extract default values from a blueprint -export function defaults(bp={}) { - return reduce(bp, (m, s, k) => { - m[k] = reduce(s.options, (m, o, k) => { - m[k] = o.default; - return m; - }, {}); - return m; - }, {}); -} - -// apply blueprint defaults onto an object -export function applyDefaults(bp, obj) { - let defaultOpts = defaults(bp); - - for (let k in defaultOpts) { - if (!has(defaultOpts, k) || (obj[k] && !has(obj, k))) continue; - - if (obj[k] == null) obj[k] = {}; - if (typeof obj[k] !== "object") continue; - - assignWith(obj[k], defaultOpts[k], (cur, def) => { - return (typeof def !== "undefined") && - (typeof cur === "undefined" || cur === "") ? - def : cur; - }); - } - - return obj; +import Blueprint from "./blueprint"; +import Schema, {defaultSchema} from "./schema"; +import Field from "./field"; +import * as rules from "./rules/index"; + +export { + Blueprint, + Schema, + Field, + defaultSchema, + rules +}; + +export default function(field, schema=defaultSchema) { + return Blueprint.create({ root: field, schema }); } diff --git a/src/rules/defaults.js b/src/rules/defaults.js new file mode 100644 index 0000000..b73eb95 --- /dev/null +++ b/src/rules/defaults.js @@ -0,0 +1,18 @@ +// rule for defaults + +function match(field) { + return field.props.has("default"); +} + +function transform(value, field) { + const def = field.props.get("default"); + + return (typeof def !== "undefined") && + (typeof value === "undefined" || value === "") ? + def : value; +} + +export default { + match, + transform +}; diff --git a/src/rules/index.js b/src/rules/index.js new file mode 100644 index 0000000..e30a750 --- /dev/null +++ b/src/rules/index.js @@ -0,0 +1,21 @@ +import legacySection from "./legacy-section"; +import legacyRoot from "./legacy-root"; +import section from "./section"; +import list from "./list"; +import defaults from "./defaults"; + +export { + legacySection, + legacyRoot, + section, + list, + defaults +}; + +export default [ + legacySection, + legacyRoot, + section, + list, + defaults +]; diff --git a/src/rules/legacy-root.js b/src/rules/legacy-root.js new file mode 100644 index 0000000..04b739d --- /dev/null +++ b/src/rules/legacy-root.js @@ -0,0 +1,22 @@ +// rule for v1 root objects + +import Field from "../field"; + +function match(field) { + return !field.type && + !field.children.size && + field.props.size; +} + +function normalize(field) { + return Field.create({ + key: field.key, + type: "section", + children: field.props.toJSON() + }); +} + +export default { + match, + normalize +}; diff --git a/src/rules/legacy-section.js b/src/rules/legacy-section.js new file mode 100644 index 0000000..247e858 --- /dev/null +++ b/src/rules/legacy-section.js @@ -0,0 +1,21 @@ +// rule for v1 type-less sections + +import Field from "../field"; + +function match(field) { + return !field.type && field.props.has("options"); +} + +function normalize(field) { + const opts = field.props.get("options"); + + return field.merge({ + type: "section", + children: Field.createList(opts) + }); +} + +export default { + match, + normalize +}; diff --git a/src/rules/list.js b/src/rules/list.js new file mode 100644 index 0000000..c011f7a --- /dev/null +++ b/src/rules/list.js @@ -0,0 +1,33 @@ +// rule for list composition + +import Field from "../field"; + +function match(field) { + return field.type === "list"; +} + +function normalize(field) { + const child = field.props.get("field"); + if (!child) return field; + + return field.merge({ + children: Field.createList([ child ]) + }); +} + +function transform(value, field) { + if (!Array.isArray(value)) return value; + + const child = field.children.first(); + if (!child) return value; + + return value.map(v => { + return this.transform(v, child); + }); +} + +export default { + match, + normalize, + transform +}; diff --git a/src/rules/section.js b/src/rules/section.js new file mode 100644 index 0000000..c1f535c --- /dev/null +++ b/src/rules/section.js @@ -0,0 +1,47 @@ +// rule for section composition + +import { List, Set } from "immutable"; + +function match(field) { + return field.type === "section"; +} + +function transform(value, field) { + if (typeof value === "undefined") value = {}; + if (value == null || value.constructor !== Object) return value; + + const keys = Object.keys(value); + field.children.forEach(o => { + if (o.key && !keys.includes(o.key)) keys.push(o.key); + }); + + return keys.reduce((m, k) => { + const child = field.getChildField(k); + if (!child) m[k] = value[k]; + else m[k] = this.transform(value[k], child); + return m; + }, {}); +} + +function join(a, b) { + if (b.type !== "section") return a; + + const keys = Set(a.children.map(o => o.key)) + .union(b.children.map(o => o.key)); + + const children = keys.reduce((m, key) => { + const aopt = a.getChildField(key); + const bopt = b.getChildField(key); + if (!aopt && !bopt) return m; + const opt = !aopt ? bopt : !bopt ? aopt : this.join(aopt, bopt); + return m.push(opt); + }, List()); + + return a.merge({ children }); +} + +export default { + match, + transform, + join +}; diff --git a/src/schema.js b/src/schema.js new file mode 100644 index 0000000..84783d6 --- /dev/null +++ b/src/schema.js @@ -0,0 +1,78 @@ +import { Record, List } from "immutable"; +import Field from "./field"; +import rules from "./rules/index"; + +const DEFAULTS = { + rules: null +}; + +export default class Schema extends Record(DEFAULTS) { + static create(props = {}) { + if (Schema.isSchema(props)) return props; + if (Array.isArray(props)) props = { rules: props }; + props.rules = List(props.rules && props.rules.map(resolveRule)); + return new Schema(props); + } + + static isSchema(b) { + return b instanceof Schema; + } + + get kind() { + return "schema"; + } + + apply(field, prop, ...args) { + return this.rules.reduce((b, rule) => { + if (typeof rule[prop] !== "function") return b; + if (!rule.match.call(this, b)) return b; + const res = rule[prop].apply(this, [b].concat(args)); + return Field.isField(res) ? res : b; + }, field); + } + + normalize(field) { + field = this.apply(field, "normalize"); + const children = field.children.map(c => this.normalize(c)); + return field.merge({ children }); + } + + transform(value, field) { + return this.rules.reduce((val, rule) => { + if (!rule.transform) return val; + if (!rule.match.call(this, field)) return val; + return rule.transform.call(this, val, field); + }, value); + } + + join(field, ...toJoin) { + return toJoin.reduce((m, b) => this.apply(m, "join", b), field); + } + + addRule(rule) { + const rules = this.rules.push(resolveRule(rule)); + return this.merge({ rules }); + } + + concat(rules) { + if (Schema.isSchema(rules)) rules = rules.rules; + + return this.merge({ + rules: this.rules.concat(rules) + }); + } +} + +export const defaultSchema = Schema.create(rules); + +function resolveRule(r = {}) { + if (typeof r.match !== "function") { + throw new Error("Rule is missing a match method"); + } + + if (r.transform && typeof r.transform !== "function") { + throw new Error("Rule has an invalid transform method"); + } + + return r; +} diff --git a/test/index.js b/test/index.js index b45b797..8320e09 100644 --- a/test/index.js +++ b/test/index.js @@ -1,187 +1 @@ -import test from "tape"; - -const blueprint = require("./"); - -test("validates valid blueprint", function(t) { - t.plan(1); - blueprint.validate({ - some_section: { - label: "Some Section", - options: { - foo: { - label: "Foo Option", - type: "text", - default: "The default value." - } - } - } - }); - t.pass("blueprint is valid"); -}); - -test("throws on invalid blueprint", function(t) { - t.plan(1); - t.throws(function() { - blueprint.validate(null); - }, /expecting object/i); -}); - -test("throws on invalid section in blueprint", function(t) { - t.plan(1); - t.throws(function() { - blueprint.validate({ - invalid_section: {} - }); - }, /invalid blueprint section/i); -}); - -test("throws on invalid section option in blueprint", function(t) { - t.plan(1); - t.throws(function() { - blueprint.validate({ - section: { - options: { - foo: {} - } - } - }); - }, /invalid blueprint option/i); -}); - -test("extracts default values from blueprint", function(t) { - t.plan(1); - t.deepEquals(blueprint.defaults({ - foo: { - options: { - bar: { - type: "text", - default: "foobar" - }, - baz: { - type: "text", - default: "foobaz" - } - } - }, - hello: { - options: { - world: { - type: "checkbox", - default: true - } - } - } - }), { - foo: { - bar: "foobar", - baz: "foobaz" - }, - hello: { - world: true - } - }, "extracted defaults"); -}); - -test("applies blueprint default to object", function(t) { - t.plan(1); - t.deepEquals(blueprint.applyDefaults({ - foo: { - options: { - bar: { - type: "text", - default: "foobar" - }, - baz: { - type: "text", - default: "foobaz" - } - } - }, - hello: { - options: { - world: { - type: "checkbox", - default: true - } - } - } - }, { - foo: { bar: "bam" } - }), { - foo: { - bar: "bam", - baz: "foobaz" - }, - hello: { - world: true - } - }, "applied defaults"); -}); - -test("merges blueprints together", function(t) { - t.plan(1); - t.deepEquals(blueprint.merge({ - foo: { - label: "Foo1", - options: { - bar: { - type: "text", - label: "Bar1", - default: "foobar" - } - } - }, - hello: { - label: "Foo1", - options: { - world: { - type: "checkbox", - label: "World1", - default: true - } - } - } - }, { - foo: { - label: "Foo2", - options: { - bar: { - type: "textarea", - label: "Bar2", - default: "overridden" - }, - baz: { - type: "text", - label: "Baz1", - default: "foobaz" - } - } - } - }), { - foo: { - label: "Foo2", - options: { - bar: { - type: "textarea", - label: "Bar2", - default: "overridden" - }, - baz: { - type: "text", - label: "Baz1", - default: "foobaz" - } - } - }, - hello: { - label: "Foo1", - options: { - world: { - type: "checkbox", - label: "World1", - default: true - } - } - } - }, "merges blueprints"); -}); +import "./legacy-tests"; diff --git a/test/legacy-tests.js b/test/legacy-tests.js new file mode 100644 index 0000000..bbb656a --- /dev/null +++ b/test/legacy-tests.js @@ -0,0 +1,201 @@ +import test from "tape"; +import {isEqual} from "lodash"; + +const {default:createBlueprint} = require("./"); + +test("validates valid blueprint", function(t) { + t.plan(1); + t.ok(createBlueprint({ + some_section: { + label: "Some Section", + options: { + foo: { + label: "Foo Option", + type: "text", + default: "The default value." + } + } + } + }), "blueprint is valid"); +}); + +test("throws on invalid blueprint", function(t) { + t.plan(1); + t.throws(function() { + createBlueprint(null); + }, /expecting object/i); +}); + +test("extracts default values from blueprint", function(t) { + t.plan(1); + + const blueprint = createBlueprint({ + foo: { + options: { + bar: { + type: "text", + default: "foobar" + }, + baz: { + type: "text", + default: "foobaz" + } + } + }, + hello: { + options: { + world: { + type: "checkbox", + default: true + } + } + } + }); + + t.deepEquals(blueprint.transform(), { + foo: { + bar: "foobar", + baz: "foobaz" + }, + hello: { + world: true + } + }, "extracted defaults"); +}); + +test("applies blueprint default to object", function(t) { + t.plan(1); + + const blueprint = createBlueprint({ + foo: { + options: { + bar: { + type: "text", + default: "foobar" + }, + baz: { + type: "text", + default: "foobaz" + } + } + }, + hello: { + options: { + world: { + type: "checkbox", + default: true + } + } + } + }); + + t.deepEquals(blueprint.transform({ + foo: { bar: "bam" } + }), { + foo: { + bar: "bam", + baz: "foobaz" + }, + hello: { + world: true + } + }, "applied defaults"); +}); + +test("joins blueprints together", function(t) { + t.plan(1); + + const bp1 = createBlueprint({ + foo: { + label: "Foo1", + options: { + bar: { + type: "text", + label: "Bar1", + default: "foobar" + } + } + }, + hello: { + label: "Foo1", + options: { + world: { + type: "checkbox", + label: "World1", + default: true + } + } + } + }); + + const bp2 = createBlueprint({ + foo: { + label: "Foo2", + options: { + bar: { + type: "textarea", + label: "Bar2", + default: "overridden" + }, + baz: { + type: "text", + label: "Baz1", + default: "foobaz" + } + } + } + }); + + const result = createBlueprint({ + foo: { + label: "Foo2", + options: { + bar: { + type: "textarea", + label: "Bar2", + default: "overridden" + }, + baz: { + type: "text", + label: "Baz1", + default: "foobaz" + } + } + }, + hello: { + label: "Foo1", + options: { + world: { + type: "checkbox", + label: "World1", + default: true + } + } + } + }); + + t.ok(isEqual(bp2.join(bp1).toJSON(), result.toJSON()), "joins blueprints"); +}); + +// OLD TESTS THAT NOW BREAK WITH NEW FUNCTIONALITY +// test("throws on invalid section in blueprint", function(t) { +// t.plan(1); +// t.throws(function() { +// blueprint.validate({ +// invalid_section: {} +// }); +// }, /invalid blueprint section/i); +// }); +// +// test("throws on invalid section option in blueprint", function(t) { +// t.plan(1); +// t.throws(function() { +// blueprint.validate({ +// section: { +// options: { +// foo: {} +// } +// } +// }); +// }, /invalid blueprint option/i); +// });