diff --git a/.eslintrc.js b/.eslintrc.js index 4ee202f..28608f0 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,3 +1,77 @@ module.exports = { - "extends": "airbnb" -}; \ No newline at end of file + extends: 'airbnb', + env: { + es6: true, + node: true, + commonjs: true, + browser: true, + }, +}; + +module.exports = { + extends: ['airbnb'], + env: { + es6: true, + node: true, + commonjs: true, + browser: true, + }, + settings: { + 'import/resolver': { + node: { + moduleDirectory: ['node_modules', 'app/javascript/packs/twitter'], + }, + }, + }, + plugins: ['react', 'import'], + rules: { + 'function-paren-newline': 0, + 'object-curly-newline': 0, + 'key-spacing': 0, + indent: 0, + 'no-multi-spaces': 0, + 'no-debugger': 0, + 'no-continue': 0, + quotes: [2, 'single'], + strict: [2, 'never'], + 'template-curly-spacing': [0, 'always'], + 'comma-dangle': [2, 'always-multiline'], + 'max-len': 0, + 'no-shadow': 0, + 'arrow-body-style': 0, + 'arrow-parens': [2, 'as-needed'], + 'global-require': 0, + 'no-unused-expressions': 0, + 'no-confusing-arrow': 0, + 'no-unused-vars': [2, { ignoreRestSiblings: true }], + 'no-constant-condition': 0, + 'import/no-dynamic-require': 0, + 'import/no-extraneous-dependencies': 0, + 'import/prefer-default-export': 0, + 'import/no-named-as-default': 0, + 'import/no-unresolved': ['error', { caseSensitive: false }], + 'react/require-default-props': [ + 'error', + { forbidDefaultForRequired: true }, + ], + 'react/forbid-prop-types': 0, + 'react/no-unused-prop-types': 0, + 'react/no-typos': 0, + 'react/default-props-match-prop-types': 0, + 'react/prefer-stateless-function': 0, + 'react/no-array-index-key': 0, + 'react/jsx-uses-react': 2, + 'react/jsx-uses-vars': 2, + 'react/jsx-no-bind': 0, + 'react/react-in-jsx-scope': 2, + 'react/jsx-filename-extension': [2, { extensions: ['.js', '.jsx'] }], + 'jsx-a11y/anchor-is-valid': [ + 2, + { + components: ['Link'], + specialLink: ['hrefLeft', 'hrefRight', 'to'], + aspects: ['noHref', 'invalidHref', 'preferButton'], + }, + ], + }, +}; diff --git a/app/controllers/api/v1/replies_controller.rb b/app/controllers/api/v1/replies_controller.rb new file mode 100644 index 0000000..e5ed7d4 --- /dev/null +++ b/app/controllers/api/v1/replies_controller.rb @@ -0,0 +1,16 @@ +class Api::V1::RepliesController < ApplicationController + def index + end + + def create + end + + def show + end + + def update + end + + def destroycreate + end +end \ No newline at end of file diff --git a/app/controllers/api/v1/tweets_controller.rb b/app/controllers/api/v1/tweets_controller.rb new file mode 100644 index 0000000..620a2d4 --- /dev/null +++ b/app/controllers/api/v1/tweets_controller.rb @@ -0,0 +1,18 @@ +class Api::V1::TweetsController < ApplicationController + def index + tweets = Tweet.where(user_id: current_user.id) + render json: tweets + end + + def create + end + + def show + end + + def update + end + + def destroy + end +end diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index c69edb7..0450155 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -6,4 +6,4 @@ // // To reference this file, add <%= javascript_pack_tag 'application' %> to the appropriate // layout file, like app/views/layouts/application.html.erb -import "bootstrap/dist/js/bootstrap"; +import 'bootstrap/dist/js/bootstrap'; diff --git a/app/javascript/packs/twitter/api/tweets.js b/app/javascript/packs/twitter/api/tweets.js new file mode 100644 index 0000000..2bc8d3a --- /dev/null +++ b/app/javascript/packs/twitter/api/tweets.js @@ -0,0 +1,10 @@ +import { get } from '../utils/fetch'; +import endpoints from '../utils/apiEndpoints'; + +export const tweets = () => { + return new Promise((resolve, reject) => { + return get(endpoints.tweets(), {}) + .then(response => resolve(response)) + .catch(error => reject(error)); + }); +}; diff --git a/app/javascript/packs/twitter/components/landingPage.jsx b/app/javascript/packs/twitter/components/landingPage.jsx deleted file mode 100644 index 734e332..0000000 --- a/app/javascript/packs/twitter/components/landingPage.jsx +++ /dev/null @@ -1,12 +0,0 @@ -import React, { Component } from "react"; - -class LandingPage extends Component { - render() { - return ( -
-

