Merge branch 'feature/nested-schema' of https://github.com/lbke/Vulcan into lbke-feature/nested-schema

This commit is contained in:
SachaG 2018-08-03 11:44:20 +09:00
commit 96a396fd8c
12 changed files with 414 additions and 125 deletions

View file

@ -393,6 +393,7 @@ class SmartForm extends Component {
if (fieldSchema.schema) {
field.nestedSchema = fieldSchema.schema;
field.nestedInput = true;
// get nested schema
// for each nested field, get field object by calling createField recursively
field.nestedFields = this.getFieldNames({ schema: field.nestedSchema }).map(subFieldName => {

View file

@ -5,6 +5,7 @@ import { registerComponent } from 'meteor/vulcan:core';
import get from 'lodash/get';
import isEqual from 'lodash/isEqual';
import { isEmptyValue, mergeValue } from '../modules/utils.js';
import SimpleSchema from 'simpl-schema'
class FormComponent extends Component {
constructor(props) {
@ -287,11 +288,24 @@ class FormComponent extends Component {
}
};
getFieldType = () => {
return this.props.datatype[0].type
}
isArrayField = () => {
return this.getFieldType() === Array
}
isObjectField = () => {
return this.getFieldType() instanceof SimpleSchema
}
render() {
if (this.props.intlInput) {
return <Components.FormIntl {...this.props} />;
} else if (this.props.nestedInput){
return <Components.FormNested {...this.props} />;
} else if (this.props.nestedInput) {
if (this.isArrayField()) {
return <Components.FormNestedArray {...this.props} />;
} else if (this.isObjectField()) {
return <Components.FormNestedObject {...this.props} />;
}
}
return (
<Components.FormComponentInner
@ -337,4 +351,6 @@ FormComponent.contextTypes = {
getDocument: PropTypes.func.isRequired,
};
module.exports = FormComponent
registerComponent('FormComponent', FormComponent);

View file

@ -25,7 +25,7 @@ class FormGroup extends PureComponent {
<div className="form-section-heading" onClick={this.toggle}>
<h3 className="form-section-heading-title">{this.props.label}</h3>
<span className="form-section-heading-toggle">
{this.state.collapsed ? <Components.IconRight height={16} width={16}/> : <Components.IconDown height={16} width={16} />}
{this.state.collapsed ? <Components.IconRight height={16} width={16} /> : <Components.IconDown height={16} width={16} />}
</span>
</div>
);
@ -80,6 +80,8 @@ FormGroup.propTypes = {
currentUser: PropTypes.object,
};
module.exports = FormGroup
registerComponent('FormGroup', FormGroup);
const IconRight = ({ width = 24, height = 24 }) => (

View file

@ -1,49 +1,9 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { Components, registerComponent } from 'meteor/vulcan:core';
import "./FormNestedItem"
const FormNestedItem = ({ nestedFields, name, path, removeItem, itemIndex, ...props }, { errors }) => {
return (
<div className="form-nested-item">
<div className="form-nested-item-inner">
{nestedFields.map((field, i) => {
return (
<Components.FormComponent
key={i}
{...props}
{...field}
path={`${path}.${field.name}`}
itemIndex={itemIndex}
/>
);
})}
</div>
<div className="form-nested-item-remove">
<Components.Button
className="form-nested-button"
variant="danger"
size="small"
onClick={() => {
removeItem(name);
}}
>
<Components.IconRemove height={12} width={12} />
</Components.Button>
</div>
<div className="form-nested-item-deleted-overlay" />
</div>
);
};
FormNestedItem.contextTypes = {
errors: PropTypes.array,
};
registerComponent('FormNestedItem', FormNestedItem);
class FormNested extends PureComponent {
class FormNestedArray extends PureComponent {
getCurrentValue() {
return this.props.currentValues[this.props.path] || []
}
@ -78,7 +38,7 @@ class FormNested extends PureComponent {
{value.map(
(subDocument, i) =>
!this.isDeleted(i) && (
<FormNestedItem
<Components.FormNestedItem
{...properties}
key={i}
itemIndex={i}
@ -98,15 +58,15 @@ class FormNested extends PureComponent {
}
}
FormNested.propTypes = {
FormNestedArray.propTypes = {
currentValues: PropTypes.object,
path: PropTypes.string,
label: PropTypes.string
};
module.exports = FormNested
module.exports = FormNestedArray
registerComponent('FormNested', FormNested);
registerComponent('FormNestedArray', FormNestedArray);
const IconAdd = ({ width = 24, height = 24 }) => (
<svg width={width} height={height} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">

View file

@ -0,0 +1,53 @@
import React from "react"
import PropTypes from 'prop-types';
import { Components, registerComponent } from 'meteor/vulcan:core';
const FormNestedItem = ({ nestedFields, name, path, removeItem, itemIndex, ...props }, { errors }) => {
const isArray = typeof itemIndex !== 'undefined'
return (
<div className="form-nested-item">
<div className="form-nested-item-inner">
{nestedFields.map((field, i) => {
return (
<Components.FormComponent
key={i}
{...props}
{...field}
path={`${path}.${field.name}`}
itemIndex={itemIndex}
/>
);
})}
</div>
{
isArray && [
<div key="remove-button" className="form-nested-item-remove">
<Components.Button
className="form-nested-button"
variant="danger"
size="small"
onClick={() => {
removeItem(name);
}}
>
<Components.IconRemove height={12} width={12} />
</Components.Button>
</div>,
<div key="remove-button-overlay" className="form-nested-item-deleted-overlay" />
]
}
</div>
);
};
FormNestedItem.propTypes = {
path: PropTypes.string.isRequired,
itemIndex: PropTypes.number
}
FormNestedItem.contextTypes = {
errors: PropTypes.array,
};
registerComponent('FormNestedItem', FormNestedItem);

View file

@ -0,0 +1,37 @@
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import { Components, registerComponent } from 'meteor/vulcan:core';
import "./FormNestedItem"
class FormNestedObject extends PureComponent {
/*getCurrentValue() {
return this.props.currentValues[this.props.path] || {}
}*/
render() {
//const value = this.getCurrentValue()
// do not pass FormNested's own value, input and inputProperties props down
const properties = _.omit(this.props, 'value', 'input', 'inputProperties', 'nestedInput');
return (
<div className="form-group row form-nested">
<label className="control-label col-sm-3">{this.props.label}</label>
<div className="col-sm-9">
<Components.FormNestedItem
{...properties}
path={`${this.props.path}`}
/>
</div>
</div>
);
}
}
FormNestedObject.propTypes = {
currentValues: PropTypes.object,
path: PropTypes.string,
label: PropTypes.string
};
module.exports = FormNestedObject
registerComponent('FormNestedObject', FormNestedObject);

View file

@ -2,7 +2,8 @@ import '../components/FieldErrors.jsx';
import '../components/FormErrors.jsx';
import '../components/FormError.jsx';
import '../components/FormComponent.jsx';
import '../components/FormNested.jsx';
import '../components/FormNestedArray.jsx';
import '../components/FormNestedObject.jsx';
import '../components/FormIntl.jsx';
import '../components/FormGroup.jsx';
import '../components/FormSubmit.jsx';

View file

@ -17,7 +17,7 @@ export const convertSchema = (schema, flatten = false) => {
// extract schema
jsonSchema[fieldName] = getFieldSchema(fieldName, schema);
// check for existence of nested schema on corresponding array field
// check for existence of nested schema
const subSchema = getNestedSchema(fieldName, schema);
// if nested schema exists, call convertSchema recursively
if (subSchema) {
@ -51,15 +51,43 @@ export const getFieldSchema = (fieldName, schema) => {
return fieldSchema;
};
// type is an array due to the possibility of using SimpleSchema.oneOf
// right now we support only fields with one type
export const getSchemaType = schema => schema.type.definitions[0].type
const getArrayNestedSchema = (fieldName, schema) => {
const arrayItemSchema = schema._schema[`${fieldName}.$`];
const nestedSchema = arrayItemSchema && getSchemaType(arrayItemSchema)
return nestedSchema
}
// nested object fields type is of the form "type: new SimpleSchema({...})"
// so they should possess a "_schema" prop
const isNestedSchemaField = (fieldSchema) => {
const fieldType = getSchemaType(fieldSchema)
//console.log('fieldType', typeof fieldType, fieldType._schema)
return fieldType && !!fieldType._schema
}
const getObjectNestedSchema = (fieldName, schema) => {
const fieldSchema = schema._schema[fieldName]
if (!isNestedSchemaField(fieldSchema)) return null
const nestedSchema = fieldSchema && getSchemaType(fieldSchema)
return nestedSchema
}
/*
Given an array field, get its nested schema
*/
export const getNestedSchema = (fieldName, schema) => {
const arrayItemSchema = schema._schema[`${fieldName}.$`];
const nestedSchema = arrayItemSchema && arrayItemSchema.type.definitions[0].type;
return nestedSchema;
const arrayItemSchema = getArrayNestedSchema(fieldName, schema)
if (!arrayItemSchema) {
// look for an object schema
const objectItemSchema = getObjectNestedSchema(fieldName, schema)
// no schema was found
if (!objectItemSchema) return null
return objectItemSchema
}
return arrayItemSchema
};
export const schemaProperties = [

View file

@ -8,7 +8,7 @@ Package.describe({
Package.onUse(function (api) {
api.versionsFrom("1.6.1");
api.use(["vulcan:core@1.11.2", "fourseven:scss@4.5.0"]);
api.use(["vulcan:core@1.11.2", "vulcan:ui-bootstrap@1.11.2", "fourseven:scss@4.5.0"]);
api.addFiles(["lib/stylesheets/style.scss", "lib/stylesheets/datetime.scss"], "client");

View file

@ -1,20 +1,32 @@
// setup JSDOM server side for testing (necessary for Enzyme to mount)
import 'jsdom-global/register'
import React from 'react'
// TODO: should be loaded from Components instead?
import Form from '../lib/components/Form'
import FormNested from '../lib/components/FormNested'
import FormGroup from "../lib/components/FormGroup"
import FormComponent from "../lib/components/FormComponent"
import '../lib/components/FormNestedArray'
import expect from 'expect'
import Enzyme, { mount, shallow } from 'enzyme'
import Adapter from 'enzyme-adapter-react-16';
// we must import all the other components, so that "registerComponent" is called
import "../lib/modules/components"
import { Components } from "meteor/vulcan:core"
// setup enzyme
// TODO: write a reusable helper and move this to the tests setup
Enzyme.configure({ adapter: new Adapter() })
// we must import all the other components, so that "registerComponent" is called
import "../lib/modules/components"
// and then load them in the app so that <Component.Whatever /> is defined
import { populateComponentsApp, initializeFragments } from "meteor/vulcan:lib"
// we need registered fragments to be initialized because populateComponentsApp will run
// hocs, like withUpdate, that rely on fragments
initializeFragments()
// actually fills the Components object
populateComponentsApp()
// fixtures
import SimpleSchema from "simpl-schema";
const addressGroup = {
@ -22,61 +34,52 @@ const addressGroup = {
label: "Addresses",
order: 10
};
const addressSchema = new SimpleSchema({
const addressSchema = {
street: {
type: String,
optional: true,
viewableBy: ["guests"],
editableBy: ["members"],
insertableBy: ["members"],
editableBy: ["quests"],
insertableBy: ["quests"],
max: 100 // limit street address to 100 characters
},
});
const schema = {
_id: {
type: String,
optional: true,
viewableBy: ["guests"]
},
createdAt: {
type: Date,
optional: true,
onInsert: (document, currentUser) => {
return new Date();
}
},
userId: {
type: String,
optional: true
},
name: {
type: String,
optional: false,
viewableBy: ["guests"],
editableBy: ["members"],
insertableBy: ["members"],
searchable: true // make field searchable
},
};
const arraySchema = {
addresses: {
type: Array,
viewableBy: ["guests"],
editableBy: ["members"],
insertableBy: ["members"],
editableBy: ["quests"],
insertableBy: ["quests"],
group: addressGroup
},
"addresses.$": {
type: addressSchema
type: new SimpleSchema(addressSchema)
}
};
const objectSchema = {
addresses: {
type: new SimpleSchema(addressSchema),
viewableBy: ["guests"],
editableBy: ["quests"],
insertableBy: ["quests"],
},
};
// stub collection
import { createCollection, getDefaultResolvers, getDefaultMutations } from 'meteor/vulcan:core'
const Customers = createCollection({
collectionName: 'Customers',
typeName: 'Customer',
schema,
resolvers: getDefaultResolvers('Customers'),
mutations: getDefaultMutations('Customers'),
const WithArrays = createCollection({
collectionName: 'WithArrays',
typeName: 'WithArray',
schema: arraySchema,
resolvers: getDefaultResolvers('WithArrays'),
mutations: getDefaultMutations('WithArrays'),
});
const WithObjects = createCollection({
collectionName: 'WithObjects',
typeName: 'WithObject',
schema: objectSchema,
resolvers: getDefaultResolvers('WithObjects'),
mutations: getDefaultMutations('WithObjects'),
});
const Addresses = createCollection({
@ -87,24 +90,26 @@ const Addresses = createCollection({
mutations: getDefaultMutations('Addresses'),
})
// tests
describe('vulcan-forms/components', function () {
describe('Form', function () {
const mountWithContext = C => mount(C, {
context: {
intl: {
formatMessage: () => "",
formatDate: () => ""
}
}
})
const shallowWithContext = C => shallow(C, {
context: {
const context = {
intl: {
formatMessage: () => "",
formatDate: () => "",
formatTime: () => ""
formatTime: () => "",
formatRelative: () => "",
formatNumber: () => "",
formatPlural: () => "",
formatHTMLMessage: () => ""
}
}
const mountWithContext = C => mount(C, {
context
})
const shallowWithContext = C => shallow(C, {
context
})
describe('basic', function () {
it('shallow render', function () {
@ -112,19 +117,165 @@ describe('vulcan-forms/components', function () {
expect(wrapper).toBeDefined()
})
})
describe('nested forms', function () {
describe('nested array', function () {
it('shallow render', () => {
const wrapper = shallowWithContext(<Form collection={Customers} />)
const wrapper = shallowWithContext(<Form collection={WithArrays} />)
expect(wrapper).toBeDefined()
})
it('render a FormGroup for addresses', function () {
const wrapper = shallowWithContext(<Form collection={WithArrays} />)
const formGroup = wrapper.find('FormGroup').find({ name: 'addresses' })
expect(formGroup).toBeDefined()
expect(formGroup).toHaveLength(1)
})
})
describe('nested object', function () {
it('shallow render', () => {
const wrapper = shallowWithContext(<Form collection={WithObjects} />)
expect(wrapper).toBeDefined()
})
it('define one field', () => {
const wrapper = shallowWithContext(<Form collection={WithObjects} />)
const defaultGroup = wrapper.find('FormGroup').first()
const fields = defaultGroup.prop('fields')
expect(fields).toHaveLength(1) // addresses field
})
const getFormFields = (wrapper) => {
const defaultGroup = wrapper.find('FormGroup').first()
const fields = defaultGroup.prop('fields')
return fields
}
const getFirstField = () => {
const wrapper = shallowWithContext(<Form collection={WithObjects} />)
const fields = getFormFields(wrapper)
return fields[0]
}
it('define the nestedSchema', () => {
const addressField = getFirstField()
expect(addressField.nestedSchema.street).toBeDefined()
})
})
})
describe('FormNested', function () {
it('mount', function () {
const wrapper = shallow(<FormNested path="foobar" currentValues={{}} />)
describe('FormComponent', function () {
const shallowWithContext = C => shallow(C, {
context: {
getDocument: () => { }
}
})
const defaultProps = {
"disabled": false,
"optional": true,
"document": {},
"name": "meetingPlace",
"path": "meetingPlace",
"datatype": [{ type: Object }],
"layout": "horizontal",
"label": "Meeting place",
"currentValues": {},
"formType": "new",
deletedValues: [],
throwError: () => { },
updateCurrentValues: () => { },
errors: [],
clearFieldErrors: () => { },
}
it('shallow render', function () {
const wrapper = shallowWithContext(<FormComponent {...defaultProps} />)
expect(wrapper).toBeDefined()
})
describe('nested array', function () {
const props = {
...defaultProps,
"datatype": [{ type: Array }],
"nestedSchema": {
"street": {},
"country": {},
"zipCode": {}
},
"nestedInput": true,
"nestedFields": [
{},
{},
{}
],
"currentValues": {},
}
it('render a FormNestedArray', function () {
const wrapper = shallowWithContext(<FormComponent {...props} />)
const formNested = wrapper.find('FormNestedArray')
expect(formNested).toHaveLength(1)
})
})
describe('nested object', function () {
const props = {
...defaultProps,
"datatype": [{ type: new SimpleSchema({}) }],
"nestedSchema": {
"street": {},
"country": {},
"zipCode": {}
},
"nestedInput": true,
"nestedFields": [
{},
{},
{}
],
"currentValues": {},
}
it('shallow render', function () {
const wrapper = shallowWithContext(<FormComponent {...props} />)
expect(wrapper).toBeDefined()
})
it('render a FormNestedObject', function () {
const wrapper = shallowWithContext(<FormComponent {...props} />)
const formNested = wrapper.find('FormNestedObject')
expect(formNested).toHaveLength(1)
})
})
})
describe('FormNestedArray', function () {
it('shallow render', function () {
const wrapper = shallow(<Components.FormNestedArray path="foobar" currentValues={{}} />)
expect(wrapper).toBeDefined()
})
it('shows a button', function () {
const wrapper = shallow(<Components.FormNestedArray path="foobar" currentValues={{}} />)
const button = wrapper.find('BootstrapButton')
expect(button).toHaveLength(1)
})
it('shows an add button', function () {
const wrapper = shallow(<Components.FormNestedArray path="foobar" currentValues={{}} />)
const addButton = wrapper.find('IconAdd')
expect(addButton).toHaveLength(1)
})
})
describe('FormNestedObject', function () {
it('shallow render', function () {
const wrapper = shallow(<Components.FormNestedObject path="foobar" currentValues={{}} />)
expect(wrapper).toBeDefined()
})
it.skip('render a form for the object', function () {
const wrapper = shallow(<Components.FormNestedObject path="foobar" currentValues={{}} />)
expect(false).toBe(true)
})
it('does not show any button', function () {
const wrapper = shallow(<Components.FormNestedObject path="foobar" currentValues={{}} />)
const button = wrapper.find('BootstrapButton')
expect(button).toHaveLength(0)
})
it('does not show add button', function () {
const wrapper = shallow(<Components.FormNestedObject path="foobar" currentValues={{}} />)
const addButton = wrapper.find('IconAdd')
expect(addButton).toHaveLength(0)
})
it('does not show remove button', function () {
const wrapper = shallow(<Components.FormNestedObject path="foobar" currentValues={{}} />)
const removeButton = wrapper.find('IconRemove')
expect(removeButton).toHaveLength(0)
})
})
})

View file

@ -1,3 +1,2 @@
import './schema_utils.test.js'
import './package.test.js'
import './components.test.js'

View file

@ -1,10 +1,51 @@
//import { convertSchema } from '../lib/modules/schema_utils.js'
import { convertSchema, getSchemaType, getNestedSchema } from '../lib/modules/schema_utils.js'
import SimpleSchema from 'simpl-schema'
import expect from 'expect'
const addressSchema = {
street: {
type: String,
},
country: {
type: String,
},
}
const addressSimpleSchema = new SimpleSchema(addressSchema)
describe('schema_utils', function () {
describe('convertSchema', function () {
it('run a test', function () {
expect(true).toBe(true)
describe('getNestedSchema', function () {
it('get nested schema of an array', function () {
const simpleSchema = new SimpleSchema({
addresses: {
type: Array
},
"addresses.$": {
// this is due to SimpleSchema objects structure
type: addressSimpleSchema
}
})
const nestedSchema = getNestedSchema('addresses', simpleSchema)
// nestedSchema is a complex SimpleSchema object, so we can only
// test its type instead (might not be the simplest way though)
expect(Object.keys(nestedSchema._schema)).toEqual(Object.keys(addressSchema))
})
it('get nested schema of an object', function () {
const simpleSchema = new SimpleSchema({
meetingPlace: {
type: addressSimpleSchema
}
})
const nestedSchema = getNestedSchema('meetingPlace', simpleSchema)
expect(Object.keys(nestedSchema._schema)).toEqual(Object.keys(addressSchema))
})
it('return null for other types', function () {
const simpleSchema = new SimpleSchema({
createdAt: {
type: Date
}
})
const nestedSchema = getNestedSchema('createdAt', simpleSchema)
expect(nestedSchema).toBeNull()
})
})
})