├── .travis.yml ├── logo.png ├── babel.config.js ├── src ├── common │ ├── config.js │ ├── components │ │ ├── errors.js │ │ ├── footer.js │ │ └── header.js │ ├── jwt.js │ └── action.js ├── profile │ ├── service.js │ ├── components │ │ ├── nav.js │ │ └── user-info.js │ ├── my.js │ ├── action.js │ └── favorited.js ├── user │ ├── service.js │ ├── login.js │ ├── register.js │ ├── action.js │ └── setting.js ├── index.html ├── article │ ├── components │ │ ├── comment-editor.js │ │ ├── list.js │ │ └── meta.js │ ├── service.js │ ├── home.js │ ├── view.js │ ├── edit.js │ └── action.js └── index.js ├── test └── unit │ ├── common │ ├── jwt.spec.js │ └── action.spec.js │ └── article │ └── action.spec.js ├── .gitignore ├── webpack.config.js ├── package.json └── README.md /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - lts/* 5 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ecomfe/san-realworld-app/HEAD/logo.png -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@babel/preset-env' 4 | ] 5 | }; 6 | -------------------------------------------------------------------------------- /src/common/config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | API_URL: 'https://conduit.productionready.io/api', 3 | PAGE_SIZE: 10 4 | }; -------------------------------------------------------------------------------- /src/common/components/errors.js: -------------------------------------------------------------------------------- 1 | import san from 'san'; 2 | import { connect } from 'san-store'; 3 | 4 | export default connect.san( 5 | { 6 | errors: 'errors' 7 | } 8 | )(san.defineComponent({ 9 | template: ` 10 | 13 | ` 14 | })) -------------------------------------------------------------------------------- /src/common/jwt.js: -------------------------------------------------------------------------------- 1 | const ID_TOKEN_KEY = "id_token"; 2 | 3 | function getToken() { 4 | return window.localStorage.getItem(ID_TOKEN_KEY); 5 | }; 6 | 7 | function setToken(token) { 8 | window.localStorage.setItem(ID_TOKEN_KEY, token); 9 | }; 10 | 11 | function clearToken(){ 12 | window.localStorage.removeItem(ID_TOKEN_KEY); 13 | } 14 | 15 | export default { getToken, setToken, clearToken }; -------------------------------------------------------------------------------- /src/common/components/footer.js: -------------------------------------------------------------------------------- 1 | import san from 'san'; 2 | 3 | export default san.defineComponent({ 4 | template: ` 5 |
6 | conduit 7 | 8 | An interactive learning project from Thinkster. Code & design licensed under MIT. 9 | 10 |
11 | ` 12 | }) -------------------------------------------------------------------------------- /src/profile/service.js: -------------------------------------------------------------------------------- 1 | import config from '../common/config'; 2 | import axios from 'axios'; 3 | 4 | export default { 5 | get(username) { 6 | return axios.get(`${config.API_URL}/profiles/${username}`); 7 | }, 8 | 9 | follow(username) { 10 | return axios.post(`${config.API_URL}/profiles/${username}/follow`); 11 | }, 12 | 13 | unfollow(username) { 14 | return axios.delete(`${config.API_URL}/profiles/${username}/follow`); 15 | } 16 | } -------------------------------------------------------------------------------- /test/unit/common/jwt.spec.js: -------------------------------------------------------------------------------- 1 | import jwt from '../../../src/common/jwt'; 2 | 3 | describe('JWT', () => { 4 | it('get token after set', () => { 5 | let token = 'abcde'; 6 | jwt.setToken(token); 7 | expect(jwt.getToken()).toBe(token); 8 | }); 9 | 10 | it('get token after clear, should be undefined', () => { 11 | let token = 'abcde'; 12 | jwt.setToken(token); 13 | jwt.clearToken(); 14 | 15 | expect(jwt.getToken() == null).toBeTruthy(); 16 | }); 17 | }); -------------------------------------------------------------------------------- /src/user/service.js: -------------------------------------------------------------------------------- 1 | import config from '../common/config'; 2 | import axios from 'axios'; 3 | 4 | export default { 5 | login(user) { 6 | return axios.post(`${config.API_URL}/users/login`, {user}); 7 | }, 8 | 9 | register(user) { 10 | return axios.post(`${config.API_URL}/users`, {user}); 11 | }, 12 | 13 | update(user) { 14 | return axios.put(`${config.API_URL}/user`, user); 15 | }, 16 | 17 | get() { 18 | return axios.get(`${config.API_URL}/user`); 19 | } 20 | } -------------------------------------------------------------------------------- /test/unit/common/action.spec.js: -------------------------------------------------------------------------------- 1 | import { store } from 'san-store'; 2 | import { Types } from '../../../src/common/action'; 3 | 4 | describe('Common Action', () => { 5 | it('set errors and clear errors', () => { 6 | store.dispatch(Types.ERRORS_SET, {title: 'one'}); 7 | 8 | let errors = store.getState('errors'); 9 | expect(errors.length).toBe(1); 10 | expect(errors[0]).toBe('title one'); 11 | 12 | store.dispatch(Types.ERRORS_CLEAR); 13 | expect(store.getState('errors') == null).toBeTruthy(); 14 | }); 15 | }); -------------------------------------------------------------------------------- /src/profile/components/nav.js: -------------------------------------------------------------------------------- 1 | import san from 'san'; 2 | import { Link } from 'san-router'; 3 | 4 | export default san.defineComponent({ 5 | components: { 6 | 'x-link': Link 7 | }, 8 | 9 | template: ` 10 |
11 | 19 |
20 | ` 21 | }) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | /node_modules 10 | 11 | # IDEs and editors 12 | .vscode/ 13 | /.idea 14 | .project 15 | .classpath 16 | .c9/ 17 | *.launch 18 | .settings/ 19 | *.sublime-workspace 20 | 21 | # IDE - VSCode 22 | .vscode/* 23 | !.vscode/settings.json 24 | !.vscode/tasks.json 25 | !.vscode/launch.json 26 | !.vscode/extensions.json 27 | 28 | # misc 29 | /.sass-cache 30 | /connect.lock 31 | /coverage 32 | /libpeerconnection.log 33 | npm-debug.log 34 | testem.log 35 | /typings 36 | yarn-error.log 37 | 38 | # e2e 39 | /e2e/*.js 40 | /e2e/*.map 41 | 42 | # System Files 43 | .DS_Store 44 | Thumbs.db -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Conduit 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /test/unit/article/action.spec.js: -------------------------------------------------------------------------------- 1 | import { store } from 'san-store'; 2 | import { Types } from '../../../src/article/action'; 3 | 4 | import Conf from '../../../src/common/config'; 5 | 6 | describe('Acticle Action', () => { 7 | it('fetch list', done => { 8 | let fetchPromise = store.dispatch(Types.FETCH); 9 | 10 | expect(store.getState('articlesLoading')).toBeTruthy(); 11 | 12 | fetchPromise.then(() => { 13 | expect(store.getState('articlesLoading')).toBeFalsy(); 14 | expect(store.getState('articleCount')).toBe(500); 15 | expect(store.getState('articlePageCount') >= store.getState('articleCount') / Conf.PAGE_SIZE).toBeTruthy(); 16 | expect(store.getState('articles') instanceof Array).toBeTruthy(); 17 | 18 | done(); 19 | }); 20 | 21 | 22 | }); 23 | }); -------------------------------------------------------------------------------- /src/common/action.js: -------------------------------------------------------------------------------- 1 | import { store } from 'san-store'; 2 | import { updateBuilder } from 'san-update'; 3 | 4 | 5 | export const Types = { 6 | ERRORS_CLEAR: 'errorClear', 7 | ERRORS_SET: 'errorSet' 8 | }; 9 | 10 | store.addAction(Types.ERRORS_CLEAR, function () { 11 | return updateBuilder().set('errors', null); 12 | }); 13 | 14 | store.addAction(Types.ERRORS_SET, function (errors) { 15 | var formattedErrors; 16 | if (errors) { 17 | formattedErrors = Object.keys(errors) 18 | .map(key => `${key} ${errors[key]}`); 19 | } 20 | 21 | return updateBuilder().set('errors', formattedErrors); 22 | }); 23 | 24 | export function whenNoError(fn) { 25 | return function ({data}) { 26 | if (data.errors) { 27 | store.dispatch(Types.ERRORS_SET, data.errors); 28 | } 29 | else if (typeof fn === 'function'){ 30 | fn(data); 31 | } 32 | 33 | return data; 34 | }; 35 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HTMLWebpackPlugin = require('html-webpack-plugin'); 3 | 4 | module.exports = { 5 | context: __dirname, 6 | entry: path.join(__dirname, 'src', 'index.js'), 7 | output: { 8 | path: path.join(__dirname, 'dist'), 9 | }, 10 | devServer: { 11 | port: 8888, 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | test: /\.js$/, 17 | exclude: /node_modules/, 18 | use: 'babel-loader', 19 | }, 20 | { 21 | test: /\.(png|jpe?g|gif|svg|woff2?|eot|ttf|otf)(\?.*)?$/, 22 | use: [ 23 | { 24 | loader: 'url-loader', 25 | options: { 26 | limit: 10000, 27 | }, 28 | }, 29 | ], 30 | }, 31 | { 32 | test: /\.css/, 33 | use: [ 34 | 'style-loader', 35 | 'css-loader', 36 | ], 37 | } 38 | ], 39 | }, 40 | resolve: { 41 | extensions: ['.js', '.json'], 42 | }, 43 | plugins: [ 44 | new HTMLWebpackPlugin({template: 'src/index.html'}), 45 | ], 46 | }; 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "san-realworld-app", 3 | "version": "0.1.0", 4 | "description": "", 5 | "private": true, 6 | "scripts": { 7 | "build": "rm -rf dist && webpack --config=webpack.config.js --mode=production", 8 | "start": "webpack-dev-server --config=webpack.config.js --mode=development", 9 | "test": "jest" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/ecomfe/san-realworld-app.git" 14 | }, 15 | "author": "", 16 | "license": "ISC", 17 | "bugs": { 18 | "url": "https://github.com/ecomfe/san-realworld-app/issues" 19 | }, 20 | "dependencies": { 21 | "san": "^3.8.0", 22 | "san-router": "^1.2.2", 23 | "san-update": "2.1.0", 24 | "san-store": "^2.0.0", 25 | "axios": "^0.19.0", 26 | "marked": "^0.6.2", 27 | "date-fns": "^1.30.1" 28 | }, 29 | "devDependencies": { 30 | "@babel/core": "^7.0.0", 31 | "@babel/preset-env": "^7.0.0", 32 | "babel-loader": "^8.0.6", 33 | "css-loader": "^2.1.1", 34 | "file-loader": "^3.0.1", 35 | "html-loader": "^0.5.5", 36 | "html-webpack-plugin": "^3.2.0", 37 | "jest": "^24.8.0", 38 | "style-loader": "^0.23.1", 39 | "url-loader": "^1.1.2", 40 | "webpack": "^4.32.2", 41 | "webpack-cli": "^3.3.2", 42 | "webpack-dev-server": "^3.4.1" 43 | }, 44 | "homepage": "https://github.com/ecomfe/san-realworld-app#readme" 45 | } 46 | -------------------------------------------------------------------------------- /src/profile/my.js: -------------------------------------------------------------------------------- 1 | import san from 'san'; 2 | import { connect } from 'san-store'; 3 | import { Types as ActionTypes } from './action'; 4 | import UserInfo from './components/user-info'; 5 | import Nav from './components/nav'; 6 | import ArticleList from '../article/components/list'; 7 | 8 | export default connect.san( 9 | { 10 | profile: 'profile', 11 | user: 'user' 12 | }, 13 | { 14 | fetch: ActionTypes.FETCH, 15 | reset: ActionTypes.RESET 16 | } 17 | )(san.defineComponent({ 18 | 19 | components: { 20 | 'x-articles': ArticleList, 21 | 'x-userinfo': UserInfo, 22 | 'x-nav': Nav 23 | }, 24 | 25 | template: ` 26 |
27 | 28 | 29 |
30 |
31 |
32 | 33 | 34 |
35 |
36 |
37 |
38 | `, 39 | 40 | route() { 41 | let author = this.data.get('route.query.user'); 42 | 43 | this.actions.fetch(author); 44 | }, 45 | 46 | disposed() { 47 | this.actions.reset(); 48 | } 49 | })) -------------------------------------------------------------------------------- /src/article/components/comment-editor.js: -------------------------------------------------------------------------------- 1 | import san from 'san'; 2 | import { connect } from 'san-store'; 3 | import { Types as ActionTypes } from '../action'; 4 | import ErrorsView from '../../common/components/errors'; 5 | 6 | 7 | export default connect.san( 8 | {}, 9 | { 10 | submit: ActionTypes.ADD_COMMENT 11 | } 12 | )(san.defineComponent({ 13 | components: { 14 | 'x-errors': ErrorsView 15 | }, 16 | 17 | template: ` 18 |
19 | 20 |
21 |
22 | 24 |
25 | 29 |
30 |
31 | `, 32 | 33 | postComment() { 34 | let {slug, comment} = this.data.get(); 35 | 36 | if (slug && comment) { 37 | this.data.set('inProgress', true); 38 | this.actions.submit({slug, comment}).then(() => { 39 | this.data.set('comment', ''); 40 | this.data.set('inProgress', false); 41 | }); 42 | 43 | } 44 | } 45 | })) -------------------------------------------------------------------------------- /src/profile/action.js: -------------------------------------------------------------------------------- 1 | import { store } from 'san-store'; 2 | import { updateBuilder } from 'san-update'; 3 | import service from './service'; 4 | import { whenNoError } from '../common/action'; 5 | 6 | 7 | export const Types = { 8 | FETCH: 'profileFetch', 9 | SET: 'profileSet', 10 | RESET: 'profileReset', 11 | FOLLOW: 'profileFollow', 12 | UNFOLLOW: 'profileUnfollow' 13 | }; 14 | 15 | store.addAction(Types.FETCH, function (user, {dispatch}) { 16 | dispatch(Types.SET, {}); 17 | return service.get(user).then( 18 | whenNoError(data => { 19 | dispatch(Types.SET, data.profile); 20 | }) 21 | ); 22 | }); 23 | 24 | store.addAction(Types.SET, function (profile, {dispatch}) { 25 | return updateBuilder().set('profile', profile); 26 | }); 27 | 28 | store.addAction(Types.RESET, function (profile, {dispatch}) { 29 | return updateBuilder().set('profile', null); 30 | }); 31 | 32 | store.addAction(Types.FOLLOW, function (user, {dispatch, getState}) { 33 | return service.follow(user).then( 34 | whenNoError(data => { 35 | if (getState('profile')) { 36 | dispatch(Types.SET, data.profile); 37 | } 38 | }) 39 | ); 40 | }); 41 | 42 | store.addAction(Types.UNFOLLOW, function (user, {dispatch, getState}) { 43 | return service.unfollow(user).then( 44 | whenNoError(data => { 45 | if (getState('profile')) { 46 | dispatch(Types.SET, data.profile); 47 | } 48 | }) 49 | ); 50 | }); -------------------------------------------------------------------------------- /src/article/service.js: -------------------------------------------------------------------------------- 1 | import config from '../common/config'; 2 | import axios from 'axios'; 3 | 4 | export default { 5 | fetch(params) { 6 | return axios.get(`${config.API_URL}/articles`, {params}); 7 | }, 8 | 9 | fetchFeed(params) { 10 | return axios.get(`${config.API_URL}/articles/feed`, {params}); 11 | }, 12 | 13 | tags() { 14 | return axios.get(`${config.API_URL}/tags`); 15 | }, 16 | 17 | add(article) { 18 | return axios.post(`${config.API_URL}/articles`, {article}); 19 | }, 20 | 21 | update(slug, article) { 22 | return axios.put(`${config.API_URL}/articles/${slug}`, {article}); 23 | }, 24 | 25 | remove(slug) { 26 | return axios.delete(`${config.API_URL}/articles/${slug}`); 27 | }, 28 | 29 | get(slug) { 30 | return axios.get(`${config.API_URL}/articles/${slug}`); 31 | }, 32 | 33 | getComments(slug) { 34 | return axios.get(`${config.API_URL}/articles/${slug}/comments`); 35 | }, 36 | 37 | removeComment(slug, commentId) { 38 | return axios.delete(`${config.API_URL}/articles/${slug}/comments/${commentId}`); 39 | }, 40 | 41 | addComment(slug, comment) { 42 | return axios.post(`${config.API_URL}/articles/${slug}/comments`, { 43 | comment: { body: comment } 44 | }); 45 | }, 46 | 47 | addFavorite(slug) { 48 | return axios.post(`${config.API_URL}/articles/${slug}/favorite`); 49 | }, 50 | 51 | removeFavorite(slug) { 52 | return axios.delete(`${config.API_URL}/articles/${slug}/favorite`); 53 | } 54 | } -------------------------------------------------------------------------------- /src/user/login.js: -------------------------------------------------------------------------------- 1 | import san from 'san'; 2 | import { router } from 'san-router'; 3 | import { connect } from 'san-store'; 4 | import { Types } from './action'; 5 | import ErrorsView from '../common/components/errors'; 6 | 7 | export default connect.san( 8 | {}, 9 | { login: Types.LOGIN } 10 | )(san.defineComponent({ 11 | components: { 12 | 'x-errors': ErrorsView 13 | }, 14 | 15 | template: ` 16 |
17 |
18 |
19 |
20 |

Sign in

21 |

22 | Need an account? 23 |

24 | 25 |
26 |
27 | 28 |
29 |
30 | 31 |
32 | 33 |
34 |
35 |
36 |
37 |
38 | `, 39 | 40 | onSubmit() { 41 | let {email, password} = this.data.get(); 42 | this.actions.login({email, password}).then(data => { 43 | if (data.user) { 44 | router.locator.redirect('/'); 45 | } 46 | }); 47 | } 48 | })) -------------------------------------------------------------------------------- /src/profile/components/user-info.js: -------------------------------------------------------------------------------- 1 | import san from 'san'; 2 | import { connect } from 'san-store'; 3 | import { Types as ActionTypes } from '../action'; 4 | 5 | 6 | 7 | export default connect.san( 8 | {}, 9 | { 10 | unfollow: ActionTypes.UNFOLLOW, 11 | follow: ActionTypes.FOLLOW 12 | } 13 | )(san.defineComponent({ 14 | template: ` 15 |
16 |
17 |
18 |
19 | 20 |

{{ profile.username }}

21 |

{{ profile.bio }}

22 |
23 | 24 | Edit Profile Settings 25 | 26 |
27 |
28 | 32 | 36 |
37 |
38 |
39 |
40 |
41 | `, 42 | 43 | unfollow() { 44 | this.actions.unfollow(this.data.get('profile.username')); 45 | }, 46 | 47 | follow() { 48 | this.actions.follow(this.data.get('profile.username')); 49 | } 50 | })) -------------------------------------------------------------------------------- /src/profile/favorited.js: -------------------------------------------------------------------------------- 1 | import san from 'san'; 2 | import { connect } from 'san-store'; 3 | import { Types as ActionTypes } from './action'; 4 | import UserInfo from './components/user-info'; 5 | import Nav from './components/nav'; 6 | import ArticleList from '../article/components/list'; 7 | 8 | export default connect.san( 9 | { 10 | profile: 'profile', 11 | user: 'user' 12 | }, 13 | { 14 | fetch: ActionTypes.FETCH, 15 | reset: ActionTypes.RESET 16 | } 17 | )(san.defineComponent({ 18 | 19 | components: { 20 | 'x-articles': ArticleList, 21 | 'x-userinfo': UserInfo, 22 | 'x-nav': Nav 23 | }, 24 | 25 | computed: { 26 | pages() { 27 | let pageCount = this.data.get('pageCount'); 28 | 29 | if (pageCount) { 30 | let result = []; 31 | for (let i = 0; i < pageCount; i++) { 32 | result.push(i); 33 | } 34 | 35 | return result; 36 | } 37 | 38 | return [0]; 39 | } 40 | }, 41 | 42 | template: ` 43 |
44 | 45 | 46 |
47 |
48 |
49 | 50 | 51 |
52 |
53 |
54 |
55 | `, 56 | 57 | route() { 58 | let favorited = this.data.get('route.query.user'); 59 | 60 | this.actions.fetch(favorited); 61 | }, 62 | 63 | disposed() { 64 | this.actions.reset(); 65 | } 66 | })) -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Header from './common/components/header'; 2 | import Footer from './common/components/footer'; 3 | import Login from './user/login'; 4 | import Register from './user/register'; 5 | import Setting from './user/setting'; 6 | import Home from './article/home'; 7 | import ArticleEdit from './article/edit'; 8 | import ArticleView from './article/view'; 9 | import ProfileMy from './profile/my'; 10 | import ProfileFavorited from './profile/favorited'; 11 | import { router } from 'san-router'; 12 | import { store } from 'san-store'; 13 | import { Types as ActionTypes } from './common/action'; 14 | import { Types as UserActionTypes } from './user/action'; 15 | import axios from 'axios'; 16 | import jwt from './common/jwt'; 17 | 18 | 19 | 20 | function bootstrap() { 21 | axios.defaults.validateStatus = function (status) { 22 | return status >= 200 && status < 500; 23 | }; 24 | 25 | (new Header).attach(document.getElementById('header')); 26 | (new Footer).attach(document.getElementById('footer')); 27 | 28 | router.listen(e => { 29 | store.dispatch(ActionTypes.ERRORS_CLEAR); 30 | store.dispatch(UserActionTypes.GET); 31 | }); 32 | 33 | router.add({rule: '/', Component: Home}); 34 | router.add({rule: '/tag/:tag', Component: Home}); 35 | router.add({rule: '/my-feed', Component: Home}); 36 | router.add({rule: '/login', Component: Login}); 37 | router.add({rule: '/register', Component: Register}); 38 | router.add({rule: '/settings', Component: Setting}); 39 | router.add({rule: '/profile/:user', Component: ProfileMy}); 40 | router.add({rule: '/profile/:user/favorites', Component: ProfileFavorited}); 41 | router.add({rule: '/editor', Component: ArticleEdit}); 42 | router.add({rule: '/editor/:slug', Component: ArticleEdit}); 43 | router.add({rule: '/article/:slug', Component: ArticleView}); 44 | 45 | 46 | router.start(); 47 | } 48 | 49 | bootstrap(); -------------------------------------------------------------------------------- /src/common/components/header.js: -------------------------------------------------------------------------------- 1 | import san from 'san'; 2 | import { Link } from 'san-router'; 3 | import { connect } from 'san-store'; 4 | 5 | export default connect.san( 6 | { 7 | isAuthenticated: 'isAuthenticated', 8 | user: 'user' 9 | } 10 | )(san.defineComponent({ 11 | components: { 12 | 'x-link': Link 13 | }, 14 | 15 | template: ` 16 |
17 | conduit 18 | 19 | 20 | 40 | 41 | 42 | 55 |
56 | ` 57 | })) -------------------------------------------------------------------------------- /src/user/register.js: -------------------------------------------------------------------------------- 1 | import san from 'san'; 2 | import { router } from 'san-router'; 3 | import { connect } from 'san-store'; 4 | import { Types } from './action'; 5 | import ErrorsView from '../common/components/errors'; 6 | 7 | export default connect.san( 8 | { 9 | isAuthenticated: 'isAuthenticated', 10 | user: 'user' 11 | }, 12 | { 13 | register: Types.REGISTER 14 | } 15 | )(san.defineComponent({ 16 | components: { 17 | 'x-errors': ErrorsView 18 | }, 19 | 20 | template: ` 21 |
22 |
23 |
24 |
25 |

Sign up

26 |

27 | Have an account? 28 |

29 | 30 |
31 |
32 | 33 |
34 |
35 | 36 |
37 |
38 | 39 |
40 | 41 |
42 |
43 |
44 |
45 |
46 | `, 47 | 48 | onSubmit() { 49 | let {username, email, password} = this.data.get(); 50 | this.actions.register({username, email, password}).then(data => { 51 | if (data.user) { 52 | router.locator.redirect('/'); 53 | } 54 | }); 55 | } 56 | })) -------------------------------------------------------------------------------- /src/article/home.js: -------------------------------------------------------------------------------- 1 | import san from 'san'; 2 | import { connect } from 'san-store'; 3 | import { Link } from 'san-router'; 4 | import { Types as ActionTypes } from './action'; 5 | import ArticleList from './components/list'; 6 | 7 | export default connect.san( 8 | { 9 | tags: 'tags', 10 | isAuthenticated: 'isAuthenticated' 11 | }, 12 | { 13 | tags: ActionTypes.TAGS 14 | } 15 | )(san.defineComponent({ 16 | components: { 17 | 'x-list': ArticleList, 18 | 'x-link': Link 19 | }, 20 | 21 | template: ` 22 |
23 | 24 | 30 | 31 |
32 |
33 | 34 |
35 |
36 | 47 |
48 | 49 | 50 |
51 | 52 | 53 |
54 | 61 |
62 | 63 |
64 |
65 | 66 |
67 | `, 68 | 69 | attached() { 70 | this.actions.tags(); 71 | } 72 | })) -------------------------------------------------------------------------------- /src/user/action.js: -------------------------------------------------------------------------------- 1 | import { store } from 'san-store'; 2 | import { updateBuilder } from 'san-update'; 3 | import axios from 'axios'; 4 | import service from './service'; 5 | import jwt from '../common/jwt'; 6 | import { whenNoError } from '../common/action'; 7 | 8 | 9 | export const Types = { 10 | LOGIN: 'userLogin', 11 | GET: 'userGet', 12 | REGISTER: 'userRegister', 13 | SET_AUTH: 'userSetAuth', 14 | PURGE_AUTH: 'userPurgeAuth', 15 | UPDATE: 'userUpdate' 16 | }; 17 | 18 | store.addAction(Types.LOGIN, function (payload, {dispatch}) { 19 | return service.login(payload).then( 20 | whenNoError(data => { 21 | dispatch(Types.SET_AUTH, data.user); 22 | }) 23 | ); 24 | }); 25 | 26 | 27 | store.addAction(Types.GET, function (payload, {getState, dispatch}) { 28 | if (getState('user')) { 29 | return; 30 | } 31 | 32 | let token = jwt.getToken(); 33 | if (token) { 34 | setRequestHeaderToken(token); 35 | return service.get().then( 36 | whenNoError(data => { 37 | dispatch(Types.SET_AUTH, data.user); 38 | }) 39 | ); 40 | } 41 | }); 42 | 43 | store.addAction(Types.REGISTER, function (payload, {dispatch}) { 44 | return service.register(payload).then( 45 | whenNoError(data => { 46 | dispatch(Types.SET_AUTH, data.user); 47 | }) 48 | ); 49 | }); 50 | 51 | store.addAction(Types.SET_AUTH, function (user, {dispatch}) { 52 | jwt.setToken(user.token); 53 | setRequestHeaderToken(user.token); 54 | return updateBuilder() 55 | .set('user', user) 56 | .set('isAuthenticated', true); 57 | }); 58 | 59 | store.addAction(Types.PURGE_AUTH, function (user, {dispatch}) { 60 | jwt.clearToken(); 61 | delete axios.defaults.headers.common['Authorization']; 62 | return updateBuilder() 63 | .set('user', {}) 64 | .set('isAuthenticated', false); 65 | }); 66 | 67 | store.addAction(Types.UPDATE, function (payload, {dispatch}) { 68 | const { email, username, password, image, bio } = payload; 69 | const user = { 70 | email, 71 | username, 72 | bio, 73 | image 74 | }; 75 | 76 | if (password) { 77 | user.password = password; 78 | } 79 | 80 | return service.update(user).then( 81 | whenNoError(data => { 82 | dispatch(Types.SET_AUTH, data.user); 83 | }) 84 | ); 85 | }); 86 | 87 | function setRequestHeaderToken(token) { 88 | if (token) { 89 | axios.defaults.headers.common['Authorization'] = `Token ${token}`; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/user/setting.js: -------------------------------------------------------------------------------- 1 | import san from 'san'; 2 | import { router } from 'san-router'; 3 | import { connect } from 'san-store'; 4 | import { Types } from './action'; 5 | import ErrorsView from '../common/components/errors'; 6 | 7 | export default connect.san( 8 | { 9 | isAuthenticated: 'isAuthenticated', 10 | user: 'user' 11 | }, 12 | { 13 | logout: Types.PURGE_AUTH, 14 | updateUser: Types.UPDATE 15 | } 16 | )(san.defineComponent({ 17 | components: { 18 | 'x-errors': ErrorsView 19 | }, 20 | 21 | template: ` 22 |
23 |
24 |
25 |
26 |

Your Settings

27 | 28 |
29 |
30 |
31 | 32 |
33 |
34 | 35 |
36 |
37 | 38 |
39 |
40 | 41 |
42 |
43 | 44 |
45 | 46 |
47 |
48 | 49 |
50 | 51 |
52 |
53 |
54 |
55 | `, 56 | 57 | updateSettings(e) { 58 | this.data.set('inProgress', true); 59 | this.actions.updateUser(this.data.get('user')).then(() => { 60 | this.data.set('inProgress', null); 61 | }); 62 | }, 63 | 64 | logout() { 65 | this.actions.logout(); 66 | router.locator.redirect('/'); 67 | } 68 | })) -------------------------------------------------------------------------------- /src/article/components/list.js: -------------------------------------------------------------------------------- 1 | import san from 'san'; 2 | import { connect } from 'san-store'; 3 | import ArticleMeta from './meta'; 4 | import { Types as ActionTypes } from '../action'; 5 | 6 | 7 | 8 | export default connect.san( 9 | { 10 | articles: 'articles', 11 | pageCount: 'articlePageCount', 12 | loading: 'articlesLoading' 13 | }, 14 | { 15 | fetch: ActionTypes.FETCH 16 | } 17 | )(san.defineComponent({ 18 | components: { 19 | 'x-meta': ArticleMeta 20 | }, 21 | 22 | template: ` 23 |
24 |
Loading articles...
25 |
26 | 27 | 28 | 29 |

{{ article.title }}

30 |

{{ article.description }}

31 | Read more... 32 |
    33 |
  • 34 | {{ tag }} 35 |
  • 36 |
37 |
38 |
39 | 40 |
41 | No articles are here... yet. 42 |
43 | 44 | 53 |
54 | `, 55 | 56 | computed: { 57 | pages() { 58 | let pageCount = this.data.get('pageCount'); 59 | 60 | if (pageCount) { 61 | let result = []; 62 | for (let i = 0; i < pageCount; i++) { 63 | result.push(i); 64 | } 65 | 66 | return result; 67 | } 68 | 69 | return [0]; 70 | } 71 | }, 72 | 73 | attached() { 74 | this.change = () => { 75 | this.updateFromOwner = true; 76 | }; 77 | 78 | this.watch('feed', this.change); 79 | this.watch('tag', this.change); 80 | 81 | this.fetch(0); 82 | }, 83 | 84 | fetch(page) { 85 | let {favorited, author, tag, feed} = this.data.get(); 86 | this.data.set('currentPage', page); 87 | 88 | this.actions.fetch({ 89 | favorited, 90 | author, 91 | tag, 92 | feed, 93 | page 94 | }); 95 | }, 96 | 97 | changePage(page) { 98 | this.fetch(page); 99 | }, 100 | 101 | updated() { 102 | if (this.updateFromOwner) { 103 | this.updateFromOwner = false; 104 | this.fetch(0); 105 | } 106 | } 107 | })) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![San Example App](logo.png) 2 | 3 | [![RealWorld Frontend](https://img.shields.io/badge/realworld-frontend-%23783578.svg)](http://realworld.io) 4 | [![Build Status](https://travis-ci.com/ecomfe/san-realworld-app.svg?branch=master)](https://travis-ci.com/ecomfe/san-realworld-app) 5 | 6 | > ### San codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API. 7 | 8 | 9 | ### [Demo](https://ecomfe.github.io/san-realworld-app/)    [RealWorld](https://github.com/gothinkster/realworld) 10 | 11 | 12 | This codebase was created to demonstrate a fully fledged fullstack application built with **San** including CRUD operations, authentication, routing, pagination, and more. 13 | 14 | We've gone to great lengths to adhere to the **San** community styleguides & best practices. 15 | 16 | For more information on how to this works with other frontends/backends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repo. 17 | 18 | 19 | 20 | 21 | # Getting started 22 | 23 | You can view a live demo over at https://ecomfe.github.io/san-realworld-app/ 24 | 25 | To get the frontend running locally: 26 | 27 | - Clone this repo 28 | - `npm install` to install all req'd dependencies 29 | - `npm start` to start the local server 30 | 31 | Before contributing please read the following: 32 | 33 | 1. [RealWorld guidelines](https://github.com/gothinkster/realworld/tree/master/spec) for implementing a new framework, 34 | 2. [RealWorld frontend instructions](https://github.com/gothinkster/realworld-starter-kit/blob/master/FRONTEND_INSTRUCTIONS.md) 35 | 3. [Realworld API endpoints](https://github.com/gothinkster/realworld/tree/master/api) 36 | 37 | 38 | Building the project: 39 | 40 | ```bash 41 | npm run build 42 | ``` 43 | 44 | ## Functionality overview 45 | 46 | The example application is a social blogging site (i.e. a Medium.com clone) called "Conduit". It uses a custom API for all requests, including authentication. You can view a live demo over at https://ecomfe.github.io/san-realworld-app/ 47 | 48 | **General functionality:** 49 | 50 | - Authenticate users via JWT (login/signup pages + logout button on settings page) 51 | - CRU* users (sign up & settings page - no deleting required) 52 | - CRUD Articles 53 | - CR*D Comments on articles (no updating required) 54 | - GET and display paginated lists of articles 55 | - Favorite articles 56 | - Follow other users 57 | 58 | **The general page breakdown looks like this:** 59 | 60 | - Home page (URL: /#/ ) 61 | - List of tags 62 | - List of articles pulled from either Feed, Global, or by Tag 63 | - Pagination for list of articles 64 | - Sign in/Sign up pages (URL: /#/login, /#/register ) 65 | - Uses JWT (store the token in localStorage) 66 | - Authentication can be easily switched to session/cookie based 67 | - Settings page (URL: /#/settings ) 68 | - Editor page to create/edit articles (URL: /#/editor, /#/editor/article-slug-here ) 69 | - Article page (URL: /#/article/article-slug-here ) 70 | - Delete article button (only shown to article's author) 71 | - Render markdown from server client side 72 | - Comments section at bottom of page 73 | - Delete comment button (only shown to comment's author) 74 | - Profile page (URL: /#/profile/:username, /#/profile/:username/favorites ) 75 | - Show basic user info 76 | - List of articles populated from author's created articles or author's favorited articles -------------------------------------------------------------------------------- /src/article/view.js: -------------------------------------------------------------------------------- 1 | import san from 'san'; 2 | import marked from "marked"; 3 | import { connect } from 'san-store'; 4 | import { Types as ActionTypes } from './action'; 5 | import CommentEditor from './components/comment-editor'; 6 | import ArticleMeta from './components/meta'; 7 | 8 | 9 | export default connect.san( 10 | { 11 | comments: 'comments', 12 | article: 'article', 13 | isAuthenticated: 'isAuthenticated', 14 | user: 'user' 15 | }, 16 | { 17 | reset: ActionTypes.RESET, 18 | get: ActionTypes.GET, 19 | getComments: ActionTypes.GET_COMMENTS, 20 | removeComment: ActionTypes.REMOVE_COMMENT 21 | } 22 | )(san.defineComponent({ 23 | components: { 24 | 'x-meta': ArticleMeta, 25 | 'x-comment-editor': CommentEditor 26 | }, 27 | 28 | filters: { 29 | marked(source) { 30 | return marked(source || ''); 31 | } 32 | }, 33 | 34 | route() { 35 | let slug = this.data.get('route.query.slug'); 36 | this.actions.get(slug); 37 | this.actions.getComments(slug); 38 | }, 39 | 40 | disposed() { 41 | this.actions.reset(); 42 | }, 43 | 44 | 45 | template: ` 46 |
47 | 53 |
54 |
55 |
56 |
{{article.body | marked | raw}}
57 | 62 |
63 |
64 |
65 |
66 | 67 |
68 |
69 |
70 | 71 |

72 | Sign in 73 | or 74 | sign up 75 | to add comments on this article. 76 |

77 | 78 |
79 |
80 |

{{comment.body}}

81 |
82 | 94 |
95 | 96 |
97 |
98 |
99 |
100 | `, 101 | 102 | removeComment(slug, commentId) { 103 | this.actions.removeComment({slug, commentId}) 104 | } 105 | })) -------------------------------------------------------------------------------- /src/article/edit.js: -------------------------------------------------------------------------------- 1 | import san from 'san'; 2 | import { router } from 'san-router'; 3 | import { connect } from 'san-store'; 4 | import { Types as ActionTypes } from './action'; 5 | import ErrorsView from '../common/components/errors'; 6 | 7 | export default connect.san( 8 | { 9 | article: 'article', 10 | isAuthenticated: 'isAuthenticated' 11 | }, 12 | { 13 | reset: ActionTypes.RESET, 14 | add: ActionTypes.ADD, 15 | edit: ActionTypes.EDIT, 16 | get: ActionTypes.GET, 17 | addTag: ActionTypes.ADD_TAG, 18 | removeTag: ActionTypes.REMOVE_TAG 19 | } 20 | )(san.defineComponent({ 21 | components: { 22 | 'x-errors': ErrorsView 23 | }, 24 | 25 | template: ` 26 |
27 |
28 |
29 |
30 | 31 |
32 |
33 |
34 | 35 |
36 |
37 | 38 |
39 |
40 | 44 |
45 |
46 | 47 |
48 | 49 | 50 | {{ tag }} 51 | 52 |
53 |
54 |
55 | 58 |
59 |
60 |
61 |
62 |
63 | `, 64 | 65 | route() { 66 | let slug = this.data.get('route.query.slug'); 67 | 68 | if (slug) { 69 | this.actions.get(slug); 70 | } 71 | else { 72 | this.actions.reset(); 73 | } 74 | }, 75 | 76 | disposed() { 77 | this.actions.reset(); 78 | }, 79 | 80 | onPublish() { 81 | this.data.set('inProgress', true); 82 | 83 | let slug = this.data.get('route.query.slug'); 84 | this.actions[slug ? 'edit' : 'add'](this.data.get('article')) 85 | .then(data => { 86 | if (data.errors) { 87 | this.data.set('inProgress', null); 88 | return; 89 | } 90 | 91 | router.locator.redirect(`/article/${data.article.slug}`); 92 | }); 93 | }, 94 | 95 | addTag(e) { 96 | if ((e.which || e.keyCode) === 13) { 97 | e.preventDefault(); 98 | let tagInput = this.data.get('tagInput'); 99 | 100 | if (tagInput) { 101 | this.actions.addTag(tagInput); 102 | } 103 | 104 | this.data.set('tagInput', ''); 105 | } 106 | }, 107 | 108 | removeTag(tag) { 109 | this.actions.removeTag(tag); 110 | } 111 | })) -------------------------------------------------------------------------------- /src/article/components/meta.js: -------------------------------------------------------------------------------- 1 | import san from 'san'; 2 | import { router } from 'san-router'; 3 | import {default as format} from "date-fns/format"; 4 | import { connect } from 'san-store'; 5 | import { Types as ActionTypes } from '../action'; 6 | import { Types as ProfileActionTypes } from '../../profile/action'; 7 | 8 | 9 | 10 | export default connect.san( 11 | { 12 | profile: 'profile', 13 | user: 'user', 14 | isAuthenticated: 'isAuthenticated' 15 | }, 16 | { 17 | removeArticle: ActionTypes.REMOVE, 18 | removeFav: ActionTypes.REMOVE_FAVORITE, 19 | addFav: ActionTypes.ADD_FAVORITE, 20 | setAuthor: ActionTypes.SET_AUTHOR, 21 | unfollow: ProfileActionTypes.UNFOLLOW, 22 | follow: ProfileActionTypes.FOLLOW 23 | } 24 | )(san.defineComponent({ 25 | filters: { 26 | date(source) { 27 | return format(new Date(source), "MMMM D, YYYY"); 28 | } 29 | }, 30 | 31 | template: ` 32 |
33 | 34 | 35 | 36 | 37 |
38 | 39 | {{article.author.username}} 40 | 41 | {{article.createdAt | date}} 42 |
43 | 44 | 45 | 46 |  Edit Article 47 | 48 |    49 | 52 | 53 | 54 | 58 |    59 | 66 | 67 | 72 |
73 | `, 74 | 75 | toggleFavorite() { 76 | if (!this.data.get('isAuthenticated')) { 77 | router.locator.redirect('/login'); 78 | return; 79 | } 80 | 81 | let favorited = this.data.get('article.favorited'); 82 | this.actions[favorited ? 'removeFav' : 'addFav'](this.data.get('article.slug')); 83 | }, 84 | 85 | toggleFollow() { 86 | if (!this.data.get('isAuthenticated')) { 87 | router.locator.redirect('/login'); 88 | return; 89 | } 90 | 91 | let author = this.data.get('article.author'); 92 | this.actions[author.following ? 'unfollow' : 'follow'](author.username) 93 | .then(data => { 94 | this.actions.setAuthor(data.profile); 95 | console.log(data.profile) 96 | }); 97 | }, 98 | 99 | deleteArticle() { 100 | this.actions.removeArticle(this.data.get('article.slug')).then(() => { 101 | router.locator.redirect('/'); 102 | }); 103 | } 104 | })) -------------------------------------------------------------------------------- /src/article/action.js: -------------------------------------------------------------------------------- 1 | import { store } from 'san-store'; 2 | import { updateBuilder } from 'san-update'; 3 | import service from './service'; 4 | import config from '../common/config'; 5 | import { whenNoError } from '../common/action'; 6 | 7 | 8 | export const Types = { 9 | FETCH: 'articleFetch', 10 | FETCHING: 'articleFetching', 11 | FETCH_FILL: 'articleFetchFill', 12 | TAGS: 'articleTags', 13 | TAGS_FILL: 'articleTagsFill', 14 | ADD: 'articleAdd', 15 | EDIT: 'articleEdit', 16 | REMOVE: 'articleRemove', 17 | RESET: 'articleReset', 18 | SET: 'articleSet', 19 | SET_AUTHOR: 'articleSetAuthor', 20 | SET_LIST_ITEM: 'articleSetListItem', 21 | GET: 'articleGet', 22 | ADD_TAG: 'articleAddTag', 23 | REMOVE_TAG: 'articleRemoveTag', 24 | ADD_COMMENT: 'articleAddComment', 25 | GET_COMMENTS: 'articleGetComments', 26 | FILL_COMMENTS: 'articleFillComments', 27 | REMOVE_COMMENT: 'articleRemoveComment', 28 | ADD_FAVORITE: 'articleAddFavorite', 29 | REMOVE_FAVORITE: 'articleRemoveFavorite' 30 | }; 31 | 32 | store.addAction(Types.FETCH, function (payload = {}, {dispatch}) { 33 | let params = { 34 | limit: config.PAGE_SIZE, 35 | offset: config.PAGE_SIZE * (payload.page || 0) 36 | }; 37 | 38 | let fetch = service.fetch; 39 | if (payload.feed) { 40 | fetch = service.fetchFeed; 41 | } 42 | else { 43 | if (payload.author) { 44 | params.author = payload.author; 45 | } 46 | 47 | if (payload.tag) { 48 | params.tag = payload.tag; 49 | } 50 | 51 | if (payload.favorited) { 52 | params.favorited = payload.favorited; 53 | } 54 | } 55 | 56 | dispatch(Types.FETCHING); 57 | return fetch(params).then(response => { 58 | dispatch(Types.FETCH_FILL, response.data); 59 | }); 60 | }); 61 | 62 | store.addAction(Types.FETCHING, function () { 63 | return updateBuilder().set('articlesLoading', true); 64 | }); 65 | 66 | store.addAction(Types.FETCH_FILL, function ({articles, articlesCount}) { 67 | return updateBuilder() 68 | .set('articles', articles) 69 | .set('articleCount', articlesCount) 70 | .set('articlesLoading', false) 71 | .set('articlePageCount', Math.ceil(articlesCount / config.PAGE_SIZE)); 72 | }); 73 | 74 | store.addAction(Types.TAGS, function (payload, {dispatch}) { 75 | return service.tags().then(response => { 76 | dispatch(Types.TAGS_FILL, response.data); 77 | }); 78 | }); 79 | 80 | store.addAction(Types.TAGS_FILL, function (data) { 81 | return updateBuilder().set('tags', data.tags); 82 | }); 83 | 84 | store.addAction(Types.RESET, function () { 85 | return updateBuilder() 86 | .set('article', { 87 | author: {}, 88 | title: "", 89 | description: "", 90 | body: "", 91 | tagList: [] 92 | }) 93 | .set('comments', []); 94 | }); 95 | 96 | store.addAction(Types.GET, function (slug, {dispatch}) { 97 | return service.get(slug).then(({data}) => { 98 | dispatch(Types.SET, data.article); 99 | }); 100 | }); 101 | 102 | store.addAction(Types.SET, function (article) { 103 | return updateBuilder().set('article', article); 104 | }); 105 | 106 | store.addAction(Types.SET_AUTHOR, function (author) { 107 | return updateBuilder().set('article.author', author); 108 | }); 109 | 110 | store.addAction(Types.ADD_TAG, function (tag) { 111 | return updateBuilder().push('article.tagList', tag); 112 | }); 113 | 114 | store.addAction(Types.REMOVE_TAG, function (tag) { 115 | return updateBuilder().remove('article.tagList', tag); 116 | }); 117 | 118 | store.addAction(Types.REMOVE, function (slug) { 119 | return service.remove(slug); 120 | }); 121 | 122 | store.addAction(Types.ADD, function (article, {dispatch}) { 123 | return service.add(article).then(whenNoError()); 124 | }); 125 | 126 | store.addAction(Types.EDIT, function (article, {dispatch}) { 127 | return service.update(article.slug, article).then(whenNoError()); 128 | }); 129 | 130 | store.addAction(Types.ADD_COMMENT, function (payload, {dispatch}) { 131 | return service.addComment(payload.slug, payload.comment) 132 | .then(() => { 133 | dispatch(Types.GET_COMMENTS, payload.slug); 134 | }); 135 | }); 136 | 137 | store.addAction(Types.GET_COMMENTS, function (slug, {dispatch}) { 138 | return service.getComments(slug).then(({data}) => { 139 | dispatch(Types.FILL_COMMENTS, data.comments); 140 | }); 141 | }); 142 | 143 | 144 | store.addAction(Types.FILL_COMMENTS, function (comments) { 145 | return updateBuilder().set('comments', comments); 146 | }); 147 | 148 | store.addAction(Types.REMOVE_COMMENT, function (payload, {dispatch}) { 149 | return service.removeComment(payload.slug, payload.commentId) 150 | .then(() => { 151 | dispatch(Types.GET_COMMENTS, payload.slug); 152 | }); 153 | }); 154 | 155 | store.addAction(Types.ADD_FAVORITE, function (slug, {dispatch}) { 156 | return service.addFavorite(slug).then( 157 | ({data}) => { 158 | dispatch(Types.SET, data.article); 159 | dispatch(Types.SET_LIST_ITEM, data.article); 160 | } 161 | ); 162 | }); 163 | 164 | store.addAction(Types.REMOVE_FAVORITE, function (slug, {dispatch}) { 165 | return service.removeFavorite(slug).then( 166 | ({data}) => { 167 | dispatch(Types.SET, data.article); 168 | dispatch(Types.SET_LIST_ITEM, data.article); 169 | } 170 | ); 171 | }); 172 | 173 | store.addAction(Types.SET_LIST_ITEM, function (article, {getState}) { 174 | let articles = getState('articles'); 175 | 176 | if (articles) { 177 | for (let i = 0; i < articles.length; i++) { 178 | if (articles[i].slug === article.slug) { 179 | return updateBuilder().set('articles[' + i + ']', article); 180 | } 181 | } 182 | } 183 | }); 184 | 185 | 186 | 187 | 188 | 189 | --------------------------------------------------------------------------------