From e56711c5a1de0c1e642123215652acc1254f9077 Mon Sep 17 00:00:00 2001 From: Walter Zalazar Date: Tue, 27 Jun 2017 15:18:12 -0300 Subject: [PATCH] :rocket: Initial commit --- .babelrc | 34 + .editorconfig | 24 + .eslintrc | 218 + .gitignore | 16 + .mocks/jest/CSSStub.js | 1 + .uml/architecture.png | Bin 0 -> 61678 bytes .uml/architecture.puml | 74 + README.md | 76 + components/Album/Album.js | 67 + components/Album/Album.test.js | 15 + components/Artist/Artist.js | 66 + components/Artist/Artist.test.js | 15 + components/Breadcrumb/Breadcrumb.js | 37 + components/Breadcrumb/Breadcrumb.test.js | 30 + components/H1/H1.js | 25 + components/H1/H1.test.js | 15 + components/H2/H2.js | 25 + components/H2/H2.test.js | 15 + components/Hamburger/Hamburger.js | 40 + components/Hamburger/Hamburger.test.js | 21 + components/Input/Input.js | 47 + components/Input/Input.test.js | 15 + components/Layout/Layout.js | 35 + components/Layout/Layout.test.js | 22 + components/Line/Line.js | 13 + components/Line/Line.test.js | 15 + components/ListAlbum/ListAlbum.js | 42 + components/ListAlbum/ListAlbum.test.js | 15 + components/ListArtist/ListArtist.js | 42 + components/ListArtist/ListArtist.test.js | 15 + components/ListTrack/ListTrack.js | 80 + components/ListTrack/ListTrack.test.js | 15 + components/Main/Main.js | 27 + components/Main/Main.test.js | 15 + components/NavigationBar/NavigationBar.js | 76 + .../NavigationBar/NavigationBar.test.js | 22 + components/ProgressBar/ProgressBar.js | 54 + components/ProgressBar/ProgressBar.test.js | 15 + components/Search/Search.js | 37 + components/Search/Search.test.js | 15 + components/SpotifyLogo/SpotifyLogo.js | 21 + components/SpotifyLogo/SpotifyLogo.test.js | 15 + components/Text/Text.js | 25 + components/Text/Text.test.js | 15 + config/config.actions.js | 3 + config/config.reducers.js | 19 + containers/Layout/Layout.container.js | 37 + containers/Main/Main.container.js | 20 + containers/Results/Results.actions.js | 13 + containers/Results/Results.container.js | 99 + containers/Results/Results.epics.js | 20 + containers/Results/Results.reducers.js | 51 + containers/Search/Search.actions.js | 23 + containers/Search/Search.container.js | 143 + containers/Search/Search.epics.js | 29 + containers/Search/Search.reducers.js | 84 + graphql/artist.js | 25 + graphql/track.js | 12 + lib/__test__/epics.test.js | 8 + lib/__test__/initStore.test.js | 55 + lib/__test__/withData.test.js | 42 + lib/epics.js | 11 + lib/initStore.js | 82 + lib/reducers.js | 11 + lib/withData.js | 64 + next.config.js | 37 + package.json | 104 + pages/_document.js | 29 + pages/about.js | 70 + pages/commands.js | 70 + pages/index.js | 57 + postcss.config.js | 6 + server.js | 41 + server/api/spotify.js | 97 + server/loaders.js | 32 + server/models/Albums.js | 70 + server/models/Artists.js | 61 + server/models/Images.js | 15 + server/models/Tracks.js | 54 + static/fonts/Sportify-Bold.eot | Bin 0 -> 173344 bytes static/fonts/Sportify-Bold.ttf | Bin 0 -> 173092 bytes static/fonts/Sportify-Bold.woff | Bin 0 -> 79320 bytes static/fonts/Sportify-Book.eot | Bin 0 -> 167196 bytes static/fonts/Sportify-Book.ttf | Bin 0 -> 166944 bytes static/fonts/Sportify-Book.woff | Bin 0 -> 76268 bytes static/fonts/Sportify-Light.eot | Bin 0 -> 171280 bytes static/fonts/Sportify-Light.ttf | Bin 0 -> 171024 bytes static/fonts/Sportify-Light.woff | Bin 0 -> 78872 bytes styles/base/_reset.scss | 67 + styles/base/_typography.scss | 11 + styles/components/Album.scss | 122 + styles/components/Artist.scss | 125 + styles/components/Breadcrumb.scss | 47 + styles/components/H1.scss | 18 + styles/components/H2.scss | 7 + styles/components/Hamburger.scss | 632 ++ styles/components/Input.scss | 16 + styles/components/Layout.scss | 19 + styles/components/Line.scss | 8 + styles/components/ListTrack.scss | 32 + styles/components/Main.scss | 10 + styles/components/NavigationBar.scss | 91 + styles/components/ProgressBar.scss | 18 + styles/components/Results.scss | 16 + styles/components/Search.scss | 26 + styles/components/SpotifyLogo.scss | 4 + styles/components/Text.scss | 5 + styles/components/Track.scss | 74 + styles/fonts/spotify.scss | 30 + styles/helpers/_animations.scss | 7 + styles/helpers/_colors.scss | 0 styles/helpers/_functions.scss | 0 styles/helpers/_helpers.scss | 17 + styles/helpers/_mixins.scss | 0 styles/helpers/_variables.scss | 16 + styles/layout/_grid.scss | 0 styles/main.scss | 27 + yarn.lock | 6314 +++++++++++++++++ 118 files changed, 10860 insertions(+) create mode 100644 .babelrc create mode 100644 .editorconfig create mode 100644 .eslintrc create mode 100644 .gitignore create mode 100644 .mocks/jest/CSSStub.js create mode 100644 .uml/architecture.png create mode 100644 .uml/architecture.puml create mode 100644 README.md create mode 100644 components/Album/Album.js create mode 100644 components/Album/Album.test.js create mode 100644 components/Artist/Artist.js create mode 100644 components/Artist/Artist.test.js create mode 100644 components/Breadcrumb/Breadcrumb.js create mode 100644 components/Breadcrumb/Breadcrumb.test.js create mode 100644 components/H1/H1.js create mode 100644 components/H1/H1.test.js create mode 100644 components/H2/H2.js create mode 100644 components/H2/H2.test.js create mode 100644 components/Hamburger/Hamburger.js create mode 100644 components/Hamburger/Hamburger.test.js create mode 100644 components/Input/Input.js create mode 100644 components/Input/Input.test.js create mode 100644 components/Layout/Layout.js create mode 100644 components/Layout/Layout.test.js create mode 100644 components/Line/Line.js create mode 100644 components/Line/Line.test.js create mode 100644 components/ListAlbum/ListAlbum.js create mode 100644 components/ListAlbum/ListAlbum.test.js create mode 100644 components/ListArtist/ListArtist.js create mode 100644 components/ListArtist/ListArtist.test.js create mode 100644 components/ListTrack/ListTrack.js create mode 100644 components/ListTrack/ListTrack.test.js create mode 100644 components/Main/Main.js create mode 100644 components/Main/Main.test.js create mode 100644 components/NavigationBar/NavigationBar.js create mode 100644 components/NavigationBar/NavigationBar.test.js create mode 100644 components/ProgressBar/ProgressBar.js create mode 100644 components/ProgressBar/ProgressBar.test.js create mode 100644 components/Search/Search.js create mode 100644 components/Search/Search.test.js create mode 100644 components/SpotifyLogo/SpotifyLogo.js create mode 100644 components/SpotifyLogo/SpotifyLogo.test.js create mode 100644 components/Text/Text.js create mode 100644 components/Text/Text.test.js create mode 100644 config/config.actions.js create mode 100644 config/config.reducers.js create mode 100644 containers/Layout/Layout.container.js create mode 100644 containers/Main/Main.container.js create mode 100644 containers/Results/Results.actions.js create mode 100644 containers/Results/Results.container.js create mode 100644 containers/Results/Results.epics.js create mode 100644 containers/Results/Results.reducers.js create mode 100644 containers/Search/Search.actions.js create mode 100644 containers/Search/Search.container.js create mode 100644 containers/Search/Search.epics.js create mode 100644 containers/Search/Search.reducers.js create mode 100644 graphql/artist.js create mode 100644 graphql/track.js create mode 100644 lib/__test__/epics.test.js create mode 100644 lib/__test__/initStore.test.js create mode 100644 lib/__test__/withData.test.js create mode 100644 lib/epics.js create mode 100644 lib/initStore.js create mode 100644 lib/reducers.js create mode 100644 lib/withData.js create mode 100644 next.config.js create mode 100644 package.json create mode 100644 pages/_document.js create mode 100644 pages/about.js create mode 100644 pages/commands.js create mode 100644 pages/index.js create mode 100644 postcss.config.js create mode 100644 server.js create mode 100644 server/api/spotify.js create mode 100644 server/loaders.js create mode 100644 server/models/Albums.js create mode 100644 server/models/Artists.js create mode 100644 server/models/Images.js create mode 100644 server/models/Tracks.js create mode 100644 static/fonts/Sportify-Bold.eot create mode 100644 static/fonts/Sportify-Bold.ttf create mode 100644 static/fonts/Sportify-Bold.woff create mode 100644 static/fonts/Sportify-Book.eot create mode 100644 static/fonts/Sportify-Book.ttf create mode 100644 static/fonts/Sportify-Book.woff create mode 100644 static/fonts/Sportify-Light.eot create mode 100644 static/fonts/Sportify-Light.ttf create mode 100644 static/fonts/Sportify-Light.woff create mode 100644 styles/base/_reset.scss create mode 100644 styles/base/_typography.scss create mode 100644 styles/components/Album.scss create mode 100644 styles/components/Artist.scss create mode 100644 styles/components/Breadcrumb.scss create mode 100644 styles/components/H1.scss create mode 100644 styles/components/H2.scss create mode 100644 styles/components/Hamburger.scss create mode 100644 styles/components/Input.scss create mode 100644 styles/components/Layout.scss create mode 100644 styles/components/Line.scss create mode 100644 styles/components/ListTrack.scss create mode 100644 styles/components/Main.scss create mode 100644 styles/components/NavigationBar.scss create mode 100644 styles/components/ProgressBar.scss create mode 100644 styles/components/Results.scss create mode 100644 styles/components/Search.scss create mode 100644 styles/components/SpotifyLogo.scss create mode 100644 styles/components/Text.scss create mode 100644 styles/components/Track.scss create mode 100644 styles/fonts/spotify.scss create mode 100644 styles/helpers/_animations.scss create mode 100644 styles/helpers/_colors.scss create mode 100644 styles/helpers/_functions.scss create mode 100644 styles/helpers/_helpers.scss create mode 100644 styles/helpers/_mixins.scss create mode 100644 styles/helpers/_variables.scss create mode 100644 styles/layout/_grid.scss create mode 100644 styles/main.scss create mode 100644 yarn.lock diff --git a/.babelrc b/.babelrc new file mode 100644 index 00000000..7984c706 --- /dev/null +++ b/.babelrc @@ -0,0 +1,34 @@ +{ + "env": { + "development": { + "presets": "next/babel", + "plugins": [ + "transform-decorators-legacy", + "transform-class-properties", + "babel-plugin-transform-async-to-generator", + "babel-plugin-transform-es2015-modules-commonjs" + ] + }, + "production": { + "presets": "next/babel", + "plugins": [ + "transform-decorators-legacy", + "transform-class-properties", + "babel-plugin-transform-async-to-generator", + "babel-plugin-transform-es2015-modules-commonjs" + ] + }, + "test": { + "presets": [ + ["env", { "modules": "commonjs" }], + "next/babel" + ], + "plugins": [ + "transform-decorators-legacy", + "transform-class-properties", + "babel-plugin-transform-async-to-generator", + "babel-plugin-transform-es2015-modules-commonjs" + ] + } + }, +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..d21ece52 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,24 @@ +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +charset = utf-8 +# Force Unix-style newlines with a newline ending every file & trim trailing whitespace +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{js,html,css,styl,scss}] +indent_style = space +indent_size = 2 + +[*.sh] +indent_style = space +indent_size = 4 + +[*.md] +indent_style = space +indent_size = 4 +trim_trailing_whitespace = false diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 00000000..9130209a --- /dev/null +++ b/.eslintrc @@ -0,0 +1,218 @@ +{ + "parser": "babel-eslint", // https://github.com/babel/babel-eslint + "plugins": [ + "react" // https://github.com/yannickcr/eslint-plugin-react + ], + "env": { // http://eslint.org/docs/user-guide/configuring.html#specifying-environments + "browser": true, // browser global variables + "node": true, // Node.js global variables and Node.js-specific rules + "meteor": true, + "es6": true + }, + "ecmaFeatures": { + "arrowFunctions": true, + "blockBindings": true, + "classes": true, + "defaultParams": true, + "destructuring": true, + "forOf": true, + "generators": false, + "modules": true, + "objectLiteralComputedProperties": true, + "objectLiteralDuplicateProperties": false, + "objectLiteralShorthandMethods": true, + "objectLiteralShorthandProperties": true, + "spread": true, + "superInFunctions": true, + "templateStrings": true + }, + "rules": { +/** + * Strict mode + */ + "strict": [2, "never"], // http://eslint.org/docs/rules/strict + +/** + * ES6 + */ + "no-var": 2, // http://eslint.org/docs/rules/no-var + "prefer-const": 2, // http://eslint.org/docs/rules/prefer-const + +/** + * Variables + */ + "no-shadow": 2, // http://eslint.org/docs/rules/no-shadow + "no-shadow-restricted-names": 2, // http://eslint.org/docs/rules/no-shadow-restricted-names + "no-unused-vars": [2, { // http://eslint.org/docs/rules/no-unused-vars + "vars": "local", + "args": "after-used" + }], + "no-use-before-define": 0, // http://eslint.org/docs/rules/no-use-before-define + +/** + * Possible errors + */ + "comma-dangle": [2, "always-multiline"], // http://eslint.org/docs/rules/comma-dangle + "no-cond-assign": [2, "always"], // http://eslint.org/docs/rules/no-cond-assign + "no-console": 1, // http://eslint.org/docs/rules/no-console + "no-debugger": 1, // http://eslint.org/docs/rules/no-debugger + "no-alert": 1, // http://eslint.org/docs/rules/no-alert + "no-constant-condition": 1, // http://eslint.org/docs/rules/no-constant-condition + "no-dupe-keys": 2, // http://eslint.org/docs/rules/no-dupe-keys + "no-duplicate-case": 2, // http://eslint.org/docs/rules/no-duplicate-case + "no-empty": 2, // http://eslint.org/docs/rules/no-empty + "no-ex-assign": 2, // http://eslint.org/docs/rules/no-ex-assign + "no-extra-boolean-cast": 0, // http://eslint.org/docs/rules/no-extra-boolean-cast + "no-extra-semi": 2, // http://eslint.org/docs/rules/no-extra-semi + "no-func-assign": 2, // http://eslint.org/docs/rules/no-func-assign + "no-inner-declarations": 2, // http://eslint.org/docs/rules/no-inner-declarations + "no-invalid-regexp": 2, // http://eslint.org/docs/rules/no-invalid-regexp + "no-irregular-whitespace": 2, // http://eslint.org/docs/rules/no-irregular-whitespace + "no-obj-calls": 2, // http://eslint.org/docs/rules/no-obj-calls + "no-sparse-arrays": 2, // http://eslint.org/docs/rules/no-sparse-arrays + "no-unreachable": 2, // http://eslint.org/docs/rules/no-unreachable + "use-isnan": 2, // http://eslint.org/docs/rules/use-isnan + "block-scoped-var": 0, // http://eslint.org/docs/rules/block-scoped-var + +/** + * Best practices + */ + "consistent-return": 2, // http://eslint.org/docs/rules/consistent-return + "curly": [2, "multi-line"], // http://eslint.org/docs/rules/curly + "default-case": 2, // http://eslint.org/docs/rules/default-case + "dot-notation": [2, { // http://eslint.org/docs/rules/dot-notation + "allowKeywords": true + }], + "eqeqeq": 2, // http://eslint.org/docs/rules/eqeqeq + "guard-for-in": 0, // http://eslint.org/docs/rules/guard-for-in + "no-caller": 2, // http://eslint.org/docs/rules/no-caller + "no-else-return": 2, // http://eslint.org/docs/rules/no-else-return + "no-eq-null": 2, // http://eslint.org/docs/rules/no-eq-null + "no-eval": 2, // http://eslint.org/docs/rules/no-eval + "no-extend-native": 2, // http://eslint.org/docs/rules/no-extend-native + "no-extra-bind": 2, // http://eslint.org/docs/rules/no-extra-bind + "no-fallthrough": 2, // http://eslint.org/docs/rules/no-fallthrough + "no-floating-decimal": 2, // http://eslint.org/docs/rules/no-floating-decimal + "no-implied-eval": 2, // http://eslint.org/docs/rules/no-implied-eval + "no-lone-blocks": 2, // http://eslint.org/docs/rules/no-lone-blocks + "no-loop-func": 2, // http://eslint.org/docs/rules/no-loop-func + "no-multi-str": 2, // http://eslint.org/docs/rules/no-multi-str + "no-native-reassign": 2, // http://eslint.org/docs/rules/no-native-reassign + "no-new": 2, // http://eslint.org/docs/rules/no-new + "no-new-func": 2, // http://eslint.org/docs/rules/no-new-func + "no-new-wrappers": 2, // http://eslint.org/docs/rules/no-new-wrappers + "no-octal": 2, // http://eslint.org/docs/rules/no-octal + "no-octal-escape": 2, // http://eslint.org/docs/rules/no-octal-escape + "no-param-reassign": 2, // http://eslint.org/docs/rules/no-param-reassign + "no-proto": 2, // http://eslint.org/docs/rules/no-proto + "no-redeclare": 2, // http://eslint.org/docs/rules/no-redeclare + "no-return-assign": 2, // http://eslint.org/docs/rules/no-return-assign + "no-script-url": 2, // http://eslint.org/docs/rules/no-script-url + "no-self-compare": 2, // http://eslint.org/docs/rules/no-self-compare + "no-sequences": 2, // http://eslint.org/docs/rules/no-sequences + "no-throw-literal": 2, // http://eslint.org/docs/rules/no-throw-literal + "no-with": 2, // http://eslint.org/docs/rules/no-with + "radix": 2, // http://eslint.org/docs/rules/radix + "vars-on-top": 2, // http://eslint.org/docs/rules/vars-on-top + "wrap-iife": [2, "any"], // http://eslint.org/docs/rules/wrap-iife + "yoda": 2, // http://eslint.org/docs/rules/yoda + +/** + * Style + */ + "indent": [2, 2], // http://eslint.org/docs/rules/indent + "brace-style": [2, // http://eslint.org/docs/rules/brace-style + "1tbs", { + "allowSingleLine": true + }], + "quotes": [ + 2, "single", "avoid-escape" // http://eslint.org/docs/rules/quotes + ], + "camelcase": [2, { // http://eslint.org/docs/rules/camelcase + "properties": "never" + }], + "comma-spacing": [2, { // http://eslint.org/docs/rules/comma-spacing + "before": false, + "after": true + }], + "comma-style": [2, "last"], // http://eslint.org/docs/rules/comma-style + "eol-last": 2, // http://eslint.org/docs/rules/eol-last + "func-names": 0, // http://eslint.org/docs/rules/func-names + "key-spacing": [2, { // http://eslint.org/docs/rules/key-spacing + "beforeColon": false, + "afterColon": true + }], + "new-cap": [2, { // http://eslint.org/docs/rules/new-cap + "newIsCap": true, + "capIsNew": false + }], + "no-multiple-empty-lines": [2, { // http://eslint.org/docs/rules/no-multiple-empty-lines + "max": 2 + }], + "no-nested-ternary": 2, // http://eslint.org/docs/rules/no-nested-ternary + "no-new-object": 2, // http://eslint.org/docs/rules/no-new-object + "no-spaced-func": 2, // http://eslint.org/docs/rules/no-spaced-func + "no-trailing-spaces": 2, // http://eslint.org/docs/rules/no-trailing-spaces + "no-extra-parens": [2, "functions"], // http://eslint.org/docs/rules/no-extra-parens + "no-underscore-dangle": 0, // http://eslint.org/docs/rules/no-underscore-dangle + "one-var": [2, "never"], // http://eslint.org/docs/rules/one-var + "padded-blocks": [2, "never"], // http://eslint.org/docs/rules/padded-blocks + "semi": [2, "always"], // http://eslint.org/docs/rules/semi + "semi-spacing": [2, { // http://eslint.org/docs/rules/semi-spacing + "before": false, + "after": true + }], + "keyword-spacing": 2, // http://eslint.org/docs/rules/keyword-spacing + "space-before-blocks": 2, // http://eslint.org/docs/rules/space-before-blocks + "space-before-function-paren": [2, "never"], // http://eslint.org/docs/rules/space-before-function-paren + "space-infix-ops": 2, // http://eslint.org/docs/rules/space-infix-ops + "spaced-comment": 2, // http://eslint.org/docs/rules/spaced-comment + +/** + * JSX style + */ + "react/display-name": 0, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/display-name.md + "react/jsx-boolean-value": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-boolean-value.md + "react/jsx-quotes": 0, // deprecated + "jsx-quotes": [2, "prefer-double"], // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-quotes.md + "react/jsx-no-undef": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-no-undef.md + "react/jsx-sort-props": 0, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-sort-props.md + "react/jsx-sort-prop-types": 0, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-sort-prop-types.md + "react/jsx-uses-react": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-uses-react.md + "react/jsx-uses-vars": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-uses-vars.md + "react/no-did-update-set-state": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/no-did-update-set-state.md + "react/no-multi-comp": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/no-multi-comp.md + "react/no-unknown-property": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/no-unknown-property.md + "react/prop-types": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/prop-types.md + "react/react-in-jsx-scope": 0, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/react-in-jsx-scope.md + "react/self-closing-comp": 2, // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/self-closing-comp.md + "react/sort-comp": [2, { // https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/sort-comp.md + "order": [ + "displayName", + "propTypes", + "contextTypes", + "childContextTypes", + "mixins", + "statics", + "defaultProps", + "/^_(?!(on|get|render))/", + "constructor", + "getDefaultProps", + "getInitialState", + "state", + "getChildContext", + "componentWillMount", + "componentDidMount", + "componentWillReceiveProps", + "shouldComponentUpdate", + "componentWillUpdate", + "componentDidUpdate", + "componentWillUnmount", + "/^_?on.+$/", + "/^_?get.+$/", + "/^_?render.+$/", + "render" + ] + }] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..918d312c --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +*.log +node_modules +coverage +dist +.build +.DS_Store +.next +next +.coverage +.build +.e2e/reports/**/* +.e2e/screenshots/**/* +.e2e/drivers/chromedriver +.e2e/drivers/geckodriver +.e2e/drivers/phantomjs +.e2e/drivers/selenium-server-standalone-3.4.0.jar diff --git a/.mocks/jest/CSSStub.js b/.mocks/jest/CSSStub.js new file mode 100644 index 00000000..0a55f349 --- /dev/null +++ b/.mocks/jest/CSSStub.js @@ -0,0 +1 @@ +module.exports = function() {}; diff --git a/.uml/architecture.png b/.uml/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..1ba81a6c18058970d4473f18103d34713d7af350 GIT binary patch literal 61678 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 g78d#R=O> 25 +} + +rectangle "Architecture overview" { + rectangle "Server" as server { + rectangle "NextJS" + rectangle "NodeJS" + rectangle "GraphiteJS" + } + + rectangle "Single page application" as client { + rectangle "ReactJS" + } + + rectangle "Isomorphic code" as isomorphic { + rectangle "Redux" + rectangle "Redux observable" as reduxObservable + rectangle "ReactJS" + } + + rectangle "Redux overview" as reduxOverview { + rectangle "action" + rectangle "dispatcher" + rectangle "view" + rectangle "store" + } +} + +cloud { + [API Spotify] +} + +note left of [reduxObservable] + Redux observable has epics, + they are listening async + redux actions +end note + +note left of [store] + Contain general state + of application +end note + +note left of [view] + React containers, + they are listening changes + redux store +end note + +note left of [action] + The React containers, + dispatch sync/async actions +end note + +note left of [NextJS] + The first request is server rendering + After that the application is a + single application +end note + +server --> isomorphic +isomorphic --> client +server --> client : server rendering +reduxOverview --> Redux +action -> dispatcher +dispatcher -> store +view -> action +store -> view +reduxObservable -> Redux #blue +reduxObservable -> GraphiteJS #blue +GraphiteJS -> [API Spotify] +@enduml diff --git a/README.md b/README.md new file mode 100644 index 00000000..0fb3fb76 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +[![Deploy to now](https://deploy.now.sh/static/button.svg)](https://deploy.now.sh/?repo=https://github.com/wzalazar/spotify/tree/master) + + +# Spotify GraphiteJS + +Example with GraphiteJS, framework graphql. In this example, you will able to search an artist, select artist, select album, and play the preview track. + +## How to use + +Download the example [or clone the repo](https://github.com/wzalazar/spotify): + + +Install it and run: + +```bash +npm install +npm run start:dev +``` + +```bash +yarn +yarn start:dev +``` + +**URL default http://localhost:3000** + + +### Demo + +Latest deploy [view](https://spotify-graphitejs-bwkuamidvu.now.sh) + +### Stack technology + + 1. NextJS [View](https://github.com/zeit/next.js/) + 2. React [View](https://github.com/facebook/react) + 3. Redux [View](https://github.com/reactjs/redux) + 4. Redux Observable [View](https://github.com/redux-observable/redux-observable) + 4. GraphiteJS [View](https://github.com/graphitejs/graphitejs) + + +### Architecture + + The design the architecture is [here](https://github.com/wzalazar/spotify/blob/master/.uml/architecture.png) + + +### Commands + +```bash + +yarn **command** + +``` + + +| Command | Description | +| ---------------- |:--------------------------------------------------------------------------------------| +| test | Run all test | +| coverage | Report coverage the all files. Terminal or folder in .coverage/lcov-report/index.html | +| lint | Linting project | +| start | Run project production, required build | +| start:dev | Run project development | +| build | Generate build | + + + + +Deploy it to the cloud with [now](https://zeit.co/now) ([download](https://zeit.co/download)) + +```bash +now +``` + + +## License + +[MIT](https://github.com/babel/babel/blob/master/LICENSE) diff --git a/components/Album/Album.js b/components/Album/Album.js new file mode 100644 index 00000000..994d3c4e --- /dev/null +++ b/components/Album/Album.js @@ -0,0 +1,67 @@ +import { Component } from 'react'; +import PropTypes from 'prop-types'; +import { noop } from 'lodash'; + + +export default class Album extends Component { + static propTypes = { + image: PropTypes.string, + name: PropTypes.string, + onClick: PropTypes.func, + } + + static defaultProps = { + image: '', + name: '', + onClick: noop, + } + + constructor() { + super(); + this.state = { + isImageLoaded: false, + }; + } + + render() { + const { image, name, onClick } = this.props; + const { isImageLoaded } = this.state; + + return ( +
+
+
+ + Album Icon + + +
+
+ + + PLAY + + +
+
+
+
+

{name}

+
+
+ ); + } + + loadImage(url) { + const img = new Image(); + img.onload = () => { + const { album } = this.refs; + if (album) { + this.setState({ isImageLoaded: true }); + } + }; + + img.src = url; + return url; + } +} diff --git a/components/Album/Album.test.js b/components/Album/Album.test.js new file mode 100644 index 00000000..fced1fb7 --- /dev/null +++ b/components/Album/Album.test.js @@ -0,0 +1,15 @@ +import { mount } from 'enzyme'; + +import Album from './Album'; + +describe('', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(); + }); + + test('Should renderer', () => { + expect(wrapper).toBeDefined(); + }); +}); diff --git a/components/Artist/Artist.js b/components/Artist/Artist.js new file mode 100644 index 00000000..b01d89ea --- /dev/null +++ b/components/Artist/Artist.js @@ -0,0 +1,66 @@ +import { Component } from 'react'; +import PropTypes from 'prop-types'; +import { noop } from 'lodash'; + +export default class Artist extends Component { + static propTypes = { + image: PropTypes.string, + name: PropTypes.string, + onClick: PropTypes.func, + } + + static defaultProps = { + image: '', + name: '', + onClick: noop, + } + + constructor() { + super(); + this.state = { + isImageLoaded: false, + }; + } + + render() { + const { image, name, onClick } = this.props; + const { isImageLoaded } = this.state; + + return ( +
+
+
+ + Artist Icon + + +
+
+ + + PLAY + + +
+
+
+
+

{name}

+
+
+ ); + } + + loadImage(url) { + const img = new Image(); + img.onload = () => { + const { artist } = this.refs; + if (artist) { + this.setState({ isImageLoaded: true }); + } + }; + + img.src = url; + return url; + } +} diff --git a/components/Artist/Artist.test.js b/components/Artist/Artist.test.js new file mode 100644 index 00000000..a35aa08b --- /dev/null +++ b/components/Artist/Artist.test.js @@ -0,0 +1,15 @@ +import { mount } from 'enzyme'; + +import Artist from './Artist'; + +describe('', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(); + }); + + test('Should renderer', () => { + expect(wrapper).toBeDefined(); + }); +}); diff --git a/components/Breadcrumb/Breadcrumb.js b/components/Breadcrumb/Breadcrumb.js new file mode 100644 index 00000000..73e1103b --- /dev/null +++ b/components/Breadcrumb/Breadcrumb.js @@ -0,0 +1,37 @@ +import { Component } from 'react'; +import PropTypes from 'prop-types'; +import { get, noop } from 'lodash'; + +export default class Breadcrumb extends Component { + static propTypes = { + items: PropTypes.array, + } + + static defaultProps = { + items: [], + } + + constructor() { + super(); + } + + onClick(item) { + item.onClick(); + } + + render() { + const { items } = this.props; + + return ( + + ); + } +} diff --git a/components/Breadcrumb/Breadcrumb.test.js b/components/Breadcrumb/Breadcrumb.test.js new file mode 100644 index 00000000..341420b6 --- /dev/null +++ b/components/Breadcrumb/Breadcrumb.test.js @@ -0,0 +1,30 @@ +import { mount } from 'enzyme'; + +import Breadcrumb from './Breadcrumb'; + +describe('', () => { + let wrapper; + const onClickMock = jest.fn(); + const items = [{ + label: '', + onClick: onClickMock, + active: true, + }, { + label: '', + onClick: () => {}, + active: false, + }]; + + beforeEach(() => { + wrapper = mount(); + }); + + test('Should renderer', () => { + expect(wrapper).toBeDefined(); + }); + + test('Should click', () => { + wrapper.find('.Breadcrumb__link .active').simulate('click'); + expect(onClickMock.mock.calls.length).toBe(1); + }); +}); diff --git a/components/H1/H1.js b/components/H1/H1.js new file mode 100644 index 00000000..ced2205e --- /dev/null +++ b/components/H1/H1.js @@ -0,0 +1,25 @@ +import { Component } from 'react'; +import PropTypes from 'prop-types'; + +export default class H1 extends Component { + + static propTypes = { + className: PropTypes.string, + children: PropTypes.any, + } + + static defaultProps = { + className: '', + } + + constructor() { + super(); + } + + render() { + const { children, className } = this.props; + return ( +

{children}

+ ); + } +} diff --git a/components/H1/H1.test.js b/components/H1/H1.test.js new file mode 100644 index 00000000..6d98a421 --- /dev/null +++ b/components/H1/H1.test.js @@ -0,0 +1,15 @@ +import { mount } from 'enzyme'; + +import H1 from './H1'; + +describe('

', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(

); + }); + + test('Should renderer', () => { + expect(wrapper).toBeDefined(); + }); +}); diff --git a/components/H2/H2.js b/components/H2/H2.js new file mode 100644 index 00000000..b30c4bb2 --- /dev/null +++ b/components/H2/H2.js @@ -0,0 +1,25 @@ +import { Component } from 'react'; +import PropTypes from 'prop-types'; + +export default class H2 extends Component { + + static propTypes = { + className: PropTypes.string, + children: PropTypes.any, + } + + static defaultProps = { + className: '', + } + + constructor() { + super(); + } + + render() { + const { children, className } = this.props; + return ( +

{children}

+ ); + } +} diff --git a/components/H2/H2.test.js b/components/H2/H2.test.js new file mode 100644 index 00000000..a29d2f4d --- /dev/null +++ b/components/H2/H2.test.js @@ -0,0 +1,15 @@ +import { mount } from 'enzyme'; + +import H2 from './H2'; + +describe('

', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(

); + }); + + test('Should renderer', () => { + expect(wrapper).toBeDefined(); + }); +}); diff --git a/components/Hamburger/Hamburger.js b/components/Hamburger/Hamburger.js new file mode 100644 index 00000000..db6539cd --- /dev/null +++ b/components/Hamburger/Hamburger.js @@ -0,0 +1,40 @@ +import { Component } from 'react'; +import PropTypes from 'prop-types'; +import { noop } from 'lodash'; + +export default class Hamburguer extends Component { + + static propTypes = { + onClick: PropTypes.func, + } + + static defaultProps = { + onClick: noop, + } + + constructor() { + super(); + this.state = { + isCollapse: true, + }; + } + + onClick() { + const { onClick } = this.props; + const { isCollapse } = this.state; + this.setState({ isCollapse: !isCollapse }); + onClick(!isCollapse); + } + + render() { + const { isCollapse } = this.state; + + return ( + + ); + } +} diff --git a/components/Hamburger/Hamburger.test.js b/components/Hamburger/Hamburger.test.js new file mode 100644 index 00000000..aa71e95a --- /dev/null +++ b/components/Hamburger/Hamburger.test.js @@ -0,0 +1,21 @@ +import { mount } from 'enzyme'; + +import Hamburger from './Hamburger'; + +describe('', () => { + let wrapper; + const onClickMock = jest.fn(); + + beforeEach(() => { + wrapper = mount(); + }); + + test('Should renderer', () => { + expect(wrapper).toBeDefined(); + }); + + test('Should click', () => { + wrapper.find('.Hamburger').simulate('click'); + expect(onClickMock.mock.calls.length).toBe(1); + }); +}); diff --git a/components/Input/Input.js b/components/Input/Input.js new file mode 100644 index 00000000..40ff9506 --- /dev/null +++ b/components/Input/Input.js @@ -0,0 +1,47 @@ +import { Component } from 'react'; +import PropTypes from 'prop-types'; +import { noop } from 'lodash'; + +export default class Input extends Component { + static propTypes = { + name: PropTypes.string.isRequired, + placeholder: PropTypes.string, + value: PropTypes.string, + type: PropTypes.string, + className: PropTypes.string, + onChange: PropTypes.func, + } + + static defaultProps = { + name: '', + placeholder: '', + type: 'text', + className: '', + onChange: noop, + } + + constructor() { + super(); + } + + onChange(event) { + const value = event.target.value; + const { onChange } = this.props; + onChange(value); + } + + render() { + const { name, placeholder, value, type, className } = this.props; + + return ( +
+ +
+ ); + } +} diff --git a/components/Input/Input.test.js b/components/Input/Input.test.js new file mode 100644 index 00000000..a32f2e89 --- /dev/null +++ b/components/Input/Input.test.js @@ -0,0 +1,15 @@ +import { mount } from 'enzyme'; + +import Input from './Input'; + +describe('', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(); + }); + + test('Should renderer', () => { + expect(wrapper).toBeDefined(); + }); +}); diff --git a/components/Layout/Layout.js b/components/Layout/Layout.js new file mode 100644 index 00000000..e571a636 --- /dev/null +++ b/components/Layout/Layout.js @@ -0,0 +1,35 @@ +import { Component } from 'react'; +import PropTypes from 'prop-types'; +import NavigationBar from '../NavigationBar/NavigationBar'; +import Line from '../Line/Line'; + +export default class Layout extends Component { + + static propTypes = { + className: PropTypes.string, + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.element), + PropTypes.object, + ]), + } + + static defaultProps = { + className: '', + } + + constructor() { + super(); + } + + render() { + const { children, className } = this.props; + return ( +
+
+ + {children} + +
+ ); + } +} diff --git a/components/Layout/Layout.test.js b/components/Layout/Layout.test.js new file mode 100644 index 00000000..a1b1ac18 --- /dev/null +++ b/components/Layout/Layout.test.js @@ -0,0 +1,22 @@ +import { mount } from 'enzyme'; + +import Layout from './Layout'; +import Router from 'next/router'; +const mockedRouter = { + push: () => {}, + prefetch: () => {}, + pathname: '/', +}; +Router.router = mockedRouter; + +describe('', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(); + }); + + test('Should renderer', () => { + expect(wrapper).toBeDefined(); + }); +}); diff --git a/components/Line/Line.js b/components/Line/Line.js new file mode 100644 index 00000000..dc91e009 --- /dev/null +++ b/components/Line/Line.js @@ -0,0 +1,13 @@ +import { Component } from 'react'; + +export default class Line extends Component { + constructor() { + super(); + } + + render() { + return ( +
+ ); + } +} diff --git a/components/Line/Line.test.js b/components/Line/Line.test.js new file mode 100644 index 00000000..708b46d3 --- /dev/null +++ b/components/Line/Line.test.js @@ -0,0 +1,15 @@ +import { mount } from 'enzyme'; + +import Line from './Line'; + +describe('', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(); + }); + + test('Should renderer', () => { + expect(wrapper).toBeDefined(); + }); +}); diff --git a/components/ListAlbum/ListAlbum.js b/components/ListAlbum/ListAlbum.js new file mode 100644 index 00000000..a911a6fe --- /dev/null +++ b/components/ListAlbum/ListAlbum.js @@ -0,0 +1,42 @@ +import { Component } from 'react'; +import PropTypes from 'prop-types'; +import { get, noop } from 'lodash'; +import Album from '../Album/Album'; +import H2 from '../H2/H2'; + +export default class ListAlbum extends Component { + static propTypes = { + items: PropTypes.array, + onClick: PropTypes.func, + } + + static defaultProps = { + items: [], + onClick: noop, + } + + constructor() { + super(); + } + + onClick(album) { + const { onClick } = this.props; + onClick(album); + } + + render() { + const { items, onClick } = this.props; + + return ( +
+

Albums

+ { items.map((album, key) => + + )} +
+ ); + } +} diff --git a/components/ListAlbum/ListAlbum.test.js b/components/ListAlbum/ListAlbum.test.js new file mode 100644 index 00000000..c78e8add --- /dev/null +++ b/components/ListAlbum/ListAlbum.test.js @@ -0,0 +1,15 @@ +import { mount } from 'enzyme'; + +import ListAlbum from './ListAlbum'; + +describe('', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(); + }); + + test('Should renderer', () => { + expect(wrapper).toBeDefined(); + }); +}); diff --git a/components/ListArtist/ListArtist.js b/components/ListArtist/ListArtist.js new file mode 100644 index 00000000..114fb8f7 --- /dev/null +++ b/components/ListArtist/ListArtist.js @@ -0,0 +1,42 @@ +import { Component } from 'react'; +import PropTypes from 'prop-types'; +import { get, noop } from 'lodash'; +import Artist from '../Artist/Artist'; +import H2 from '../H2/H2'; + +export default class ListArtist extends Component { + static propTypes = { + items: PropTypes.array, + onClick: PropTypes.func, + } + + static defaultProps = { + items: [], + onClick: noop, + } + + constructor() { + super(); + } + + onClick(artist) { + const { onClick } = this.props; + onClick(artist); + } + + render() { + const { items, onClick } = this.props; + + return ( +
+

Artists

+ { items.map((artist, key) => + + )} +
+ ); + } +} diff --git a/components/ListArtist/ListArtist.test.js b/components/ListArtist/ListArtist.test.js new file mode 100644 index 00000000..a0e665a9 --- /dev/null +++ b/components/ListArtist/ListArtist.test.js @@ -0,0 +1,15 @@ +import { mount } from 'enzyme'; + +import ListArtist from './ListArtist'; + +describe('', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(); + }); + + test('Should renderer', () => { + expect(wrapper).toBeDefined(); + }); +}); diff --git a/components/ListTrack/ListTrack.js b/components/ListTrack/ListTrack.js new file mode 100644 index 00000000..358d04d3 --- /dev/null +++ b/components/ListTrack/ListTrack.js @@ -0,0 +1,80 @@ +import { Component } from 'react'; +import PropTypes from 'prop-types'; +import Album from '../Album/Album'; +import { get, noop } from 'lodash'; +import H2 from '../H2/H2'; + +export default class ListTrack extends Component { + static propTypes = { + items: PropTypes.array, + onClick: PropTypes.func, + image: PropTypes.string, + name: PropTypes.string, + } + + static defaultProps = { + items: [], + onClick: noop, + image: '', + name: '', + } + + constructor() { + super(); + this.state = { + active: '', + }; + } + + onClick(track) { + const { onClick } = this.props; + this.setState({ active: track._id }); + onClick(track); + } + + render() { + const { items, image, name } = this.props; + const { active } = this.state; + const firstTrack = get(items, '[0]', { _id: '' }); + + return ( +
+

Tracks

+
+ +
+
+

Tracks

+
    + { items.map((track, key) => +
  • +
    +
    + {key + 1}. +
    +
    + + + PLAY + + +
    +
    +
    {track.name}
    +
    {this.convertTime(track.duration_ms)}
    +
  • + )} +
+
+
+ ); + } + + convertTime(ms) { + const minutes = Math.floor(ms / 60000); + const seconds = ((ms % 60000) / 1000).toFixed(0); + return `${minutes}:${(seconds < 10 ? '0' : '') + seconds}`; + } +} diff --git a/components/ListTrack/ListTrack.test.js b/components/ListTrack/ListTrack.test.js new file mode 100644 index 00000000..e975b38c --- /dev/null +++ b/components/ListTrack/ListTrack.test.js @@ -0,0 +1,15 @@ +import { mount } from 'enzyme'; + +import ListTrack from './ListTrack'; + +describe('', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(); + }); + + test('Should renderer', () => { + expect(wrapper).toBeDefined(); + }); +}); diff --git a/components/Main/Main.js b/components/Main/Main.js new file mode 100644 index 00000000..0382410b --- /dev/null +++ b/components/Main/Main.js @@ -0,0 +1,27 @@ +import { Component } from 'react'; +import PropTypes from 'prop-types'; + +export default class Main extends Component { + + static propTypes = { + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.element), + PropTypes.object, + ]), + items: PropTypes.array, + model: PropTypes.string, + } + + constructor() { + super(); + } + + render() { + const { children } = this.props; + return ( +
+ {children} +
+ ); + } +} diff --git a/components/Main/Main.test.js b/components/Main/Main.test.js new file mode 100644 index 00000000..6ea9b6a0 --- /dev/null +++ b/components/Main/Main.test.js @@ -0,0 +1,15 @@ +import { mount } from 'enzyme'; + +import Main from './Main'; + +describe('
', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(
); + }); + + test('Should renderer', () => { + expect(wrapper).toBeDefined(); + }); +}); diff --git a/components/NavigationBar/NavigationBar.js b/components/NavigationBar/NavigationBar.js new file mode 100644 index 00000000..5776a2ff --- /dev/null +++ b/components/NavigationBar/NavigationBar.js @@ -0,0 +1,76 @@ +import { Component } from 'react'; +import Router from 'next/router'; +import SpotifyLogo from '../SpotifyLogo/SpotifyLogo'; +import Hamburger from '../Hamburger/Hamburger'; +import Link from 'next/link'; + + +export default class NavigationBar extends Component { + + constructor() { + super(); + this.state = { + pathname: '', + isCollapse: true, + }; + } + + componentDidMount() { + const pathname = Router.pathname.toLowerCase(); + this.setState({ pathname }); + } + + onClickHamburguer(isCollapse) { + this.setState({ isCollapse }); + } + + render() { + const { pathname, isCollapse } = this.state; + const classes = 'NavigationBar__nav__list__group__link spotify-bold '; + + return ( +
+ +
+ ); + } +} diff --git a/components/NavigationBar/NavigationBar.test.js b/components/NavigationBar/NavigationBar.test.js new file mode 100644 index 00000000..4f1d2a2c --- /dev/null +++ b/components/NavigationBar/NavigationBar.test.js @@ -0,0 +1,22 @@ +import { mount } from 'enzyme'; + +import NavigationBar from './NavigationBar'; +import Router from 'next/router'; +const mockedRouter = { + push: () => {}, + prefetch: () => {}, + pathname: '/', +}; +Router.router = mockedRouter; + +describe('', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(); + }); + + test('Should renderer', () => { + expect(wrapper).toBeDefined(); + }); +}); diff --git a/components/ProgressBar/ProgressBar.js b/components/ProgressBar/ProgressBar.js new file mode 100644 index 00000000..9d9ae77a --- /dev/null +++ b/components/ProgressBar/ProgressBar.js @@ -0,0 +1,54 @@ +import { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Line, Circle } from 'react-progressbar.js'; + +export default class ProgressBar extends Component { + static propTypes = { + show: PropTypes.bool, + } + + static defaultProps = { + show: false, + } + + constructor() { + super(); + } + + + render() { + const { show } = this.props; + + const optionsBar = { + strokeWidth: 2, + color: '#1db954', + duration: 1200, + }; + + const optionsCircle = { + strokeWidth: 8, + color: '#1db954', + easing: 'easeInOut', + }; + + const showProgressBar = show ? ( +
+ + + +
+ ) : null; + + return ( +
+ {showProgressBar} +
+ ); + } +} diff --git a/components/ProgressBar/ProgressBar.test.js b/components/ProgressBar/ProgressBar.test.js new file mode 100644 index 00000000..6817131e --- /dev/null +++ b/components/ProgressBar/ProgressBar.test.js @@ -0,0 +1,15 @@ +import { mount } from 'enzyme'; + +import ProgressBar from './ProgressBar'; + +describe('', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(); + }); + + test('Should renderer', () => { + expect(wrapper).toBeDefined(); + }); +}); diff --git a/components/Search/Search.js b/components/Search/Search.js new file mode 100644 index 00000000..04bfd4d8 --- /dev/null +++ b/components/Search/Search.js @@ -0,0 +1,37 @@ +import { Component } from 'react'; +import PropTypes from 'prop-types'; +import Input from '../Input/Input'; +import { noop } from 'lodash'; + +export default class Search extends Component { + + static propTypes = { + placeholder: PropTypes.string, + value: PropTypes.string, + onChange: PropTypes.func, + } + + static defaultProps = { + onChange: noop, + } + + constructor() { + super(); + } + + render() { + const { placeholder, value, onChange } = this.props; + + return ( +
+

Search for an Artist

+
+ +
+
+ ); + } +} diff --git a/components/Search/Search.test.js b/components/Search/Search.test.js new file mode 100644 index 00000000..154b6d9d --- /dev/null +++ b/components/Search/Search.test.js @@ -0,0 +1,15 @@ +import { mount } from 'enzyme'; + +import Search from './Search'; + +describe('', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(); + }); + + test('Should renderer', () => { + expect(wrapper).toBeDefined(); + }); +}); diff --git a/components/SpotifyLogo/SpotifyLogo.js b/components/SpotifyLogo/SpotifyLogo.js new file mode 100644 index 00000000..7d6b40b7 --- /dev/null +++ b/components/SpotifyLogo/SpotifyLogo.js @@ -0,0 +1,21 @@ +import { Component } from 'react'; + + +export default class SpotifyLogo extends Component { + constructor() { + super(); + } + + render() { + const { } = this.props; + + return ( +
+ + Spotify + + +
+ ); + } +} diff --git a/components/SpotifyLogo/SpotifyLogo.test.js b/components/SpotifyLogo/SpotifyLogo.test.js new file mode 100644 index 00000000..805a9d38 --- /dev/null +++ b/components/SpotifyLogo/SpotifyLogo.test.js @@ -0,0 +1,15 @@ +import { mount } from 'enzyme'; + +import SpotifyLogo from './SpotifyLogo'; + +describe('', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(); + }); + + test('Should renderer', () => { + expect(wrapper).toBeDefined(); + }); +}); diff --git a/components/Text/Text.js b/components/Text/Text.js new file mode 100644 index 00000000..bef40535 --- /dev/null +++ b/components/Text/Text.js @@ -0,0 +1,25 @@ +import { Component } from 'react'; +import PropTypes from 'prop-types'; + +export default class Text extends Component { + + static propTypes = { + className: PropTypes.string, + children: PropTypes.any, + } + + static defaultProps = { + className: '', + } + + constructor() { + super(); + } + + render() { + const { children, className } = this.props; + return ( +

{children}

+ ); + } +} diff --git a/components/Text/Text.test.js b/components/Text/Text.test.js new file mode 100644 index 00000000..e512421b --- /dev/null +++ b/components/Text/Text.test.js @@ -0,0 +1,15 @@ +import { mount } from 'enzyme'; + +import Text from './Text'; + +describe('', () => { + let wrapper; + + beforeEach(() => { + wrapper = mount(); + }); + + test('Should renderer', () => { + expect(wrapper).toBeDefined(); + }); +}); diff --git a/config/config.actions.js b/config/config.actions.js new file mode 100644 index 00000000..d1c8c84a --- /dev/null +++ b/config/config.actions.js @@ -0,0 +1,3 @@ +export const SET_CONFIG = 'SET_CONFIG'; + +export const onSetConfig = payload => ({ type: SET_CONFIG, payload }); diff --git a/config/config.reducers.js b/config/config.reducers.js new file mode 100644 index 00000000..b52fb4aa --- /dev/null +++ b/config/config.reducers.js @@ -0,0 +1,19 @@ +const defaultState = { + host: 'localhost', + isSetConfig: false, +}; + +export default function(state = defaultState, action) { + switch (action.type) { + + case 'SET_CONFIG': + return { + ...state, + ...action.payload, + isSetConfig: true, + }; + + default: + return state; + } +} diff --git a/containers/Layout/Layout.container.js b/containers/Layout/Layout.container.js new file mode 100644 index 00000000..28a54e54 --- /dev/null +++ b/containers/Layout/Layout.container.js @@ -0,0 +1,37 @@ +import { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect} from 'react-redux'; +import Layout from '../../components/Layout/Layout'; + +@connect(({ results }) => ({ + results, +})) +class LayoutContainer extends Component { + static propTypes = { + results: PropTypes.object, + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.element), + PropTypes.object, + ]), + } + + static defaultProps = { + results: {}, + } + + constructor() { + super(); + } + + render() { + const { results, children } = this.props; + const { view } = results; + const className = view === 'RESULTS_TRACKS' ? 'on-track' : ''; + return ( + + ); + } +} + +export default LayoutContainer; diff --git a/containers/Main/Main.container.js b/containers/Main/Main.container.js new file mode 100644 index 00000000..732b348e --- /dev/null +++ b/containers/Main/Main.container.js @@ -0,0 +1,20 @@ +import { Component } from 'react'; +import Main from '../../components/Main/Main'; +import Search from '../Search/Search.container'; +import Results from '../Results/Results.container'; + + +export default class MainContainer extends Component { + constructor() { + super(); + } + + render() { + return ( +
+ + +
+ ); + } +} diff --git a/containers/Results/Results.actions.js b/containers/Results/Results.actions.js new file mode 100644 index 00000000..9e6426e4 --- /dev/null +++ b/containers/Results/Results.actions.js @@ -0,0 +1,13 @@ +export const SHOW_RESULTS_NOTHING = 'SHOW_RESULTS_NOTHING'; +export const SHOW_RESULTS_ARTISTS = 'SHOW_RESULTS_ARTISTS'; +export const SHOW_RESULTS_ALBUMS = 'SHOW_RESULTS_ALBUMS'; +export const SHOW_RESULTS_TRACKS = 'SHOW_RESULTS_TRACKS'; +export const SHOW_RESULTS_NO_RESULTS = 'SHOW_RESULTS_NO_RESULTS'; +export const SHOW_RESULTS_ERROR = 'SHOW_RESULTS_ERROR'; + +export const onShowResultsNothing = payload => ({ type: SHOW_RESULTS_NOTHING, payload }); +export const onShowResultsArtists = payload => ({ type: SHOW_RESULTS_ARTISTS, payload }); +export const onShowResultsAlbums = payload => ({ type: SHOW_RESULTS_ALBUMS, payload }); +export const onShowResultsTracks = payload => ({ type: SHOW_RESULTS_TRACKS, payload }); +export const onShowResultsNoResults = payload => ({ type: SHOW_RESULTS_NO_RESULTS, payload }); +export const onShowResultsError = payload => ({ type: SHOW_RESULTS_ERROR, payload }); diff --git a/containers/Results/Results.container.js b/containers/Results/Results.container.js new file mode 100644 index 00000000..f0e0e550 --- /dev/null +++ b/containers/Results/Results.container.js @@ -0,0 +1,99 @@ +import { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect} from 'react-redux'; +import { get } from 'lodash'; +import ListArtist from '../../components/ListArtist/ListArtist'; +import ListAlbum from '../../components/ListAlbum/ListAlbum'; +import ListTrack from '../../components/ListTrack/ListTrack'; +import H2 from '../../components/H2/H2'; +import { getTrack } from '../../graphql/track'; +import { onShowResultsAlbums, onShowResultsTracks } from './Results.actions'; +import { onSearchTracksByAlbum } from '../Search/Search.actions'; + +@connect(({ results, search }) => ({ + search, + results, +})) +class ResultsContainer extends Component { + static propTypes = { + search: PropTypes.object, + results: PropTypes.object, + } + + static contextTypes = { + store: PropTypes.object, + } + + static defaultProps = { + search: {}, + results: {}, + } + + constructor() { + super(); + this.scroll; + this.state = { + searchArtist: '', + currentSelectedArtist: false, + currentSelectedAlbum: false, + }; + } + + + componentDidMount() { + this.audio = new Audio(); + } + + componentWillReceiveProps() { + const { results } = this.props; + const { view } = results; + if (view !== 'RESULTS_TRACKS') { + this.audio.pause(); + } + } + + onClickArtist(artist) { + const { store } = this.context; + store.dispatch(onShowResultsAlbums(artist)); + window.scrollTo(0, 0); + } + + onClickAlbum(album) { + const { store } = this.context; + store.dispatch(onShowResultsTracks(album)); + store.dispatch(onSearchTracksByAlbum({ query: getTrack, album: album._id })); + window.scrollTo(0, 0); + } + + onClickTrack(track) { + this.audio.src = track.preview_url; + this.audio.play(); + } + + render() { + const { search, results } = this.props; + const { view, currentSelectedArtist, currentSelectedAlbum } = results; + const artists = get(search, 'artists', []); + const albums = get(currentSelectedArtist, 'album', []); + const tracks = get(search, 'tracks', []); + const name = get(currentSelectedAlbum, 'name', ''); + const image = get(currentSelectedAlbum, 'image[0].url', ''); + + const currentView = { + 'RESULTS_NOTHING': null, + 'RESULTS_ARTISTS': , + 'RESULTS_ALBUMS': , + 'RESULTS_TRACKS': , + 'RESULTS_NO_RESULTS':

No results, try again :(

, + 'RESULTS_ERROR':

sin implemntar

, + }[view]; + + return ( +
+ {currentView} +
+ ); + } +} + +export default ResultsContainer; diff --git a/containers/Results/Results.epics.js b/containers/Results/Results.epics.js new file mode 100644 index 00000000..115585f6 --- /dev/null +++ b/containers/Results/Results.epics.js @@ -0,0 +1,20 @@ +import 'rxjs'; + +import { + SHOW_RESULTS_ARTISTS, + SHOW_RESULTS_NOTHING, +} from './Results.actions'; + +import { + SEARCH, + SEARCH_SUCCESS, + SEARCH_CLEAR, +} from '../Search/Search.actions'; + +export const showResultsNothingEpic = (action$) => + action$.ofType(SEARCH, SEARCH_CLEAR) + .mapTo({ type: SHOW_RESULTS_NOTHING }); + +export const showResultsArtistsEpic = (action$) => + action$.ofType(SEARCH_SUCCESS) + .mapTo({ type: SHOW_RESULTS_ARTISTS }); diff --git a/containers/Results/Results.reducers.js b/containers/Results/Results.reducers.js new file mode 100644 index 00000000..5de2b106 --- /dev/null +++ b/containers/Results/Results.reducers.js @@ -0,0 +1,51 @@ +const defaultState = { + view: 'RESULTS_NOTHING', + currentSelectedArtist: {}, + currentSelectedAlbum: {}, +}; + +export default function(state = defaultState, action) { + switch (action.type) { + + case 'SHOW_RESULTS_NOTHING': + return { + ...state, + view: 'RESULTS_NOTHING', + }; + + case 'SHOW_RESULTS_ARTISTS': + return { + ...state, + view: 'RESULTS_ARTISTS', + }; + + case 'SHOW_RESULTS_ALBUMS': + return { + ...state, + view: 'RESULTS_ALBUMS', + currentSelectedArtist: action.payload, + }; + + case 'SHOW_RESULTS_TRACKS': + return { + ...state, + view: 'RESULTS_TRACKS', + currentSelectedAlbum: action.payload, + }; + + case 'SHOW_RESULTS_NO_RESULTS': + return { + ...state, + view: 'RESULTS_NO_RESULTS', + }; + + case 'SHOW_RESULTS_ERROR': + return { + ...state, + view: 'RESULTS_ERROR', + }; + + default: + return state; + } +} diff --git a/containers/Search/Search.actions.js b/containers/Search/Search.actions.js new file mode 100644 index 00000000..37b0cd36 --- /dev/null +++ b/containers/Search/Search.actions.js @@ -0,0 +1,23 @@ +export const SEARCH = 'SEARCH'; +export const SEARCH_SUCCESS = 'SEARCH_SUCCESS'; +export const SEARCH_ERROR = 'SEARCH_ERROR'; +export const SEARCH_CANCELLED = 'SEARCH_CANCELLED'; +export const SEARCH_CLEAR = 'SEARCH_CLEAR'; + +export const SEARCH_TRACKS_BY_ALBUM = 'SEARCH_TRACKS_BY_ALBUM'; +export const SEARCH_TRACKS_BY_ALBUM_SUCCESS = 'SEARCH_TRACKS_BY_ALBUM_SUCCESS'; +export const SEARCH_TRACKS_BY_ALBUM_ERROR = 'SEARCH_TRACKS_BY_ALBUM_ERROR'; +export const SEARCH_TRACKS_BY_ALBUM_CANCELLED = 'SEARCH_TRACKS_BY_ALBUM_CANCELLED'; +export const SEARCH_TRACKS_BY_ALBUM_CLEAR = 'SEARCH_TRACKS_BY_ALBUM_CLEAR'; + +export const onSearch = payload => ({ type: SEARCH, payload }); +export const onSearchSuccess = payload => ({ type: SEARCH_SUCCESS, payload }); +export const onSearchError = payload => ({ type: SEARCH_ERROR, payload }); +export const onSearchCancelled = payload => ({ type: SEARCH_CANCELLED, payload }); +export const onSearchClear = payload => ({ type: SEARCH_CLEAR, payload }); + +export const onSearchTracksByAlbum = payload => ({ type: SEARCH_TRACKS_BY_ALBUM, payload }); +export const onSearchTracksByAlbumSuccess = payload => ({ type: SEARCH_TRACKS_BY_ALBUM_SUCCESS, payload }); +export const onSearchTracksByAlbumError = payload => ({ type: SEARCH_TRACKS_BY_ALBUM_ERROR, payload }); +export const onSearchTracksByAlbumCancelled = payload => ({ type: SEARCH_TRACKS_BY_ALBUM_CANCELLED, payload }); +export const onSearchTracksByAlbumClear = payload => ({ type: SEARCH_TRACKS_BY_ALBUM_CLEAR, payload }); diff --git a/containers/Search/Search.container.js b/containers/Search/Search.container.js new file mode 100644 index 00000000..2ee7b6ef --- /dev/null +++ b/containers/Search/Search.container.js @@ -0,0 +1,143 @@ +import { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect} from 'react-redux'; +import { onSearch, onSearchCancelled, onSearchClear } from './Search.actions'; +import { onShowResultsArtists, onShowResultsAlbums } from '../Results/Results.actions'; +import { getArtist } from '../../graphql/artist'; +import { get, isEmpty, debounce, noop } from 'lodash'; +import Search from '../../components/Search/Search'; +import ProgressBar from '../../components/ProgressBar/ProgressBar'; +import Breadcrumb from '../../components/Breadcrumb/Breadcrumb'; + + +@connect(({ search, results }) => ({ + search, + results, +})) +class SearchContainer extends Component { + static propTypes = { + search: PropTypes.object, + results: PropTypes.object, + } + + static contextTypes = { + store: PropTypes.object, + } + + static defaultProps = { + search: {}, + results: {}, + } + + constructor() { + super(); + + this.state = { + searchArtist: '', + currentSelectedArtist: '', + currentSelectedAlbum: '', + }; + } + + componentDidMount() { + const { store } = this.context; + const state = store.getState(); + const searchArtist = get(state, 'search.currentSelectedArtist', ''); + this.setState({ searchArtist }); + + const annyang = require('annyang'); + this.audio = new Audio(); + if (annyang) { + const commands = { + 'search *artist': (artist) => { + this.setState({ searchArtist: artist }); + store.dispatch(onSearch({ query: getArtist, artist })); + }, + }; + + annyang.addCommands(commands); + annyang.start({ autoRestart: true, continuous: false }); + + annyang.debug(); + } + } + + onChange = debounce((artist) => { + const { store } = this.context; + store.dispatch(onSearchCancelled()); + + if (isEmpty(artist)) { + store.dispatch(onSearchClear()); + } else { + store.dispatch(onSearch({ query: getArtist, artist })); + } + }, 250); + + render() { + const { search, results } = this.props; + const { view, currentSelectedArtist } = results; + const { searchArtist } = this.state; + const { isSearching } = search; + const placeholder = 'Search...'; + + const showProgressBar = isSearching ? ( + + ) : null; + + const items = [{ + label: 'Artists', + onClick: this.goToArtists.bind(this), + active: false, + }]; + + const itemAlbum = { + label: 'Albums', + onClick: view !== 'RESULTS_ALBUMS' ? this.goToAlbums.bind(this) : noop, + active: view === 'RESULTS_ALBUMS' ? true : false, + }; + + const itemArtist = { + label: get(currentSelectedArtist, 'name', ''), + onClick: noop, + active: view === 'RESULTS_TRACKS' ? true : false, + }; + + if (view === 'RESULTS_ALBUMS' || view === 'RESULTS_TRACKS') { + items.push(itemAlbum); + items.push(itemArtist); + } + + const showBreadcrumb = view === 'RESULTS_ALBUMS' || view === 'RESULTS_TRACKS' ? ( + + ) : null; + + return ( +
+ { this.onChange(e); this.changeValue(e); } } + placeholder={placeholder} + value={searchArtist} /> + + {showProgressBar} + {showBreadcrumb} +
+ ); + } + + changeValue(artist) { + this.setState({ searchArtist: artist }); + } + + goToArtists() { + const { store } = this.context; + store.dispatch(onShowResultsArtists()); + } + + goToAlbums() { + const { results } = this.props; + const { currentSelectedArtist } = results; + const { store } = this.context; + store.dispatch(onShowResultsAlbums(currentSelectedArtist)); + } +} + +export default SearchContainer; diff --git a/containers/Search/Search.epics.js b/containers/Search/Search.epics.js new file mode 100644 index 00000000..32b967e4 --- /dev/null +++ b/containers/Search/Search.epics.js @@ -0,0 +1,29 @@ +import 'rxjs'; +import { + SEARCH, + SEARCH_CANCELLED, + onSearchSuccess, + onSearchError, + SEARCH_TRACKS_BY_ALBUM, + SEARCH_TRACKS_BY_ALBUM_CANCELLED, + onSearchTracksByAlbumSuccess, + onSearchTracksByAlbumError, +} from './Search.actions'; + +export const searchEpic = (action$, store, graphql) => + action$.ofType(SEARCH) + .debounceTime(250) + .mergeMap(action => graphql(action.payload.query, { artist: action.payload.artist }) + .map(({ response }) => onSearchSuccess(response.data.artist)) + .takeUntil(action$.ofType(SEARCH_CANCELLED)) + .catch(error => Observable.of(onSearchError(error.xhr.response))) + ); + +export const searchTrackEpic = (action$, store, graphql) => + action$.ofType(SEARCH_TRACKS_BY_ALBUM) + .debounceTime(250) + .mergeMap(action => graphql(action.payload.query, { album: action.payload.album }) + .map(({ response }) => onSearchTracksByAlbumSuccess(response.data.track)) + .takeUntil(action$.ofType(SEARCH_TRACKS_BY_ALBUM_CANCELLED)) + .catch(error => Observable.of(onSearchTracksByAlbumError(error.xhr.response))) + ); diff --git a/containers/Search/Search.reducers.js b/containers/Search/Search.reducers.js new file mode 100644 index 00000000..1f4120ee --- /dev/null +++ b/containers/Search/Search.reducers.js @@ -0,0 +1,84 @@ +const defaultState = { + isWithoutArtist: false, + isEmptySearch: true, + error: false, + artists: [], + tracks: [], +}; + +export default function(state = defaultState, action) { + switch (action.type) { + + case 'SEARCH': + return { + ...state, + currentSelectedArtist: action.payload.artist, + isEmptySearch: false, + isSearching: true, + tracks: [], + error: false, + }; + + case 'SEARCH_SUCCESS': + const isWithoutArtist = action.payload.length === 0 ? true : false; + + return { + ...state, + isWithoutArtist, + isEmptySearch: false, + isSearching: false, + artists: action.payload, + error: false, + }; + + case 'SEARCH_ERROR': + return { + ...state, + isEmptySearch: false, + isSearching: false, + error: true, + }; + + case 'SEARCH_CLEAR': + return { + ...state, + isEmptySearch: true, + isSearching: false, + }; + + case 'SEARCH_TRACKS_BY_ALBUM': + return { + ...state, + isSearching: true, + tracks: [], + }; + + case 'SEARCH_TRACKS_BY_ALBUM_SUCCESS': + return { + ...state, + isSearching: false, + tracks: action.payload, + }; + + case 'SEARCH_TRACKS_BY_ALBUM_ERROR': + return { + ...state, + isSearching: false, + }; + + case 'SEARCH_TRACKS_BY_ALBUM_CANCELLED': + return { + ...state, + isSearching: false, + }; + + case 'SEARCH_TRACKS_BY_ALBUM_CLEAR': + return { + ...state, + isSearching: false, + }; + + default: + return state; + } +} diff --git a/graphql/artist.js b/graphql/artist.js new file mode 100644 index 00000000..fc2385ff --- /dev/null +++ b/graphql/artist.js @@ -0,0 +1,25 @@ +export const getArtist = ` + query getArtist($artist: String) { + artist(_id: $artist) { + _id + name + image { + height + width + url + } + album { + _id + name + album_type + type + image { + _id + height + width + url + } + } + } + } +`; diff --git a/graphql/track.js b/graphql/track.js new file mode 100644 index 00000000..ab40bf67 --- /dev/null +++ b/graphql/track.js @@ -0,0 +1,12 @@ +export const getTrack = ` + query getTrack($album: String) { + track(_id: $album) { + _id + name + disc_number + duration_ms + track_number + preview_url + } + } +`; diff --git a/lib/__test__/epics.test.js b/lib/__test__/epics.test.js new file mode 100644 index 00000000..da99a0e7 --- /dev/null +++ b/lib/__test__/epics.test.js @@ -0,0 +1,8 @@ +import { rootEpic } from '../epics'; +import { isFunction } from 'lodash'; + +describe('Epics', () => { + test('Should be rootEpic a function', () => { + expect(isFunction(rootEpic)).toBeTruthy(); + }); +}); diff --git a/lib/__test__/initStore.test.js b/lib/__test__/initStore.test.js new file mode 100644 index 00000000..5f29c02a --- /dev/null +++ b/lib/__test__/initStore.test.js @@ -0,0 +1,55 @@ +import initStore from '../initStore'; +import { isObject, isFunction } from 'lodash'; + +describe('Init store', () => { + describe('When NODE_ENV is not equal to development', () => { + test('Should be initStore return object', () => { + expect(isObject(initStore())).toBeTruthy(); + }); + + test('Should be initStore contain functions dispatch, subscribe, getState, replaceReducer', () => { + const { store } = initStore(); + expect(isFunction(store.dispatch)).toBeTruthy(); + expect(isFunction(store.subscribe)).toBeTruthy(); + expect(isFunction(store.getState)).toBeTruthy(); + expect(isFunction(store.replaceReducer)).toBeTruthy(); + }); + + test('Should be add middlewares epicMiddleware and combineActionsMiddleware', () => { + const { middlewareApplied } = initStore(); + const expected = [ 'epicMiddleware', 'combineActionsMiddleware' ]; + expect(middlewareApplied).toEqual(expect.arrayContaining(expected)); + }); + }); + + describe('When NODE_ENV is equal to development', () => { + beforeEach(() => { + process.env.NODE_ENV = 'development'; + }); + + afterEach(() => { + process.env.NODE_ENV = 'test'; + }); + + test('Should be add middlewares epicMiddleware, combineActionsMiddleware and logger', () => { + const { middlewareApplied } = initStore(); + const expected = [ 'epicMiddleware', 'combineActionsMiddleware' ]; + expect(middlewareApplied).toEqual(expect.arrayContaining(expected)); + }); + }); + + describe('When process is browser', () => { + beforeEach(() => { + process.browser = true; + window.devToolsExtension = () => { + return (f) => f; + }; + }); + + test('Should be add devToolsExtension', () => { + const { middlewareApplied } = initStore(); + const expected = [ 'epicMiddleware', 'combineActionsMiddleware' ]; + expect(middlewareApplied).toEqual(expect.arrayContaining(expected)); + }); + }); +}); diff --git a/lib/__test__/withData.test.js b/lib/__test__/withData.test.js new file mode 100644 index 00000000..e3272e34 --- /dev/null +++ b/lib/__test__/withData.test.js @@ -0,0 +1,42 @@ +import withData from '../withData'; +import { shallow } from 'enzyme'; + +describe('withData', () => { + let wrapper; + let HOC; + let Component; + + beforeAll(() => { + Component = () => { + return ( +

withData

+ ); + }; + + HOC = withData(Component); + wrapper = shallow(); + }); + + test('Should has initialState', () => { + const instance = wrapper.instance(); + expect(typeof instance.props.initialState === 'object').toBeTruthy(); + }); + + describe('When Component has not getInitialProps', () => { + test('Should be return object', (done) => { + const resultPromise = HOC.getInitialProps({}); + resultPromise.then(data => { + expect(typeof data === 'object').toBeTruthy(); + done(); + }); + }); + }); + + describe('When Component has getInitialProps', () => { + test('Should be called getInitialProps', () => { + Component.getInitialProps = jest.fn(); + HOC.getInitialProps({}); + expect(Component.getInitialProps).toHaveBeenCalled(); + }); + }); +}); diff --git a/lib/epics.js b/lib/epics.js new file mode 100644 index 00000000..4fccd1b4 --- /dev/null +++ b/lib/epics.js @@ -0,0 +1,11 @@ +import { combineEpics } from 'redux-observable'; + +import { searchEpic, searchTrackEpic } from '../containers/Search/Search.epics'; +import { showResultsArtistsEpic, showResultsNothingEpic } from '../containers/Results/Results.epics'; + +export const rootEpic = combineEpics( + searchEpic, + searchTrackEpic, + showResultsArtistsEpic, + showResultsNothingEpic, +); diff --git a/lib/initStore.js b/lib/initStore.js new file mode 100644 index 00000000..e600d6b2 --- /dev/null +++ b/lib/initStore.js @@ -0,0 +1,82 @@ +import { ajax } from 'rxjs/observable/dom/ajax'; +import { get } from 'lodash'; +import { createStore, applyMiddleware, compose } from 'redux'; +import { createEpicMiddleware } from 'redux-observable'; +import combineActionsMiddleware from 'redux-combine-actions'; +import { rootReducer } from './reducers'; +import { rootEpic } from './epics'; + +const graphql = function(query, variables) { + const state = store.getState(); + const host = get(state, 'config.host', 'localhost'); + const isProduction = get(state, 'config.isProduction', true); + + return ajax({ + url: `//${host}${isProduction ? '' : ':3000'}/graphql`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + variables: variables, + query: query, + }), + }); +}; + +const epicMiddleware = createEpicMiddleware(rootEpic, { + dependencies: graphql, +}); + + +let store = null; + +export default function initStore(state) { + const middlewareApplied = ['epicMiddleware', 'combineActionsMiddleware']; + const middlewares = [ + epicMiddleware, + combineActionsMiddleware, + ]; + + if (process.env.NODE_ENV === 'development') { + const { logger } = require('redux-logger'); + middlewares.push(logger); + middlewareApplied.push('logger'); + } + + if (process.browser && window.devToolsExtension && !store) { + store = createStore( + rootReducer, + state, + compose( + applyMiddleware(...middlewares), + window.devToolsExtension() + ) + ); + + window.store = store; + + middlewareApplied.push('devToolsExtension'); + return { + store, + middlewareApplied, + }; + } + + if (!process.browser || !store) { + store = createStore( + rootReducer, + state, + compose( + applyMiddleware(...middlewares) + ) + ); + + global.store = store; + } + + return { + store, + middlewareApplied, + }; +} diff --git a/lib/reducers.js b/lib/reducers.js new file mode 100644 index 00000000..5292c2aa --- /dev/null +++ b/lib/reducers.js @@ -0,0 +1,11 @@ +import { combineReducers } from 'redux'; + +import search from '../containers/Search/Search.reducers'; +import results from '../containers/Results/Results.reducers'; +import config from '../config/config.reducers'; + +export const rootReducer = combineReducers({ + search, + results, + config, +}); diff --git a/lib/withData.js b/lib/withData.js new file mode 100644 index 00000000..9221f837 --- /dev/null +++ b/lib/withData.js @@ -0,0 +1,64 @@ +import React from 'react'; +import PropsTypes from 'prop-types'; +import { Provider } from 'react-redux'; +import initStore from './initStore'; +import { get } from 'lodash'; +import ip from 'ip'; + +export default (Component) => ( + class extends React.Component { + + static propTypes = { + initialState: PropsTypes.object, + } + + static defaultProps = { + initialState: {}, + } + + static async getInitialProps(ctx) { + const headers = get(ctx, 'req.headers', {}); + ctx.isServer = get(ctx, 'req', false); + const currentHost = get(ctx, 'req.headers.host', 'localhost'); + const host = process.env.NODE_ENV === 'production' ? currentHost : ip.address(); + const isProduction = process.env.NODE_ENV === 'production' ? true : false; + const config = { host, isProduction }; + + if (ctx.isServer) { + ctx.config = { config }; + } + + const { store } = initStore(); + ctx.store = store; + + const props = { + url: { ...ctx.query, pathname: ctx.pathname }, + ...await (Component.getInitialProps ? Component.getInitialProps(ctx) : {}), + }; + + const state = ctx.store.getState(); + + return { + initialState: { + ...state, + }, + headers, + ...props, + }; + } + + constructor(props) { + super(props); + const { store } = initStore(); + this.store = store; + } + + render() { + return ( + + + + ); + } + } +); diff --git a/next.config.js b/next.config.js new file mode 100644 index 00000000..5d10254d --- /dev/null +++ b/next.config.js @@ -0,0 +1,37 @@ +const path = require('path'); +const glob = require('glob'); + +module.exports = { + webpack: (config) => { + config.module.rules.push( + { + test: /\.(css|scss)/, + loader: 'emit-file-loader', + options: { + name: 'dist/[path][name].[ext]', + }, + }, + { + test: /\.css$/, + use: ['babel-loader', 'raw-loader', 'postcss-loader'], + }, + { + test: /\.s(a|c)ss$/, + use: ['babel-loader', 'raw-loader', 'postcss-loader', + { + loader: 'sass-loader', + options: { + includePaths: ['styles', 'node_modules'] + .map((d) => path.join(__dirname, d)) + .map((g) => glob.sync(g)) + .reduce((a, c) => a.concat(c), []), + }, + }, + ], + } + ); + + // Important: return the modified config + return config; + }, +}; diff --git a/package.json b/package.json new file mode 100644 index 00000000..9cd0e84f --- /dev/null +++ b/package.json @@ -0,0 +1,104 @@ +{ + "name": "spotify-graphitejs", + "version": "0.0.1", + "private": false, + "description": "website", + "repository": "", + "author": "Walter Zalazar", + "scripts": { + "test": "jest", + "coverage": "jest --coverage", + "lint": "eslint -f table ./components ./config ./containers ./lib ./pages ./server || exit 1", + "start": "NODE_ENV=production node_modules/babel-cli/bin/babel-node.js ./server.js", + "start:dev": "NODE_ENV=development node_modules/babel-cli/bin/babel-node.js ./server.js", + "build": "next build", + "uml:architecture": "puml generate ./.uml/architecture.puml -o ./.uml/architecture.png" + }, + "pre-push": [ + "lint", + "test" + ], + "dependencies": { + "@graphite/apollo-express": "^0.2.22", + "@graphite/decorators": "^0.2.22", + "annyang": "^2.6.0", + "dataloader": "^1.3.0", + "es6-promise": "^4.1.0", + "ip": "^1.1.5", + "load-script": "^1.0.0", + "lodash": "^4.17.4", + "next": "beta", + "react": "^15.6.1", + "react-dom": "^15.6.1", + "react-fastclick": "^3.0.2", + "react-progressbar.js": "^0.2.0", + "react-redux": "^5.0.4", + "redux": "^3.6.0", + "redux-combine-actions": "^0.1.2", + "redux-observable": "^0.14.1", + "rxjs": "^5.4.0", + "spotify-web-api-node": "^2.4.0" + }, + "devDependencies": { + "autoprefixer": "^6.7.7", + "babel-cli": "^6.24.1", + "babel-core": "^6.25.0", + "babel-eslint": "^7.2.3", + "babel-istanbul": "^0.11.0", + "babel-jest": "^20.0.3", + "babel-loader": "^6.4.0", + "babel-plugin-add-module-exports": "^0.2.1", + "babel-plugin-transform-class-properties": "^6.23.0", + "babel-plugin-transform-decorators-legacy": "^1.3.4", + "babel-plugin-transform-es2015-modules-commonjs": "^6.24.1", + "babel-plugin-transform-es2015-modules-umd": "^6.24.1", + "babel-plugin-transform-object-rest-spread": "^6.23.0", + "babel-preset-env": "^1.4.0", + "babel-preset-es2015": "^6.24.1", + "babel-preset-latest": "^6.16.0", + "babel-preset-react": "^6.24.1", + "chai": "^3.5.0", + "enzyme": "^2.8.2", + "eslint": "^3.19.0", + "eslint-plugin-react": "^7.0.1", + "jest": "^20.0.4", + "json-proxy": "^0.9.3", + "node-plantuml": "^0.5.0", + "node-sass": "^4.5.3", + "postcss-easy-import": "^2.0.0", + "postcss-loader": "^1.3.3", + "raw-loader": "^0.5.1", + "react-addons": "^0.9.1-deprecated", + "react-addons-test-utils": "^15.5.1", + "react-test-renderer": "^15.5.4", + "redux-logger": "^3.0.6", + "redux-mock-store": "^1.2.3", + "redux-test-utils": "^0.1.2", + "regenerator-runtime": "^0.10.5", + "sass-loader": "^6.0.3", + "sinon": "^2.3.2" + }, + "jest": { + "coverageDirectory": ".coverage", + "verbose": true, + "globals": { + "window": {} + }, + "moduleNameMapper": { + "^.+\\.(css|less|scss)$": "/.mocks/jest/CSSStub.js" + }, + "collectCoverageFrom": [ + "components/**/*.js", + "componentsBusiness/**/*.js", + "containers/**/*.js", + "lib/**/*.js", + "pages/**/*.js", + "sever/**/*.js" + ] + }, + "now": { + "engines": { + "node": "8.1.2" + } + } +} diff --git a/pages/_document.js b/pages/_document.js new file mode 100644 index 00000000..d20d3cae --- /dev/null +++ b/pages/_document.js @@ -0,0 +1,29 @@ +import Document, { Head, Main, NextScript } from 'next/document'; +import flush from 'styled-jsx/server'; +import stylesheet from '../styles/main.scss'; + +export default class MyDocument extends Document { + + static getInitialProps({ renderPage }) { + const { html, head, chunks } = renderPage(); + const styles = flush(); + return { html, head, styles, chunks }; + } + + render() { + return ( + + + Spotify - GraphiteJS +