diff --git a/.gitignore b/.gitignore index fce7d1d993be4..415e2b7c4b16c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .DS_STORE node_modules *~ +*.swp *.pyc static .grunt diff --git a/src/ReactWithAddons.js b/src/ReactWithAddons.js index 3b92d59f813ac..43a41a1034417 100644 --- a/src/ReactWithAddons.js +++ b/src/ReactWithAddons.js @@ -28,13 +28,15 @@ var LinkedStateMixin = require('LinkedStateMixin'); var React = require('React'); var ReactTransitionGroup = require('ReactTransitionGroup'); +var defineClass = require('defineClass'); var cx = require('cx'); React.addons = { classSet: cx, LinkedStateMixin: LinkedStateMixin, - TransitionGroup: ReactTransitionGroup + TransitionGroup: ReactTransitionGroup, + defineClass: defineClass }; module.exports = React; diff --git a/src/addons/defineClass/Advice.js b/src/addons/defineClass/Advice.js new file mode 100644 index 0000000000000..ae07953506416 --- /dev/null +++ b/src/addons/defineClass/Advice.js @@ -0,0 +1,94 @@ +/** + * Copyright 2013 Facebook, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @providesModule Advice + */ + +'use strict'; + +var Advice = { + /** + * Creates a function which invokes the callback before the base function. + * The callback is invoked with the same context and arguments as the wrapper + * function. The return value is the same as it would have been otherwise. + * + * @param {function} base Function to be wrapped + * @param {function} callback Function to be invoked before base + * @return {function} Wrapped function + */ + before: function wrappedBefore(base, callback) { + return function() { + callback.apply(this, arguments); + return base.apply(this, arguments); + }; + }, + + /** + * Creates a function which invokes the callback after the base function. + * The callback is invoked with the same context and arguments as the wrapper + * function. The return value is the same as it would have been otherwise. + * + * @param {function} base Function to be wrapped + * @param {function} callback Function to be invoked after base + * @return {function} Wrapped function + */ + after: function(base, callback) { + return function wrappedAfter() { + var result = base.apply(this, arguments); + callback.apply(this, arguments); + return result; + }; + }, + + /** + * Creates a function which invokes the callback with the base function + * unshifted onto its arguments. The remaining arguments and context are + * callback are preserved, but the return value is replaced with the return + * value of the callback. + * + * @param {function} base Function to be wrapped + * @param {function} callback Function to be invoked with base as first arg + * @return {function} Wrapped function + */ + around: function(base, callback) { + return function wrappedAround() { + var args = []; + args.push(base.bind(this)); + for (var i = 0, l = arguments.length; i < l; i++) { + args.push(arguments[i]); + } + return callback.apply(this, args); + }; + }, + + /** + * Creates a function which takes a predicate, evaluates the predicate with + * the same arguments and context as the base, and turns the function into a + * no-op if the predicate evaluates to false. + * + * @param {function} base Function to be wrapped + * @param {function(): boolean} predicate A predicate function + * @return {function} Wrapped function + */ + filter: function(base, predicate) { + return function wrappedFiltered() { + if (predicate.apply(this, arguments)) { + return base.apply(this, arguments); + } + }; + } +}; + +module.exports = Advice; diff --git a/src/addons/defineClass/AdviceDefinition.js b/src/addons/defineClass/AdviceDefinition.js new file mode 100644 index 0000000000000..ecec12240d5e4 --- /dev/null +++ b/src/addons/defineClass/AdviceDefinition.js @@ -0,0 +1,155 @@ +/** + * Copyright 2013 Facebook, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @providesModule AdviceDefinition + */ + +'use strict'; + +// For more information on some of the terminology/variable names used in this +// file, see the Wikipedia page on aspect-oriented programming. +// http://en.wikipedia.org/wiki/Aspect-oriented_programming + +var Advice = require('Advice'); +var ReactCompositeComponent = require('ReactCompositeComponent'); +var SpecPolicy = ReactCompositeComponent.SpecPolicy; +var ReactCompositeComponentInterface = ReactCompositeComponent.Interface; + +var invariant = require('invariant'); +var mergeMethod = require('mergeMethod'); + +/** + * These are shims to simulate pointcuts on ReactCompositeComponent which + * correspond to its lifecycle. @see ReactCompositeComponent#LifeCycle. + * Refactoring ReactCompositeComponent to actually use these methods names is + * left as an exercise for the reader. + * I apologize for the repetition. I apologize. + */ +var RESERVED_ADVICE_KEYS = { + mount: function(joinPoint, callback) { + invariant( + joinPoint === 'before' || + joinPoint === 'after', + 'AdviceDefinition: The %s method is not supported for "mount"', + joinPoint + ); + switch (joinPoint) { + case 'before': + mergeMethod(this, 'componentWillMount', callback); + break; + case 'after': + mergeMethod(this, 'componentDidMount', callback); + break; + default: + break; + } + }, + + receiveProps: function(joinPoint, callback) { + invariant( + joinPoint === 'before', + 'AdviceDefinition: The %s method is not supported for "receiveProps"', + joinPoint + ); + switch (joinPoint) { + case 'before': + mergeMethod(this, 'componentWillReceiveProps', callback); + break; + default: + break; + } + }, + + update: function(joinPoint, callback) { + invariant( + joinPoint === 'before' || + joinPoint === 'after' || + joinPoint === 'filter', + 'AdviceDefinition: The %s method is not supported for "update"', + joinPoint + ); + switch (joinPoint) { + case 'before': + mergeMethod(this, 'componentWillUpdate', callback); + break; + case 'after': + mergeMethod(this, 'componentDidUpdate', callback); + break; + case 'filter': + mergeMethod(this, 'shouldComponentUpdate', callback); + break; + default: + break; + } + }, + + unmount: function(joinPoint, callback) { + invariant( + joinPoint === 'before', + 'AdviceDefinition: The % method is not supported for "unmount"', + joinPoint + ); + switch (joinPoint) { + case 'before': + mergeMethod(this, 'componentWillUnmount', callback); + default: + break; + } + } +}; + +function validateAdvice(spec, joinPoint, methodName) { + invariant( + typeof spec[methodName] === 'function', + 'AdviceDefinition: You are attempting to use advice methods on %s, when ' + + 'its type is %s. Advice methods can only work on spec properties which' + + 'are functions.', + methodName, typeof spec[methodName] + ); + + // We should not use around and filter with DEFINE_ONCE methods because they + // might modify the method in unacceptable ways (e.g. modify the return value, + // turn the method into a no-op). + var specPolicy = ReactCompositeComponentInterface[methodName]; + if (specPolicy === SpecPolicy.DEFINE_ONCE) { + invariant( + joinPoint === 'before' || + joinPoint === 'after', + 'AdviceDefinition: You may not use the %s method to override %s. ' + + 'Use the before or after methods instead.', + joinPoint, + methodName + ); + } +} + +function wrapMethod(joinPoint, methodName, callback) { + if (RESERVED_ADVICE_KEYS.hasOwnProperty(methodName)) { + RESERVED_ADVICE_KEYS[methodName].call(this, joinPoint, callback); + } else { + validateAdvice(this, joinPoint, methodName); + this[methodName] = Advice[joinPoint](this[methodName], callback); + } +} + +function AdviceDefinition() { + 'before after around filter'.split(' ').forEach(function(joinPoint) { + this[joinPoint] = function(methodName, callback) { + wrapMethod.call(this, joinPoint, methodName, callback); + }; + }.bind(this)); +} + +module.exports = AdviceDefinition; diff --git a/src/addons/defineClass/BaseDefinition.js b/src/addons/defineClass/BaseDefinition.js new file mode 100644 index 0000000000000..1a5327b3247ae --- /dev/null +++ b/src/addons/defineClass/BaseDefinition.js @@ -0,0 +1,37 @@ +/** + * Copyright 2013 Facebook, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @providesModule BaseDefinition + */ + +'use strict'; + +var mergeMethod = require('mergeMethod'); + +function BaseDefinition() { + this.initialState = function(stateObj) { + mergeMethod(this, 'getInitialState', function() { + return stateObj; + }); + }; + + this.defaultProps = function(propsObj) { + mergeMethod(this, 'getDefaultProps', function() { + return propsObj; + }); + }; +} + +module.exports = BaseDefinition; diff --git a/src/addons/defineClass/__tests__/Advice-test.js b/src/addons/defineClass/__tests__/Advice-test.js new file mode 100644 index 0000000000000..4ab66908ad988 --- /dev/null +++ b/src/addons/defineClass/__tests__/Advice-test.js @@ -0,0 +1,157 @@ +/** + * Copyright 2013 Facebook, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @emails react-core + */ + +"use strict"; + +var Advice = require('Advice'); +var mocks = require('mocks'); + +describe('Advice', function() { + var base, callback; + + beforeEach(function() { + base = mocks.getMockFunction(); + callback = mocks.getMockFunction(); + }); + + describe('wrappedBefore', function() { + it('should invoke callback before base', function() { + var stack = []; + + base.mockImplementation(function() { + stack.push(base); + }); + callback.mockImplementation(function() { + stack.push(callback); + }); + + Advice.before(base, callback)(); + expect(stack[0]).toBe(callback); + expect(stack[1]).toBe(base); + }); + + it('should pass `this` and `arguments` to base and callback', function() { + var args = ['Tom', 'Dick', 'Harry']; + var context = {}; + + Advice.before(base, callback).apply(context, args); + expect(base.mock.calls[0]).toEqual(args); + expect(base.mock.instances[0]).toBe(context); + expect(callback.mock.calls[0]).toEqual(args); + expect(callback.mock.instances[0]).toBe(context); + }); + + it('should return whatever base returns', function() { + var result = {}; + base.mockDefaultReturnValue(result); + expect(Advice.before(base, callback)()).toBe(base()); + }); + }); + + describe('wrappedAfter', function() { + it('should invoke callback after base', function() { + var stack = []; + + base.mockImplementation(function() { + stack.push(base); + }); + callback.mockImplementation(function() { + stack.push(callback); + }); + + Advice.after(base, callback)(); + expect(stack[0]).toBe(base); + expect(stack[1]).toBe(callback); + }); + + it('should pass `this` and `arguments` to base and callback', function() { + var args = ['Tom', 'Dick', 'Harry']; + var context = {}; + + Advice.after(base, callback).apply(context, args); + expect(base.mock.calls[0]).toEqual(args); + expect(base.mock.instances[0]).toBe(context); + expect(callback.mock.calls[0]).toEqual(args); + expect(callback.mock.instances[0]).toBe(context); + }); + + it('should return whatever base returns', function() { + var result = {}; + base.mockDefaultReturnValue(result); + expect(Advice.after(base, callback)()).toBe(base()); + }); + }); + + describe('wrappedAround', function() { + it('should invoke callback with base as first argument', function() { + var stack = []; + callback.mockImplementation(function(first) { + stack.push(first); + first('pizza'); + }); + Advice.around(base, callback)(); + expect(base.mock.calls[0]).toEqual(['pizza']); + }); + + it('should bind base to context', function() { + var context = {}; + callback.mockImplementation(function(first) { + first.call(null); + }); + Advice.around(base, callback).call(context); + expect(base.mock.instances[0]).toBe(context); + }); + + it('should invoke callback with `arguments` unshifted but same `this`', + function() { + var args = ['Tom', 'Dick', 'Harry']; + var context1 = {}; + var context2 = {}; + Advice.around(base, callback).apply(context1, args); + Advice.around(base, callback).apply(context2, args); + expect(callback.mock.calls[0].slice(1)).toEqual(args); + expect(callback.mock.calls[1].slice(1)).toEqual(args); + expect(callback.mock.instances[0]).toBe(context1); + expect(callback.mock.instances[1]).toBe(context2); + }); + + it('should return whatever the callback returns', function() { + var result = {}; + base.mockDefaultReturnValue(null); + callback.mockDefaultReturnValue(result); + expect(Advice.around(base, callback)()).toBe(result); + }); + }); + + describe('wrappedFilter', function() { + it('should work as normal if callback returns truthy', function() { + var result = {}; + base.mockDefaultReturnValue(result); + callback.mockDefaultReturnValue(1); + expect(Advice.filter(base, callback)()).toBe(result); + expect(base.mock.calls.length).toEqual(1); + }); + + it('should disable base if callback returns falsy', function() { + base.mockDefaultReturnValue(null); + callback.mockDefaultReturnValue(0); + expect(Advice.filter(base, callback)()).toBeUndefined(); + expect(base.mock.calls.length).toEqual(0); + }); + }); +}); diff --git a/src/addons/defineClass/__tests__/defineClass-test.js b/src/addons/defineClass/__tests__/defineClass-test.js new file mode 100644 index 0000000000000..df408150d7457 --- /dev/null +++ b/src/addons/defineClass/__tests__/defineClass-test.js @@ -0,0 +1,61 @@ +/** + * Copyright 2013 Facebook, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @emails react-core + * @jsx React.DOM + */ + +var mocks = require('mocks'); +var React = require('React'); +var defineClass = require('defineClass'); + +describe('defineClass', function() { + var initialState, defaultProps; + beforeEach(function() { + initialState = { + activated: false + } + defaultProps = { + displayActive: 'Hooray!', + displayInactive: 'Boo!' + }; + }); + + it('should create a valid React component', function() { + var afterMount = mocks.getMockFunction(); + + var Component = defineClass(function() { + this.initialState(initialState); + this.defaultProps(defaultProps); + + this.render = function() { + var text = this.state.activated + ? this.props.displayActive + : this.props.displayInactive + return