Hello World

-
- ); - } -} -export default LandingPage; diff --git a/app/javascript/packs/twitter/components/tweetsList.jsx b/app/javascript/packs/twitter/components/tweetsList.jsx new file mode 100644 index 0000000..a294f4d --- /dev/null +++ b/app/javascript/packs/twitter/components/tweetsList.jsx @@ -0,0 +1,27 @@ +import React, { Component } from 'react'; +import { tweets } from '../api/tweets'; + +class TweetsList extends Component { + constructor(props) { + super(props); + this.state = { + tweets: [], + }; + } + + componentDidMount() { + tweets().then(response => { + this.setState({ tweets: response }); + }); + } + + render() { + const { tweets } = this.state; + return ( +
+

Tweets

+
+ ); + } +} +export default TweetsList; diff --git a/app/javascript/packs/twitter/routes.js b/app/javascript/packs/twitter/routes.js index 188f5cd..16d76a0 100644 --- a/app/javascript/packs/twitter/routes.js +++ b/app/javascript/packs/twitter/routes.js @@ -1,11 +1,11 @@ -import React from "react"; -import { BrowserRouter as Router, Route } from "react-router-dom"; -import LandingPage from "./components/landingPage"; +import React from 'react'; +import { BrowserRouter as Router, Route } from 'react-router-dom'; +import TweetsList from './components/tweetsList'; -const App = props => ( +const App = () => (
- +
); diff --git a/app/javascript/packs/twitter/utils/apiEndpoints.js b/app/javascript/packs/twitter/utils/apiEndpoints.js new file mode 100644 index 0000000..5bda9c4 --- /dev/null +++ b/app/javascript/packs/twitter/utils/apiEndpoints.js @@ -0,0 +1,8 @@ +export const apiBaseRoute = process.env.API_BASE_URL || 'http://localhost:3000/api/v1'; + +export default { + //--------------------------------------------------------------------------- + + // /tweets GET + tweets: () => `${apiBaseRoute}/tweets`, +}; diff --git a/app/javascript/packs/twitter/utils/fetch.js b/app/javascript/packs/twitter/utils/fetch.js new file mode 100644 index 0000000..7bf8ffa --- /dev/null +++ b/app/javascript/packs/twitter/utils/fetch.js @@ -0,0 +1,94 @@ +import { stringify } from 'qs'; +import { curry, isEmpty } from 'lodash'; +import { getToken } from './token'; + +const API_VERSION = 1; +const ACCEPT_HEADER = { Accept: `application/json; version=${API_VERSION}` }; +const CONTENT_TYPE_HEADER = { 'Content-type': 'application/json' }; + +/** + * Wrapper for built-in `fetch` which adds the expected Authorization header to the + * request. If no access token is available, a rejected Promise will be returned. + * + * @param {string} url - The URL to fetch from + * @param {object} options - Standard options for fetch + * @returns {Promise} + */ +const authenticatedFetch = (url, options) => { + const accessToken = getToken(); + + if (!accessToken) { + return Promise.reject( + new Error( + `Attempting to access an API that requires authentication, but no auth token is available. URL=${url}` + ) + ); + } + + const authedOpts = { + ...options, + headers: { + Authorization: `Bearer ${accessToken}`, + ...options.headers, + }, + }; + + return fetch(url, authedOpts); +}; + +const makeApiUrl = (endpoint, query) => { + const queryString = stringify(query); + return `${endpoint}${queryString ? '?' : ''}${queryString}`; +}; + +const parseJSON = response => new Promise(resolve => response.json().then(json => resolve({ + status: response.status, + ok: response.ok, + json, + }) + ) + ); + +const buildFetchArgs = ({ method, headers = {}, requestBody = {} }) => { + const fetchOpts = { + method, + headers: { + ...ACCEPT_HEADER, + ...CONTENT_TYPE_HEADER, + ...headers, + }, + }; + return isEmpty(requestBody) + ? fetchOpts + : { ...fetchOpts, body: JSON.stringify(requestBody) }; +}; + +function execute( + method, + endpoint, + { query, auth = false, headers = {}, requestBody = {} } = {} +) { + const url = makeApiUrl(endpoint, query); + const options = buildFetchArgs({ method, headers, requestBody }); + const fetchFn = auth ? authenticatedFetch : fetch; + return new Promise((resolve, reject) => { + fetchFn(url, options) + .then(parseJSON) + .then(response => { + if (response.ok) { + return resolve(response.json); + } + // extract the error from the server's json + return reject(response.json); + }) + .catch(error => reject(new Error(error))); + }); +} + +// These are the functions that are exported for use: +export { makeApiUrl }; +export const get = curry(execute)('GET'); +export const post = curry(execute)('POST'); +export const patch = curry(execute)('PATCH'); +export const put = curry(execute)('PUT'); +// Any other functions exported above are only exported for test purposes. diff --git a/app/javascript/packs/twitter/utils/token.js b/app/javascript/packs/twitter/utils/token.js new file mode 100644 index 0000000..371b258 --- /dev/null +++ b/app/javascript/packs/twitter/utils/token.js @@ -0,0 +1,37 @@ +export const getToken = () => JSON.parse(localStorage.getItem('token')); + +export const getRefreshToken = () => JSON.parse(localStorage.getItem('refreshToken')); + +export const setAuthToken = ({ token, refreshToken }) => { + localStorage.setItem('token', JSON.stringify(token)); + localStorage.setItem('refreshToken', JSON.stringify(refreshToken)); +}; + +export const decodeToken = (token) => { + if (token) { + try { + const jwtDecode = require('jwt-decode'); + return jwtDecode(token); + } catch (error) { + throw new Error('Invalid token'); + } + } + return null; +}; + +export const isNotExpired = (token) => { + try { + const decodedToken = decodeToken(token); + if (!decodedToken) return false; + const date = decodedToken.exp ? decodedToken.exp : null; + // convert to millis + return new Date(date * 1000) > Date.now(); + } catch (error) { + throw new Error(error); + } +}; + +export const removeAuthToken = () => { + localStorage.removeItem('token'); + localStorage.removeItem('refreshToken'); +}; diff --git a/app/models/tweet.rb b/app/models/tweet.rb new file mode 100644 index 0000000..9bc77ea --- /dev/null +++ b/app/models/tweet.rb @@ -0,0 +1,7 @@ +class Tweet < ApplicationRecord + belongs_to :user, class_name: "User", foreign_key: "user_id" + has_many :replies, class_name: "Tweet", foreign_key: "to_id" + + validates :user_id, presence: true + validates :content, presence: true, length: { maximum: 140 } +end diff --git a/app/views/pages/root.html.erb b/app/views/pages/root.html.erb index a097762..042bb9c 100644 --- a/app/views/pages/root.html.erb +++ b/app/views/pages/root.html.erb @@ -1 +1,3 @@ -
+
+
+
diff --git a/config/routes.rb b/config/routes.rb index 204b2da..4ef392e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,4 +3,11 @@ devise_for :users, path: '', path_names: { sign_in: 'login', sign_out: 'logout', sign_up: 'register', edit: 'settings' } get '/users', to: 'users#index' root to: "pages#root" + namespace :api, defaults: { format: :json } do + namespace :v1 do + resources :tweets do + resources :replies + end + end + end end diff --git a/config/webpack/development.js b/config/webpack/development.js index c5edff9..89d35d9 100644 --- a/config/webpack/development.js +++ b/config/webpack/development.js @@ -1,5 +1,5 @@ -process.env.NODE_ENV = process.env.NODE_ENV || 'development' +process.env.NODE_ENV = process.env.NODE_ENV || 'development'; -const environment = require('./environment') +const environment = require('./environment'); -module.exports = environment.toWebpackConfig() +module.exports = environment.toWebpackConfig(); diff --git a/config/webpack/environment.js b/config/webpack/environment.js index be7b30c..1ee35bb 100644 --- a/config/webpack/environment.js +++ b/config/webpack/environment.js @@ -1,19 +1,6 @@ const { environment } = require('@rails/webpacker'); const webpack = require('webpack'); -{ - [ - { - test: /\.js(\.erb)?$/, - exclude: /node_modules/, - loader: 'babel-loader', - options: { - presets: [['env', { modules: false }]], - }, - }, - ]; -} - // Add an additional plugin of your choosing : ProvidePlugin environment.plugins.prepend( 'Provide', @@ -26,11 +13,11 @@ environment.plugins.prepend( }), ); -environment.loaders.get('sass').use.splice(-1, 0, { - loader: 'resolve-url-loader', - options: { - attempts: 1, - }, -}); +// environment.loaders.get('sass').use.splice(-1, 0, { +// loader: 'resolve-url-loader', +// options: { +// attempts: 1, +// }, +// }); module.exports = environment; diff --git a/config/webpack/production.js b/config/webpack/production.js index be0f53a..7684a10 100644 --- a/config/webpack/production.js +++ b/config/webpack/production.js @@ -1,5 +1,5 @@ -process.env.NODE_ENV = process.env.NODE_ENV || 'production' +process.env.NODE_ENV = process.env.NODE_ENV || 'production'; -const environment = require('./environment') +const environment = require('./environment'); -module.exports = environment.toWebpackConfig() +module.exports = environment.toWebpackConfig(); diff --git a/db/migrate/20180828000002_create_tweets.rb b/db/migrate/20180828000002_create_tweets.rb new file mode 100644 index 0000000..08ad166 --- /dev/null +++ b/db/migrate/20180828000002_create_tweets.rb @@ -0,0 +1,11 @@ +class CreateTweets < ActiveRecord::Migration[5.2] + def change + create_table :tweets do |t| + t.integer :user_id + t.text :content + t.integer :to_id + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 16ceb5b..cf100c8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,15 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2018_08_20_232436) do +ActiveRecord::Schema.define(version: 2018_08_28_000002) do + + create_table "tweets", force: :cascade do |t| + t.integer "user_id" + t.text "content" + t.integer "to_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end create_table "users", force: :cascade do |t| t.string "email", default: "", null: false diff --git a/package.json b/package.json index e1e688b..94856ec 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,12 @@ "babel-preset-react": "^6.24.1", "bootstrap": "^4.1.3", "jquery": "^3.3.1", + "jwt-decode": "^2.2.0", + "lodash": "^4.17.10", + "lodash.curry": "^4.1.1", "popper.js": "^1.14.4", "prop-types": "^15.6.2", + "qs": "^6.5.2", "react": "^16.4.2", "react-dom": "^16.4.2", "react-router": "^4.3.1", diff --git a/spec/factories/tweets.rb b/spec/factories/tweets.rb new file mode 100644 index 0000000..4f853bf --- /dev/null +++ b/spec/factories/tweets.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :tweet do + user_id 1 + content "MyText" + to_id 1 + end +end diff --git a/spec/models/tweet_spec.rb b/spec/models/tweet_spec.rb new file mode 100644 index 0000000..9a94ce1 --- /dev/null +++ b/spec/models/tweet_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Tweet, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/yarn.lock b/yarn.lock index ad98bdf..b5b5e7b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3578,6 +3578,10 @@ jsx-ast-utils@^2.0.1: dependencies: array-includes "^3.0.3" +jwt-decode@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-2.2.0.tgz#7d86bd56679f58ce6a84704a657dd392bba81a79" + killable@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/killable/-/killable-1.0.0.tgz#da8b84bd47de5395878f95d64d02f2449fe05e6b" @@ -3732,6 +3736,10 @@ lodash.clonedeep@^4.3.2: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" +lodash.curry@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/lodash.curry/-/lodash.curry-4.1.1.tgz#248e36072ede906501d75966200a86dab8b23170" + lodash.debounce@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" @@ -5284,7 +5292,7 @@ qs@6.5.1: version "6.5.1" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8" -qs@~6.5.1, qs@~6.5.2: +qs@^6.5.2, qs@~6.5.1, qs@~6.5.2: version "6.5.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"