diff --git a/VSReact.Api/App_Start/JsonContentNegatiator.cs b/VSReact.Api/App_Start/JsonContentNegatiator.cs new file mode 100644 index 0000000..551fa12 --- /dev/null +++ b/VSReact.Api/App_Start/JsonContentNegatiator.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Formatting; +using System.Net.Http.Headers; + +namespace VSReact.Api.App_Start +{ + public class JsonContentNegotiator : IContentNegotiator + { + private readonly JsonMediaTypeFormatter _jsonFormatter; + + public JsonContentNegotiator(JsonMediaTypeFormatter formatter) + { + _jsonFormatter = formatter; + } + + public ContentNegotiationResult Negotiate(Type type, HttpRequestMessage request, IEnumerable formatters) + { + var result = new ContentNegotiationResult(_jsonFormatter, new MediaTypeHeaderValue("application/json")); + return result; + } + } +} \ No newline at end of file diff --git a/VSReact.Api/App_Start/WebApiConfig.cs b/VSReact.Api/App_Start/WebApiConfig.cs index ea78951..12bd2fb 100644 --- a/VSReact.Api/App_Start/WebApiConfig.cs +++ b/VSReact.Api/App_Start/WebApiConfig.cs @@ -1,7 +1,12 @@ -using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using System; using System.Collections.Generic; using System.Linq; +using System.Net.Http.Formatting; using System.Web.Http; +using System.Web.Http.Cors; +using VSReact.Api.App_Start; namespace VSReact.Api { @@ -10,15 +15,28 @@ public static class WebApiConfig public static void Register(HttpConfiguration config) { // Web API configuration and services + var cors = new EnableCorsAttribute("*", "*", "*"); + config.EnableCors(cors); + + var json = GlobalConfiguration.Configuration.Formatters.JsonFormatter; + json.SerializerSettings = new JsonSerializerSettings() + { + ContractResolver = new CamelCasePropertyNamesContractResolver(), + NullValueHandling = NullValueHandling.Ignore + }; + + + // JSON only + config.Services.Replace(typeof(IContentNegotiator), new JsonContentNegotiator(json)); // Web API routes config.MapHttpAttributeRoutes(); config.Routes.MapHttpRoute( name: "DefaultApi", - routeTemplate: "api/{controller}/{id}", + routeTemplate: "{controller}/{id}", defaults: new { id = RouteParameter.Optional } ); } } -} +} \ No newline at end of file diff --git a/VSReact.Api/Controllers/TodoController.cs b/VSReact.Api/Controllers/TodoController.cs new file mode 100644 index 0000000..0f2dfad --- /dev/null +++ b/VSReact.Api/Controllers/TodoController.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Web.Http; + +namespace TodoMvc.Controllers +{ + public class TodoController : ApiController + { + private readonly Data.TodoRepository repo; + + public TodoController() + { + repo = new Data.TodoRepository(); + } + + // GET: Todo + public IEnumerable Get() + { + var items = repo.GetAll(); + + foreach (var t in items) + { + t.Url = String.Format("{0}/{1}", Request.RequestUri.ToString(), t.Id.ToString()); + } + + return items; + } + + // GET: Todo/5 + public Models.Todo Get(int id) + { + var t = repo.Get(id); + if (t != null) + { + t.Url = Request.RequestUri.ToString(); + } + return t; + } + + // POST: Todo + public HttpResponseMessage Post(Models.Todo item) + { + var t = repo.Save(item); + t.Url = String.Format("{0}/{1}", Request.RequestUri.ToString(), t.Id.ToString()); + + var response = Request.CreateResponse(HttpStatusCode.OK, t); + response.Headers.Location = new Uri(Request.RequestUri, "todo/" + t.Id.ToString()); + + return response; + } + + // PATCH: Todo/5 + public HttpResponseMessage Patch(int id, Models.Todo item) + { + var todo = repo.Get(id); + if (item.Title != null) + { + todo.Title = item.Title; + } + if (item.Completed.HasValue) + { + todo.Completed = item.Completed; + } + var t = repo.Save(todo); + t.Url = Request.RequestUri.ToString(); + + var response = Request.CreateResponse(HttpStatusCode.OK, t); + response.Headers.Location = new Uri(Request.RequestUri, t.Id.ToString()); + + return response; + } + + // DELETE: Todo + public void Delete() + { + repo.Delete(); + } + + // DELETE: Todo/5 + public void Delete(int id) + { + repo.Delete(id); + } + } +} diff --git a/VSReact.Api/Models/Todo.cs b/VSReact.Api/Models/Todo.cs new file mode 100644 index 0000000..0e87151 --- /dev/null +++ b/VSReact.Api/Models/Todo.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Web; + +namespace TodoMvc.Models +{ + public class Todo + { + public int Id { get; set; } + public int? Order { get; set; } + public string Title { get; set; } + public string Url { get; set; } + public bool? Completed { get; set; } + + public override bool Equals(object obj) + { + var todo = obj as Todo; + return (todo != null) && (Id == todo.Id); + } + + public override int GetHashCode() + { + return Id.GetHashCode(); + } + } +} \ No newline at end of file diff --git a/VSReact.Api/Models/TodoRepository.cs b/VSReact.Api/Models/TodoRepository.cs new file mode 100644 index 0000000..a72fb18 --- /dev/null +++ b/VSReact.Api/Models/TodoRepository.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Web; + +namespace TodoMvc.Data +{ + public class TodoRepository + { + public static List Todos = new List(); + public static int MaxId = 0; + + public IEnumerable GetAll() + { + return Todos.OrderByDescending(t => t.Order); + } + + public Models.Todo Get(int id) + { + return Todos.Where(t => t.Id == id).FirstOrDefault(); + } + + public Models.Todo Save(Models.Todo item) + { + if (item.Id == 0) + { + item.Id = ++MaxId; + if (!item.Order.HasValue) + { + item.Order = item.Id; + } + } + + int index = Todos.IndexOf(item); + if (index != -1) + { + Todos[index] = item; + } + else + { + Todos.Add(item); + } + + return item; + } + + public void Delete() + { + Todos.Clear(); + } + + public void Delete(int id) + { + Todos.RemoveAll(t => t.Id == id); + } + } +} \ No newline at end of file diff --git a/VSReact.Api/VSReact.Api.csproj b/VSReact.Api/VSReact.Api.csproj index 5d143bd..48aff37 100644 --- a/VSReact.Api/VSReact.Api.csproj +++ b/VSReact.Api/VSReact.Api.csproj @@ -49,6 +49,9 @@ + + ..\packages\Microsoft.AspNet.Cors.5.2.3\lib\net45\System.Web.Cors.dll + @@ -58,6 +61,9 @@ + + ..\packages\Microsoft.AspNet.WebApi.Cors.5.2.3\lib\net45\System.Web.Http.Cors.dll + @@ -85,10 +91,14 @@ + + Global.asax + + @@ -102,8 +112,6 @@ - - 10.0 diff --git a/VSReact.Api/packages.config b/VSReact.Api/packages.config index 09cda17..618068f 100644 --- a/VSReact.Api/packages.config +++ b/VSReact.Api/packages.config @@ -1,5 +1,7 @@  + + diff --git a/VSReact.Web/.eslintrc b/VSReact.Web/.eslintrc new file mode 100644 index 0000000..570eee0 --- /dev/null +++ b/VSReact.Web/.eslintrc @@ -0,0 +1,21 @@ +{ + "ecmaFeatures": { + "jsx": true, + "modules": true + }, + "env": { + "browser": true, + "node": true + }, + "parser": "babel-eslint", + "rules": { + "quotes": [2, "single"], + "strict": [2, "never"], + "react/jsx-uses-react": 2, + "react/jsx-uses-vars": 2, + "react/react-in-jsx-scope": 2 + }, + "plugins": [ + "react" + ] +} \ No newline at end of file diff --git a/VSReact.Web/VSReact.Web.csproj b/VSReact.Web/VSReact.Web.csproj index 0a508f3..1a11937 100644 --- a/VSReact.Web/VSReact.Web.csproj +++ b/VSReact.Web/VSReact.Web.csproj @@ -1,5 +1,6 @@  + @@ -24,6 +25,7 @@ + 1.7 true @@ -67,6 +69,9 @@ + + + @@ -82,7 +87,6 @@ - @@ -91,12 +95,33 @@ - + + + + + + 10.0 $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + ES6 + React + False + False + System + False + + + False + True + True + + + + diff --git a/VSReact.Web/app/actions/TodoActionsAsyncBackend.js b/VSReact.Web/app/actions/TodoActionsAsyncBackend.js new file mode 100644 index 0000000..765aec6 --- /dev/null +++ b/VSReact.Web/app/actions/TodoActionsAsyncBackend.js @@ -0,0 +1,272 @@ +import * as types from '../constants/ActionTypes'; +import fetch from 'isomorphic-fetch' + +const apiUrl = __API_URL__; +const todoApiUrl = apiUrl + '/todo'; + +function checkStatus(response) { + if (response.status >= 200 && response.status < 300) { + return response + } else { + var error = new Error(response.statusText) + error.response = response + throw error + } +} + +function parseJSON(response) { + return response.json() +} + +function handleApiError(error) { + console.log('request failed', error) +} + +function getTodosRequest() { + return { + type: types.GET_TODOS_REQUEST + }; +} + +function getTodosSuccess(todos) { + return { + type: types.GET_TODOS_SUCCESS, + todos: todos + }; +} + +export function getTodos() { + return (dispatch, getState) => { + dispatch(getTodosRequest()); + fetch(todoApiUrl, { + headers: { + 'Accept': 'application/json' + } + }) + .then(checkStatus) + .then(parseJSON) + .then(data => { + dispatch(getTodosSuccess(data)); + }) + .catch(handleApiError); + }; +} + +function addTodoRequest(text) { + return { + type: types.ADD_TODO_REQUEST, + text: text + } +} + +function addTodoSuccess(id, text) { + return { + type: types.ADD_TODO_SUCCESS, + id: id, + text: text + } +} + +export function addTodo(text) { + return (dispatch, getState) => { + dispatch(addTodoRequest(text)); + fetch(todoApiUrl, { + method: 'post', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + title: text + }) + }) + .then(checkStatus) + .then(parseJSON) + .then(data => { + dispatch(addTodoSuccess(data.id, data.title)); + }) + .catch(handleApiError); + }; +} + +function deleteTodoRequest(id) { + return { + type: types.DELETE_TODO_REQUEST, + id: id + } +} + +function deleteTodoSuccess(id) { + return { + type: types.DELETE_TODO_SUCCESS, + id: id + } +} + +export function deleteTodo(id) { + return (dispatch, getState) => { + dispatch(deleteTodoRequest(id)); + fetch(`${todoApiUrl}/${id}`, { + method: 'delete' + }) + .then(checkStatus) + .then(response => { + dispatch(deleteTodoSuccess(id)); + }) + .catch(handleApiError); + }; +} + +function editTodoRequest(id) { + return { + type: types.EDIT_TODO_REQUEST, + id: id + }; +} + +function editTodoSuccess(id, text) { + return { + type: types.EDIT_TODO_SUCCESS, + id, + text + }; +} + +export function editTodo(id, text) { + return (dispatch, getState) => { + dispatch(editTodoRequest(id)); + fetch(`${todoApiUrl}/${id}`, { + method: 'patch', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + title: text + }) + }) + .then(checkStatus) + .then(parseJSON) + .then(data => { + dispatch(editTodoSuccess(id, data.title)); + }) + .catch(handleApiError); + }; +} + +function markTodoRequest(id) { + return { + type: types.MARK_TODO_REQUEST, + id: id + } +} + +function markTodoSuccess(id, marked) { + return { + type: types.MARK_TODO_SUCCESS, + id, + marked + } +} + +export function markTodo(id) { + return (dispatch, getState) => { + dispatch(markTodoRequest(id)); + + const todo = getState().todos.find(todo => { return todo.id === id }); + const marked = todo && ! todo.marked; + + fetch(`${todoApiUrl}/${id}`, { + method: 'patch', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + completed: marked + }) + }) + .then(checkStatus) + .then(parseJSON) + .then(data => { + dispatch(markTodoSuccess(id, marked)); + }) + .catch(handleApiError); + }; +} + +function markAllRequest() { + return { + type: types.MARK_ALL_REQUEST + } +} + +function markAllSuccess(marked) { + return { + type: types.MARK_ALL_SUCCESS, + areAllMarked: marked + } +} + +export function markAll() { + return (dispatch, getState) => { + const todos = getState().todos; + const shouldMarkAll = todos.some(todo => !todo.marked); + const markRequests = []; + dispatch(markAllRequest()); + todos.forEach(todo => { + markRequests.push( + fetch(`${todoApiUrl}/${todo.id}`, { + method: 'patch', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + completed: shouldMarkAll + }) + }) + .then(checkStatus) + .catch(handleApiError) + ); + }); + Promise.all(markRequests) + .then(() => dispatch(markAllSuccess(shouldMarkAll))); + }; +} + +function clearMarkedRequest() { + return { + type: types.CLEAR_MARKED_REQUEST + } +} + +function clearMarkedSuccess(idsCleared) { + return { + type: types.CLEAR_MARKED_SUCCESS, + idsCleared + } +} + +export function clearMarked() { + return (dispatch, getState) => { + const markedTodos = getState().todos.filter(todo => todo.marked); + let clearRequests = []; + let idsCleared = []; + dispatch(clearMarkedRequest()); + markedTodos.forEach(todo => { + clearRequests.push( + fetch(`${todoApiUrl}/${todo.id}`, { + method: 'delete' + }) + .then(checkStatus) + .then(() => idsCleared.push(todo.id)) + .catch(handleApiError) + ); + }); + Promise.all(clearRequests) + .then(() => { + dispatch(clearMarkedSuccess(idsCleared)); + }); + }; +} \ No newline at end of file diff --git a/VSReact.Web/app/constants/ActionTypes.js b/VSReact.Web/app/constants/ActionTypes.js index cdd72cc..7ee5f22 100644 --- a/VSReact.Web/app/constants/ActionTypes.js +++ b/VSReact.Web/app/constants/ActionTypes.js @@ -1,6 +1,24 @@ +/* original actions */ export const ADD_TODO = 'ADD_TODO'; export const DELETE_TODO = 'DELETE_TODO'; export const EDIT_TODO = 'EDIT_TODO'; export const MARK_TODO = 'MARK_TODO'; export const MARK_ALL = 'MARK_ALL'; export const CLEAR_MARKED = 'CLEAR_MARKED'; + +/* actions for async todos */ +export const GET_TODOS_REQUEST = 'GET_TODOS_REQUEST'; +export const GET_TODOS_SUCCESS = 'GET_TODOS_SUCCESS'; +export const ADD_TODO_REQUEST = 'ADD_TODO_REQUEST'; +export const ADD_TODO_SUCCESS = 'ADD_TODO_SUCCESS'; +export const DELETE_TODO_REQUEST = 'DELETE_TODO_REQUEST'; +export const DELETE_TODO_SUCCESS = 'DELETE_TODO_SUCCESS'; +export const EDIT_TODO_REQUEST = 'EDIT_TODO_REQUEST'; +export const EDIT_TODO_SUCCESS = 'EDIT_TODO_SUCCESS'; +export const MARK_TODO_REQUEST = 'MARK_TODO_REQUEST'; +export const MARK_TODO_SUCCESS = 'MARK_TODO_SUCCESS'; +export const MARK_ALL_REQUEST = 'MARK_ALL_REQUEST'; +export const MARK_ALL_SUCCESS = 'MARK_ALL_SUCCESS'; +export const CLEAR_MARKED_REQUEST = 'CLEAR_MARKED_REQUEST'; +export const CLEAR_MARKED_SUCCESS = 'CLEAR_MARKED_SUCCESS'; + diff --git a/VSReact.Web/app/containers/App.js b/VSReact.Web/app/containers/App.js index f2166ea..11ad08b 100644 --- a/VSReact.Web/app/containers/App.js +++ b/VSReact.Web/app/containers/App.js @@ -1,12 +1,14 @@ import React, { Component } from 'react'; import TodoApp from './TodoApp'; -import { createStore, combineReducers, compose } from 'redux'; +import { createStore, combineReducers, compose, applyMiddleware } from 'redux'; +import thunk from 'redux-thunk'; import { devTools, persistState } from 'redux-devtools'; import { DevTools, DebugPanel, LogMonitor } from 'redux-devtools/lib/react'; import { Provider } from 'react-redux'; import * as reducers from '../reducers'; const finalCreateStore = compose( + applyMiddleware(thunk), devTools(), persistState(window.location.href.match(/[?&]debug_session=([^&]+)\b/)) )(createStore); diff --git a/VSReact.Web/app/containers/TodoApp.js b/VSReact.Web/app/containers/TodoApp.js index 8ce5a97..24197b1 100644 --- a/VSReact.Web/app/containers/TodoApp.js +++ b/VSReact.Web/app/containers/TodoApp.js @@ -3,9 +3,17 @@ import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import Header from '../components/Header'; import MainSection from '../components/MainSection'; -import * as TodoActions from '../actions/TodoActions'; +//import * as TodoActions from '../actions/TodoActions'; +import * as TodoActions from '../actions/TodoActionsAsyncBackend'; class TodoApp extends Component { + + componentDidMount() { + if (this.props.actions.getTodos) { + this.props.actions.getTodos(); + } + } + render() { const { todos, actions } = this.props; diff --git a/VSReact.Web/app/index.js b/VSReact.Web/app/index.js index 3da96f6..8142f20 100644 --- a/VSReact.Web/app/index.js +++ b/VSReact.Web/app/index.js @@ -1,3 +1,4 @@ +import 'babel-core/polyfill'; import React from 'react'; import App from './containers/App'; import 'todomvc-app-css/index.css'; @@ -5,4 +6,4 @@ import 'todomvc-app-css/index.css'; React.render( , document.getElementById('root') -); +); \ No newline at end of file diff --git a/VSReact.Web/app/reducers/index.js b/VSReact.Web/app/reducers/index.js index b340db2..176023b 100644 --- a/VSReact.Web/app/reducers/index.js +++ b/VSReact.Web/app/reducers/index.js @@ -1 +1,2 @@ -export { default as todos } from './todos'; +//export { default as todos } from './todos'; +export { default as todos } from './todosAsyncBackend'; \ No newline at end of file diff --git a/VSReact.Web/app/reducers/todosAsyncBackend.js b/VSReact.Web/app/reducers/todosAsyncBackend.js new file mode 100644 index 0000000..a58d91a --- /dev/null +++ b/VSReact.Web/app/reducers/todosAsyncBackend.js @@ -0,0 +1,50 @@ +import { GET_TODOS_SUCCESS, ADD_TODO_SUCCESS, DELETE_TODO_SUCCESS, EDIT_TODO_SUCCESS, MARK_TODO_SUCCESS, MARK_ALL_SUCCESS, CLEAR_MARKED_SUCCESS } from '../constants/ActionTypes'; + +const initialState = []; + +export default function todos(state = initialState, action) { + switch (action.type) { + case GET_TODOS_SUCCESS: + return action.todos.map(todo => { + return { id: todo.id, marked: todo.completed, text: todo.title } + }); + + case ADD_TODO_SUCCESS: + return [{ + id: action.id, + marked: false, + text: action.text + }, ...state]; + + case DELETE_TODO_SUCCESS: + return state.filter(todo => + todo.id !== action.id + ); + + case EDIT_TODO_SUCCESS: + return state.map(todo => + todo.id === action.id ? + { ...todo, text: action.text } : + todo + ); + + case MARK_TODO_SUCCESS: + return state.map(todo => + todo.id === action.id ? + { ...todo, marked: action.marked } : + todo + ); + + case MARK_ALL_SUCCESS: + return state.map(todo => ({ + ...todo, + marked: action.areAllMarked + })); + + case CLEAR_MARKED_SUCCESS: + return state.filter(todo => action.idsCleared.indexOf(todo.id) === -1); + + default: + return state; + } +} diff --git a/VSReact.Web/package.json b/VSReact.Web/package.json index 0290e36..843d938 100644 --- a/VSReact.Web/package.json +++ b/VSReact.Web/package.json @@ -7,13 +7,15 @@ "start": "node server.js" }, "dependencies": { + "babel-core": "^5.6.18", "classnames": "^2.1.2", + "isomorphic-fetch": "^2.2.0", "react": "^0.13.3", "react-redux": "^3.0.0", - "redux": "^3.0.0" + "redux": "^3.0.0", + "redux-thunk": "^1.0.0" }, "devDependencies": { - "babel-core": "^5.6.18", "babel-loader": "^5.1.4", "node-libs-browser": "^0.5.2", "raw-loader": "^0.5.1", diff --git a/VSReact.Web/webpack.config.js b/VSReact.Web/webpack.config.js index 4d5f006..af4d2e1 100644 --- a/VSReact.Web/webpack.config.js +++ b/VSReact.Web/webpack.config.js @@ -14,7 +14,10 @@ module.exports = { publicPath: '/static/' }, plugins: [ - new webpack.HotModuleReplacementPlugin() + new webpack.HotModuleReplacementPlugin(), + new webpack.DefinePlugin({ + __API_URL__: JSON.stringify(process.env.API_URL || '//localhost:51407') + }) ], resolveLoader: { 'fallback': path.join(__dirname, 'node_modules')