diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..8df53fe --- /dev/null +++ b/.babelrc @@ -0,0 +1,3 @@ +{ +"presets": ["react-native"] +} \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 3cfc9ab..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "extends": "eslint-config-shakacode", - - "plugins": [ - "react-native", - "flowtype" - ], - - "env": { - "mocha": true, - "browser": true, - "node": true - }, - - "settings": { - "import/resolver": { - "node": { - "extensions": [".js", ".android.js", ".ios.js"], - "moduleDirectory": ["..", "node_modules"] - } - }, - "flowtype": { - "onlyFilesWithFlowAnnotation": true - } - }, - - "globals": { - "__DEV__": true - }, - - "rules": { - "react-native/no-unused-styles": 2, - "react-native/split-platform-components": 2, - "react-native/no-inline-styles": 2, - "react-native/no-color-literals": 2, - "react/jsx-no-bind": 1, - "react/prefer-stateless-function": 1, - "react/jsx-indent": 1, - - "flowtype/require-parameter-type": 1, - "flowtype/require-return-type": [ - 0, - "always", - { - "annotateUndefined": "never" - } - ], - "flowtype/space-after-type-colon": [ - 1, - "always" - ], - "flowtype/space-before-type-colon": [ - 1, - "never" - ], - "flowtype/type-id-match": [ - 1, - "^([A-Z][a-z0-9]+)+Type$" - ] - } -} diff --git a/.eslintrc.yml b/.eslintrc.yml new file mode 100644 index 0000000..40278be --- /dev/null +++ b/.eslintrc.yml @@ -0,0 +1,51 @@ +--- +extends: eslint-config-shakacode +plugins: +- react-native +- flowtype +env: + mocha: true + browser: true + node: true +settings: + import/resolver: + node: + extensions: + - ".js" + - ".android.js" + - ".ios.js" + moduleDirectory: + - ".." + - node_modules + flowtype: + onlyFilesWithFlowAnnotation: true +globals: + __DEV__: true + ReactClass: true + ReactElement: true +rules: + react-native/no-unused-styles: 2 + react-native/split-platform-components: 2 + react-native/no-inline-styles: 2 + react-native/no-color-literals: 2 + react/jsx-no-bind: 1 + react/prefer-stateless-function: 1 + react/jsx-indent: 1 + flowtype/require-parameter-type: 1 + flowtype/require-return-type: + - 0 + - always + - annotateUndefined: never + flowtype/space-after-type-colon: + - 1 + - always + flowtype/space-before-type-colon: + - 1 + - never + flowtype/type-id-match: + - 1 + - "^([A-Z][a-z0-9]+)+Type$" + + react/jsx-filename-extension: + - 1 + - extensions: [".js", ".jsx"] diff --git a/.flowconfig b/.flowconfig index 9881873..11d8b13 100644 --- a/.flowconfig +++ b/.flowconfig @@ -1,26 +1,34 @@ [ignore] +; We fork some components by platform +.*/*[.]android.js -# We fork some components by platform. -.*/*.android.js +; Ignore "BUCK" generated dirs +/\.buckd/ -# Ignore templates with `@flow` in header -.*/local-cli/generator.* +; Ignore unexpected extra "@providesModule" +.*/node_modules/.*/node_modules/fbjs/.* -# Ignore malformed json -.*/node_modules/y18n/test/.*\.json -.*/node_modules/jsonlint/test/.*\.json +; Ignore duplicate module providers +; For RN Apps installed via npm, "Libraries" folder is inside +; "node_modules/react-native" but in the source repo it is in the root +.*/Libraries/react-native/React.js +.*/Libraries/react-native/ReactNative.js +.*/node_modules/react-native/Libraries/Components/StaticContainer.js + +; https://github.com/aksonov/react-native-router-flux/issues/892#issuecomment-230104301 +.*/node_modules/react-native-experimental-navigation/.* [include] [libs] node_modules/react-native/Libraries/react-native/react-native-interface.js node_modules/react-native/flow +flow/ [options] module.system=haste -esproposal.class_static_fields=enable -esproposal.class_instance_fields=enable +experimental.strict_type_args=true munge_underscores=true @@ -31,9 +39,11 @@ suppress_type=$FlowIssue suppress_type=$FlowFixMe suppress_type=$FixMe -suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe\\($\\|[^(]\\|(\\(>=0\\.\\(2[0-7]\\|1[0-9]\\|[0-9]\\).[0-9]\\)? *\\(site=[a-z,_]*react_native[a-z,_]*\\)?)\\) -suppress_comment=\\(.\\|\n\\)*\\$FlowIssue\\((\\(>=0\\.\\(2[0-7]\\|1[0-9]\\|[0-9]\\).[0-9]\\)? *\\(site=[a-z,_]*react_native[a-z,_]*\\)?)\\)?:? #[0-9]+ +suppress_comment=\\(.\\|\n\\)*\\$FlowFixMe\\($\\|[^(]\\|(\\(>=0\\.\\(3[0-6]\\|[1-2][0-9]\\|[0-9]\\).[0-9]\\)? *\\(site=[a-z,_]*react_native[a-z,_]*\\)?)\\) +suppress_comment=\\(.\\|\n\\)*\\$FlowIssue\\((\\(>=0\\.\\(3[0-6]\\|1[0-9]\\|[1-2][0-9]\\).[0-9]\\)? *\\(site=[a-z,_]*react_native[a-z,_]*\\)?)\\)?:? #[0-9]+ suppress_comment=\\(.\\|\n\\)*\\$FlowFixedInNextDeploy +unsafe.enable_getters_and_setters=true + [version] -^0.22.1 +^0.35.0 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d42ff18 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.pbxproj -text diff --git a/.gitignore b/.gitignore index eb1535e..a26a140 100644 --- a/.gitignore +++ b/.gitignore @@ -22,12 +22,13 @@ DerivedData *.xcuserstate project.xcworkspace -# Android/IJ +# Android/IntelliJ # -*.iml +build/ .idea .gradle local.properties +*.iml # node.js # @@ -38,4 +39,15 @@ npm-debug.log buck-out/ \.buckd/ android/app/libs -android/keystores/debug.keystore +*.keystore + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots diff --git a/Readme.md b/Readme.md index c8acf37..eaac43b 100644 --- a/Readme.md +++ b/Readme.md @@ -1,39 +1,64 @@ ## React Native Tutorial -This is a simple "Hello world" app in React Native. -This tutorial shows how to connect to the the http://www.reactrails.com API for a sample microblog. -Please see https://github.com/shakacode/react-webpack-rails-tutorial for more information on the back end. +This is a simple mobile app example for posting comments in React Native. It connexts the API at +https://www.reactrails.com. You can see a web client there, plus links to the source. ### Setup 1. Install the latest version of Xcode from AppStore or https://developer.apple.com/download/ (Apple ID required) 2. Install the latest version of Android Studio from https://developer.android.com/studio/index.html -3. Install Node JS 4.0 or higher +3. Install nvm (Node Version Manager) ``` - brew install node + curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.32.1/install.sh | bash ``` -4. Install React Native and recommended packages + +4. Install NodeJS stable + + ``` + nvm install node + ``` + +5. Install React Native and recommended packages ``` npm install -g react-native-cli brew install watchman brew install flow ``` -5. Clone react-native-tutorial repo. Note, you need to name the directory in PascalCase, per below. The reason is for running the tests with an absolute file path. - ``` - git clone git@github.com:shakacode/react-native-tutorial.git ReactNativeTutorial - ``` -6. Install dependencies +6. Install npm dependencies ``` - cd react-native-tutorial && npm i + npm i ``` +7. Install Native Dependencies (maybe) + +* vector-icons + + +## Customization in Native Files + +Besides adding vector-icons + +1. App name +2. Icons, both ios and android + +### Android + +Android Keystore +1. Edit `android/app/src/main/AndroidManifest.xml` + + + ### Backend API -* Currently connecting by default to http://www.reactrails.com/. Be aware of that! +* Currently connecting by default to https://www.reactrails.com/. Be aware of that! +* The url can be changed app/api/index.js. Keep in mind, that Android emulator is +a separate Virtual Machine with its own localhost binding. To make the api available under that emulator, +you will have to use ip address of your computer, which can be seen by running `ifconfig` in the shell ### Running IOS + ``` react-native run-ios ``` @@ -44,18 +69,33 @@ react-native run-ios - Run `android sdk` from bash and find installed build tools version there 2. Run emulator from Android studio or `emulator @` from bash (you can find installed version by running `emulator -list-avds` from bash) 3. From project folder run + ``` react-native run-android ``` ### Testing Testing framework uses mocha + enzyme, to run tests type + ``` npm test ``` ### Linters This projects uses Eslint with React and React Native rules. To run linters type + ``` npm run lint ``` + + +### Flow +This projects uses Eslint with React and React Native rules. To run linters type + +``` +npm run flow +``` + +### Detailed docs + +Can be found in `docs` folder. See [Introduction](docs/Introduction.md) to start. diff --git a/__mocks__/mock.js b/__mocks__/mock.js new file mode 100644 index 0000000..fa7c739 --- /dev/null +++ b/__mocks__/mock.js @@ -0,0 +1,8 @@ +import React from 'react'; +import _ from 'lodash/fp'; + +export default (props) => React.createElement( + 'Mock', + _.omit('store', props), + props.children, +); diff --git a/__mocks__/mockCall.js b/__mocks__/mockCall.js new file mode 100644 index 0000000..19ff766 --- /dev/null +++ b/__mocks__/mockCall.js @@ -0,0 +1,39 @@ +class MockCall { + constructor() { + this.queue = []; + } + + setMocks(mocks) { + this.queue = mocks; + } + + getNextMock() { + if (!this.queue || !this.queue.length) return undefined; + return this.queue.shift(); + } + + reset() { + this.queue = []; + } +} + +const mockCall = new MockCall(); + +// Mocks calls inside a function under test. This function takes several args and +// stubs the return from call in order of occurence. If no mock were specified +// it returns underfined +export const mockCalls = (...args) => mockCall.setMocks(args); + +// Clears all mocks for calls +export const resetMockCalls = () => mockCall.reset(); + +// The mock call dispatches a fake action to the store with type: 'CALL' and +// function name and args as parameters. +const call = ({ dispatch }) => (f, ...args) => { + dispatch({ type: 'CALL', name: f.name, args }); + const mock = mockCall.getNextMock(); + const result = typeof mock === 'function' ? mock() : mock; + return f.then ? Promise.resolve(result) : result; +}; + +export default call; diff --git a/__mocks__/mockThunkMiddleware.js b/__mocks__/mockThunkMiddleware.js new file mode 100644 index 0000000..cff35cd --- /dev/null +++ b/__mocks__/mockThunkMiddleware.js @@ -0,0 +1,11 @@ +import call from './mockCall'; + +export const thunkMiddlewareCreator = (callEffect) => + ({ dispatch, getState }) => next => action => { + if (typeof action === 'function') { + return action(dispatch, getState, callEffect({ dispatch })); + } + return next(action); + }; + +export default thunkMiddlewareCreator(call); diff --git a/__mocks__/redux-mock-store.js b/__mocks__/redux-mock-store.js new file mode 100644 index 0000000..4173907 --- /dev/null +++ b/__mocks__/redux-mock-store.js @@ -0,0 +1,9 @@ +import configureMockStore from 'redux-mock-store'; +import { initialState as reduxInitialState } from 'ReactRailsApp/app/reducers'; + +import mockThunkMiddleware from './mockThunkMiddleware'; + +export const createStoreFromState = configureMockStore([mockThunkMiddleware]); +export const initialState = reduxInitialState; + +export default () => createStoreFromState(initialState); diff --git a/__tests__/bundles/comments/components/Add.js b/__tests__/bundles/comments/components/Add.js new file mode 100644 index 0000000..50a9e09 --- /dev/null +++ b/__tests__/bundles/comments/components/Add.js @@ -0,0 +1,24 @@ +import React from 'react'; +import Add from 'ReactRailsApp/app/bundles/comments/components/Add/Add'; + +import renderer from 'react-test-renderer'; + +const actions = { + fetch: jest.fn(), + updateForm: jest.fn(), + createComment: jest.fn(), +}; + +describe('Add', () => { + it('renders correctly', () => { + const props = { + author: 'Alexey', + text: 'Some random comment', + actions, + }; + const tree = renderer.create( + + ); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/__tests__/bundles/comments/components/Index.js b/__tests__/bundles/comments/components/Index.js new file mode 100644 index 0000000..ed73b30 --- /dev/null +++ b/__tests__/bundles/comments/components/Index.js @@ -0,0 +1,43 @@ +import React from 'react'; +import Index from 'ReactRailsApp/app/bundles/comments/components/Index/Index'; + +import renderer from 'react-test-renderer'; + +const actions = { + fetch: jest.fn(), + updateForm: jest.fn(), + createComment: jest.fn(), +}; + +describe('Index', () => { + it('renders correctly when loaded', () => { + const props = { + comments: [ + { id: 1, author: 'Alexey', text: 'Just some random comment' }, + { id: 2, author: 'Justin', text: 'Another random comment' }, + ], + meta: { + loading: false, + }, + actions, + }; + const tree = renderer.create( + + ); + expect(tree).toMatchSnapshot(); + }); + + it('renders correctly when loading', () => { + const props = { + meta: { + loading: true, + }, + actions, + }; + const tree = renderer.create( + + ); + expect(tree).toMatchSnapshot(); + }); +}); + diff --git a/__tests__/bundles/comments/components/__snapshots__/Add.js.snap b/__tests__/bundles/comments/components/__snapshots__/Add.js.snap new file mode 100644 index 0000000..272389c --- /dev/null +++ b/__tests__/bundles/comments/components/__snapshots__/Add.js.snap @@ -0,0 +1,325 @@ +exports[`Add renders correctly 1`] = ` + + + + AUTHOR NAME + + + + + + + + COMMENT + + + + + + + + +  + + + Send + + + + +`; diff --git a/__tests__/bundles/comments/components/__snapshots__/Index.js.snap b/__tests__/bundles/comments/components/__snapshots__/Index.js.snap new file mode 100644 index 0000000..452ee1f --- /dev/null +++ b/__tests__/bundles/comments/components/__snapshots__/Index.js.snap @@ -0,0 +1,300 @@ +exports[`Index renders correctly when loaded 1`] = ` + + + } + removeClippedSubviews={true} + renderRow={[Function]} + scrollEventThrottle={50} + scrollRenderAheadDistance={1000} + stickyHeaderIndices={Array []}> + + + Alexey + + + Just some random comment + + + + + Justin + + + Another random comment + + + + + + +  + + + + +`; + +exports[`Index renders correctly when loading 1`] = ` + + + } + removeClippedSubviews={true} + renderRow={[Function]} + scrollEventThrottle={50} + scrollRenderAheadDistance={1000} + stickyHeaderIndices={Array []} /> + + + +  + + + + +`; diff --git a/__tests__/bundles/comments/hocs/__snapshots__/withAddProps.js.snap b/__tests__/bundles/comments/hocs/__snapshots__/withAddProps.js.snap new file mode 100644 index 0000000..35dc090 --- /dev/null +++ b/__tests__/bundles/comments/hocs/__snapshots__/withAddProps.js.snap @@ -0,0 +1,13 @@ +exports[`withAddProps adds AddProps to a component 1`] = ` + +`; diff --git a/__tests__/bundles/comments/hocs/__snapshots__/withIndexProps.js.snap b/__tests__/bundles/comments/hocs/__snapshots__/withIndexProps.js.snap new file mode 100644 index 0000000..8e4eaf0 --- /dev/null +++ b/__tests__/bundles/comments/hocs/__snapshots__/withIndexProps.js.snap @@ -0,0 +1,34 @@ +exports[`withIndexProps adds props to Index component 1`] = ` + +`; diff --git a/__tests__/bundles/comments/hocs/withAddProps.js b/__tests__/bundles/comments/hocs/withAddProps.js new file mode 100644 index 0000000..42c63ad --- /dev/null +++ b/__tests__/bundles/comments/hocs/withAddProps.js @@ -0,0 +1,20 @@ +import React from 'react'; +import withAddProps from 'ReactRailsApp/app/bundles/comments/hocs/withAddProps'; +import Mock from 'mock'; +import renderer from 'react-test-renderer'; +import {createStoreFromState, initialState} from 'redux-mock-store'; + +describe('withAddProps', () => { + it('adds AddProps to a component', () => { + const state = initialState.mergeDeep({ + commentForm: { + author: 'Alexey', + text: 'Random comment', + }, + }); + const store = createStoreFromState(state); + const Component = withAddProps(Mock); + const tree = renderer.create(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/__tests__/bundles/comments/hocs/withIndexProps.js b/__tests__/bundles/comments/hocs/withIndexProps.js new file mode 100644 index 0000000..c693320 --- /dev/null +++ b/__tests__/bundles/comments/hocs/withIndexProps.js @@ -0,0 +1,21 @@ +import React from 'react'; +import withIndexProps from 'ReactRailsApp/app/bundles/comments/hocs/withIndexProps'; +import Mock from 'mock'; +import renderer from 'react-test-renderer'; +import { createStoreFromState, initialState } from 'redux-mock-store'; + +describe('withIndexProps', () => { + it('adds props to Index component', () => { + const state = initialState.mergeDeep({ + commentsStore: { + 2: { id: 2, author: 'Justin', text: 'Another random comment' }, + 3: { id: 3, author: 'John', text: 'Yet another random comment' }, + 1: { id: 1, author: 'Alexey', text: 'Just some random comment' }, + }, + }); + const store = createStoreFromState(state); + const Component = withIndexProps(Mock); + const tree = renderer.create(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/__tests__/bundles/comments/thunks/__snapshots__/createComment.js.snap b/__tests__/bundles/comments/thunks/__snapshots__/createComment.js.snap new file mode 100644 index 0000000..d209091 --- /dev/null +++ b/__tests__/bundles/comments/thunks/__snapshots__/createComment.js.snap @@ -0,0 +1,47 @@ +exports[`createComment creates a comment in the store and sends request to api 1`] = ` +Array [ + Object { + "entities": Object { + "temp:0": Object { + "author": "Alexey", + "id": "temp:0", + "text": "Random comment", + }, + }, + "type": "COMMENTS_STORE:CREATE", + }, + Object { + "args": Array [], + "name": "bound pop", + "type": "CALL", + }, + Object { + "args": Array [ + Object { + "author": "Alexey", + "id": "temp:0", + "text": "Random comment", + }, + ], + "name": "postComment", + "type": "CALL", + }, + Object { + "id": "temp:0", + "type": "COMMENTS_STORE:REMOVE", + }, + Object { + "entities": Object { + "1": Object { + "author": "Alexey", + "id": 1, + "text": "Random comment", + }, + }, + "type": "COMMENTS_STORE:CREATE", + }, + Object { + "type": "COMMENT_FORM:RESET", + }, +] +`; diff --git a/__tests__/bundles/comments/thunks/__snapshots__/fetch.js.snap b/__tests__/bundles/comments/thunks/__snapshots__/fetch.js.snap new file mode 100644 index 0000000..e85eb6f --- /dev/null +++ b/__tests__/bundles/comments/thunks/__snapshots__/fetch.js.snap @@ -0,0 +1,63 @@ +exports[`fetch failing fetch dispatches an action with error 1`] = ` +Array [ + Object { + "loading": true, + "type": "COMMENTS_STORE:SET_LOADING", + }, + Object { + "args": Array [], + "name": "fetchComments", + "type": "CALL", + }, + Object { + "args": Array [ + "Error", + "Could not connect to server", + Array [ + Object { + "text": "OK", + }, + ], + ], + "name": "alert", + "type": "CALL", + }, + Object { + "loading": false, + "type": "COMMENTS_STORE:SET_LOADING", + }, +] +`; + +exports[`fetch successful fetch saves comments into the store 1`] = ` +Array [ + Object { + "loading": true, + "type": "COMMENTS_STORE:SET_LOADING", + }, + Object { + "args": Array [], + "name": "fetchComments", + "type": "CALL", + }, + Object { + "loading": false, + "type": "COMMENTS_STORE:SET_LOADING", + }, + Object { + "entities": Object { + "1": Object { + "author": "Alexey", + "id": 1, + "text": "Just some random comment", + }, + "2": Object { + "author": "Justin", + "id": 2, + "text": "Another random comment", + }, + }, + "type": "COMMENTS_STORE:CREATE", + }, +] +`; diff --git a/__tests__/bundles/comments/thunks/createComment.js b/__tests__/bundles/comments/thunks/createComment.js new file mode 100644 index 0000000..2138bc3 --- /dev/null +++ b/__tests__/bundles/comments/thunks/createComment.js @@ -0,0 +1,24 @@ +import { fromJS } from 'immutable'; +import { createStoreFromState } from 'redux-mock-store'; +import { mockCalls } from 'mockCall'; +import * as actions from 'ReactRailsApp/app/bundles/comments/thunks'; + +describe('createComment', () => { + it('creates a comment in the store and sends request to api', async() => { + const data = { + commentsStore: {}, + commentForm: { author: 'Alexey', text: 'Random comment' }, + }; + const store = createStoreFromState(fromJS(data)); + const response = { + entities: { + comments: { + 1: { id: 1, author: 'Alexey', text: 'Random comment' }, + }, + }, + }; + mockCalls(null, response); + await store.dispatch(actions.createComment()); + expect(store.getActions()).toMatchSnapshot(); + }); +}); diff --git a/__tests__/bundles/comments/thunks/fetch.js b/__tests__/bundles/comments/thunks/fetch.js new file mode 100644 index 0000000..f70f880 --- /dev/null +++ b/__tests__/bundles/comments/thunks/fetch.js @@ -0,0 +1,39 @@ +import createDefaultStore from 'redux-mock-store'; +import { mockCalls, resetMockCalls } from 'mockCall'; +import * as actions from 'ReactRailsApp/app/bundles/comments/thunks'; + +describe('fetch', () => { + let store; + + beforeEach(() => { + store = createDefaultStore(); + }); + + afterEach(() => { + resetMockCalls(); + }); + + describe('successful fetch', () => { + it('saves comments into the store', async () => { + const response = { + entities: { + comments: { + 1: { id: 1, author: 'Alexey', text: 'Just some random comment' }, + 2: { id: 2, author: 'Justin', text: 'Another random comment' }, + }, + }, + }; + mockCalls(response); + await store.dispatch(actions.fetch()); + expect(store.getActions()).toMatchSnapshot(); + }); + }); + + describe('failing fetch', () => { + it('dispatches an action with error', async () => { + mockCalls(() => { throw new Error('Invalid Json'); }); + await store.dispatch(actions.fetch()); + expect(store.getActions()).toMatchSnapshot(); + }); + }); +}); diff --git a/__tests__/bundles/common/hocs/withInitialAction.js b/__tests__/bundles/common/hocs/withInitialAction.js new file mode 100644 index 0000000..2fa8879 --- /dev/null +++ b/__tests__/bundles/common/hocs/withInitialAction.js @@ -0,0 +1,14 @@ +import React from 'react'; +import _ from 'lodash/fp'; +import withInitialAction from 'ReactRailsApp/app/bundles/common/hocs/withInitialAction'; +import Mock from 'mock'; +import renderer from 'react-test-renderer'; + +describe('withIndexProps', () => { + it('adds props to Index component', () => { + const Component = withInitialAction(_.get('action'))(Mock); + const action = jest.fn(); + renderer.create(); + expect(action).toBeCalled(); + }); +}); diff --git a/__tests__/index.android.js b/__tests__/index.android.js new file mode 100644 index 0000000..b49b908 --- /dev/null +++ b/__tests__/index.android.js @@ -0,0 +1,12 @@ +import 'react-native'; +import React from 'react'; +import Index from '../index.android.js'; + +// Note: test renderer must be required after react-native. +import renderer from 'react-test-renderer'; + +it('renders correctly', () => { + const tree = renderer.create( + + ); +}); diff --git a/__tests__/index.ios.js b/__tests__/index.ios.js new file mode 100644 index 0000000..ba7c5b5 --- /dev/null +++ b/__tests__/index.ios.js @@ -0,0 +1,12 @@ +import 'react-native'; +import React from 'react'; +import Index from '../index.ios.js'; + +// Note: test renderer must be required after react-native. +import renderer from 'react-test-renderer'; + +it('renders correctly', () => { + const tree = renderer.create( + + ); +}); diff --git a/__tests__/reducers/__snapshots__/commentFormReducer.js.snap b/__tests__/reducers/__snapshots__/commentFormReducer.js.snap new file mode 100644 index 0000000..82815de --- /dev/null +++ b/__tests__/reducers/__snapshots__/commentFormReducer.js.snap @@ -0,0 +1,13 @@ +exports[`commentFormReducer resetCommentForm clears all values incl. meta 1`] = ` +Object { + "meta": Object {}, +} +`; + +exports[`commentFormReducer updateCommentForm updates comment form with payload 1`] = ` +Object { + "author": "Alexey", + "meta": Object {}, + "text": "Random text", +} +`; diff --git a/__tests__/reducers/__snapshots__/commentsStoreReducer.js.snap b/__tests__/reducers/__snapshots__/commentsStoreReducer.js.snap new file mode 100644 index 0000000..18c5bfd --- /dev/null +++ b/__tests__/reducers/__snapshots__/commentsStoreReducer.js.snap @@ -0,0 +1,46 @@ +exports[`commentsStoreReducer createComments adds comments to the store 1`] = ` +Object { + "1": Object { + "author": "Alexey", + "id": 1, + "text": "Random comment", + }, + "2": Object { + "author": "Justin", + "id": 2, + "text": "Another random comment", + }, + "meta": Object { + "loading": false, + }, +} +`; + +exports[`commentsStoreReducer removeComment removes comment by id 1`] = ` +Object { + "2": Object { + "author": "Justin", + "id": 2, + "text": "Another random comment", + }, + "meta": Object { + "loading": false, + }, +} +`; + +exports[`commentsStoreReducer setLoadingComments sets the loading meta information 1`] = ` +Object { + "meta": Object { + "loading": true, + }, +} +`; + +exports[`commentsStoreReducer setLoadingComments sets the loading meta information 2`] = ` +Object { + "meta": Object { + "loading": false, + }, +} +`; diff --git a/__tests__/reducers/commentFormReducer.js b/__tests__/reducers/commentFormReducer.js new file mode 100644 index 0000000..09a98db --- /dev/null +++ b/__tests__/reducers/commentFormReducer.js @@ -0,0 +1,27 @@ +import commentFormReducer, { actions, initialState } from 'ReactRailsApp/app/reducers/commentFormReducer'; + +describe('commentFormReducer', () => { + describe('updateCommentForm', () => { + it('updates comment form with payload', () => { + const payload = { author: 'Alexey', text: 'Random text' }; + expect( + commentFormReducer(initialState, actions.updateCommentForm(payload)) + ).toMatchSnapshot(); + }); + }); + + describe('resetCommentForm', () => { + it('clears all values incl. meta', () => { + const state = initialState.mergeDeep({ + meta: { + errors: { + author: 'Empty name', + }, + }, + author: '', + text: 'Random text', + }); + expect(commentFormReducer(state, actions.resetCommentForm())).toMatchSnapshot(); + }); + }); +}); diff --git a/__tests__/reducers/commentsStoreReducer.js b/__tests__/reducers/commentsStoreReducer.js new file mode 100644 index 0000000..c34ba2e --- /dev/null +++ b/__tests__/reducers/commentsStoreReducer.js @@ -0,0 +1,37 @@ +import { fromJS } from 'immutable'; +import commentsStoreReducer, { actions, initialState } from 'ReactRailsApp/app/reducers/commentsStoreReducer'; + +const sampleData = { + 1: { id: 1, author: 'Alexey', text: 'Random comment' }, + 2: { id: 2, author: 'Justin', text: 'Another random comment' }, +}; + +describe('commentsStoreReducer', () => { + describe('createComments', () => { + it('adds comments to the store', () => { + expect( + commentsStoreReducer( + initialState, + actions.createComments(sampleData)) + ).toMatchSnapshot(); + }); + }); + + describe('removeComment', () => { + it('removes comment by id', () => { + expect( + commentsStoreReducer(initialState.merge(sampleData), actions.removeComment('1')) + ).toMatchSnapshot(); + }); + }); + + describe('setLoadingComments', () => { + it('sets the loading meta information', () => { + const trueState = commentsStoreReducer(initialState, actions.setLoadingComments(true)); + const falseState = commentsStoreReducer(trueState, actions.setLoadingComments(false)); + expect(trueState).toMatchSnapshot(); + expect(falseState).toMatchSnapshot(); + }); + }); +}); + diff --git a/__tests__/selectors/__snapshots__/commentFormSelector.js.snap b/__tests__/selectors/__snapshots__/commentFormSelector.js.snap new file mode 100644 index 0000000..a4a72c8 --- /dev/null +++ b/__tests__/selectors/__snapshots__/commentFormSelector.js.snap @@ -0,0 +1,6 @@ +exports[`commentFormSelector selects the comment form store 1`] = ` +Object { + "author": "Alexey", + "text": "Random comment", +} +`; diff --git a/__tests__/selectors/__snapshots__/commentsPropsSelector.js.snap b/__tests__/selectors/__snapshots__/commentsPropsSelector.js.snap new file mode 100644 index 0000000..b556490 --- /dev/null +++ b/__tests__/selectors/__snapshots__/commentsPropsSelector.js.snap @@ -0,0 +1,24 @@ +exports[`commentsPropsSelector maps commentsStore to props 1`] = ` +Object { + "comments": Array [ + Object { + "author": "John", + "id": 3, + "text": "Yet another random comment", + }, + Object { + "author": "Justin", + "id": 2, + "text": "Another random comment", + }, + Object { + "author": "Alexey", + "id": 1, + "text": "Just some random comment", + }, + ], + "meta": Object { + "loading": false, + }, +} +`; diff --git a/__tests__/selectors/__snapshots__/commentsStoreSelector.js.snap b/__tests__/selectors/__snapshots__/commentsStoreSelector.js.snap new file mode 100644 index 0000000..0bf909e --- /dev/null +++ b/__tests__/selectors/__snapshots__/commentsStoreSelector.js.snap @@ -0,0 +1,14 @@ +exports[`commentsStoreSelector selects the comments store 1`] = ` +Object { + "1": Object { + "author": "Alexey", + "id": 1, + "text": "Random comment", + }, + "2": Object { + "author": "Justin", + "id": 2, + "text": "Another random comment", + }, +} +`; diff --git a/__tests__/selectors/commentFormSelector.js b/__tests__/selectors/commentFormSelector.js new file mode 100644 index 0000000..ca6d172 --- /dev/null +++ b/__tests__/selectors/commentFormSelector.js @@ -0,0 +1,14 @@ +import { fromJS } from 'immutable'; +import commentFormSelector from 'ReactRailsApp/app/selectors/commentFormSelector'; + +describe('commentFormSelector', () => { + it('selects the comment form store', () => { + const state = fromJS({ + commentForm: { + author: 'Alexey', + text: 'Random comment', + }, + }); + expect(commentFormSelector(state)).toMatchSnapshot(); + }); +}); diff --git a/__tests__/selectors/commentsPropsSelector.js b/__tests__/selectors/commentsPropsSelector.js new file mode 100644 index 0000000..8acf4fe --- /dev/null +++ b/__tests__/selectors/commentsPropsSelector.js @@ -0,0 +1,16 @@ +import { fromJS } from 'immutable'; +import commentsPropsSelector from 'ReactRailsApp/app/selectors/commentsPropsSelector'; + +describe('commentsPropsSelector', () => { + it('maps commentsStore to props', () => { + const state = fromJS({ + commentsStore: { + 2: { id: 2, author: 'Justin', text: 'Another random comment' }, + 3: { id: 3, author: 'John', text: 'Yet another random comment' }, + 1: { id: 1, author: 'Alexey', text: 'Just some random comment' }, + meta: { loading: false }, + }, + }); + expect(commentsPropsSelector(state)).toMatchSnapshot(); + }); +}); diff --git a/__tests__/selectors/commentsStoreSelector.js b/__tests__/selectors/commentsStoreSelector.js new file mode 100644 index 0000000..a872393 --- /dev/null +++ b/__tests__/selectors/commentsStoreSelector.js @@ -0,0 +1,14 @@ +import { fromJS } from 'immutable'; +import commentsStoreSelector from 'ReactRailsApp/app/selectors/commentsStoreSelector'; + +describe('commentsStoreSelector', () => { + it('selects the comments store', () => { + const state = fromJS({ + commentsStore: { + 1: { id: 1, author: 'Alexey', text: 'Random comment' }, + 2: { id: 2, author: 'Justin', text: 'Another random comment' }, + }, + }); + expect(commentsStoreSelector(state)).toMatchSnapshot(); + }); +}); diff --git a/android/app/BUCK b/android/app/BUCK index e1b2572..d023e98 100644 --- a/android/app/BUCK +++ b/android/app/BUCK @@ -5,7 +5,7 @@ import re # - install Buck # - `npm start` - to start the packager # - `cd android` -# - `keytool -genkey -v -keystore keystores/debug.keystore -storepass android -alias androiddebugkey -keypass android -dname "CN=Android Debug,O=Android,C=US` +# - `keytool -genkey -v -keystore keystores/debug.keystore -storepass android -alias androiddebugkey -keypass android -dname "CN=Android Debug,O=Android,C=US"` # - `./gradlew :app:copyDownloadableDepsToLibs` - make all Gradle compile dependencies available to Buck # - `buck install -r android/app` - compile, install and run application # @@ -46,13 +46,13 @@ android_library( android_build_config( name = 'build_config', - package = 'com.reactnativetutorial', + package = 'com.reactrailsapp', ) android_resource( name = 'res', res = 'src/main/res', - package = 'com.reactnativetutorial', + package = 'com.reactrailsapp', ) android_binary( diff --git a/android/app/build.gradle b/android/app/build.gradle index ecfcac3..d7b520f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -84,10 +84,10 @@ def enableProguardInReleaseBuilds = false android { compileSdkVersion 23 - buildToolsVersion "23.0.3" + buildToolsVersion "23.0.1" defaultConfig { - applicationId "com.reactnativetutorial" + applicationId "com.reactrailsapp" minSdkVersion 16 targetSdkVersion 22 versionCode 1 @@ -126,6 +126,7 @@ android { } dependencies { + compile project(':react-native-vector-icons') compile fileTree(dir: "libs", include: ["*.jar"]) compile "com.android.support:appcompat-v7:23.0.1" compile "com.facebook.react:react-native:+" // From node_modules @@ -134,6 +135,6 @@ dependencies { // Run this once to be able to run the application with BUCK // puts all compile dependencies into folder libs for BUCK to use task copyDownloadableDepsToLibs(type: Copy) { - from configurations.compile - into 'libs' + from configurations.compile + into 'libs' } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 10969cf..3f08320 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,5 @@ @@ -14,7 +14,7 @@ android:name=".MainApplication" android:allowBackup="true" android:label="@string/app_name" - android:icon="@mipmap/ic_launcher" + android:icon="@mipmap/shaka_logo" android:theme="@style/AppTheme"> getPackages() { return Arrays.asList( - new MainReactPackage() + new MainReactPackage(), + new VectorIconsPackage() ); } }; @Override public ReactNativeHost getReactNativeHost() { - return mReactNativeHost; + return mReactNativeHost; + } + + @Override + public void onCreate() { + super.onCreate(); + SoLoader.init(this, /* native exopackage */ false); } } diff --git a/android/app/src/main/res/mipmap-hdpi/shaka_logo.png b/android/app/src/main/res/mipmap-hdpi/shaka_logo.png new file mode 100644 index 0000000..f818fcd Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/shaka_logo.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/shaka_logo.png b/android/app/src/main/res/mipmap-mdpi/shaka_logo.png new file mode 100644 index 0000000..32e94c7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/shaka_logo.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/shaka_logo.png b/android/app/src/main/res/mipmap-xhdpi/shaka_logo.png new file mode 100644 index 0000000..77e246e Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/shaka_logo.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/shaka_logo.png b/android/app/src/main/res/mipmap-xxhdpi/shaka_logo.png new file mode 100644 index 0000000..3ba1b08 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/shaka_logo.png differ diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index ff5cfb6..068482e 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,3 +1,3 @@ - ReactNativeTutorial + ReactRailsApp diff --git a/android/settings.gradle b/android/settings.gradle index d0e863d..c3c5f72 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,3 +1,5 @@ -rootProject.name = 'ReactNativeTutorial' +rootProject.name = 'ReactRailsApp' +include ':react-native-vector-icons' +project(':react-native-vector-icons').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-vector-icons/android') include ':app' diff --git a/app/App.js b/app/App.js index a64bc9a..088632c 100644 --- a/app/App.js +++ b/app/App.js @@ -1,15 +1,13 @@ // @flow import React from 'react'; import { Provider } from 'react-redux'; +import store from './setup/store'; +import Router from './setup/Router/Router'; -import createStore from './store/store'; -import ReduxContainer from './containers/ReduxContainer'; +const App = () => ( + + + +); -export default () => { - const store = createStore(); - return ( - - - - ); -}; +export default App; diff --git a/app/actions/commentsActionCreators.js b/app/actions/commentsActionCreators.js deleted file mode 100644 index 3f70231..0000000 --- a/app/actions/commentsActionCreators.js +++ /dev/null @@ -1,23 +0,0 @@ -import actionTypes from 'ReactNativeTutorial/app/constants/commentsConstants'; -import * as reduxUtils from 'ReactNativeTutorial/app/libs/utils/redux'; - -export const fetchCommentsRequest = - reduxUtils.makeActionCreator(actionTypes.FETCH_COMMENTS_REQUEST); - -export const submitCommentRequest = - reduxUtils.makeActionCreator(actionTypes.SUBMIT_COMMENT_REQUEST, 'comment'); - -export const resetErrorState = - reduxUtils.makeActionCreator(actionTypes.RESET_ERROR_STATE); - -export const fetchCommentsSuccess = - reduxUtils.makeActionCreator(actionTypes.FETCH_COMMENTS_SUCCESS, 'comments'); - -export const fetchCommentsFailure = - reduxUtils.makeActionCreator(actionTypes.FETCH_COMMENTS_FAILURE, 'error'); - -export const submitCommentSuccess = - reduxUtils.makeActionCreator(actionTypes.SUBMIT_COMMENT_SUCCESS, 'comment'); - -export const submitCommentFailure = - reduxUtils.makeActionCreator(actionTypes.SUBMIT_COMMENT_FAILURE, 'error'); diff --git a/app/api/index.js b/app/api/index.js new file mode 100644 index 0000000..085e247 --- /dev/null +++ b/app/api/index.js @@ -0,0 +1,38 @@ +import _ from 'lodash/fp'; +import { normalize } from 'normalizr'; +import { commentSchema, commentsSchema } from './schemas'; + +// You can use localhost for development, but only on iOS. Android emulator is considered +// a standalone machine with it's own localhost. Workaround is to specify the actual +// IP address of your PC. +// const API_URL = __DEV__ ? 'http://localhost:3000/' : 'http://www.reactrails.com/'; +const API_URL = 'https://www.reactrails.com/'; + +const apiRequest = async (url, method, payload) => { + let options = { + method, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-Auth': 'tutorial_secret', + }, + }; + if (payload) options = { ...options, body: JSON.stringify(payload) }; + const response = await fetch(`${API_URL}${url}`, options); + return await response.json(); +}; + +const getRequest = async (url) => apiRequest(url, 'GET'); +const postRequest = async (url, payload) => apiRequest(url, 'POST', payload); + +export const fetchComments = async () => { + const response = await getRequest('comments.json'); + const camelizedResponse = _.mapKeys(_.camelCase, response); + return normalize(camelizedResponse, { comments: commentsSchema }); +}; + +export const postComment = async (payload) => { + const response = await postRequest('comments.json', { comment: payload }); + const camelizedResponse = _.mapKeys(_.camelCase, response); + return normalize(camelizedResponse, commentSchema); +}; diff --git a/app/api/schemas.js b/app/api/schemas.js new file mode 100644 index 0000000..fede2a0 --- /dev/null +++ b/app/api/schemas.js @@ -0,0 +1,4 @@ +import { Schema, arrayOf } from 'normalizr'; + +export const commentSchema = new Schema('comments'); +export const commentsSchema = arrayOf(commentSchema); diff --git a/app/bundles/comments/components/Add/Add.js b/app/bundles/comments/components/Add/Add.js new file mode 100644 index 0000000..1bff24e --- /dev/null +++ b/app/bundles/comments/components/Add/Add.js @@ -0,0 +1,28 @@ +// @flow +import React from 'react'; +import { View } from 'react-native'; +import { FormLabel, FormInput, Button } from 'react-native-elements'; + +import type { AddPropsType } from '../../hocs/withAddProps'; + +import styles from './AddStyle'; + +type PropsType = AddPropsType; + +const Add = (props: PropsType) => ( + + Author name + props.actions.updateForm({ author: text })} /> + Comment + props.actions.updateForm({ text })} /> +