From 78351bb003039b94bf2dcbe8324a2964256a5f37 Mon Sep 17 00:00:00 2001 From: Alexandre Brasseur Date: Sat, 14 Nov 2020 16:05:28 +0100 Subject: [PATCH 01/17] refactor(redux): Split redux actions and reducers --- src/components/Header.jsx | 8 +- src/components/MainLoader.jsx | 3 +- src/components/MessageSystem.jsx | 2 +- src/components/common/ProtectedRoute.jsx | 6 +- src/components/orders/UserOrdersList.jsx | 4 +- src/pages/Account.jsx | 2 +- src/pages/admin/AdminNav.jsx | 2 +- src/pages/admin/Dashboard.jsx | 8 +- src/pages/admin/SaleEditor/index.jsx | 41 +-- src/pages/admin/index.jsx | 4 +- src/pages/public/OrderDetail.jsx | 6 +- src/pages/public/SaleDetail.jsx | 27 +- src/redux/{actions.js => actions/api.js} | 75 ++--- src/redux/actions/index.js | 5 + src/redux/actions/messages.js | 37 +++ src/redux/hooks.js | 23 +- src/redux/reducers/api.js | 316 +++++++++++++++++++ src/redux/reducers/index.js | 11 + src/redux/reducers/messages.jsx | 21 ++ src/redux/store.js | 382 +---------------------- 20 files changed, 500 insertions(+), 483 deletions(-) rename src/redux/{actions.js => actions/api.js} (79%) create mode 100644 src/redux/actions/index.js create mode 100644 src/redux/actions/messages.js create mode 100644 src/redux/reducers/api.js create mode 100644 src/redux/reducers/index.js create mode 100644 src/redux/reducers/messages.jsx diff --git a/src/components/Header.jsx b/src/components/Header.jsx index 01535f1..dc53456 100644 --- a/src/components/Header.jsx +++ b/src/components/Header.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import actions from '../redux/actions'; +import apiActions from '../redux/actions/api'; import { makeStyles } from '@material-ui/core/styles'; import { AppBar, Toolbar, Container, Button, Menu, Divider, useMediaQuery } from '@material-ui/core'; @@ -30,12 +30,12 @@ export default function Header(props) { const [menuTarget, setMenuTarget] = React.useState(null); const dispatch = useDispatch(); - const auth = useSelector(store => store.getData('auth', {})); - const userAssos = useSelector(store => store.getAuthRelatedData('associations', null)); + const auth = useSelector(store => store.api.getData('auth', {})); + const userAssos = useSelector(store => store.api.getAuthRelatedData('associations', null)); React.useEffect(() => { if (auth.user && !userAssos) - dispatch(actions.auth(auth.user.id).associations.all({ page_size: 'max' })); + dispatch(apiActions.auth(auth.user.id).associations.all({ page_size: 'max' })); }); return ( diff --git a/src/components/MainLoader.jsx b/src/components/MainLoader.jsx index 57e22dd..ef932eb 100644 --- a/src/components/MainLoader.jsx +++ b/src/components/MainLoader.jsx @@ -18,11 +18,12 @@ const useStyles = makeStyles({ export default function MainLoader(props) { const classes = useStyles(); - const auth = useSelector(store => store.get('auth')); + const auth = useSelector(store => store.api.get('auth')); // Fail if cannot get auth if (auth.error) { console.warn(auth.error) + // TODO Deal with errors throw new Error("Impossible de contacter le serveur") } diff --git a/src/components/MessageSystem.jsx b/src/components/MessageSystem.jsx index 9737683..3767b11 100644 --- a/src/components/MessageSystem.jsx +++ b/src/components/MessageSystem.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { Snackbar } from '@material-ui/core'; import { Alert, AlertTitle } from '@material-ui/lab'; -import { messagesActions } from '../redux/actions'; +import messagesActions from '../redux/actions/messages'; export function Message({ title, details=null, severity="info", onClose, ...props }) { diff --git a/src/components/common/ProtectedRoute.jsx b/src/components/common/ProtectedRoute.jsx index a9cbd54..186d70f 100644 --- a/src/components/common/ProtectedRoute.jsx +++ b/src/components/common/ProtectedRoute.jsx @@ -22,8 +22,8 @@ const authFunctions = { }; export default function ProtectedRoute({ only, authOptions, redirection, component: Component, ...routeProps }) { - const auth = useSelector(store => store.getData('auth')); - const userAssos = useSelector(store => store.getAuthRelatedData('associations')); + const auth = useSelector(store => store.api.getData('auth')); + const userAssos = useSelector(store => store.api.getAuthRelatedData('associations')); const isAuthorized = ( typeof only == 'function' @@ -32,7 +32,7 @@ export default function ProtectedRoute({ only, authOptions, redirection, compone ); return ( ( isAuthorized ? : diff --git a/src/components/orders/UserOrdersList.jsx b/src/components/orders/UserOrdersList.jsx index 69927fd..e3ed469 100644 --- a/src/components/orders/UserOrdersList.jsx +++ b/src/components/orders/UserOrdersList.jsx @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useHistory } from 'react-router'; import { useDispatch } from 'react-redux' -import actions, { apiAxios } from '../../redux/actions'; +import apiActions, { apiAxios } from '../../redux/actions/api'; import { TableContainer, Table, TableHead, TableBody, @@ -34,7 +34,7 @@ function getStatusActions(dispatch, history, fetchOrders) { cancel(event) { const orderId = event.currentTarget.getAttribute('data-order-id'); - const action = actions.orders(orderId).delete(); + const action = apiActions.orders(orderId).delete(); dispatch(action); action.payload.finally(fetchOrders); }, diff --git a/src/pages/Account.jsx b/src/pages/Account.jsx index 202dcc0..296166f 100644 --- a/src/pages/Account.jsx +++ b/src/pages/Account.jsx @@ -10,7 +10,7 @@ import UserOrdersList from '../components/orders/UserOrdersList'; export default function Account(props) { - const user = useSelector(store => store.getAuthUser()); + const user = useSelector(store => store.api.getAuthUser()); const { orders, fetchOrders } = useUserOrders(); return ( diff --git a/src/pages/admin/AdminNav.jsx b/src/pages/admin/AdminNav.jsx index f48e3e1..33c50b9 100644 --- a/src/pages/admin/AdminNav.jsx +++ b/src/pages/admin/AdminNav.jsx @@ -111,7 +111,7 @@ export default function AdminNav(props) { const classes = useStyles(); const match = getMatch(useLocation()); const resource = useSelector(store => ( - match && store.findData(match.resource, match.params && match.params.id) + match && store.api.findData(match.resource, match.params && match.params.id) )); const { title, actions } = getNavData(match, resource); diff --git a/src/pages/admin/Dashboard.jsx b/src/pages/admin/Dashboard.jsx index 4155fc7..5634d0b 100644 --- a/src/pages/admin/Dashboard.jsx +++ b/src/pages/admin/Dashboard.jsx @@ -1,5 +1,5 @@ import React from 'react' -import actions from '../../redux/actions'; +import apiActions from '../../redux/actions/api'; import { useDispatch, useSelector } from 'react-redux'; import { Container, Grid } from '@material-ui/core'; @@ -8,11 +8,11 @@ import AssoSalesList from '../../components/sales/AssoSalesList'; export default function Dashboard(props) { const dispatch = useDispatch(); - const assos = useSelector(store => store.getAuthRelatedData('associations', {})); - const sales = useSelector(store => store.getResourceDataById('associations', 'sales', null)); + const assos = useSelector(store => store.api.getAuthRelatedData('associations', {})); + const sales = useSelector(store => store.api.getResourceDataById('associations', 'sales', null)); function handleFetchSales(assoId) { - dispatch(actions.associations(assoId).sales.all({ include_inactive: true })); + dispatch(apiActions.associations(assoId).sales.all({ include_inactive: true })); } return ( diff --git a/src/pages/admin/SaleEditor/index.jsx b/src/pages/admin/SaleEditor/index.jsx index 09fd569..feeba8a 100644 --- a/src/pages/admin/SaleEditor/index.jsx +++ b/src/pages/admin/SaleEditor/index.jsx @@ -1,7 +1,8 @@ import React from 'react' // import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import actions, { messagesActions } from '../../../redux/actions'; +import apiActions from '../../../redux/actions/api'; +import messagesActions from '../../../redux/actions/messages'; import produce from 'immer'; import { Container, Box } from '@material-ui/core'; @@ -25,15 +26,15 @@ const BLANK_RESOURCES = { const connector = connect((store, props) => { const saleId = props.match.params.sale_id || null; - const assos = store.getAuthRelatedData('associations', {}); - const usertypes = store.get('usertypes'); - const itemgroups = saleId ? store.getData(['sales', saleId, 'itemgroups'], {}) : {}; - const fields = store.get('fields'); + const assos = store.api.getAuthRelatedData('associations', {}); + const usertypes = store.api.get('usertypes'); + const itemgroups = saleId ? store.api.getData(['sales', saleId, 'itemgroups'], {}) : {}; + const fields = store.api.get('fields'); return { saleId, - sale: saleId ? store.getData(['sales', saleId], null) : null, - items: saleId ? store.getData(['sales', saleId, 'items'], {}) : {}, + sale: saleId ? store.api.getData(['sales', saleId], null) : null, + items: saleId ? store.api.getData(['sales', saleId, 'items'], {}) : {}, itemgroups, itemgroupsChoices: { ...dataToChoices(itemgroups, 'name'), null: { label: 'Sans groupe', value: 'null' } }, @@ -60,9 +61,9 @@ class SaleEditor extends React.Component { // Fetch side resources if (!this.props.usertypes.fetched) - this.props.dispatch(actions.usertypes.all()); + this.props.dispatch(apiActions.usertypes.all()); if (!this.props.fields.fetched) - this.props.dispatch(actions.fields.all()); + this.props.dispatch(apiActions.fields.all()); } componentDidUpdate(prevProps) { @@ -93,9 +94,9 @@ class SaleEditor extends React.Component { loading_itemgroups: true, }); const saleId = this.props.saleId; - this.props.dispatch(actions.sales.find(saleId)); - this.props.dispatch(actions.sales(saleId).items.all({ include: 'itemfields' })); - this.props.dispatch(actions.sales(saleId).itemgroups.all()); + this.props.dispatch(apiActions.sales.find(saleId)); + this.props.dispatch(apiActions.sales(saleId).items.all({ include: 'itemfields' })); + this.props.dispatch(apiActions.sales(saleId).itemgroups.all()); } getStateFor(resource, prevState = {}) { @@ -193,9 +194,9 @@ class SaleEditor extends React.Component { // Save changes and return a Promise for all calls // TODO Get and update items from items(itemId).itemfields return Promise.all([ - ...changes.to_create.map(data => actions.itemfields.create(null, data)), - ...changes.to_update.map(([id, data]) => actions.itemfields.update(id, null, data)), - ...changes.to_delete.map(id => actions.itemfields.delete(id)), + ...changes.to_create.map(data => apiActions.itemfields.create(null, data)), + ...changes.to_update.map(([id, data]) => apiActions.itemfields.update(id, null, data)), + ...changes.to_delete.map(id => apiActions.itemfields.delete(id)), ].map(action => action.payload)); } @@ -263,14 +264,14 @@ class SaleEditor extends React.Component { try { if (this.isCreator()) { // Create sale - const action = actions.sales.create(null, details); + const action = apiActions.sales.create(null, details); const response = await action.payload; // Dispatch creation and go to edit mode this.props.dispatch(action); this.props.history.push(`/admin/sales/${response.data.id}/edit`); } else { // Update sale details - const action = actions.sales.update(this.props.saleId, null, details); + const action = apiActions.sales.update(this.props.saleId, null, details); await action.payload; this.props.dispatch(action); } @@ -327,7 +328,7 @@ class SaleEditor extends React.Component { data.sale = saleId; // Create resource - const action = actions.sales(saleId)[resource].create(null, data); + const action = apiActions.sales(saleId)[resource].create(null, data); await action.payload; // Creation succeeded, remove fake id and dispatch created @@ -347,7 +348,7 @@ class SaleEditor extends React.Component { // Update resource and wait for feedback to dispatch const queryParams = resource === 'items' ? { include: 'itemfields' } : null; - const action = actions[resource].update(id, queryParams, data) + const action = apiActions[resource].update(id, queryParams, data) await action.payload; this.props.dispatch(action); @@ -379,7 +380,7 @@ class SaleEditor extends React.Component { draft.selected = null; delete draft[resource][id]; return draft; - }), () => isNew || this.props.dispatch(actions[resource].delete(id))); + }), () => isNew || this.props.dispatch(apiActions[resource].delete(id))); } handleResetResource = event => { diff --git a/src/pages/admin/index.jsx b/src/pages/admin/index.jsx index ce98dd0..50e2ea3 100644 --- a/src/pages/admin/index.jsx +++ b/src/pages/admin/index.jsx @@ -15,8 +15,8 @@ import Error404 from '../Error404'; export default function AdminSite(props) { // Get data from store - const auth = useSelector(store => store.getData('auth')); - const userAssos = useSelector(store => store.getAuthRelatedData('associations', null)); + const auth = useSelector(store => store.api.getData('auth')); + const userAssos = useSelector(store => store.api.getAuthRelatedData('associations', null)); // Wait for user's associations to be fetched if (userAssos === null) diff --git a/src/pages/public/OrderDetail.jsx b/src/pages/public/OrderDetail.jsx index a6b5b04..cae6dfc 100644 --- a/src/pages/public/OrderDetail.jsx +++ b/src/pages/public/OrderDetail.jsx @@ -1,7 +1,7 @@ import React from 'react'; import produce from 'immer'; import { connect } from 'react-redux'; -import actions, { apiAxios } from '../../redux/actions'; +import apiActions, { apiAxios } from '../../redux/actions/api'; import { API_URL, ORDER_STATUS, STATUS_MESSAGES } from '../../constants'; import { arrayToMap } from '../../utils'; @@ -39,7 +39,7 @@ const connector = connect((store, props) => { const orderId = props.match.params.order_id; return { orderId, - order: store.getData(['orders', orderId], null, true), + order: store.api.getData(['orders', orderId], null, true), }; }); @@ -80,7 +80,7 @@ class OrderDetail extends React.Component { }) fetchOrder = () => this.props.dispatch( - actions.orders.find(this.props.orderId, { include: INCLUDE_QUERY }) + apiActions.orders.find(this.props.orderId, { include: INCLUDE_QUERY }) ) /** Fetch status and redirect to payment or refresh order */ diff --git a/src/pages/public/SaleDetail.jsx b/src/pages/public/SaleDetail.jsx index 2ec54be..b933ef6 100644 --- a/src/pages/public/SaleDetail.jsx +++ b/src/pages/public/SaleDetail.jsx @@ -1,7 +1,9 @@ import React from 'react' import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import actions, { apiAxios, messagesActions } from '../../redux/actions'; +import apiActions, { apiAxios } from '../../redux/actions/api'; +import messagesActions from '../../redux/actions/messages'; + import { isPast } from 'date-fns'; import { formatDate } from '../../utils'; import { getButtonColoredVariant } from '../../styles'; @@ -21,10 +23,10 @@ const connector = connect((store, props) => { const saleId = props.match.params.sale_id; return { saleId, - authenticated: Boolean(store.getData('auth', {}).authenticated), - sale: store.findData('sales', saleId, 'id'), - order: store.getData(['sales', saleId, 'userOrder'], null, true), - items: store.getData(['sales', saleId, 'items']), + authenticated: Boolean(store.api.getData('auth', {}).authenticated), + sale: store.api.findData('sales', saleId, 'id'), + order: store.api.getData(['sales', saleId, 'userOrder'], null, true), + items: store.api.getData(['sales', saleId, 'items']), }; }); @@ -42,10 +44,10 @@ class SaleDetail extends React.Component{ this.fetchOrder(); if (!this.props.sale) - this.props.dispatch(actions.sales.find(saleId, { include: 'association' })); + this.props.dispatch(apiActions.sales.find(saleId, { include: 'association' })); if (!this.props.items) - this.props.dispatch(actions.sales(saleId).items.all()); + this.props.dispatch(apiActions.sales(saleId).items.all()); } componentDidUpdate(prevProps) { @@ -69,10 +71,11 @@ class SaleDetail extends React.Component{ fetchOrder = () => { const saleId = this.props.saleId; this.props.dispatch( - actions.sales(saleId).orders - .definePath(['sales', saleId, 'userOrder' ]) - .setOptions({ meta: { action: 'updateAll'} }) - .create({ include: 'orderlines' }) + apiActions + .sales(saleId).orders + .definePath(['sales', saleId, 'userOrder' ]) + .setOptions({ meta: { action: 'updateAll'} }) + .create({ include: 'orderlines' }) ); } @@ -116,7 +119,7 @@ class SaleDetail extends React.Component{ /** Cancel an order */ cancelOrder = event => { - actions.orders.delete(this.props.order.id).payload.finally(this.fetchOrder); + apiActions.orders.delete(this.props.order.id).payload.finally(this.fetchOrder); } /** Save order and redirect to payment */ diff --git a/src/redux/actions.js b/src/redux/actions/api.js similarity index 79% rename from src/redux/actions.js rename to src/redux/actions/api.js index 811b508..bd71324 100644 --- a/src/redux/actions.js +++ b/src/redux/actions/api.js @@ -2,14 +2,15 @@ * Création et gestion automatique des actions que l'on dispatch via redux * * @author Samy Nastuzzi - * @author Alexandre Brasseur + * @author Alexandre Brasseur * * @copyright Copyright (c) 2018, SiMDE-UTC * @license GNU GPL-3.0 */ import axios from 'axios'; -import { API_URL } from '../constants'; -import { API_PREFIX, MESSAGE_PREFIX } from './store'; +import { API_URL } from '../../constants'; + +export const API_REDUX_PREFIX = 'API'; // Default axios for the api export const apiAxios = axios.create({ @@ -18,37 +19,7 @@ export const apiAxios = axios.create({ xsrfCookieName: 'csrftoken', }); -/* -|--------------------------------------------------------- -| Message System Actions -|--------------------------------------------------------- -*/ -export const messagesActions = { - pushError: (error, title=null, params={}) => { - console.error(title || "Erreur inconnue", error); - if (error.isAxiosError && error.response) { - const { data, status } = error.response; - title = title || data.error || `Erreur API inconnue (${status})`; - const details = data.message; - return messagesActions.pushMessage(title, details, "error", params); - } - return messagesActions.pushMessage(title || "Erreur inconnue", String(error), "error", params) - }, - - pushMessage: (title, details=null, severity=null, params={}) => ({ - type: `${MESSAGE_PREFIX}_PUSH`, - payload: { - id: Math.random().toString(36).substring(2), - title, details, severity, params, - }, - }), - - popMessage: (id) => ({ - type: `${MESSAGE_PREFIX}_POP`, - payload: { id }, - }), -}; /* |--------------------------------------------------------- @@ -99,38 +70,38 @@ export const ACTION_CONFIG_METHODS = { definePath: action => path => { action.path = path.slice(); action.pathLocked = true; - return new Proxy(action, actionHandler); + return new Proxy(action, apiActionHandler); }, /** Define the path for the resource in the store */ defineUri: action => uri => { action.uri = uri; - return new Proxy(action, actionHandler); + return new Proxy(action, apiActionHandler); }, setUriFromPath: action => path => { action.path = path.slice(); action.uri = path.join('/'); action.idIsGiven = path.length % 2 === 0; - return new Proxy(action, actionHandler); + return new Proxy(action, apiActionHandler); }, /** Add a valid status */ addValidStatus: action => validStatus => { action.validStatus.push(validStatus); - return new Proxy(action, actionHandler); + return new Proxy(action, apiActionHandler); }, /** Define the valid status */ defineValidStatus: action => validStatus => { action.validStatus = validStatus; - return new Proxy(action, actionHandler); + return new Proxy(action, apiActionHandler); }, /** Set Action options */ setOptions: action => options => { action.options = { ...action.options, ...options }; - return new Proxy(action, actionHandler); + return new Proxy(action, apiActionHandler); }, @@ -138,19 +109,18 @@ export const ACTION_CONFIG_METHODS = { auth: action => (authId = null) => { action.path = ['auth']; action.uri = authId ? `/users/${authId}` : 'auth/me'; - return new Proxy(action, actionHandler); + return new Proxy(action, apiActionHandler); }, }; - /* |--------------------------------------------------------- -| Handler and APIAction class +| Proxy Handler |--------------------------------------------------------- */ // Gestionnaire d'actions (crée dynamiquement les routes api à appeler et où stocker les données) -export const actionHandler = { +export const apiActionHandler = { get(action, attr) { // Access instance if (attr === '_instance') @@ -203,7 +173,7 @@ export const actionHandler = { // Not an HTTP Method (ex: actions.users(1)) if (args.length === 1) action.addId(args[0]); - return new Proxy(action, actionHandler); + return new Proxy(action, apiActionHandler); }; // HTTP Action (ex: actions.users.get()) @@ -218,7 +188,12 @@ export const actionHandler = { }, }; -// REST Action management class +/* +|--------------------------------------------------------- +| API Action generator +|--------------------------------------------------------- +*/ + export class APIAction { constructor(axios_instance = apiAxios) { this.axios = axios_instance; @@ -227,7 +202,7 @@ export class APIAction { this.path = []; this.pathLocked = false; this.actions = API_METHODS; - this.validStatus = [200, 201, 202, 203, 204, 416]; + this.validStatus = [200, 201, 202, 203, 204]; this.options = { type: undefined, axios: {}, @@ -235,7 +210,7 @@ export class APIAction { action: {}, }; - return new Proxy(this, actionHandler); + return new Proxy(this, apiActionHandler); } addUri(step) { @@ -264,7 +239,7 @@ export class APIAction { const value = queryParams[key]; if (value !== undefined) { - if (Object.is(value)) + if (Object.is(value)) queries.push(this.generateQueries(value, true)); else queries.push( @@ -282,7 +257,7 @@ export class APIAction { } generateType(action) { - return [ API_PREFIX, this.actions[action].type, ...this.path ].join('_').toUpperCase(); + return [ API_REDUX_PREFIX, this.actions[action].type, ...this.path ].join('_').toUpperCase(); } generateAction(action, queryParams = {}, jsonData = {}) { @@ -320,4 +295,4 @@ export const actions = new Proxy(axios_instance => new APIAction(axios_instance) }, }); -export default actions; +export default actions; diff --git a/src/redux/actions/index.js b/src/redux/actions/index.js new file mode 100644 index 0000000..fe733a4 --- /dev/null +++ b/src/redux/actions/index.js @@ -0,0 +1,5 @@ +export { default as apiActions } from './api'; +export { default as userActions } from './user'; +export { default as messagesActions } from './messages'; + +export { APIAction, apiAxios } from './api'; diff --git a/src/redux/actions/messages.js b/src/redux/actions/messages.js new file mode 100644 index 0000000..84c99cf --- /dev/null +++ b/src/redux/actions/messages.js @@ -0,0 +1,37 @@ +export const MESSAGE_REDUX_PREFIX = "MESSAGE"; + +const messages = { + pushError: (error, title = null, params = {}) => { + console.error(title || "Erreur inconnue", error); + if (error.isAxiosError && error.response) { + const { data, status } = error.response; + title = title || data.error || `Erreur API inconnue (${status})`; + const details = data.message; + return messages.pushMessage(title, details, "error", params); + } + return messages.pushMessage( + title || "Erreur inconnue", + String(error), + "error", + params + ); + }, + + pushMessage: (title, details = null, severity = null, params = {}) => ({ + type: `${MESSAGE_REDUX_PREFIX}_PUSH`, + payload: { + id: Math.random().toString(36).substring(2), + title, + details, + severity, + params, + }, + }), + + popMessage: (id) => ({ + type: `${MESSAGE_REDUX_PREFIX}_POP`, + payload: { id }, + }), +}; + +export default messages; diff --git a/src/redux/hooks.js b/src/redux/hooks.js index 354f76e..d6c4c99 100644 --- a/src/redux/hooks.js +++ b/src/redux/hooks.js @@ -1,8 +1,8 @@ import { useEffect } from 'react'; import useDeepCompareEffect from 'use-deep-compare-effect'; import { useSelector, useDispatch } from 'react-redux'; -import { pathToArray } from './store'; -import actions, { APIAction, apiAxios } from './actions'; +import { pathToArray } from './reducers/api'; +import apiActions, { APIAction, apiAxios } from './actions/api'; const USE_API_STORE_DEFAULT_OPTIONS = { @@ -14,7 +14,8 @@ const USE_API_STORE_DEFAULT_OPTIONS = { fetchingValue: undefined, }; -// FIXME Process pagination +// HERE FIXME Process pagination +// Maybe use https://redux-saga.js.org/ export function useStoreAPIData(path, options = {}) { // Patch params path = pathToArray(path); @@ -24,7 +25,7 @@ export function useStoreAPIData(path, options = {}) { // Get data from redux store const dispatch = useDispatch(); - const resource = useSelector(store => store.get(path)); + const resource = useSelector(store => store.api.get(path)); // FIXME Fires fetching multiple times useDeepCompareEffect(() => { @@ -49,11 +50,11 @@ export function useStoreAPIData(path, options = {}) { return resource.data; } -export async function useUpdateOrderStatus(orderId, auto = { fetch: false, redirect: false }) { - const dispatch = useDispatch(); +// TODO Check updateOrderStatus that is used +export async function updateOrderStatus(dispatch, orderId, auto = { fetch: false, redirect: false }) { const resp = (await apiAxios.get(`/orders/${orderId}/status`)).data - const fetchOrder = () => dispatch(actions.orders.find(orderId)); + const fetchOrder = () => dispatch(apiActions.orders.find(orderId)); const redirectToPayment = () => resp.redirect_url ? window.location.href = resp.redirect_url : null; if (auto.fetch && resp.updated) @@ -64,9 +65,9 @@ export async function useUpdateOrderStatus(orderId, auto = { fetch: false, redi return { resp, fetchOrder, redirectToPayment }; } -function fetchOrders(dispatch, userId) { +function fetchUserOrders(dispatch, userId) { dispatch( - actions + apiActions .defineUri(`users/${userId}/orders`) .definePath(['auth', 'orders']) .all({ @@ -83,8 +84,8 @@ export function useUserOrders() { useEffect(() => { if (userId) - fetchOrders(dispatch, userId); + fetchUserOrders(dispatch, userId); }, [dispatch, userId]); - return { userId, orders, fetchOrders: () => fetchOrders(dispatch, userId) }; + return { userId, orders, fetchOrders: () => fetchUserOrders(dispatch, userId) }; } diff --git a/src/redux/reducers/api.js b/src/redux/reducers/api.js new file mode 100644 index 0000000..73d036c --- /dev/null +++ b/src/redux/reducers/api.js @@ -0,0 +1,316 @@ +import produce from 'immer'; +import { deepcopy, isEmpty } from '../../utils'; +import { API_REDUX_PREFIX } from '../actions/api'; +import { ASYNC_SUFFIXES } from '../store'; + +/* +|--------------------------------------------------------- +| Path helpers +|--------------------------------------------------------- +*/ + +/** + * Split a URI route into an array + * Example: 'assos/1/calendars' => ['assos', '1', 'calendars'] + */ +export function pathToArray(path) { + if (typeof path === 'string') + return path.split('/'); + else if (path instanceof Array) + return path.slice(); + else + return []; +} + +/** + * Helper to get path and id from an action meta + */ +function getPathFromMeta(meta) { + const path = meta.path.slice(); + let id = undefined; + + // Pop id from path if needed + if (!['updateAll', 'create', 'insert'].includes(meta.action)) { + id = path.pop(); + } + return { path, id }; +} + + +/* +|--------------------------------------------------------- +| Default Store +|--------------------------------------------------------- +| This API store is auto building itself with each request +*/ + +// Base storage for each resource +export const INITIAL_RESOURCE_STATE = { + data: {}, + error: null, + failed: false, + status: null, + fetching: false, + fetched: false, + lastUpdate: null, + pagination: {}, + resources: {}, +}; + +// La racine du store +export const apiStore = { + + // Actual store + resources: {}, + + /** + * Easy access an element in the store + * Should NOT return copied data from the store (arr.map, Object.values) for better performance + * + * @param {} path The path to the target resource + * @param {} [replacement={}] The returned Objet if the resource if infindable + * @param {boolean} [forceReplacement=false] Return remplacement resource is empty or null + */ + get(path, replacement = INITIAL_RESOURCE_STATE, forceReplacement = true) { + let data = this; + + // Find the resource from the path + // Search in direct data and in resources + for (const step of pathToArray(path)) { + if (data[step] !== undefined) + data = data[step]; + else if (data.resources && data.resources[step] !== undefined) + data = data.resources[step]; + else + return replacement; + } + + // Return replacement if the data is empty or null + if (forceReplacement && isEmpty(data)) + return replacement; + + return data; + }, + + /** Retrieve the data object of a resource */ + getData(path, replacement = null, forceReplacement = false) { + return this.get([ ...pathToArray(path), 'data'], replacement, forceReplacement); + }, + + /** Retrieve the data with a particuliar value of a resource */ + findData(path, value, key = 'id', replacement = null, forceReplacement = true) { + // Data is stored by id + if (key === 'id') + return this.getData([ ...pathToArray(path), value], replacement, forceReplacement); + + // Otherwise, search the data for the right key + const data = this.getData(path); + for (const k in data) + if (data[k][key] === value) + return data[k]; + + return replacement; + }, + + /** + * Get specified resource for multiple data by id + * Example: ('sales', 'assos') => { a: asso_of_sale_a, b: asso_of_sale_b } + * + * @param {[type]} path [description] + * @param {[type]} resource [description] + * @param {[type]} replacement [description] + * @return {Object} La map de resource par id + */ + getResourceDataById(path, resource, replacement = null) { + const pathResources = this.get(path).resources; + return Object.keys(pathResources).reduce((acc, id) => { + const subResources = pathResources[id].resources[resource] || {}; + acc[id] = subResources.fetched ? subResources.data : replacement; + return acc; + }, {}); + }, + + // TODO Custom methods + getAuthUser(path, replacement = null, forceReplacement = true) { + return this.get(['auth', 'data', 'user', ...pathToArray(path)], replacement, forceReplacement); + }, + getAuthRelatedData(path, replacement = {}, forceReplacement = true) { + return this.getData(['auth', 'resources', ...pathToArray(path)], replacement, forceReplacement); + }, +}; + + +/* +|--------------------------------------------------------- +| Resource helpers +|--------------------------------------------------------- +*/ + +/** + * Dynamically generate the resource storage in the store from the path + * @param {Object} store The Redux API store + * @param {String[]} path The path to the place wanted + * @return {Object} The place located at the required steps + */ +function buildPathInStore(store, path) { + return path.reduce((place, step) => { + // Create nested resources if doesn't exist + if (place.resources[step] === undefined) + place.resources[step] = deepcopy(INITIAL_RESOURCE_STATE); + + // Go forward in the path + return place.resources[step]; + }, store); +} + +function makeResourceSuccessful(resource, timestamp, status) { + resource.fetching = false + resource.fetched = true + resource.error = null + resource.failed = false + resource.lastUpdate = timestamp + resource.status = status + return resource +} + +function processPagination(payload) { + if (payload.hasOwnProperty('results')) { + const { results, ...pagination } = payload; + return { data: results, pagination: pagination }; + } else { + return { data: payload, pagination: null }; + } +} + + +/* +|--------------------------------------------------------- +| Reducer +|--------------------------------------------------------- +*/ + +/** This reducer manages the async API operations */ +export default function apiReducer(state = apiStore, action) { + + // Api actions + if (action.type && action.type.startsWith(API_REDUX_PREFIX)) { + return produce(state, draft => { + // Get path and id from action.meta + let { path, id } = getPathFromMeta(action.meta); + let place = buildPathInStore(draft, path); + + // CASE LOADING: Async call is loading + if (action.type.endsWith(`_${ASYNC_SUFFIXES.loading}`)) { + place.fetching = true; + place.status = null; + return draft; + } + + const status = (action.payload.status || ( + action.payload.response && action.payload.response.status + )); + const statusIsValid = action.meta.validStatus.includes(status); + + // ====== CASE ERROR: Async call has failed + if (action.type.endsWith(`_${ASYNC_SUFFIXES.error}`)) { + // if (id) // TODO ???? + // place = buildPathInStore(draft, path.concat([id])); + + // place.data = {}; + place.fetching = false; + place.fetched = statusIsValid; + place.error = action.payload; + place.failed = statusIsValid; + place.status = status; + return draft; + } + + // Async call has succeeded + if (action.type.endsWith(`_${ASYNC_SUFFIXES.success}`)) { + + // ====== CASE HTTP ERROR: HTTP status is not acceptable + if (!statusIsValid) { + // place.data = {}; + place.fetching = false; + place.fetched = false; + place.error = 'NOT ACCEPTED'; + place.failed = true; + place.status = action.payload.status; + return draft; + } + + // ====== CASE SUCCESS: Update store + + // Set pagination, timestamp, status and others indicators + const { timestamp, status } = action.payload; + const { data, pagination } = processPagination(action.payload.data); + if (pagination) + place.pagination = pagination + + // The resource place where to store the data + place = makeResourceSuccessful(place, timestamp, status); + id = id || data.id; // TODO + + // Helper to build a store for the data if it has a key or an id + function buildSuccessfulDataStorePath(element, key) { + if (key) { + let placeForData = buildPathInStore(draft, path.concat([key])); + placeForData = makeResourceSuccessful(placeForData, timestamp, status); + placeForData.data = element; + } + } + + // Update the data and resources according to the action required + if (action.meta.action === 'updateAll') { + // Multiple elements + + // Modify data and Create places in resources for each element according to id + if (Array.isArray(data)) { // Array: Multiple elements with id + data.forEach(element => { + const e_id = element.id; // TODO + place.data[e_id] = element; + buildSuccessfulDataStorePath(element, e_id); + }); + } else if (id) { // Resource with id: Single id + place.data = data; + buildSuccessfulDataStorePath(data, id); + } else { // Resource without id: keys for resources + // TODO Check object, Useful ?? + place.data = data; + // for (const key in data) + // buildSuccessfulDataStorePath(data[key], key); + } + + } else { // Single element + + // Modify place.data and place.resources + if (['create', 'insert', 'update'].includes(action.meta.action)) { + if (id) { + place.data[id] = data; + buildSuccessfulDataStorePath(data, id); + } else { + place.data = data; + for (const key in data) + buildSuccessfulDataStorePath(data[key], key); + } + } else if ('delete' === action.meta.action) { + if (id) { + delete place.data[id]; + delete place.resources[id]; + } else { + place.data = {}; + place.resources = {}; + } + } + } + + return draft; + } + + // Return the draft state anyway + return draft; + }); + } + + return state; +} diff --git a/src/redux/reducers/index.js b/src/redux/reducers/index.js new file mode 100644 index 0000000..07a2a0e --- /dev/null +++ b/src/redux/reducers/index.js @@ -0,0 +1,11 @@ +import { combineReducers } from 'redux'; + +import messagesReducer from './messages'; +import apiReducer from './api'; + +const rootReducer = combineReducers({ + api: apiReducer, + messages: messagesReducer, +}) + +export default rootReducer diff --git a/src/redux/reducers/messages.jsx b/src/redux/reducers/messages.jsx new file mode 100644 index 0000000..ab26062 --- /dev/null +++ b/src/redux/reducers/messages.jsx @@ -0,0 +1,21 @@ +import { MESSAGE_REDUX_PREFIX } from '../actions/messages'; + +const DEFAULT_MESSAGE_STORE = []; + +/** + * Simple push/pop messaging system reducer + */ +export default function messagesReducer(state = DEFAULT_MESSAGE_STORE, action) { + switch (action.type) { + case `${MESSAGE_REDUX_PREFIX}_PUSH`: + const data = action.payload; + return [ ...state, data ] + + case `${MESSAGE_REDUX_PREFIX}_POP`: + const removeId = action.payload.id; + return state.filter(data => data.id !== removeId); + + default: + return state; + } +} diff --git a/src/redux/store.js b/src/redux/store.js index 3bc8855..89da824 100644 --- a/src/redux/store.js +++ b/src/redux/store.js @@ -1,399 +1,45 @@ -/** - * Création et gestion automatique et dynmaique du store géré par redux (store refait sur la base du travail d'Alexandre) - * - * @author Samy Nastuzzi - * @author Alexandre Brasseur - * - * @copyright Copyright (c) 2018, SiMDE-UTC - * @license GNU GPL-3.0 - */ - -import produce from 'immer'; import { createStore, applyMiddleware } from 'redux'; -import { deepcopy, isEmpty } from '../utils'; +import rootReducer from './reducers'; -// Import Middlewares +// Middlewares import thunk from 'redux-thunk'; import { createPromise } from 'redux-promise-middleware'; +// TODO Preload store ? +const preloadedStore = {}; + + /* |--------------------------------------------------------- | Middlewares |--------------------------------------------------------- */ -// Suffixes des actions asynchrones -const ASYNC_SUFFIXES = { +export const ASYNC_SUFFIXES = { loading: 'LOADING', success: 'SUCCESS', error: 'ERROR', }; -// Configure Middlewares -const middlewares = applyMiddleware( +const middlewares = [ thunk, createPromise({ promiseTypeSuffixes: Object.values(ASYNC_SUFFIXES) }), - // require('redux-logger').createLogger({ collapsed: true }), -); - - -/* -|--------------------------------------------------------- -| Path helpers -|--------------------------------------------------------- -*/ +]; -/** - * Split a URI route into an array - * Example: 'assos/1/calendars' => ['assos', '1', 'calendars'] - */ -export function pathToArray(path) { - if (typeof path === 'string') - return path.split('/'); - else if (path instanceof Array) - return path.slice(); - else - return []; -} +if (process.env.NODE_ENV === 'development') + middlewares.push(require('redux-logger').createLogger({ collapsed: true })); -function mergePath(path, ...additionalSteps) { - return pathToArray(path).concat(additionalSteps); -} -/** Helper to get path and id from an action meta */ -function getPathFromMeta(meta) { - const path = meta.path.slice(); - let id = undefined; - - // Pop id from path if needed - if (!['updateAll', 'create', 'insert'].includes(meta.action)) { - id = path.pop(); - } - return { path, id }; -} +const enhancers = applyMiddleware(...middlewares); /* |--------------------------------------------------------- | Store |--------------------------------------------------------- -| This API store is auto building itself with each request */ -export const API_PREFIX = 'API'; -export const MESSAGE_PREFIX = 'MESSAGE'; - -// Base storage for each resource -export const INITIAL_RESOURCE_STATE = { - data: {}, // A map of the needed data by id - error: null, - failed: false, - status: null, - fetching: false, - fetched: false, - lastUpdate: null, - pagination: {}, - resources: {}, -}; - -// La racine du store -export const apiStore = { - - // Actual store - resources: {}, - messages: {}, - - /** - * Easy access an element in the store - * Should NOT return copied data from the store (arr.map, Object.values) for better performance - * - * @param {} path The path to the target resource - * @param {} [replacement={}] The returned Objet if the resource if infindable - * @param {boolean} [forceReplacement=false] Return remplacement resource is empty or null - */ - get(path, replacement = INITIAL_RESOURCE_STATE, forceReplacement = true) { - let data = this; - - // Find the resource from the path - // Search in direct data and in resources - for (const step of pathToArray(path)) { - if (data[step] !== undefined) - data = data[step]; - else if (data.resources && data.resources[step] !== undefined) - data = data.resources[step]; - else - return replacement; - } - - // Return replacement if the data is empty or null - if (forceReplacement && isEmpty(data)) - return replacement; - - return data; - }, - - /** Retrieve the data object of a resource */ - getData(path, replacement = null, forceReplacement = false) { - return this.get(mergePath(path, 'data'), replacement, forceReplacement); - }, - - /** Retrieve the data with a particuliar value of a resource */ - findData(path, value, key = 'id', replacement = null, forceReplacement = true) { - // Data is stored by id - if (key === 'id') - return this.getData(mergePath(path, value), replacement, forceReplacement); - - // Otherwise, search the data for the right key - const data = this.getData(path); - for (const k in data) - if (data[k][key] === value) - return data[k]; - - return replacement; - }, - - /** - * Get specified resource for multiple data by id - * Example: ('sales', 'assos') => { a: asso_of_sale_a, b: asso_of_sale_b } - * - * @param {[type]} path [description] - * @param {[type]} resource [description] - * @param {[type]} replacement [description] - * @return {Object} La map de resource par id - */ - getResourceDataById(path, resource, replacement = null) { - const pathResources = this.get(path).resources; - return Object.keys(pathResources).reduce((acc, id) => { - const subResources = pathResources[id].resources[resource] || {}; - acc[id] = subResources.fetched ? subResources.data : replacement; - return acc; - }, {}); - }, - - // TODO Custom methods - getAuthUser(path, replacement = null, forceReplacement = true) { - return this.get(['auth', 'data', 'user', ...pathToArray(path)], replacement, forceReplacement); - }, - getAuthRelatedData(path, replacement = {}, forceReplacement = true) { - return this.getData(['auth', 'resources', ...pathToArray(path)], replacement, forceReplacement); - }, -}; - - -/* -|--------------------------------------------------------- -| Resource helpers -|--------------------------------------------------------- -*/ - -/** - * Dynamically generate the resource storage in the store from the path - * @param {Object} store The Redux API store - * @param {String[]} path The path to the place wanted - * @return {Object} The place located at the required steps - */ -function buildPathInStore(store, path) { - return path.reduce((place, step) => { - // Create nested resources if doesn't exist - if (place.resources[step] === undefined) - place.resources[step] = deepcopy(INITIAL_RESOURCE_STATE); - - // Go forward in the path - return place.resources[step]; - }, store); -} - -function makeResourceSuccessful(resource, timestamp, status) { - resource.fetching = false - resource.fetched = true - resource.error = null - resource.failed = false - resource.lastUpdate = timestamp - resource.status = status - return resource -} - -function processPagination(payload) { - if (payload.hasOwnProperty('results')) { - const { results, ...pagination } = payload; - return { data: results, pagination: pagination }; - } else { - return { data: payload, pagination: null }; - } -} - - -/* -|--------------------------------------------------------- -| Reducer -|--------------------------------------------------------- -*/ - -/** This reducer manages the async API operations */ -export const apiReducer = (state = apiStore, action) => { - - // Message system actions - if (action.type && action.type.startsWith(MESSAGE_PREFIX)) { - return produce(state, draft => { - const data = action.payload; - switch (action.type) { - case `${MESSAGE_PREFIX}_PUSH`: - draft.messages[data.id] = data; - break; - case `${MESSAGE_PREFIX}_POP`: - delete draft.messages[data.id]; - break; - default: - break; - } - return draft; - }) - } - - // Api actions - if (action.type && action.type.startsWith(API_PREFIX)) { - return produce(state, draft => { - // Get path and id from action.meta - let { path, id } = getPathFromMeta(action.meta); - let place = buildPathInStore(draft, path); - - // CASE LOADING: Async call is loading - if (action.type.endsWith(`_${ASYNC_SUFFIXES.loading}`)) { - place.fetching = true; - place.status = null; - return draft; - } - - const status = (action.payload.status || ( - action.payload.response && action.payload.response.status - )); - const statusIsValid = action.meta.validStatus.includes(status); - - // ====== CASE ERROR: Async call has failed - if (action.type.endsWith(`_${ASYNC_SUFFIXES.error}`)) { - // if (id) // TODO ???? - // place = buildPathInStore(draft, path.concat([id])); - - // place.data = {}; - place.fetching = false; - place.fetched = statusIsValid; - place.error = action.payload; - place.failed = statusIsValid; - place.status = status; - return draft; - } - - // Async call has succeeded - if (action.type.endsWith(`_${ASYNC_SUFFIXES.success}`)) { - - // ====== CASE HTTP ERROR: HTTP status is not acceptable - if (!statusIsValid) { - // place.data = {}; - place.fetching = false; - place.fetched = false; - place.error = 'NOT ACCEPTED'; - place.failed = true; - place.status = action.payload.status; - return draft; - } - - // ====== CASE SUCCESS: Update store - - // Set pagination, timestamp, status and others indicators - const { timestamp, status } = action.payload; - const { data, pagination } = processPagination(action.payload.data); - if (pagination) - place.pagination = pagination - - // The resource place where to store the data - place = makeResourceSuccessful(place, timestamp, status); - id = id || data.id; // TODO - - // Helper to build a store for the data if it has a key or an id - function buildSuccessfulDataStorePath(element, key) { - if (key) { - let placeForData = buildPathInStore(draft, path.concat([key])); - placeForData = makeResourceSuccessful(placeForData, timestamp, status); - placeForData.data = element; - } - } - - // Update the data and resources according to the action required - if (action.meta.action === 'updateAll') { - // Multiple elements - - // Modify data and Create places in resources for each element according to id - if (Array.isArray(data)) { // Array: Multiple elements with id - data.forEach(element => { - const e_id = element.id; // TODO - place.data[e_id] = element; - buildSuccessfulDataStorePath(element, e_id); - }); - } else if (id) { // Resource with id: Single id - place.data = data; - buildSuccessfulDataStorePath(data, id); - } else { // Resource without id: keys for resources - // TODO Check object, Useful ?? - place.data = data; - // for (const key in data) - // buildSuccessfulDataStorePath(data[key], key); - } - - } else { // Single element - - // Modify place.data and place.resources - if (['create', 'insert', 'update'].includes(action.meta.action)) { - if (id) { - place.data[id] = data; - buildSuccessfulDataStorePath(data, id); - } else { - place.data = data; - for (const key in data) - buildSuccessfulDataStorePath(data[key], key); - } - } else if ('delete' === action.meta.action) { - if (id) { - delete place.data[id]; - delete place.resources[id]; - } else { - place.data = {}; - place.resources = {}; - } - } - } - - return draft; - } - - // Return the draft state anyway - return draft; - }); - } - - return state; -} - -/* -// TODO -const NAMESPACES_CONFIG = { - config: { - type: 'CONFIG', - reducer: (state = {}, action) => ({ - ...state, - config: { - ...state.config, - ...action.payload, - }, - }), - }, -} - -combineReducers({ - resources: apiReducer, - ...NAMESPACES_CONFIG, -}) -*/ +const store = createStore(rootReducer, preloadedStore, enhancers); -// Finally create and export the redux store -export default createStore(apiReducer, middlewares); +export default store; From 259e66bc7d42659ca2c9a2a660fea934dc33a132 Mon Sep 17 00:00:00 2001 From: Alexandre Brasseur Date: Mon, 16 Nov 2020 00:27:48 +0100 Subject: [PATCH 02/17] refactor(redux): WIP Refactor API action and reducer --- src/redux/actions/api.js | 294 ++++++++++++++------------------ src/redux/actions/messages.js | 10 +- src/redux/constants.js | 39 +++++ src/redux/reducers/api.js | 199 +++++++++++---------- src/redux/reducers/messages.jsx | 6 +- 5 files changed, 272 insertions(+), 276 deletions(-) create mode 100644 src/redux/constants.js diff --git a/src/redux/actions/api.js b/src/redux/actions/api.js index bd71324..0b98868 100644 --- a/src/redux/actions/api.js +++ b/src/redux/actions/api.js @@ -9,229 +9,189 @@ */ import axios from 'axios'; import { API_URL } from '../../constants'; +import { isObject } from '../../utils'; -export const API_REDUX_PREFIX = 'API'; +import { API_REDUX_PREFIX, DATA_CHANGES, DATA_SCOPES } from '../constants'; -// Default axios for the api +// TODO API AXIOS Where to put ? +/** + * Default axios for the Woolly API + */ export const apiAxios = axios.create({ baseURL: API_URL, xsrfHeaderName: 'X-CSRFToken', xsrfCookieName: 'csrftoken', }); - - -/* -|--------------------------------------------------------- -| Action Methods -|--------------------------------------------------------- -*/ - -// Methods calling the API with alliases +/** + * Methods calling the API with alliases + */ export const API_METHODS = { all: { type: 'ALL', - method: 'get', - action: 'updateAll', + httpMethod: 'get', + dataChange: DATA_CHANGES.ASSIGN, + dataScope: DATA_SCOPES.MULTIPLE, + takesId: false, }, find: { type: 'FIND', - method: 'get', - action: 'update', + httpMethod: 'get', + dataChange: DATA_CHANGES.ASSIGN, + dataScope: DATA_SCOPES.ONE, + takesId: true, }, create: { type: 'CREATE', - method: 'post', - action: 'insert', + httpMethod: 'post', + dataChange: DATA_CHANGES.ASSIGN, + dataScope: DATA_SCOPES.ONE, + takesId: false, }, update: { type: 'UPDATE', - method: 'put', - action: 'update', - }, - remove: { - type: 'DELETE', - method: 'delete', - action: 'delete', + httpMethod: 'put', + dataChange: DATA_CHANGES.ASSIGN, + dataScope: DATA_SCOPES.ONE, + takesId: true, }, delete: { type: 'DELETE', - method: 'delete', - action: 'delete', - }, -}; -API_METHODS.get = API_METHODS.find; -API_METHODS.remove = API_METHODS.delete; - -// Methods modifying the action -export const ACTION_CONFIG_METHODS = { - - /** Define the path for the resource in the store */ - definePath: action => path => { - action.path = path.slice(); - action.pathLocked = true; - return new Proxy(action, apiActionHandler); - }, - - /** Define the path for the resource in the store */ - defineUri: action => uri => { - action.uri = uri; - return new Proxy(action, apiActionHandler); - }, - - setUriFromPath: action => path => { - action.path = path.slice(); - action.uri = path.join('/'); - action.idIsGiven = path.length % 2 === 0; - return new Proxy(action, apiActionHandler); - }, - - /** Add a valid status */ - addValidStatus: action => validStatus => { - action.validStatus.push(validStatus); - return new Proxy(action, apiActionHandler); - }, - - /** Define the valid status */ - defineValidStatus: action => validStatus => { - action.validStatus = validStatus; - return new Proxy(action, apiActionHandler); + httpMethod: 'delete', + dataChange: DATA_CHANGES.REMOVE, + dataScope: DATA_SCOPES.ONE, + takesId: true, }, - - /** Set Action options */ - setOptions: action => options => { - action.options = { ...action.options, ...options }; - return new Proxy(action, apiActionHandler); + get: { + type: 'GET', + httpMethod: 'get', + dataChange: DATA_CHANGES.ASSIGN, + dataScope: DATA_SCOPES.FULL, + takesId: false, }, +}; - - // TODO Custom methods - auth: action => (authId = null) => { - action.path = ['auth']; - action.uri = authId ? `/users/${authId}` : 'auth/me'; - return new Proxy(action, apiActionHandler); +/** + * Shortcuts and helpers to the API Action + */ +export const apiShortcuts = { + /** + * Get information about the specified or authenticated user + */ + authUser: action => { + function addAuthPath(userId = null, skipUri = false) { + if (action.path.length) + console.warning(`actions.api.authUser should be called first, path is ${action.path}`) + + action.path = ['auth']; + if (!skipUri) + action.uri = userId != null ? `/users/${userId}` : 'auth/me'; + return new Proxy(action, apiActionHandler); + } + return new Proxy(addAuthPath, { get: (func, key) => func()[key] }); }, }; -/* -|--------------------------------------------------------- -| Proxy Handler -|--------------------------------------------------------- -*/ - -// Gestionnaire d'actions (crée dynamiquement les routes api à appeler et où stocker les données) +/** + * Action handler that dynamically creates the URI path to the resource + */ export const apiActionHandler = { get(action, attr) { // Access instance if (attr === '_instance') return action; - // Real attribute of Action + // Access a real attribute of this Action if (action[attr] !== undefined) return action[attr]; - // Methods that configure the action - if (attr in ACTION_CONFIG_METHODS) - return ACTION_CONFIG_METHODS[attr](action); + if (attr in apiShortcuts) + return apiShortcuts[attr](action); - // Build the API query method - const apiMethod = (...args) => { - let id, queryParams, jsonData; + // HTTP Action (ex: `actions.api.users.all()`) + if (attr in API_METHODS) { + return function (...args) { + const methodData = API_METHODS[attr]; + // TODO { id, query, data } ?? - // GET query on a single element - if (['find', 'get'].includes(attr)) { - if (args.length > 0 || action.idIsGiven) { - if (action.idIsGiven) { - [queryParams, jsonData] = args; - } else { - [id, queryParams, jsonData] = args; + let id, queryParams, jsonData; + if (methodData.takesId) + [id, queryParams, jsonData] = args; + else + [queryParams, jsonData] = args; + + if (id != null && !action.idIsGiven) action.addId(id); - } - return action.generateAction('get', queryParams, jsonData); - } - // ID not specified, fallback to all - return action.generateAction('all'); - } - // GET on multiple elements - if (attr === 'all') { - [queryParams, jsonData] = args; - return action.generateAction('all', queryParams, jsonData); + return action.generateAction(methodData, queryParams, jsonData); } + } - // POST PUT DELETE an element - if (['create', 'update', 'remove', 'delete'].includes(attr)) { - if (action.idIsGiven || attr === 'create') { - [queryParams, jsonData] = args; - } else { - [id, queryParams, jsonData] = args; - action.addId(id); - } - return action.generateAction(attr, queryParams, jsonData); - } + // At this point, we dynamically build the URI + // Example: `actions.api.users` build the URI /users + action.addUri(attr); - // Not an HTTP Method (ex: actions.users(1)) - if (args.length === 1) - action.addId(args[0]); + // Next we return a Proxy on a function + // that will be used to specify a resource id + // Example: `actions.api.users(1)` + // If it is not called, we will access the Action with a Proxy anyway + function resourceSpecifier(id) { + if (id != null) + action.addId(id); return new Proxy(action, apiActionHandler); - }; - - // HTTP Action (ex: actions.users.get()) - if (attr in API_METHODS) - return apiMethod; - - // If not, callback the apiMethod and build the URI - // Example: `actions.users` build the URI /users - action.addUri(attr); + } - return new Proxy(apiMethod, { get: (func, key) => func()[key] }); + return new Proxy(resourceSpecifier, { get: (func, key) => func()[key] }); }, }; -/* -|--------------------------------------------------------- -| API Action generator -|--------------------------------------------------------- -*/ - +/** + * API Action generator + */ export class APIAction { - constructor(axios_instance = apiAxios) { - this.axios = axios_instance; + constructor(axiosInstance = apiAxios) { + this.axios = axiosInstance; this.uri = ''; this.idIsGiven = false; this.path = []; this.pathLocked = false; - this.actions = API_METHODS; - this.validStatus = [200, 201, 202, 203, 204]; this.options = { type: undefined, axios: {}, meta: {}, - action: {}, }; return new Proxy(this, apiActionHandler); } + configure(modify) { + modify(this); + return new Proxy(this, apiActionHandler); + } + addUri(step) { this.uri += `/${step}`; + this.idIsGiven = false; - if (!this.pathLocked) { + if (!this.pathLocked) this.path.push(step); - this.idIsGiven = false; - } } addId(id) { this.uri += `/${id}`; + this.idIsGiven = true; - if (!this.pathLocked) { + if (!this.pathLocked) this.path.push(id); - this.idIsGiven = true; - } } - generateQueries(queryParams, prefix) { + /** + * Transform an object into a queryParams string recursively if needed + * @param {Object} queryParams The object to stringify + * @param {Boolean} prefix Used when processing recursively + * @return {String} The queryParams string + */ + stringifyQueryParams(queryParams, prefix=undefined) { const queries = []; for (const key in queryParams) { @@ -239,46 +199,45 @@ export class APIAction { const value = queryParams[key]; if (value !== undefined) { - if (Object.is(value)) - queries.push(this.generateQueries(value, true)); + const _key = encodeURIComponent(key); + const prefixedKey = prefix ? `${prefix}[${_key}]` : _key; + if (isObject(value)) + queries.push(this.stringifyQueryParams(value, prefixedKey)); else - queries.push( - `${encodeURIComponent(prefix ? `[${key}]` : key)}=${encodeURIComponent(value)}` - ); + queries.push(`${prefixedKey}=${encodeURIComponent(value)}`); } } } return queries.join('&'); } - generateUri(uri, queryParams) { - const queries = this.generateQueries(queryParams); - return uri + (queries.length === 0 ? '' : `?${queries}`); + generateUri(uri, queryParams = {}) { + const queries = this.stringifyQueryParams(queryParams); + return uri + (queries.length > 0 ? `?${queries}`: ''); } - generateType(action) { - return [ API_REDUX_PREFIX, this.actions[action].type, ...this.path ].join('_').toUpperCase(); + generateType(methodType) { + return [ API_REDUX_PREFIX, methodType, ...this.path ].join('_').toUpperCase(); } - generateAction(action, queryParams = {}, jsonData = {}) { - const actionData = this.actions[action]; + generateAction(methodData, queryParams = {}, jsonData = {}) { return { - type: this.options.type || this.generateType(action), + type: this.options.type || this.generateType(methodData.type), meta: { - action: actionData.action, - validStatus: this.validStatus, path: this.path, + idIsGiven: this.idIsGiven, + dataChange: methodData.dataChange, + dataScope: methodData.dataScope, timestamp: Date.now(), ...this.options.meta, }, payload: this.axios.request({ url: this.generateUri(this.uri, queryParams), - method: actionData.method, + method: methodData.httpMethod, data: jsonData, withCredentials: true, ...this.options.axios, }), - ...this.options.action, }; } } @@ -286,11 +245,12 @@ export class APIAction { /** * Actions are created dynamically (each use returns a new APIAction instance) * Examples: - * - actions.users.all() - * - actions.users(1).orders.create(null, { status: 'ok' }) + * - actions.api.users.all() + * - actions.api.users(1).orders.create(null, { status: 'ok' }) */ -export const actions = new Proxy(axios_instance => new APIAction(axios_instance), { +const actions = new Proxy(axiosInstance => new APIAction(axiosInstance), { get(target, attr) { + // If the axiosInstance is not specified through the call, we use the default one return new APIAction()[attr]; }, }); diff --git a/src/redux/actions/messages.js b/src/redux/actions/messages.js index 84c99cf..3abbc34 100644 --- a/src/redux/actions/messages.js +++ b/src/redux/actions/messages.js @@ -1,15 +1,15 @@ -export const MESSAGE_REDUX_PREFIX = "MESSAGE"; +import { MESSAGE_REDUX_PREFIX } from "../constants"; -const messages = { +const messagesActions = { pushError: (error, title = null, params = {}) => { console.error(title || "Erreur inconnue", error); if (error.isAxiosError && error.response) { const { data, status } = error.response; title = title || data.error || `Erreur API inconnue (${status})`; const details = data.message; - return messages.pushMessage(title, details, "error", params); + return messagesActions.pushMessage(title, details, "error", params); } - return messages.pushMessage( + return messagesActions.pushMessage( title || "Erreur inconnue", String(error), "error", @@ -34,4 +34,4 @@ const messages = { }), }; -export default messages; +export default messagesActions; diff --git a/src/redux/constants.js b/src/redux/constants.js new file mode 100644 index 0000000..3b4f913 --- /dev/null +++ b/src/redux/constants.js @@ -0,0 +1,39 @@ +import axios from "axios"; +import { API_URL } from "../constants"; + +/** + * Type prefixes for each reducer + */ +export const API_REDUX_PREFIX = "API"; +export const MESSAGE_REDUX_PREFIX = "MESSAGE"; + +/** + * Default axios for the Woolly API + */ +export const apiAxios = axios.create({ + baseURL: API_URL, + xsrfHeaderName: "X-CSRFToken", + xsrfCookieName: "csrftoken", +}); + +/** + */ +export const ASYNC_SUFFIXES = { + loading: "LOADING", + success: "SUCCESS", + error: "ERROR", +}; + +/** + * Data changes and scopes enums + */ +export const DATA_CHANGES = { + ASSIGN: "ASSIGN", + REMOVE: "REMOVE", +}; + +export const DATA_SCOPES = { + ONE: "ONE", + MULTIPLE: "MULTIPLE", + FULL: "FULL", +}; diff --git a/src/redux/reducers/api.js b/src/redux/reducers/api.js index 73d036c..2b6bb6a 100644 --- a/src/redux/reducers/api.js +++ b/src/redux/reducers/api.js @@ -1,7 +1,6 @@ import produce from 'immer'; import { deepcopy, isEmpty } from '../../utils'; -import { API_REDUX_PREFIX } from '../actions/api'; -import { ASYNC_SUFFIXES } from '../store'; +import { API_REDUX_PREFIX, ASYNC_SUFFIXES, DATA_CHANGES, DATA_SCOPES } from '../constants'; /* |--------------------------------------------------------- @@ -30,9 +29,9 @@ function getPathFromMeta(meta) { let id = undefined; // Pop id from path if needed - if (!['updateAll', 'create', 'insert'].includes(meta.action)) { + if (meta.idIsGiven) id = path.pop(); - } + return { path, id }; } @@ -58,7 +57,7 @@ export const INITIAL_RESOURCE_STATE = { }; // La racine du store -export const apiStore = { +export const DEFAULT_API_STORE = { // Actual store resources: {}, @@ -121,6 +120,7 @@ export const apiStore = { * @param {[type]} replacement [description] * @return {Object} La map de resource par id */ + // TODO API REDUCER Useful ? getResourceDataById(path, resource, replacement = null) { const pathResources = this.get(path).resources; return Object.keys(pathResources).reduce((acc, id) => { @@ -130,6 +130,10 @@ export const apiStore = { }, {}); }, + getStatus(path, id = null) { + // TODO + }, + // TODO Custom methods getAuthUser(path, replacement = null, forceReplacement = true) { return this.get(['auth', 'data', 'user', ...pathToArray(path)], replacement, forceReplacement); @@ -182,6 +186,10 @@ function processPagination(payload) { } } +function getStatus(payload) { + return payload.status || (payload.response && payload.response.status); +} + /* |--------------------------------------------------------- @@ -189,126 +197,115 @@ function processPagination(payload) { |--------------------------------------------------------- */ +function changeOneElement(place, dataChange, id, element, timestamp, status) { + if (id == null) + throw Error(`Invalid id ${id}`) + + switch (dataChange) { + case DATA_CHANGES.ASSIGN: + place.data[id] = element; + + if (place.resources[id] === undefined) + place.resources[id] = deepcopy(INITIAL_RESOURCE_STATE); + + // Duplicate element in resources as it is only a pointer + makeResourceSuccessful(place.resources[id], timestamp, status); + place.resources[id].data = element; + break; + + case DATA_CHANGES.REMOVE: + delete place.data[id]; + if (place.resources[id] !== undefined) + delete place.resources[id] + break; + + default: + throw Error(`Unknown dataChange ${dataChange}`) + } +} + /** This reducer manages the async API operations */ -export default function apiReducer(state = apiStore, action) { +export default function apiReducer(state = DEFAULT_API_STORE, action) { + + // if (typeof action.type !== 'string' || !action.type.startsWith(API_REDUX_PREFIX)) + // return state; // Api actions if (action.type && action.type.startsWith(API_REDUX_PREFIX)) { return produce(state, draft => { // Get path and id from action.meta - let { path, id } = getPathFromMeta(action.meta); + const { path, id } = getPathFromMeta(action.meta); + // This is the resource place where to store the data let place = buildPathInStore(draft, path); - // CASE LOADING: Async call is loading - if (action.type.endsWith(`_${ASYNC_SUFFIXES.loading}`)) { - place.fetching = true; - place.status = null; - return draft; - } + const callStatus = action.type.split('_').pop(); + switch (callStatus) { - const status = (action.payload.status || ( - action.payload.response && action.payload.response.status - )); - const statusIsValid = action.meta.validStatus.includes(status); - - // ====== CASE ERROR: Async call has failed - if (action.type.endsWith(`_${ASYNC_SUFFIXES.error}`)) { - // if (id) // TODO ???? - // place = buildPathInStore(draft, path.concat([id])); - - // place.data = {}; - place.fetching = false; - place.fetched = statusIsValid; - place.error = action.payload; - place.failed = statusIsValid; - place.status = status; - return draft; - } - - // Async call has succeeded - if (action.type.endsWith(`_${ASYNC_SUFFIXES.success}`)) { + case ASYNC_SUFFIXES.loading: + // TODO + place.fetching = true; + place.status = null; + return draft; - // ====== CASE HTTP ERROR: HTTP status is not acceptable - if (!statusIsValid) { + case ASYNC_SUFFIXES.error: + // if (id) // TODO ???? + // place = buildPathInStore(draft, path.concat([id])); // place.data = {}; place.fetching = false; place.fetched = false; - place.error = 'NOT ACCEPTED'; + place.error = action.payload; place.failed = true; - place.status = action.payload.status; + place.status = getStatus(action.payload); return draft; - } - // ====== CASE SUCCESS: Update store + case ASYNC_SUFFIXES.success: + // Get data from payload + const { dataScope, dataChange, timestamp } = action.meta; + const { data, pagination } = processPagination(action.payload.data); + const status = getStatus(action.payload); - // Set pagination, timestamp, status and others indicators - const { timestamp, status } = action.payload; - const { data, pagination } = processPagination(action.payload.data); - if (pagination) place.pagination = pagination + place = makeResourceSuccessful(place, timestamp, status); + + switch (dataScope) { + case DATA_SCOPES.ONE: + const dataId = id || data.id; + changeOneElement(place, dataChange, dataId, data, timestamp, status); + break; + + case DATA_SCOPES.FULL: + switch (dataChange) { + case DATA_CHANGES.ASSIGN: + place.data = data; + break; + + case DATA_CHANGES.REMOVE: + // TODO Delete place ? + delete place.data; + delete place.resources; + break; + + default: + break; + } + break; - // The resource place where to store the data - place = makeResourceSuccessful(place, timestamp, status); - id = id || data.id; // TODO + case DATA_SCOPES.MULTIPLE: + Object.values(data).forEach((element, index) => { + const elementId = element.id || index; + changeOneElement(place, dataChange, elementId, element, timestamp, status); + }); + break; - // Helper to build a store for the data if it has a key or an id - function buildSuccessfulDataStorePath(element, key) { - if (key) { - let placeForData = buildPathInStore(draft, path.concat([key])); - placeForData = makeResourceSuccessful(placeForData, timestamp, status); - placeForData.data = element; - } - } - - // Update the data and resources according to the action required - if (action.meta.action === 'updateAll') { - // Multiple elements - - // Modify data and Create places in resources for each element according to id - if (Array.isArray(data)) { // Array: Multiple elements with id - data.forEach(element => { - const e_id = element.id; // TODO - place.data[e_id] = element; - buildSuccessfulDataStorePath(element, e_id); - }); - } else if (id) { // Resource with id: Single id - place.data = data; - buildSuccessfulDataStorePath(data, id); - } else { // Resource without id: keys for resources - // TODO Check object, Useful ?? - place.data = data; - // for (const key in data) - // buildSuccessfulDataStorePath(data[key], key); + default: + break; } - } else { // Single element - - // Modify place.data and place.resources - if (['create', 'insert', 'update'].includes(action.meta.action)) { - if (id) { - place.data[id] = data; - buildSuccessfulDataStorePath(data, id); - } else { - place.data = data; - for (const key in data) - buildSuccessfulDataStorePath(data[key], key); - } - } else if ('delete' === action.meta.action) { - if (id) { - delete place.data[id]; - delete place.resources[id]; - } else { - place.data = {}; - place.resources = {}; - } - } - } + return draft; - return draft; + default: + return draft; } - - // Return the draft state anyway - return draft; }); } diff --git a/src/redux/reducers/messages.jsx b/src/redux/reducers/messages.jsx index ab26062..dd57f85 100644 --- a/src/redux/reducers/messages.jsx +++ b/src/redux/reducers/messages.jsx @@ -1,4 +1,4 @@ -import { MESSAGE_REDUX_PREFIX } from '../actions/messages'; +import { MESSAGE_REDUX_PREFIX } from "../constants"; const DEFAULT_MESSAGE_STORE = []; @@ -9,11 +9,11 @@ export default function messagesReducer(state = DEFAULT_MESSAGE_STORE, action) { switch (action.type) { case `${MESSAGE_REDUX_PREFIX}_PUSH`: const data = action.payload; - return [ ...state, data ] + return [...state, data]; case `${MESSAGE_REDUX_PREFIX}_POP`: const removeId = action.payload.id; - return state.filter(data => data.id !== removeId); + return state.filter((data) => data.id !== removeId); default: return state; From ef667ae318745f486453f671a2433c8313c2faa0 Mon Sep 17 00:00:00 2001 From: Alexandre Brasseur Date: Mon, 16 Nov 2020 01:20:12 +0100 Subject: [PATCH 03/17] feat(redux): Add better pagination process in api reducer --- src/constants.js | 2 + src/redux/reducers/api.js | 120 +++++++++++++++++++++++++++----------- 2 files changed, 88 insertions(+), 34 deletions(-) diff --git a/src/constants.js b/src/constants.js index 3a47fd6..dcac636 100644 --- a/src/constants.js +++ b/src/constants.js @@ -11,6 +11,8 @@ export const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000'; export const REGEX_SLUG = /^[a-zA-Z]([-_]?[a-zA-Z0-9])*$/; +export const PAGE_SIZES = [10, 25, 50]; +export const DEFAULT_PAGE_SIZE = 10; // Orders diff --git a/src/redux/reducers/api.js b/src/redux/reducers/api.js index 2b6bb6a..3e325f6 100644 --- a/src/redux/reducers/api.js +++ b/src/redux/reducers/api.js @@ -1,5 +1,6 @@ import produce from 'immer'; import { deepcopy, isEmpty } from '../../utils'; +import { DEFAULT_PAGE_SIZE } from '../../constants'; import { API_REDUX_PREFIX, ASYNC_SUFFIXES, DATA_CHANGES, DATA_SCOPES } from '../constants'; /* @@ -134,6 +135,14 @@ export const DEFAULT_API_STORE = { // TODO }, + getPagination(path) { + // TODO ? + }, + + getError() { + // TODO + }, + // TODO Custom methods getAuthUser(path, replacement = null, forceReplacement = true) { return this.get(['auth', 'data', 'user', ...pathToArray(path)], replacement, forceReplacement); @@ -167,6 +176,9 @@ function buildPathInStore(store, path) { }, store); } +/** + * Make a resource place successful + */ function makeResourceSuccessful(resource, timestamp, status) { resource.fetching = false resource.fetched = true @@ -174,29 +186,11 @@ function makeResourceSuccessful(resource, timestamp, status) { resource.failed = false resource.lastUpdate = timestamp resource.status = status - return resource -} - -function processPagination(payload) { - if (payload.hasOwnProperty('results')) { - const { results, ...pagination } = payload; - return { data: results, pagination: pagination }; - } else { - return { data: payload, pagination: null }; - } -} - -function getStatus(payload) { - return payload.status || (payload.response && payload.response.status); } - -/* -|--------------------------------------------------------- -| Reducer -|--------------------------------------------------------- -*/ - +/** + * Apply a change to one element in place according to dataChange + */ function changeOneElement(place, dataChange, id, element, timestamp, status) { if (id == null) throw Error(`Invalid id ${id}`) @@ -224,18 +218,78 @@ function changeOneElement(place, dataChange, id, element, timestamp, status) { } } +/** + * Parse the pagination next and previous url to extract queryParams + */ +function parsePaginationUrl(url) { + if (url == null) + return {}; + const params = new URL(url).searchParams; + return { + page: parseInt(params.get('page')) || undefined, + pageSize: parseInt(params.get('page_size')) || undefined, + }; +} + +/** + * Process pagination in place and return results + */ +function processPagination(place, data) { + // No pagination + if (!data.hasOwnProperty('results')) { + place.pagination = null; + return data; + } + + const { results, ...pagination } = data; + const prevPagination = place.pagination || {}; + + const prevParams = parsePaginationUrl(pagination.previous); + const nextParams = parsePaginationUrl(pagination.next); + const pageSize = prevParams.pageSize || nextParams.pageSize || DEFAULT_PAGE_SIZE; + const currentPage = (prevParams.page + 1) || (nextParams.page - 1) || 1; + + // If pagination is different, clean data as some might be missing with a different page size + if (prevPagination.pageSize && prevPagination.pageSize !== pageSize) { + place.data = {}; + place.resources = {}; + place.pagination = {}; + } + + // Update pagination in place + const fetchedPages = (prevPagination.fetchedPages || new Set()).add(currentPage); + place.pagination = { + count: pagination.count, + pageSize, + fetchedPages, + lastFetched: currentPage, + nbPages: Math.ceil(pagination.count / pageSize), + } + + return results; +} + +/** + * Get the status of the response + */ +function getStatus(payload) { + return payload.status || (payload.response && payload.response.status); +} + +/* +|--------------------------------------------------------- +| Reducer +|--------------------------------------------------------- +*/ + /** This reducer manages the async API operations */ export default function apiReducer(state = DEFAULT_API_STORE, action) { - // if (typeof action.type !== 'string' || !action.type.startsWith(API_REDUX_PREFIX)) - // return state; - - // Api actions if (action.type && action.type.startsWith(API_REDUX_PREFIX)) { return produce(state, draft => { - // Get path and id from action.meta + + // place is the resource place where to store the data const { path, id } = getPathFromMeta(action.meta); - // This is the resource place where to store the data let place = buildPathInStore(draft, path); const callStatus = action.type.split('_').pop(); @@ -248,9 +302,8 @@ export default function apiReducer(state = DEFAULT_API_STORE, action) { return draft; case ASYNC_SUFFIXES.error: - // if (id) // TODO ???? + // if (id) // TODO test ???? // place = buildPathInStore(draft, path.concat([id])); - // place.data = {}; place.fetching = false; place.fetched = false; place.error = action.payload; @@ -259,13 +312,12 @@ export default function apiReducer(state = DEFAULT_API_STORE, action) { return draft; case ASYNC_SUFFIXES.success: - // Get data from payload + // Get all data need from action + const data = processPagination(place, action.payload.data); const { dataScope, dataChange, timestamp } = action.meta; - const { data, pagination } = processPagination(action.payload.data); - const status = getStatus(action.payload); - place.pagination = pagination - place = makeResourceSuccessful(place, timestamp, status); + const status = getStatus(action.payload); + makeResourceSuccessful(place, timestamp, status); switch (dataScope) { case DATA_SCOPES.ONE: From 9adc75048b91b0e31fc54fbfab379d146f5d9dac Mon Sep 17 00:00:00 2001 From: Alexandre Brasseur Date: Tue, 17 Nov 2020 02:19:51 +0100 Subject: [PATCH 04/17] feat(redux): Refactor useStoreAPIData for pagination --- src/App.jsx | 6 ++-- src/components/Header.jsx | 2 +- src/pages/public/Sales.jsx | 17 +++++++--- src/redux/actions/api.js | 6 ++-- src/redux/hooks.js | 65 ++++++++++++-------------------------- src/redux/reducers/api.js | 12 +++---- src/utils.js | 4 +++ 7 files changed, 50 insertions(+), 62 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index ea80d2f..83affdc 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,10 +2,10 @@ import React from 'react'; import { BrowserRouter, Route, Switch } from 'react-router-dom'; import { Provider } from 'react-redux'; import store from './redux/store'; -import actions from './redux/actions'; -import { Box } from '@material-ui/core'; +import apiActions from './redux/actions/api'; // Style +import { Box } from '@material-ui/core'; import CssBaseline from '@material-ui/core/CssBaseline'; import { ThemeProvider } from '@material-ui/core/styles'; import { MuiPickersUtilsProvider } from '@material-ui/pickers'; @@ -57,7 +57,7 @@ class App extends React.Component { componentDidMount() { // Get connected user - store.dispatch(actions.auth().all()); + store.dispatch(apiActions.authUser.get()); } render() { diff --git a/src/components/Header.jsx b/src/components/Header.jsx index dc53456..fa1fecb 100644 --- a/src/components/Header.jsx +++ b/src/components/Header.jsx @@ -35,7 +35,7 @@ export default function Header(props) { React.useEffect(() => { if (auth.user && !userAssos) - dispatch(apiActions.auth(auth.user.id).associations.all({ page_size: 'max' })); + dispatch(apiActions.authUser(auth.user.id).associations.all({ page_size: 'max' })); }); return ( diff --git a/src/pages/public/Sales.jsx b/src/pages/public/Sales.jsx index 29a3a8b..2c82eb2 100644 --- a/src/pages/public/Sales.jsx +++ b/src/pages/public/Sales.jsx @@ -1,24 +1,33 @@ import React from 'react' import { useStoreAPIData } from '../../redux/hooks'; -import { Container, Grid } from '@material-ui/core'; +import { Container, Grid, Box, Button } from '@material-ui/core'; import SaleCard, { SaleCardSkeleton } from '../../components/sales/SaleCard'; export default function Sales(props) { - const sales = useStoreAPIData('sales', { queryParams: { include: 'association', order_by: '-begin_at' }}); - + const { data: sales, fetched, pagination, fetchData } = useStoreAPIData('sales', { include: 'association', order_by: '-begin_at' }); return (

Liste des ventes

- {sales ? ( + {fetched ? ( Object.values(sales).map(sale => ) ) : ( [...Array(3).keys()].map(index => ) )} + {pagination && ( + + + + )}
); } diff --git a/src/redux/actions/api.js b/src/redux/actions/api.js index 0b98868..b60e242 100644 --- a/src/redux/actions/api.js +++ b/src/redux/actions/api.js @@ -13,10 +13,10 @@ import { isObject } from '../../utils'; import { API_REDUX_PREFIX, DATA_CHANGES, DATA_SCOPES } from '../constants'; -// TODO API AXIOS Where to put ? /** * Default axios for the Woolly API */ +// TODO API AXIOS Where to put ? export const apiAxios = axios.create({ baseURL: API_URL, xsrfHeaderName: 'X-CSRFToken', @@ -248,11 +248,11 @@ export class APIAction { * - actions.api.users.all() * - actions.api.users(1).orders.create(null, { status: 'ok' }) */ -const actions = new Proxy(axiosInstance => new APIAction(axiosInstance), { +const apiActions = new Proxy(axiosInstance => new APIAction(axiosInstance), { get(target, attr) { // If the axiosInstance is not specified through the call, we use the default one return new APIAction()[attr]; }, }); -export default actions; +export default apiActions; diff --git a/src/redux/hooks.js b/src/redux/hooks.js index d6c4c99..8cbafc7 100644 --- a/src/redux/hooks.js +++ b/src/redux/hooks.js @@ -2,52 +2,30 @@ import { useEffect } from 'react'; import useDeepCompareEffect from 'use-deep-compare-effect'; import { useSelector, useDispatch } from 'react-redux'; import { pathToArray } from './reducers/api'; -import apiActions, { APIAction, apiAxios } from './actions/api'; +import apiActions, { APIAction, apiAxios, API_METHODS } from './actions/api'; - -const USE_API_STORE_DEFAULT_OPTIONS = { - action: 'get', - queryParams: undefined, - jsonData: undefined, - - raiseError: true, - fetchingValue: undefined, -}; - -// HERE FIXME Process pagination -// Maybe use https://redux-saga.js.org/ -export function useStoreAPIData(path, options = {}) { - // Patch params - path = pathToArray(path); - options = { ...USE_API_STORE_DEFAULT_OPTIONS, ...options }; - if (options.action === 'get' && path.length % 2) - options.action = 'all'; +/** + * Hook to get data from the store using automatic API calls + */ +export function useStoreAPIData(_path, queryParams = undefined, options = {}) { + const path = pathToArray(_path); + const actionData = options.actionData || API_METHODS.all; // Get data from redux store const dispatch = useDispatch(); const resource = useSelector(store => store.api.get(path)); - // FIXME Fires fetching multiple times - useDeepCompareEffect(() => { - // Fetch if not fetched - if (!resource.fetched && !resource.fetching) { - const action = new APIAction(); - action.path = path; - action.uri = path.join('/'); - const actionData = action.generateAction(options.action, options.queryParams, options.jsonData); - // console.log(actionData) - dispatch(actionData); - } - - // Raise error if needed - if (resource.error && options.raiseError) - throw Error(resource.error); - }, [resource, path, options, dispatch]); + function fetchData(page = 1) { + const action = new APIAction(); + action.path = path; + action.uri = path.join('/'); + dispatch(action.generateAction(actionData, { ...queryParams, page })); + } - if (!resource.fetched || resource.fetching) - return options.fetchingValue; + // At first use or when data changes, automaticaly fire fetching + useDeepCompareEffect(fetchData, [actionData, path, queryParams, dispatch]); - return resource.data; + return { ...resource, fetchData }; } // TODO Check updateOrderStatus that is used @@ -67,13 +45,10 @@ export async function updateOrderStatus(dispatch, orderId, auto = { fetch: fals function fetchUserOrders(dispatch, userId) { dispatch( - apiActions - .defineUri(`users/${userId}/orders`) - .definePath(['auth', 'orders']) - .all({ - order_by: '-id', - include: 'sale,orderlines,orderlines__item,orderlines__orderlineitems', - }) + apiActions.authUser(userId).orders.all({ + order_by: '-id', + include: 'sale,orderlines,orderlines__item,orderlines__orderlineitems', + }) ); } diff --git a/src/redux/reducers/api.js b/src/redux/reducers/api.js index 3e325f6..ad89e79 100644 --- a/src/redux/reducers/api.js +++ b/src/redux/reducers/api.js @@ -227,7 +227,7 @@ function parsePaginationUrl(url) { const params = new URL(url).searchParams; return { page: parseInt(params.get('page')) || undefined, - pageSize: parseInt(params.get('page_size')) || undefined, + page_size: parseInt(params.get('page_size')) || undefined, }; } @@ -246,11 +246,11 @@ function processPagination(place, data) { const prevParams = parsePaginationUrl(pagination.previous); const nextParams = parsePaginationUrl(pagination.next); - const pageSize = prevParams.pageSize || nextParams.pageSize || DEFAULT_PAGE_SIZE; + const page_size = prevParams.page_size || nextParams.page_size || DEFAULT_PAGE_SIZE; const currentPage = (prevParams.page + 1) || (nextParams.page - 1) || 1; // If pagination is different, clean data as some might be missing with a different page size - if (prevPagination.pageSize && prevPagination.pageSize !== pageSize) { + if (prevPagination.page_size && prevPagination.page_size !== page_size) { place.data = {}; place.resources = {}; place.pagination = {}; @@ -260,10 +260,10 @@ function processPagination(place, data) { const fetchedPages = (prevPagination.fetchedPages || new Set()).add(currentPage); place.pagination = { count: pagination.count, - pageSize, + page_size, fetchedPages, lastFetched: currentPage, - nbPages: Math.ceil(pagination.count / pageSize), + nbPages: Math.ceil(pagination.count / page_size), } return results; @@ -305,7 +305,7 @@ export default function apiReducer(state = DEFAULT_API_STORE, action) { // if (id) // TODO test ???? // place = buildPathInStore(draft, path.concat([id])); place.fetching = false; - place.fetched = false; + // place.fetched = false; place.error = action.payload; place.failed = true; place.status = getStatus(action.payload); diff --git a/src/utils.js b/src/utils.js index e0c8608..6672b88 100644 --- a/src/utils.js +++ b/src/utils.js @@ -9,6 +9,10 @@ window.__localeId__ = 'fr' |--------------------------------------------------------- */ +export function isObject(object) { + return typeof object === 'object' && object !== null; +} + export function isList(object) { return object && object.length !== undefined; } From 9603def101b2b40907bd9def8107ae0acb280fe8 Mon Sep 17 00:00:00 2001 From: Alexandre Brasseur Date: Sun, 22 Nov 2020 16:19:44 +0100 Subject: [PATCH 05/17] feat(data): Create APIDataTable component --- src/components/common/APIDataTable.jsx | 108 +++++++++++++++++++++++++ src/constants.js | 2 +- src/redux/hooks.js | 21 +++-- src/redux/reducers/api.js | 52 ++++++------ src/redux/store.js | 5 +- 5 files changed, 149 insertions(+), 39 deletions(-) create mode 100644 src/components/common/APIDataTable.jsx diff --git a/src/components/common/APIDataTable.jsx b/src/components/common/APIDataTable.jsx new file mode 100644 index 0000000..65e5bc9 --- /dev/null +++ b/src/components/common/APIDataTable.jsx @@ -0,0 +1,108 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { useStoreAPIData } from "../../redux/hooks"; +import { processPagination, pathToArray } from "../../redux/reducers/api"; +import { DEFAULT_PAGE_SIZE, PAGE_SIZES } from "../../constants"; + +import MaterialTable from "material-table"; +import { + Add, Check, Clear, Delete, Edit, GetApp, FilterList, ArrowUpward, + FirstPage, LastPage, NavigateNext, NavigateBefore, Search +} from '@material-ui/icons'; + + +export const MaterialTableIcons = { + Add: Add, + Check: Check, + Clear: Clear, + Delete: Delete, + // DetailPanel: DetailPanel, + Edit: Edit, + Export: GetApp, + Filter: FilterList, + FirstPage: FirstPage, + LastPage: LastPage, + NextPage: NavigateNext, + PreviousPage: NavigateBefore, + ResetSearch: Clear, + Search: Search, + SortArrow: ArrowUpward, + // ThirdStateCheck: ThirdStateCheck, + // ViewColumn: ViewColumn, +}; + +/* +query = { + error: undefined, + filters: [], + orderBy: undefined, + orderDirection: "", + page: 0, + pageSize: 5, + search: "", + totalCount: 12, +} +*/ +export function paginateResource(resource, mapData = null) { + async function paginateData(query) { + const page = query.page + 1; + const shouldFetch = ( + !resource.fetched + || !resource.pagination + || query.pageSize !== resource.pagination.pageSize + || (resource.pagination.fetchedPages || {})[page] == null + ); + + if (shouldFetch) { + const queryParams = { page, page_size: query.pageSize }; + const resp = await resource.fetchData(queryParams, true).payload; + const { data, pagination } = processPagination(resp.data); + return { + data: mapData ? data.map(mapData) : data, + page: query.page, + totalCount: pagination ? pagination.count : data.length, + } + } else { + const pagination = resource.pagination; + const data = pagination + ? pagination.fetchedPages[page].map(id => resource.data[id]) + : resource.data; + return { + data: mapData ? data.map(mapData) : data, + page: query.page, + totalCount: pagination ? pagination.count : data.length, + }; + } + } + return paginateData; +} + +export default function APIDataTable({ path, queryParams = {}, mapData = null, options = {}, ...props }) { + const [pageSize, setPageSize] = React.useState(options.pageSize || DEFAULT_PAGE_SIZE); + const _queryParams = { ...(queryParams || {}), page_size: pageSize }; + const resource = useStoreAPIData(pathToArray(path), _queryParams); + + return ( + + ); +} + +APIDataTable.propTypes = { + path: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.array, + ]).isRequired, + queryParams: PropTypes.object, + mapData: PropTypes.func, + options: PropTypes.object, +}; diff --git a/src/constants.js b/src/constants.js index dcac636..847ac7b 100644 --- a/src/constants.js +++ b/src/constants.js @@ -11,7 +11,7 @@ export const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000'; export const REGEX_SLUG = /^[a-zA-Z]([-_]?[a-zA-Z0-9])*$/; -export const PAGE_SIZES = [10, 25, 50]; +export const PAGE_SIZES = [10, 25, 50, 100]; export const DEFAULT_PAGE_SIZE = 10; // Orders diff --git a/src/redux/hooks.js b/src/redux/hooks.js index 8cbafc7..a0a7097 100644 --- a/src/redux/hooks.js +++ b/src/redux/hooks.js @@ -9,21 +9,28 @@ import apiActions, { APIAction, apiAxios, API_METHODS } from './actions/api'; */ export function useStoreAPIData(_path, queryParams = undefined, options = {}) { const path = pathToArray(_path); - const actionData = options.actionData || API_METHODS.all; + const actionData = options.actionData || ( + options.singleElement ? API_METHODS.find : API_METHODS.all + ); // Get data from redux store const dispatch = useDispatch(); const resource = useSelector(store => store.api.get(path)); - function fetchData(page = 1) { - const action = new APIAction(); - action.path = path; - action.uri = path.join('/'); - dispatch(action.generateAction(actionData, { ...queryParams, page })); + function fetchData(additionalQuery = null, returnAction = false) { + const actionGen = new APIAction(); + actionGen.path = path; + actionGen.uri = path.join('/'); + actionGen.idIsGiven = Boolean(options.singleElement); + const query = additionalQuery ? { ...queryParams, ...additionalQuery } : queryParams; + const action = actionGen.generateAction(actionData, query) + dispatch(action); + if (returnAction) + return action; } // At first use or when data changes, automaticaly fire fetching - useDeepCompareEffect(fetchData, [actionData, path, queryParams, dispatch]); + useDeepCompareEffect(fetchData, [actionData, path, queryParams, options, dispatch]); return { ...resource, fetchData }; } diff --git a/src/redux/reducers/api.js b/src/redux/reducers/api.js index ad89e79..123b197 100644 --- a/src/redux/reducers/api.js +++ b/src/redux/reducers/api.js @@ -131,18 +131,6 @@ export const DEFAULT_API_STORE = { }, {}); }, - getStatus(path, id = null) { - // TODO - }, - - getPagination(path) { - // TODO ? - }, - - getError() { - // TODO - }, - // TODO Custom methods getAuthUser(path, replacement = null, forceReplacement = true) { return this.get(['auth', 'data', 'user', ...pathToArray(path)], replacement, forceReplacement); @@ -226,47 +214,53 @@ function parsePaginationUrl(url) { return {}; const params = new URL(url).searchParams; return { - page: parseInt(params.get('page')) || undefined, - page_size: parseInt(params.get('page_size')) || undefined, + page: parseInt(params.get('page')) || 1, + pageSize: parseInt(params.get('page_size')) || undefined, }; } /** * Process pagination in place and return results */ -function processPagination(place, data) { +export function processPagination(data, place = undefined) { // No pagination if (!data.hasOwnProperty('results')) { - place.pagination = null; - return data; + if (place) + place.pagination = null; + return { data, pagination: null }; } const { results, ...pagination } = data; - const prevPagination = place.pagination || {}; + const prevPagination = (place && place.pagination) || {}; const prevParams = parsePaginationUrl(pagination.previous); const nextParams = parsePaginationUrl(pagination.next); - const page_size = prevParams.page_size || nextParams.page_size || DEFAULT_PAGE_SIZE; + const pageSize = prevParams.pageSize || nextParams.pageSize || DEFAULT_PAGE_SIZE; const currentPage = (prevParams.page + 1) || (nextParams.page - 1) || 1; // If pagination is different, clean data as some might be missing with a different page size - if (prevPagination.page_size && prevPagination.page_size !== page_size) { + const resetPagination = place && prevPagination.pageSize && prevPagination.pageSize !== pageSize; + if (resetPagination) { place.data = {}; place.resources = {}; place.pagination = {}; } - // Update pagination in place - const fetchedPages = (prevPagination.fetchedPages || new Set()).add(currentPage); - place.pagination = { + const newPagination = { count: pagination.count, - page_size, - fetchedPages, + pageSize, + fetchedPages: { + ...(!resetPagination ? prevPagination.fetchedPages || {} : {}), + [currentPage]: Object.values(results).map(result => result.id), + }, lastFetched: currentPage, - nbPages: Math.ceil(pagination.count / page_size), - } + nbPages: Math.ceil(pagination.count / pageSize), + }; + + if (place) + place.pagination = newPagination; - return results; + return { data: results, pagination: newPagination }; } /** @@ -313,7 +307,7 @@ export default function apiReducer(state = DEFAULT_API_STORE, action) { case ASYNC_SUFFIXES.success: // Get all data need from action - const data = processPagination(place, action.payload.data); + const { data } = processPagination(action.payload.data, place); const { dataScope, dataChange, timestamp } = action.meta; const status = getStatus(action.payload); diff --git a/src/redux/store.js b/src/redux/store.js index 89da824..5290adb 100644 --- a/src/redux/store.js +++ b/src/redux/store.js @@ -2,7 +2,8 @@ import { createStore, applyMiddleware } from 'redux'; import rootReducer from './reducers'; // Middlewares -import thunk from 'redux-thunk'; +// TODO Useful ? +// import thunk from 'redux-thunk'; import { createPromise } from 'redux-promise-middleware'; @@ -23,7 +24,7 @@ export const ASYNC_SUFFIXES = { }; const middlewares = [ - thunk, + // thunk, createPromise({ promiseTypeSuffixes: Object.values(ASYNC_SUFFIXES) }), ]; From 510ce10ce5908d5ad477bb8008bc37c8c13d0d20 Mon Sep 17 00:00:00 2001 From: Alexandre Brasseur Date: Sun, 22 Nov 2020 19:19:35 +0100 Subject: [PATCH 06/17] refactor(redux): Patch API actions uses --- src/components/common/APIDataTable.jsx | 17 +- src/components/common/DetailsTable.jsx | 97 +++++----- src/components/sales/AssoSalesList.jsx | 40 ++-- src/components/sales/SalesList.jsx | 12 +- src/pages/admin/AdminNav.jsx | 2 +- src/pages/admin/AssoDashboard.jsx | 104 +++++----- src/pages/admin/Dashboard.jsx | 21 +- src/pages/admin/SaleDetail/OrdersList.jsx | 142 +++++++------- src/pages/admin/SaleDetail/QuantitiesSold.jsx | 182 +++++++++--------- src/pages/admin/SaleDetail/TicketsList.jsx | 133 +++++++------ src/pages/admin/SaleDetail/index.jsx | 62 +++--- src/pages/public/OrderDetail.jsx | 1 + src/pages/public/SaleDetail.jsx | 16 +- src/pages/public/Sales.jsx | 26 +-- src/themes.js | 114 ++++++----- 15 files changed, 506 insertions(+), 463 deletions(-) diff --git a/src/components/common/APIDataTable.jsx b/src/components/common/APIDataTable.jsx index 65e5bc9..276cfc9 100644 --- a/src/components/common/APIDataTable.jsx +++ b/src/components/common/APIDataTable.jsx @@ -7,7 +7,7 @@ import { DEFAULT_PAGE_SIZE, PAGE_SIZES } from "../../constants"; import MaterialTable from "material-table"; import { Add, Check, Clear, Delete, Edit, GetApp, FilterList, ArrowUpward, - FirstPage, LastPage, NavigateNext, NavigateBefore, Search + FirstPage, LastPage, NavigateNext, NavigateBefore, Search, ViewColumn } from '@material-ui/icons'; @@ -28,7 +28,7 @@ export const MaterialTableIcons = { Search: Search, SortArrow: ArrowUpward, // ThirdStateCheck: ThirdStateCheck, - // ViewColumn: ViewColumn, + ViewColumn: ViewColumn, }; /* @@ -43,7 +43,8 @@ query = { totalCount: 12, } */ -export function paginateResource(resource, mapData = null) { +// TODO Add sort, search, filtering functionalities +export function paginateResource(resource, transformData = null) { async function paginateData(query) { const page = query.page + 1; const shouldFetch = ( @@ -58,7 +59,7 @@ export function paginateResource(resource, mapData = null) { const resp = await resource.fetchData(queryParams, true).payload; const { data, pagination } = processPagination(resp.data); return { - data: mapData ? data.map(mapData) : data, + data: transformData ? transformData(data) : data, page: query.page, totalCount: pagination ? pagination.count : data.length, } @@ -68,7 +69,7 @@ export function paginateResource(resource, mapData = null) { ? pagination.fetchedPages[page].map(id => resource.data[id]) : resource.data; return { - data: mapData ? data.map(mapData) : data, + data: transformData ? transformData(data) : data, page: query.page, totalCount: pagination ? pagination.count : data.length, }; @@ -77,14 +78,14 @@ export function paginateResource(resource, mapData = null) { return paginateData; } -export default function APIDataTable({ path, queryParams = {}, mapData = null, options = {}, ...props }) { +export default function APIDataTable({ path, queryParams = {}, transformData = null, options = {}, ...props }) { const [pageSize, setPageSize] = React.useState(options.pageSize || DEFAULT_PAGE_SIZE); const _queryParams = { ...(queryParams || {}), page_size: pageSize }; const resource = useStoreAPIData(pathToArray(path), _queryParams); return ( +export default function DetailsTable({ data, fetched, labels, renderValue }) { + const classes = useStyles(); + if (!fetched) { + return ; + } - const keys = Object.keys(labels || data); - return ( - - - - {keys.map((key, index) => ( - - - {labels ? labels[key] : key} - - - {renderValue(data[key])} - - - ))} - -
-
- ); + const keys = Object.keys(labels || data); + return ( + + + + {keys.map((key, index) => ( + + + {labels ? labels[key] : key} + + {renderValue(data[key])} + + ))} + +
+
+ ); } DetailsTable.propTypes = { - data: PropTypes.object, - labels: PropTypes.object, - renderValue: PropTypes.func, + data: PropTypes.object, + fetched: PropTypes.bool, + labels: PropTypes.object, + renderValue: PropTypes.func, }; DetailsTable.defaultProps = { - renderValue: renderDetailValue, + data: undefined, + fetched: undefined, + labels: undefined, + renderValue: renderDetailValue, }; diff --git a/src/components/sales/AssoSalesList.jsx b/src/components/sales/AssoSalesList.jsx index f97835d..6ba68ba 100644 --- a/src/components/sales/AssoSalesList.jsx +++ b/src/components/sales/AssoSalesList.jsx @@ -1,29 +1,31 @@ -import React from 'react' -import { makeStyles } from '@material-ui/styles'; -import { Collapse, IconButton, List, ListItemSecondaryAction, ListItemText } from '@material-ui/core'; -import { NavListItem, NavIconButton } from '../common/Nav'; -import { ExpandMore, ExpandLess, Visibility } from '@material-ui/icons'; -import SalesList from './SalesList'; +import React from "react"; +import { makeStyles } from "@material-ui/styles"; +import { Collapse, IconButton, List, ListItemSecondaryAction, ListItemText } from "@material-ui/core"; +import { NavListItem, NavIconButton } from "../common/Nav"; +import { ExpandMore, ExpandLess, Visibility } from "@material-ui/icons"; +import SalesList from "./SalesList"; - -const useStyles = makeStyles(theme => ({ +const useStyles = makeStyles((theme) => ({ nested: { paddingLeft: theme.spacing(4), }, })); -export default function AssoSalesList({ assos, sales, ...props}) { - const [isOpen, setOpen] = React.useState({}); +export default function AssoSalesList({ assos, sales, ...props }) { const classes = useStyles(); + const [isOpen, setOpen] = React.useState({}); - const handleOpen = event => { + // TODO Deal with pagination + function handleOpen(event) { event.preventDefault(); - const id = event.currentTarget.getAttribute('value'); - // Ask for sales data if not available - if (!isOpen[id] && !sales[id]) - props.fetchSales(id) + const id = event.currentTarget.getAttribute("value"); + // Toggle collapse setOpen({ ...isOpen, [id]: !isOpen[id] }); + + // Ask for sales data if not available + if (!isOpen[id] && !sales[id]) + props.fetchSales(id); } return ( @@ -40,6 +42,7 @@ export default function AssoSalesList({ assos, sales, ...props}) { - + - ))} diff --git a/src/components/sales/SalesList.jsx b/src/components/sales/SalesList.jsx index c0388f7..3acefc2 100644 --- a/src/components/sales/SalesList.jsx +++ b/src/components/sales/SalesList.jsx @@ -7,9 +7,9 @@ import { SkeletonList } from '../../components/common/Skeletons'; import { isEmpty } from '../../utils'; -export default function SalesList({ sales, baseUrl, withEdit, assoId, ...props }) { +export default function SalesList({ sales, fetched, hasMore, baseUrl, withEdit, assoId, ...props }) { - if (!sales) + if (!fetched) return ; const createSaleLink = { @@ -18,7 +18,7 @@ export default function SalesList({ sales, baseUrl, withEdit, assoId, ...props } }; return ( - {isEmpty(sales) ? ( + {isEmpty(sales) ? ( @@ -50,12 +50,14 @@ export default function SalesList({ sales, baseUrl, withEdit, assoId, ...props } )} - + ); } SalesList.propTypes = { sales: PropTypes.object, + fetched: PropTypes.bool, + hasMore: PropTypes.bool, baseUrl: PropTypes.string, withEdit: PropTypes.bool, assoId: PropTypes.string, @@ -63,6 +65,8 @@ SalesList.propTypes = { SalesList.defaultProps = { sales: null, + fetched: false, + hasMore: false, baseUrl: '', withEdit: false, assoId: null, diff --git a/src/pages/admin/AdminNav.jsx b/src/pages/admin/AdminNav.jsx index 33c50b9..77950f2 100644 --- a/src/pages/admin/AdminNav.jsx +++ b/src/pages/admin/AdminNav.jsx @@ -50,7 +50,7 @@ function getMatch(location) { path: action.path, exact: true, strict: true, - }) + }); if (match) return { ...match, ...action }; } diff --git a/src/pages/admin/AssoDashboard.jsx b/src/pages/admin/AssoDashboard.jsx index 34ad262..f2c78e5 100644 --- a/src/pages/admin/AssoDashboard.jsx +++ b/src/pages/admin/AssoDashboard.jsx @@ -1,51 +1,53 @@ -import React from 'react' -import { useStoreAPIData } from '../../redux/hooks'; - -import { Container, Grid, Box } from '@material-ui/core' -import { Alert, AlertTitle } from '@material-ui/lab'; -import SalesList from '../../components/sales/SalesList'; -import DetailsTable from '../../components/common/DetailsTable'; - -export default function AssoDashboard(props) { - const assoId = props.match.params.asso_id; - const asso = useStoreAPIData(['associations', assoId]); - const sales = useStoreAPIData(['associations', assoId, 'sales']); - - return ( - - - -

Informations

- {asso && !asso.fun_id && ( - - - Configuration de l'association non complète -

- L'attribut fun_id de l'association n'est pas défini. - Veuillez contacter un administrateur pour terminer la configuration. -

-
-
- )} - -
- - -

Ventes

- -
-
-
- ); -} +import React from "react"; +import { useStoreAPIData } from "../../redux/hooks"; + +import { Container, Grid, Box } from "@material-ui/core"; +import { Alert, AlertTitle } from "@material-ui/lab"; +import SalesList from "../../components/sales/SalesList"; +import DetailsTable from "../../components/common/DetailsTable"; + +export default function AssoDashboard(props) { + const assoId = props.match.params.asso_id; + const asso = useStoreAPIData(["associations", assoId], null, { singleElement: true }); + const sales = useStoreAPIData(["associations", assoId, "sales"]); + + return ( + + + +

Informations

+ {asso.fetched && asso.data && !asso.data.fun_id && ( + + + Configuration de l'association non complète +
+ L'attribut fun_id de l'association n'est pas défini. + Veuillez contacter un administrateur pour terminer la configuration. +
+
+
+ )} + +
+ + +

Ventes

+ +
+
+
+ ); +} diff --git a/src/pages/admin/Dashboard.jsx b/src/pages/admin/Dashboard.jsx index 5634d0b..247a3e8 100644 --- a/src/pages/admin/Dashboard.jsx +++ b/src/pages/admin/Dashboard.jsx @@ -1,18 +1,17 @@ -import React from 'react' -import apiActions from '../../redux/actions/api'; -import { useDispatch, useSelector } from 'react-redux'; -import { Container, Grid } from '@material-ui/core'; - -import AssoSalesList from '../../components/sales/AssoSalesList'; +import React from "react"; +import apiActions from "../../redux/actions/api"; +import { useDispatch, useSelector } from "react-redux"; +import { Container, Grid } from "@material-ui/core"; +import AssoSalesList from "../../components/sales/AssoSalesList"; export default function Dashboard(props) { const dispatch = useDispatch(); - const assos = useSelector(store => store.api.getAuthRelatedData('associations', {})); - const sales = useSelector(store => store.api.getResourceDataById('associations', 'sales', null)); + const assos = useSelector(store => store.api.getAuthRelatedData("associations", {})); + const sales = useSelector(store => store.api.getResourceDataById("associations", "sales", null)); - function handleFetchSales(assoId) { - dispatch(apiActions.associations(assoId).sales.all({ include_inactive: true })); + function fetchSales(assoId, page = 1) { + dispatch(apiActions.associations(assoId).sales.all({ page, include_inactive: true })); } return ( @@ -28,7 +27,7 @@ export default function Dashboard(props) { diff --git a/src/pages/admin/SaleDetail/OrdersList.jsx b/src/pages/admin/SaleDetail/OrdersList.jsx index d5936fe..12ca0a4 100644 --- a/src/pages/admin/SaleDetail/OrdersList.jsx +++ b/src/pages/admin/SaleDetail/OrdersList.jsx @@ -1,78 +1,74 @@ -import React from 'react'; -import MaterialTable from 'material-table'; -import { SkeletonTable } from '../../../components/common/Skeletons'; -import OrderLinesList from '../../../components/orders/OrderLinesList'; -import { useStoreAPIData } from '../../../redux/hooks'; -import { ORDER_STATUS, MaterialTableIcons } from '../../../constants'; -import { formatDate } from '../../../utils'; -import { parseISO } from 'date-fns' +import React from "react"; +import { useDispatch } from "react-redux"; +import { updateOrderStatus } from "../../../redux/hooks"; +import { parseISO } from "date-fns"; +import { formatDate } from "../../../utils"; +import { ORDER_STATUS } from "../../../constants"; -const queryParams = { - include: 'owner,orderlines', -}; +import APIDataTable from "../../../components/common/APIDataTable"; +import OrderLinesList from "../../../components/orders/OrderLinesList"; +import { Link } from "../../../components/common/Nav"; export default function OrdersList({ saleId, items, ...props }) { - const orders = useStoreAPIData(['sales', saleId, 'orders'], { queryParams }); - - if (!orders) - return ; - - // Get items names lookup - const ordersList = Object.values(orders).map(order => ({ - id: order.id, - owner: `${order.owner.first_name} ${order.owner.last_name}`, - updated_at: parseISO(order.updated_at), - status: ORDER_STATUS[order.status] || {}, - orderlines: order.orderlines, - })) - - // OrderLinesList - - return ( - ( - - {row.status.label || 'Inconnu'} - - ), - }, - { - title: 'Mise à jour', - field: 'updated_at', - searchable: false, - render: row => ( - {formatDate(row.updated_at, 'datetime')} - ), - }, - { - title: 'Articles', - field: 'orderlines', - searchable: false, - render: row => ( - - ), - }, - ]} - icons={MaterialTableIcons} - options={{ - search: true, - }} - - /> - ); + const dispatch = useDispatch(); + return ( + orders.map((order) => ({ + id: order.id, + owner: `${order.owner.first_name} ${order.owner.last_name}`, + updated_at: parseISO(order.updated_at), + status: ORDER_STATUS[order.status] || {}, + orderlines: order.orderlines, + }))} + columns={[ + { + title: "ID", + field: "id", + render: (order) => ( + {order.id} + ), + }, + { + title: "Acheteur", + field: "owner", + }, + { + title: "Status", + field: "status", + render: (order) => ( + updateOrderStatus(dispatch, order.id, { fetch: true })} + style={{ color: order.status && order.status.color, cursor: "pointer" }} + > + {(order.status && order.status.label) || "Inconnu"} + + ), + }, + { + title: "Mise à jour", + field: "updated_at", + searchable: false, + render: (order) => {formatDate(order.updated_at, "datetime")}, + }, + { + title: "Articles", + field: "orderlines", + searchable: false, + render: (order) => ( + + ), + }, + ]} + /> + ); } diff --git a/src/pages/admin/SaleDetail/QuantitiesSold.jsx b/src/pages/admin/SaleDetail/QuantitiesSold.jsx index 45d4b15..6783a8e 100644 --- a/src/pages/admin/SaleDetail/QuantitiesSold.jsx +++ b/src/pages/admin/SaleDetail/QuantitiesSold.jsx @@ -1,105 +1,109 @@ -import React from 'react'; +import React from "react"; import { - Box, Grid, Collapse, Paper, IconButton, TableContainer, - Table, TableHead, TableBody, TableRow, TableCell, - } from '@material-ui/core' -import { Done, Pause, ExpandLess, ExpandMore } from '@material-ui/icons'; -import { Skeleton } from '@material-ui/lab'; -import { formatPrice } from '../../../utils'; - + Box, Grid, Collapse, Paper, IconButton, + TableContainer, Table, TableHead, TableBody, TableRow, TableCell, +} from "@material-ui/core"; +import { Done, Pause, ExpandLess, ExpandMore } from "@material-ui/icons"; +import { Skeleton } from "@material-ui/lab"; +import { formatPrice, isEmpty } from "../../../utils"; export function ItemsSold({ items, ...props }) { + if (!items) + return
; - if (!items) - return
; - - if (Object.values(items).length === 0) - return Pas d'article; + if (Object.values(items).length === 0) + return Pas d'article; - return ( - - - - - Actif - Item - Prix - Quantitées - - - - {Object.values(items).map(item => ( - - {item.is_active ? : } - {item.name} - {formatPrice(item.price)} - {item.quantity_sold || 0} / {item.quantity ? item.quantity : } - - ))} - -
-
- ); + return ( + + + + + Actif + Item + Prix + Quantitées + + + + {Object.values(items).map((item) => ( + + {item.is_active ? : } + {item.name} + {formatPrice(item.price)} + + {item.quantity_sold || 0} +  /  + {item.quantity ? item.quantity : } + + + ))} + +
+
+ ); } export function GroupSold({ itemgroup, items, ...props }) { - const [open, setOpen] = React.useState(true); + const [open, setOpen] = React.useState(true); - const totalSold = items ? items.reduce((sum, item) => sum + item.quantity_sold, 0) : '...'; - return ( - - - - -

{itemgroup ? itemgroup.name : '...'}

-
- - {totalSold || 0} - setOpen(!open)}> - {open ? : } - - -
- - - - - -
-
- ); + const totalSold = items + ? items.reduce((sum, item) => sum + item.quantity_sold, 0) + : "..."; + return ( + + + + + +

{itemgroup ? itemgroup.name : "..."}

+
+
+ + {totalSold || 0} + setOpen(!open)}> + {open ? : } + + +
+ + + + + +
+
+ ); } -export default function QuantitiesSold({ items, itemgroups, ...props }) { +export default function QuantitiesSold({ items, itemgroups, fetched, ...props }) { + if (!fetched) + return ; - if (!items || !itemgroups) - return ; + // TODO Better empty state + if (isEmpty(items)) + return Aucun article; - const itemsByGroup = Object.values(items).reduce((groupMap, { group, ...item }) => { - if (group in groupMap) - groupMap[group].push(item); - else - groupMap[group] = [ item ]; - return groupMap; - }, {}); + const itemsByGroup = Object.values(items).reduce((groupMap, { group, ...item }) => { + if (group in groupMap) groupMap[group].push(item); + else groupMap[group] = [item]; + return groupMap; + }, {}); - const orphans = itemsByGroup[null]; - return ( - - {Object.values(itemgroups).map(itemgroup => ( - - ))} - {orphans && orphans.length && ( - - )} - - ); + const orphans = itemsByGroup[null]; + return ( + + {Object.values(itemgroups).map((itemgroup) => ( + + ))} + {orphans && orphans.length && ( + + )} + + ); } diff --git a/src/pages/admin/SaleDetail/TicketsList.jsx b/src/pages/admin/SaleDetail/TicketsList.jsx index 5b7e704..ce8f99e 100644 --- a/src/pages/admin/SaleDetail/TicketsList.jsx +++ b/src/pages/admin/SaleDetail/TicketsList.jsx @@ -1,70 +1,75 @@ -import React from 'react'; -import MaterialTable from 'material-table'; -import { SkeletonTable } from '../../../components/common/Skeletons'; -import { useStoreAPIData } from '../../../redux/hooks'; -import { VALID_ORDER_STATUS, MaterialTableIcons } from '../../../constants'; -import { arrayToMap } from '../../../utils'; +import React from "react"; +import { useStoreAPIData } from "../../../redux/hooks"; +import APIDataTable from "../../../components/common/APIDataTable"; +import { VALID_ORDER_STATUS } from "../../../constants"; +import { arrayToMap } from "../../../utils"; +export default function TicketsList({ saleId, items, itemgroups, ...props }) { + // Get all fields for the moment, shouldn't be too much + const fields = useStoreAPIData("fields", { page_size: "max" }).data; -const queryParams = { - filter: `order__status__in=${VALID_ORDER_STATUS.join(',')}`, - include: 'orderlineitems__orderlinefields', -}; + // Get items and groups names lookups + const itemNames = items ? arrayToMap(Object.values(items), "id", "name") : {}; + const itemgroupNames = items && itemgroups ? Object.values(items).reduce((acc, item) => { + if (item.group) + acc[item.id] = (itemgroups[item.group] || {}).name || "Inconnu"; + return acc; + }, {}) : {}; -export default function TicketsList({ saleId, items, ...props }) { + const fieldsColumns = ( + items && fields + ? Object.values(items).reduce((map, item) => { + item.fields.forEach((field) => { + if (!map.hasOwnProperty(field)) + map[field] = { + field: field, + title: fields[field] ? fields[field].name : field, + }; + }); + return map; + }, {}) + : {} + ); - // Get all fields for the moment, shouldn't be too much - const fields = useStoreAPIData('fields'); - const orderlines = useStoreAPIData(['sales', saleId, 'orderlines'], { queryParams }); + // Patch data for table display + function reduceOrderlines(orderlines) { + return orderlines.reduce((arr, orderline) => { + arr.push( + ...orderline.orderlineitems.map((orderlineitem) => ({ + ...orderlineitem, + item: orderline.item, + ...orderlineitem.orderlinefields.reduce((fields, orderlinefield) => { + fields[orderlinefield.field] = orderlinefield.value; + return fields; + }, {}), + })) + ); + return arr; + }, []); + } - if (!orderlines) - return ; - - - // Get items names lookup - const itemNames = items ? arrayToMap(Object.values(items), 'id', 'name') : {}; - - // Get fields columns - const fieldsColumns = items ? Object.values(items).reduce((map, item) => { - item.fields.forEach(field => { - if (!map.hasOwnProperty(field)) - map[field] = { - field: field, - title: fields[field] ? fields[field].name : field, - }; - }) - return map; - }, {}) : {}; - - // Patch data for table - const orderlineitems = Object.values(orderlines).reduce((arr, orderline) => { - arr.push(...orderline.orderlineitems.map(orderlineitem => ({ - ...orderlineitem, - item: orderline.item, - ...orderlineitem.orderlinefields.reduce((fields, orderlinefield) => { - fields[orderlinefield.field] = orderlinefield.value; - return fields; - }, {}), - }))); - return arr; - }, []); - - return ( - - ); + return ( + + ); } diff --git a/src/pages/admin/SaleDetail/index.jsx b/src/pages/admin/SaleDetail/index.jsx index 6a93e6f..0741aed 100644 --- a/src/pages/admin/SaleDetail/index.jsx +++ b/src/pages/admin/SaleDetail/index.jsx @@ -1,31 +1,32 @@ -import React from 'react'; -import { useStoreAPIData } from '../../../redux/hooks'; -import { formatDate } from '../../../utils'; +import React from "react"; +import { useStoreAPIData } from "../../../redux/hooks"; +import { formatDate } from "../../../utils"; -import { Box, Container, Grid, Chip, Tabs, Tab } from '@material-ui/core'; -import { PlayArrow, Pause, Public, Lock } from '@material-ui/icons'; +import { Box, Container, Grid, Chip, Tabs, Tab } from "@material-ui/core"; +import { PlayArrow, Pause, Public, Lock } from "@material-ui/icons"; -import Stat from '../../../components/common/Stat'; -import { Link } from '../../../components/common/Nav'; -import { CopyButton } from '../../../components/common/Buttons'; +import Stat from "../../../components/common/Stat"; +import { Link } from "../../../components/common/Nav"; +import { CopyButton } from "../../../components/common/Buttons"; -import QuantitiesSold from './QuantitiesSold'; -import OrdersList from './OrdersList'; -import TicketsList from './TicketsList'; +import QuantitiesSold from "./QuantitiesSold"; +import OrdersList from "./OrdersList"; +import TicketsList from "./TicketsList"; export default function SaleDetail(props) { - const [tab, setTab] = React.useState('quantities'); + const [tab, setTab] = React.useState("tickets"); const saleId = props.match.params.sale_id; - const sale = useStoreAPIData(['sales', saleId], { queryParams: { include: 'association' } }); - const items = useStoreAPIData(['sales', saleId, 'items']); - const itemgroups = useStoreAPIData(['sales', saleId, 'itemgroups']); + const { data: sale, fetched } = useStoreAPIData(["sales", saleId], { include: "association" }, { singleElement: true }); + const items = useStoreAPIData(["sales", saleId, "items"], { page_size: 'max' }); + const itemgroups = useStoreAPIData(["sales", saleId, "itemgroups"], { page_size: 'max' }); - if (!sale) + // TODO Better loader + if (!fetched) return "Loading" - const saleLink = window.location.href.replace('/admin/', '/'); + const saleLink = window.location.href.replace("/admin/", "/"); const chipMargin = { marginBottom: 4, marginRight: 4 }; return ( @@ -48,8 +49,8 @@ export default function SaleDetail(props) {

Dates

    -
  • Ouverture: {sale.begin_at ? formatDate(sale.begin_at, 'datetime') : "Inconnue"}
  • -
  • Fermeture: {sale.end_at ? formatDate(sale.end_at, 'datetime') : "Inconnue"}
  • +
  • Ouverture: {sale.begin_at ? formatDate(sale.begin_at, "datetime") : "Inconnue"}
  • +
  • Fermeture: {sale.end_at ? formatDate(sale.end_at, "datetime") : "Inconnue"}
@@ -78,28 +79,31 @@ export default function SaleDetail(props) { - {(tab === 'quantities' && ( + {(tab === "quantities" && ( - )) || (tab === 'orders' && ( + )) || (tab === "orders" && ( - )) || (tab === 'tickets' && ( + )) || (tab === "tickets" && ( - )) || (tab === 'chart' && ( + )) || (tab === "chart" && (

À venir...

))}
diff --git a/src/pages/public/OrderDetail.jsx b/src/pages/public/OrderDetail.jsx index cae6dfc..26d57b4 100644 --- a/src/pages/public/OrderDetail.jsx +++ b/src/pages/public/OrderDetail.jsx @@ -131,6 +131,7 @@ class OrderDetail extends React.Component { render() { const { classes, order } = this.props; + // TODO Better loading if (!order) return "Loading" const { orderlineitems, saving, changing, updatingStatus } = this.state; diff --git a/src/pages/public/SaleDetail.jsx b/src/pages/public/SaleDetail.jsx index b933ef6..cc9358c 100644 --- a/src/pages/public/SaleDetail.jsx +++ b/src/pages/public/SaleDetail.jsx @@ -1,8 +1,10 @@ import React from 'react' import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import apiActions, { apiAxios } from '../../redux/actions/api'; +import { DATA_SCOPES } from '../../redux/constants'; +import apiActions from '../../redux/actions/api'; import messagesActions from '../../redux/actions/messages'; +import { apiAxios } from '../../utils/api'; import { isPast } from 'date-fns'; import { formatDate } from '../../utils'; @@ -72,14 +74,16 @@ class SaleDetail extends React.Component{ const saleId = this.props.saleId; this.props.dispatch( apiActions - .sales(saleId).orders - .definePath(['sales', saleId, 'userOrder' ]) - .setOptions({ meta: { action: 'updateAll'} }) - .create({ include: 'orderlines' }) + .configure(action => { + action.path = ['sales', saleId, 'userOrder' ]; + action.pathLocked = true; + action.options.meta = { dataScope: DATA_SCOPES.FULL }; + }) + .sales(saleId).orders.create({}, { include: 'orderlines' }) ); } - /** Sabe order on the server */ + /** Save order on the server */ saveOrder = (event, notif = true, update = false) => { if (!this.props.order) { console.warn("No order") diff --git a/src/pages/public/Sales.jsx b/src/pages/public/Sales.jsx index 2c82eb2..cd2e60c 100644 --- a/src/pages/public/Sales.jsx +++ b/src/pages/public/Sales.jsx @@ -1,28 +1,30 @@ -import React from 'react' -import { useStoreAPIData } from '../../redux/hooks'; - -import { Container, Grid, Box, Button } from '@material-ui/core'; -import SaleCard, { SaleCardSkeleton } from '../../components/sales/SaleCard'; +import React from "react"; +import { useStoreAPIData } from "../../redux/hooks"; +import { Container, Grid, Box, Button } from "@material-ui/core"; +import SaleCard, { SaleCardSkeleton } from "../../components/sales/SaleCard"; export default function Sales(props) { - const { data: sales, fetched, pagination, fetchData } = useStoreAPIData('sales', { include: 'association', order_by: '-begin_at' }); + const { pagination, ...sales } = useStoreAPIData("sales", { + include: "association", + order_by: "-begin_at", + }); return (

Liste des ventes

- {fetched ? ( - Object.values(sales).map(sale => ) - ) : ( - [...Array(3).keys()].map(index => ) - )} + {sales.fetched + ? Object.values(sales.data).map((sale) => ( + + )) + : [...Array(3).keys()].map((index) => )} {pagination && ( diff --git a/src/themes.js b/src/themes.js index 03b542b..a54f468 100644 --- a/src/themes.js +++ b/src/themes.js @@ -1,57 +1,69 @@ -import { createMuiTheme, responsiveFontSizes } from '@material-ui/core/styles'; +import { createMuiTheme, responsiveFontSizes } from "@material-ui/core/styles"; function createResponsiveTheme(...args) { - return responsiveFontSizes(createMuiTheme(...args)); + return responsiveFontSizes(createMuiTheme(...args)); } export const WoollyTheme = createResponsiveTheme({ - themeName: 'Woolly', - typography: { - fontFamily: [ - '-apple-system', - 'BlinkMacSystemFont', - '"Segoe UI"', - 'Roboto', - '"Helvetica Neue"', - 'Arial', - 'sans-serif', - ].join(','), - }, - palette: { - primary: { - main: '#025862', - }, - secondary: { - main: '#2196f3', - light: '#00b7ff', - dark: '#1976d2', - contrastText: '#fff', - }, - success: { - main: "#008805", - }, - warning: { - main: '#FA8C05', - light: '#F2B705', - dark: '#EE6B4D', - contrastText: '#fff', - }, - error: { - main: "#e00000", - }, - neutral: { - dark: '#293241', - main: '#3D5B81', - light: '#98C0D9', - }, - }, - overrides: { - MuiFormControl: { - root: { - marginBottom: 10, - minWidth: 100, - maxWidth: 300, - }, - }, - }, + themeName: "Woolly", + typography: { + fontFamily: [ + "-apple-system", + "BlinkMacSystemFont", + '"Segoe UI"', + "Roboto", + '"Helvetica Neue"', + "Arial", + "sans-serif", + ].join(","), + }, + palette: { + primary: { + main: "#025862", + }, + secondary: { + main: "#2196f3", + light: "#00b7ff", + dark: "#1976d2", + contrastText: "#fff", + }, + success: { + main: "#008805", + }, + warning: { + main: "#FA8C05", + light: "#F2B705", + dark: "#EE6B4D", + contrastText: "#fff", + }, + error: { + main: "#e00000", + }, + neutral: { + dark: "#293241", + main: "#3D5B81", + light: "#98C0D9", + }, + }, + overrides: { + MuiFormControl: { + root: { + marginBottom: 10, + minWidth: 100, + maxWidth: 300, + }, + }, + MuiListItem: { + dense: { + paddingTop: 0, + paddingBottom: 0, + }, + }, + MuiListItemText: { + dense: { + marginTop: 0, + marginBottom: 0, + }, + }, + }, }); From ddd0cb70fb05f3f3879150ccdd285cb94f911ccc Mon Sep 17 00:00:00 2001 From: Alexandre Brasseur Date: Sun, 22 Nov 2020 22:55:05 +0100 Subject: [PATCH 07/17] feat(redux): Fetch more sales on Dashboard if needed --- src/components/common/ProtectedRoute.jsx | 4 ++-- src/components/sales/AssoSalesList.jsx | 18 ++++++++++++++---- src/components/sales/SalesList.jsx | 20 +++++++++++++++----- src/pages/admin/AssoDashboard.jsx | 3 +++ src/pages/admin/Dashboard.jsx | 15 ++++++++++++--- src/pages/admin/SaleDetail/index.jsx | 2 +- src/pages/admin/SaleEditor/index.jsx | 19 +++++++++++-------- 7 files changed, 58 insertions(+), 23 deletions(-) diff --git a/src/components/common/ProtectedRoute.jsx b/src/components/common/ProtectedRoute.jsx index 186d70f..1336f36 100644 --- a/src/components/common/ProtectedRoute.jsx +++ b/src/components/common/ProtectedRoute.jsx @@ -10,14 +10,14 @@ const authFunctions = { return auth.authenticated; }, admin(auth, userAssos, props) { - return auth.authenticated && auth.is_admin; + return auth.authenticated && auth.user?.is_admin; }, manager(auth, userAssos, props) { return hasManagerRights(auth, userAssos); }, asso_manager(auth, userAssos, props) { const asso_id = this.props.computedMatch.params.asso_id; - return hasManagerRights(auth, userAssos, props) && (asso_id in userAssos || auth.is_admin); + return hasManagerRights(auth, userAssos, props) && (asso_id in userAssos || auth.user?.is_admin); }, }; diff --git a/src/components/sales/AssoSalesList.jsx b/src/components/sales/AssoSalesList.jsx index 6ba68ba..9f7157d 100644 --- a/src/components/sales/AssoSalesList.jsx +++ b/src/components/sales/AssoSalesList.jsx @@ -15,7 +15,6 @@ export default function AssoSalesList({ assos, sales, ...props }) { const classes = useStyles(); const [isOpen, setOpen] = React.useState({}); - // TODO Deal with pagination function handleOpen(event) { event.preventDefault(); const id = event.currentTarget.getAttribute("value"); @@ -24,10 +23,20 @@ export default function AssoSalesList({ assos, sales, ...props }) { setOpen({ ...isOpen, [id]: !isOpen[id] }); // Ask for sales data if not available - if (!isOpen[id] && !sales[id]) + if (!isOpen[id] && !sales[id]?.fetched) props.fetchSales(id); } + function getFetchMoreHandler(assoId) { + if (!sales[assoId]) + return null; + + const { lastFetched, nbPages } = sales[assoId].pagination; + if (lastFetched < nbPages) + return () => props.fetchSales(assoId, lastFetched + 1); + return null; + } + return ( {Object.values(assos).map(({ id, ...asso }) => ( @@ -41,8 +50,9 @@ export default function AssoSalesList({ assos, sales, ...props }) { ; @@ -40,6 +40,16 @@ export default function SalesList({ sales, fetched, hasMore, baseUrl, withEdit, )) )} + {fetchMore && ( + + + + + + + + + )} {withEdit && ( @@ -57,7 +67,7 @@ export default function SalesList({ sales, fetched, hasMore, baseUrl, withEdit, SalesList.propTypes = { sales: PropTypes.object, fetched: PropTypes.bool, - hasMore: PropTypes.bool, + fetchMore: PropTypes.func, baseUrl: PropTypes.string, withEdit: PropTypes.bool, assoId: PropTypes.string, @@ -66,7 +76,7 @@ SalesList.propTypes = { SalesList.defaultProps = { sales: null, fetched: false, - hasMore: false, + fetchMore: undefined, baseUrl: '', withEdit: false, assoId: null, diff --git a/src/pages/admin/AssoDashboard.jsx b/src/pages/admin/AssoDashboard.jsx index f2c78e5..6649add 100644 --- a/src/pages/admin/AssoDashboard.jsx +++ b/src/pages/admin/AssoDashboard.jsx @@ -11,6 +11,8 @@ export default function AssoDashboard(props) { const asso = useStoreAPIData(["associations", assoId], null, { singleElement: true }); const sales = useStoreAPIData(["associations", assoId, "sales"]); + const { lastFetched, nbPages } = sales.pagination; + const fetchMore = (lastFetched < nbPages) ? () => sales.fetchData(lastFetched + 1) : null; return ( @@ -42,6 +44,7 @@ export default function AssoDashboard(props) { store.api.getAuthRelatedData("associations", {})); - const sales = useSelector(store => store.api.getResourceDataById("associations", "sales", null)); + const assos = useSelector(store => store.api.getAuthRelatedData("associations")); + const sales = useSelector(store => { + if (!assos || !store.api.resources?.associations?.resources) + return {}; + + const assoResources = store.api.resources.associations.resources; + return Object.keys(assoResources).reduce((salesMap, assoId) => { + salesMap[assoId] = assoResources[assoId]?.resources?.sales; + return salesMap + }, {}); + }); function fetchSales(assoId, page = 1) { - dispatch(apiActions.associations(assoId).sales.all({ page, include_inactive: true })); + dispatch(apiActions.associations(assoId).sales.all({ page, page_size: 1, include_inactive: true })); } return ( diff --git a/src/pages/admin/SaleDetail/index.jsx b/src/pages/admin/SaleDetail/index.jsx index 0741aed..a981fa2 100644 --- a/src/pages/admin/SaleDetail/index.jsx +++ b/src/pages/admin/SaleDetail/index.jsx @@ -15,7 +15,7 @@ import TicketsList from "./TicketsList"; export default function SaleDetail(props) { - const [tab, setTab] = React.useState("tickets"); + const [tab, setTab] = React.useState("quantities"); const saleId = props.match.params.sale_id; const { data: sale, fetched } = useStoreAPIData(["sales", saleId], { include: "association" }, { singleElement: true }); diff --git a/src/pages/admin/SaleEditor/index.jsx b/src/pages/admin/SaleEditor/index.jsx index feeba8a..3e130e7 100644 --- a/src/pages/admin/SaleEditor/index.jsx +++ b/src/pages/admin/SaleEditor/index.jsx @@ -17,6 +17,10 @@ import Loader from '../../../components/common/Loader'; import DetailsEditor from './DetailsEditor'; import ItemsManager from './ItemsManager/'; +const QUERY_PARAMS = { + sale: { with: "is_public,max_item_quantity" }, + items: { include: "itemfields" }, +} const BLANK_RESOURCES = { items: BLANK_ITEM, @@ -94,9 +98,9 @@ class SaleEditor extends React.Component { loading_itemgroups: true, }); const saleId = this.props.saleId; - this.props.dispatch(apiActions.sales.find(saleId)); - this.props.dispatch(apiActions.sales(saleId).items.all({ include: 'itemfields' })); - this.props.dispatch(apiActions.sales(saleId).itemgroups.all()); + this.props.dispatch(apiActions.sales.find(saleId, QUERY_PARAMS.sale)); + this.props.dispatch(apiActions.sales(saleId).items.all(QUERY_PARAMS.items)); + this.props.dispatch(apiActions.sales(saleId).itemgroups.all(QUERY_PARAMS.itemgroups)); } getStateFor(resource, prevState = {}) { @@ -264,14 +268,14 @@ class SaleEditor extends React.Component { try { if (this.isCreator()) { // Create sale - const action = apiActions.sales.create(null, details); + const action = apiActions.sales.create(QUERY_PARAMS.sale, details); const response = await action.payload; // Dispatch creation and go to edit mode this.props.dispatch(action); this.props.history.push(`/admin/sales/${response.data.id}/edit`); } else { // Update sale details - const action = apiActions.sales.update(this.props.saleId, null, details); + const action = apiActions.sales.update(this.props.saleId, QUERY_PARAMS.sale, details); await action.payload; this.props.dispatch(action); } @@ -328,7 +332,7 @@ class SaleEditor extends React.Component { data.sale = saleId; // Create resource - const action = apiActions.sales(saleId)[resource].create(null, data); + const action = apiActions.sales(saleId)[resource].create(QUERY_PARAMS[resource], data); await action.payload; // Creation succeeded, remove fake id and dispatch created @@ -347,8 +351,7 @@ class SaleEditor extends React.Component { await this._saveItemFields(data); // Update resource and wait for feedback to dispatch - const queryParams = resource === 'items' ? { include: 'itemfields' } : null; - const action = apiActions[resource].update(id, queryParams, data) + const action = apiActions[resource].update(id, QUERY_PARAMS[resource], data) await action.payload; this.props.dispatch(action); From 7b4eb56bdfd50621d5c43391d636f22fc70c706e Mon Sep 17 00:00:00 2001 From: Alexandre Brasseur Date: Wed, 25 Nov 2020 21:04:43 +0100 Subject: [PATCH 08/17] fix(redux): Fix getting previous order on sale page --- src/pages/public/SaleDetail.jsx | 66 ++++++++++++++++++++------------- src/redux/actions/api.js | 6 +-- src/redux/reducers/api.js | 61 ++++++++++-------------------- 3 files changed, 62 insertions(+), 71 deletions(-) diff --git a/src/pages/public/SaleDetail.jsx b/src/pages/public/SaleDetail.jsx index cc9358c..db8a688 100644 --- a/src/pages/public/SaleDetail.jsx +++ b/src/pages/public/SaleDetail.jsx @@ -26,7 +26,7 @@ const connector = connect((store, props) => { return { saleId, authenticated: Boolean(store.api.getData('auth', {}).authenticated), - sale: store.api.findData('sales', saleId, 'id'), + sale: store.api.findData('sales', saleId), order: store.api.getData(['sales', saleId, 'userOrder'], null, true), items: store.api.getData(['sales', saleId, 'items']), }; @@ -42,11 +42,11 @@ class SaleDetail extends React.Component{ componentDidMount() { const saleId = this.props.saleId; - if (this.props.authenticated && !this.props.order) + if (this.props.authenticated) this.fetchOrder(); - if (!this.props.sale) - this.props.dispatch(apiActions.sales.find(saleId, { include: 'association' })); + if (!this.props.sale?.association?.id) + this.props.dispatch(apiActions.sales.find(saleId, { include: "association" })); if (!this.props.items) this.props.dispatch(apiActions.sales(saleId).items.all()); @@ -56,7 +56,7 @@ class SaleDetail extends React.Component{ const order = this.props.order; // Update quantities from current order - if (prevProps.order !== order && order && order.orderlines.length) { + if (prevProps.order !== order && order?.orderlines?.length) { this.setState({ quantities: order.orderlines.reduce((acc, orderline) => { acc[orderline.item] = orderline.quantity; @@ -70,28 +70,39 @@ class SaleDetail extends React.Component{ // Order handlers // ----------------------------------------------------- + getOrderAction = () => ( + apiActions + .sales(this.props.saleId).orders() + .configure(action => { + action.path = ['sales', this.props.saleId, 'userOrder' ]; + action.idIsGiven = false; + action.options.meta = { + dataScope: DATA_SCOPES.FULL, + }; + }) + ) + fetchOrder = () => { - const saleId = this.props.saleId; - this.props.dispatch( - apiActions - .configure(action => { - action.path = ['sales', saleId, 'userOrder' ]; - action.pathLocked = true; - action.options.meta = { dataScope: DATA_SCOPES.FULL }; - }) - .sales(saleId).orders.create({}, { include: 'orderlines' }) - ); + // Try to find a previous ongoing order + this.getOrderAction().get({ filter: "status=0" }).payload.then(resp => { + if (resp.data.results.length) { + const orderId = resp.data.results[0].id + this.props.dispatch(this.getOrderAction().find(orderId, { include: 'orderlines' })); + } + }); } /** Save order on the server */ - saveOrder = (event, notif = true, update = false) => { - if (!this.props.order) { - console.warn("No order") - return; + saveOrder = async (event, notif = true, update = false) => { + let order = this.props.order?.id; + // Create order if not fetched + if (!order) { + const action = this.getOrderAction().create({}); + order = (await action.payload).data.id; + this.props.dispatch(action); } // Save all orderlines - const order = this.props.order.id; const options = { withCredentials: true }; const promises = Promise.all( Object.entries(this.state.quantities).reduce((calls, [item, quantity]) => { @@ -101,7 +112,7 @@ class SaleDetail extends React.Component{ }, []) ); - if (notif) // TODO + if (notif) promises.then(resp => console.log('Saved')); if (update) promises.then(this.fetchOrder); @@ -110,7 +121,10 @@ class SaleDetail extends React.Component{ /** Redirect to payment */ payOrder = async event => { - const orderId = this.props.order.id; + const orderId = this.props.order?.id; + if (orderId == null) + return this.fetchOrder(); + const returnUrl = window.location.href.replace(this.props.location.pathname, `/orders/${orderId}`); try { const resp = await apiAxios.get(`/orders/${orderId}/pay?return_url=${returnUrl}`, { withCredentials: true }); @@ -130,7 +144,7 @@ class SaleDetail extends React.Component{ handleBuy = event => { if (this.canBuy()) { this.setState({ buying: true }, async () => { - await this.saveOrder(); + await this.saveOrder(null, false, false); await this.payOrder(); }); } @@ -196,7 +210,7 @@ class SaleDetail extends React.Component{

{sale.name}

-

Par {sale.association.shortname}

+

Par {sale.association?.shortname || "..."}

@@ -225,7 +239,7 @@ class SaleDetail extends React.Component{ La vente n'a pas encore commencée - Revenez d'ici {formatDate(sale.begin_at, 'fromNowStrict')} pour pouvoir commander. + Revenez d'ici {sale.begin_at && formatDate(sale.begin_at, 'fromNowStrict')} pour pouvoir commander. )} @@ -297,7 +311,7 @@ class SaleDetail extends React.Component{ + /> {status.actions.map(key => ( - @@ -136,7 +76,7 @@ export default function UserOrdersList({ orders, fetchOrders, ...props }) { {Object.values(orders).map(order => ( - store.api.getAuthUser()); - const { orders, fetchOrders } = useUserOrders(); + const orders = useSelector(store => store.api.getAuthRelatedData("lastOrders", undefined)); + + React.useEffect(() => { + if (user.id) { + dispatch( + apiActions.authUser(user.id).orders + .configure(action => action.path = ["auth", "lastOrders"]) + .all({ + order_by: "-id", + page_size: 5, + filter: { status__in: [1,2,3,4] }, + include: 'sale,orderlines,orderlines__item,orderlines__orderlineitems', + }) + ) + } + }, [dispatch, user.id]); return ( @@ -24,12 +41,12 @@ export default function Account(props) { -

Mes commandes

- - +

Mes 5 dernières commandes

+ + + + Voir toutes mes commandes +
diff --git a/src/pages/admin/SaleDetail/OrdersList.jsx b/src/pages/admin/SaleDetail/OrdersList.jsx deleted file mode 100644 index 12ca0a4..0000000 --- a/src/pages/admin/SaleDetail/OrdersList.jsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from "react"; -import { useDispatch } from "react-redux"; -import { updateOrderStatus } from "../../../redux/hooks"; - -import { parseISO } from "date-fns"; -import { formatDate } from "../../../utils"; -import { ORDER_STATUS } from "../../../constants"; - -import APIDataTable from "../../../components/common/APIDataTable"; -import OrderLinesList from "../../../components/orders/OrderLinesList"; -import { Link } from "../../../components/common/Nav"; - -export default function OrdersList({ saleId, items, ...props }) { - const dispatch = useDispatch(); - return ( - orders.map((order) => ({ - id: order.id, - owner: `${order.owner.first_name} ${order.owner.last_name}`, - updated_at: parseISO(order.updated_at), - status: ORDER_STATUS[order.status] || {}, - orderlines: order.orderlines, - }))} - columns={[ - { - title: "ID", - field: "id", - render: (order) => ( - {order.id} - ), - }, - { - title: "Acheteur", - field: "owner", - }, - { - title: "Status", - field: "status", - render: (order) => ( - updateOrderStatus(dispatch, order.id, { fetch: true })} - style={{ color: order.status && order.status.color, cursor: "pointer" }} - > - {(order.status && order.status.label) || "Inconnu"} - - ), - }, - { - title: "Mise à jour", - field: "updated_at", - searchable: false, - render: (order) => {formatDate(order.updated_at, "datetime")}, - }, - { - title: "Articles", - field: "orderlines", - searchable: false, - render: (order) => ( - - ), - }, - ]} - /> - ); -} diff --git a/src/pages/admin/SaleDetail/OrdersTable.jsx b/src/pages/admin/SaleDetail/OrdersTable.jsx new file mode 100644 index 0000000..b645e80 --- /dev/null +++ b/src/pages/admin/SaleDetail/OrdersTable.jsx @@ -0,0 +1,15 @@ +import React from "react"; +import Loader from "../../../components/common/Loader"; +import OrdersTable from "../../../components/orders/OrdersTable"; + +export default function SaleOrders({ saleId, ...props }) { + return ( + + + + ); +} diff --git a/src/pages/admin/SaleDetail/index.jsx b/src/pages/admin/SaleDetail/index.jsx index a981fa2..44018db 100644 --- a/src/pages/admin/SaleDetail/index.jsx +++ b/src/pages/admin/SaleDetail/index.jsx @@ -10,7 +10,7 @@ import { Link } from "../../../components/common/Nav"; import { CopyButton } from "../../../components/common/Buttons"; import QuantitiesSold from "./QuantitiesSold"; -import OrdersList from "./OrdersList"; +import OrdersTable from "./OrdersTable"; import TicketsList from "./TicketsList"; @@ -92,7 +92,7 @@ export default function SaleDetail(props) { /> )) || (tab === "orders" && ( - diff --git a/src/pages/public/OrderDetail.jsx b/src/pages/public/OrderDetail.jsx index 26d57b4..373053e 100644 --- a/src/pages/public/OrderDetail.jsx +++ b/src/pages/public/OrderDetail.jsx @@ -2,11 +2,12 @@ import React from 'react'; import produce from 'immer'; import { connect } from 'react-redux'; import apiActions, { apiAxios } from '../../redux/actions/api'; +import { updateOrderStatus, OrderStatusButton } from '../../components/orders/OrderStatus'; import { API_URL, ORDER_STATUS, STATUS_MESSAGES } from '../../constants'; import { arrayToMap } from '../../utils'; import { withStyles } from '@material-ui/core/styles'; -import { Box, Container, Grid, Button, Chip, CircularProgress } from '@material-ui/core'; +import { Box, Container, Grid, Button } from '@material-ui/core'; import { Alert, AlertTitle } from '@material-ui/lab'; import { LoadingButton } from '../../components/common/Buttons'; import { NavButton } from '../../components/common/Nav'; @@ -86,11 +87,11 @@ class OrderDetail extends React.Component { /** Fetch status and redirect to payment or refresh order */ updateStatus = () => { this.setState({ updatingStatus: true }, async () => { - const resp = (await apiAxios.get(`/orders/${this.props.orderId}/status`)).data - if (resp.redirect_url) - window.location.href = resp.redirect_url; + const resp = await updateOrderStatus(this.props.dispatch, this.props.orderId); + if (resp.data.redirect_url) + resp.redirectToPayment(); else - this.setState({ updatingStatus: false }, resp.updated ? this.fetchOrder : null); + this.setState({ updatingStatus: false }, resp.data.updated ? this.fetchOrder : null); }); } @@ -141,12 +142,11 @@ class OrderDetail extends React.Component {

Commande n°{order.id}

- : null} - clickable +
@@ -197,7 +197,7 @@ class OrderDetail extends React.Component { - - )} -
+ + + Liste des ventes} + path={["sales"]} + queryParams={{ + include: "association", + order_by: "-begin_at", + }} + columns={[ + { + title: "Lien", + field: "id", + render: (sale) => ( + {sale.id} + ), + }, + { + title: "Titre", + field: "name", + }, + { + title: "Association", + render: (sale) => ( + + {sale.association?.shortname} + + ) + }, + { + title: "Début", + field: "begin_at", + render: (sale) => formatDate(sale.begin_at, "datetime"), + } + ]} + /> + + ); } diff --git a/src/pages/public/UserOrders.jsx b/src/pages/public/UserOrders.jsx index 4ea38ad..eb95897 100644 --- a/src/pages/public/UserOrders.jsx +++ b/src/pages/public/UserOrders.jsx @@ -2,18 +2,23 @@ import React from "react" import { useSelector } from "react-redux"; import OrdersTable from "../../components/orders/OrdersTable"; import Loader from "../../components/common/Loader"; +import { Container, Box } from "@material-ui/core"; export default function UserOrders(props) { const userId = useSelector(store => store.api.getAuthUser("id", null)); return ( - - Mes commandes} - path={["auth", "orders"]} - show={["sale", "items", "actions"]} - apiOptions={{ uri: `/users/${userId}/orders` }} - /> - + + + + Mes commandes} + path={["auth", "orders"]} + show={["sale", "items", "actions"]} + apiOptions={{ uri: `/users/${userId}/orders` }} + /> + + + ); } diff --git a/src/pages/public/index.jsx b/src/pages/public/index.jsx index d48d87d..d4e3bdd 100644 --- a/src/pages/public/index.jsx +++ b/src/pages/public/index.jsx @@ -1,12 +1,36 @@ -import React from 'react'; +import React from "react"; +import { useStoreAPIData } from "../../redux/hooks"; -// import SalesList from '../components/common/SalesList'; -import Sales from './Sales'; +import { Container, Grid, Box, Button } from "@material-ui/core"; +import SaleCard, { SaleCardSkeleton } from "../../components/sales/SaleCard"; -// TODO: Custom home -export default function PublicSite(props) { - return ( - - ); -}; +export default function Home(props) { + const { pagination, ...sales } = useStoreAPIData("sales", { + include: "association", + order_by: "-begin_at", + }); + return ( + +

Liste des ventes

+ + + {sales.fetched + ? Object.values(sales.data).map((sale) => ( + + )) + : [...Array(3).keys()].map((index) => )} + + {pagination && ( + + + + )} +
+ ); +} From 23582f02a2add97fa7af47b3e08b11f313897d37 Mon Sep 17 00:00:00 2001 From: Alexandre Brasseur Date: Sat, 5 Dec 2020 17:40:28 +0100 Subject: [PATCH 11/17] refactor: Add absolute path import and refactor utils --- jsconfig.json | 6 + src/components/Header.jsx | 7 +- src/components/common/APIDataTable.jsx | 6 +- src/components/common/Loader.jsx | 4 +- src/components/common/ProtectedRoute.jsx | 2 +- .../{OrderStatus.jsx => OrderButtons.jsx} | 55 +---- src/components/orders/OrderLineItemTicket.jsx | 2 +- src/components/orders/OrderLinesList.jsx | 4 +- src/components/orders/OrdersTable.jsx | 14 +- src/components/orders/UserOrdersList.jsx | 8 +- src/components/sales/ItemsTable.jsx | 10 +- src/components/sales/SaleCard.jsx | 4 +- src/components/sales/SalesList.jsx | 4 +- src/components/users/AccountDetails.jsx | 2 +- src/pages/LoginLogout.jsx | 12 +- src/pages/admin/SaleDetail/QuantitiesSold.jsx | 3 +- src/pages/admin/SaleDetail/TicketsList.jsx | 8 +- src/pages/admin/SaleDetail/index.jsx | 10 +- src/pages/admin/SaleEditor/DetailsEditor.jsx | 88 +++---- .../SaleEditor/ItemsManager/GroupBlock.jsx | 2 +- .../SaleEditor/ItemsManager/ItemCard.jsx | 2 +- .../SaleEditor/ItemsManager/ItemEditor.jsx | 220 +++++++++--------- .../ItemsManager/ItemGroupEditor.jsx | 4 +- .../SaleEditor/ItemsManager/ItemsDisplay.jsx | 4 +- .../admin/SaleEditor/ItemsManager/index.jsx | 10 +- src/pages/admin/SaleEditor/index.jsx | 19 +- src/pages/admin/index.jsx | 18 +- src/pages/public/OrderDetail.jsx | 16 +- src/pages/public/SaleDetail.jsx | 20 +- src/pages/public/Sales.jsx | 11 +- src/redux/actions/api.js | 17 +- src/redux/actions/messages.js | 2 +- src/redux/constants.js | 13 +- src/redux/reducers/api.js | 6 +- src/redux/reducers/messages.jsx | 2 +- src/redux/store.js | 3 - src/styles.js | 82 ------- src/utils.js | 145 ------------ src/utils/api.js | 71 ++++++ src/{ => utils}/constants.js | 29 +-- src/utils/format.js | 57 +++++ src/utils/helpers.js | 79 +++++++ src/utils/styles.js | 76 ++++++ 43 files changed, 560 insertions(+), 597 deletions(-) create mode 100644 jsconfig.json rename src/components/orders/{OrderStatus.jsx => OrderButtons.jsx} (50%) delete mode 100644 src/styles.js delete mode 100644 src/utils.js create mode 100644 src/utils/api.js rename src/{ => utils}/constants.js (80%) create mode 100644 src/utils/format.js create mode 100644 src/utils/helpers.js create mode 100644 src/utils/styles.js diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..5875dc5 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "baseUrl": "src" + }, + "include": ["src"] +} diff --git a/src/components/Header.jsx b/src/components/Header.jsx index fa1fecb..6de6fee 100644 --- a/src/components/Header.jsx +++ b/src/components/Header.jsx @@ -1,13 +1,14 @@ import React from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import apiActions from '../redux/actions/api'; +import apiActions from 'redux/actions/api'; import { makeStyles } from '@material-ui/core/styles'; import { AppBar, Toolbar, Container, Button, Menu, Divider, useMediaQuery } from '@material-ui/core'; import { MoreVert, Home, ShoppingCart, AccountCircle } from '@material-ui/icons'; import { NavLink } from 'react-router-dom'; -import { NavButton, NavMenuItem } from './common/Nav.jsx'; -import { hasManagerRights, textOrIcon } from '../utils'; +import { NavButton, NavMenuItem } from 'components/common/Nav.jsx'; +import { textOrIcon } from 'utils/format'; +import { hasManagerRights } from 'utils/api'; const useStyles = makeStyles(theme => ({ diff --git a/src/components/common/APIDataTable.jsx b/src/components/common/APIDataTable.jsx index b4debad..9e893ed 100644 --- a/src/components/common/APIDataTable.jsx +++ b/src/components/common/APIDataTable.jsx @@ -1,8 +1,8 @@ import React from "react"; import PropTypes from "prop-types"; -import { useStoreAPIData } from "../../redux/hooks"; -import { processPagination, pathToArray } from "../../redux/reducers/api"; -import { DEFAULT_PAGE_SIZE, PAGE_SIZES } from "../../constants"; +import { useStoreAPIData } from "redux/hooks"; +import { processPagination, pathToArray } from "redux/reducers/api"; +import { DEFAULT_PAGE_SIZE, PAGE_SIZES } from "utils/constants"; import MaterialTable from "material-table"; import { diff --git a/src/components/common/Loader.jsx b/src/components/common/Loader.jsx index 4454635..46e353c 100644 --- a/src/components/common/Loader.jsx +++ b/src/components/common/Loader.jsx @@ -1,8 +1,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import { withStyles } from '@material-ui/core/styles'; -import { CircularProgress } from '@material-ui/core'; -import { capitalFirst } from '../../utils'; +import { CircularProgress } from '@material-ui/core'; +import { capitalFirst } from 'utils/format'; const FLEX_DIRECTIONS = { right: 'row', diff --git a/src/components/common/ProtectedRoute.jsx b/src/components/common/ProtectedRoute.jsx index 1336f36..18e9ad2 100644 --- a/src/components/common/ProtectedRoute.jsx +++ b/src/components/common/ProtectedRoute.jsx @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; import { Route, Redirect } from 'react-router-dom'; -import { hasManagerRights } from '../../utils'; +import { hasManagerRights } from 'utils/api'; const authFunctions = { diff --git a/src/components/orders/OrderStatus.jsx b/src/components/orders/OrderButtons.jsx similarity index 50% rename from src/components/orders/OrderStatus.jsx rename to src/components/orders/OrderButtons.jsx index a621e6d..0c95f51 100644 --- a/src/components/orders/OrderStatus.jsx +++ b/src/components/orders/OrderButtons.jsx @@ -1,61 +1,8 @@ import React from "react"; import PropTypes from "prop-types"; -import apiActions, { apiAxios } from "../../redux/actions/api"; -import { ORDER_STATUS, API_URL } from "../../constants"; - import { Chip, Button, IconButton, CircularProgress } from "@material-ui/core"; - -/** - * Helper to update the status of an order - */ -export async function updateOrderStatus(dispatch, orderId, auto = { fetch: false, redirect: false }) { - const data = (await apiAxios.get(`/orders/${orderId}/status`)).data - - const fetchOrder = () => dispatch(apiActions.orders.find(orderId)); - const redirectToPayment = () => data.redirect_url ? window.location.href = data.redirect_url : null; - - if (auto.fetch && data.updated) - fetchOrder(); - if (auto.redirect && data.redirect_url) - redirectToPayment(); - - return { data, fetchOrder, redirectToPayment }; -} - - -/** - * Helper to get Order status actions - */ -export function getStatusActions(dispatch, history) { - return { - updateStatus(event, id = undefined) { - const orderId = id || event.currentTarget.getAttribute("data-order-id"); - updateOrderStatus(dispatch, orderId, { fetch: true }); - }, - - download(event, id = undefined) { - const orderId = id || event.currentTarget.getAttribute("data-order-id"); - window.open(`${API_URL}/orders/${orderId}/pdf?download`, "_blank"); - }, - - modify(event, id = undefined) { - const orderId = id || event.currentTarget.getAttribute("data-order-id"); - history.push(`/orders/${orderId}`); - }, - - pay(event, id = undefined) { - const saleId = id || event.currentTarget.getAttribute("data-sale-id"); - history.push(`/sales/${saleId}`); - }, - - cancel(event, id = undefined) { - const orderId = id || event.currentTarget.getAttribute("data-order-id"); - const action = apiActions.orders(orderId).delete(); - action.payload.finally(() => updateOrderStatus(dispatch, orderId, { fetch: true })); - }, - }; -}; +import { ORDER_STATUS } from "utils/constants"; export function OrderStatusButton({ status, updateStatus, variant, updating, ...props }) { diff --git a/src/components/orders/OrderLineItemTicket.jsx b/src/components/orders/OrderLineItemTicket.jsx index 93b1ab1..52f65a4 100644 --- a/src/components/orders/OrderLineItemTicket.jsx +++ b/src/components/orders/OrderLineItemTicket.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { Box, Paper, TextField } from '@material-ui/core'; -import { isEmpty } from '../../utils'; +import { isEmpty } from 'utils/helpers'; // TODO Different Input types diff --git a/src/components/orders/OrderLinesList.jsx b/src/components/orders/OrderLinesList.jsx index d4442e1..a52ca49 100644 --- a/src/components/orders/OrderLinesList.jsx +++ b/src/components/orders/OrderLinesList.jsx @@ -1,8 +1,8 @@ import React from "react" import PropTypes from "prop-types"; import { List, ListItem, ListItemText } from "@material-ui/core"; -import { SkeletonList } from "../common/Skeletons"; -import { isEmpty } from "../../utils"; +import { SkeletonList } from "components/common/Skeletons"; +import { isEmpty } from "utils/helpers"; export default function OrderLinesList({ orderlines, items, prefix, empty, ...props }) { diff --git a/src/components/orders/OrdersTable.jsx b/src/components/orders/OrdersTable.jsx index 5f5f558..4c31625 100644 --- a/src/components/orders/OrdersTable.jsx +++ b/src/components/orders/OrdersTable.jsx @@ -3,14 +3,14 @@ import PropTypes from "prop-types"; import { useHistory } from "react-router"; import { useDispatch } from "react-redux"; -import { parseISO } from "date-fns"; -import { formatDate } from "../../utils"; -import { ORDER_STATUS, ORDER_ACTIONS } from "../../constants"; +import { formatDate } from "utils/format"; +import { updateOrderStatus, getStatusActions } from "utils/api"; +import { ORDER_STATUS, ORDER_ACTIONS } from "utils/constants"; -import APIDataTable from "../common/APIDataTable"; -import { updateOrderStatus, getStatusActions, OrderStatusButton, OrderActionButton } from "./OrderStatus"; +import APIDataTable from "components/common/APIDataTable"; +import { Link } from "components/common/Nav"; +import { OrderStatusButton, OrderActionButton } from "./OrderButtons"; import OrderLinesList from "./OrderLinesList"; -import { Link } from "../common/Nav"; export default function OrdersTable({ items, show, queryParams = {}, ...props }) { @@ -110,7 +110,7 @@ export default function OrdersTable({ items, show, queryParams = {}, ...props }) id: order.id, sale: order.sale, owner: order.owner ? `${order.owner.first_name} ${order.owner.last_name}` : null, - updated_at: parseISO(order.updated_at), + updated_at: order.updated_at, status: ORDER_STATUS[order.status] || {}, orderlines: order.orderlines, }))} diff --git a/src/components/orders/UserOrdersList.jsx b/src/components/orders/UserOrdersList.jsx index 879ff07..1c963c6 100644 --- a/src/components/orders/UserOrdersList.jsx +++ b/src/components/orders/UserOrdersList.jsx @@ -2,16 +2,16 @@ import React from "react"; import PropTypes from "prop-types"; import { useHistory } from "react-router"; import { useDispatch } from "react-redux"; -import { getStatusActions, OrderStatusButton, OrderActionButton } from "./OrderStatus"; -import { ORDER_STATUS, ORDER_ACTIONS } from "../../constants"; +import { getStatusActions } from "utils/api"; +import { ORDER_STATUS, ORDER_ACTIONS } from "utils/constants"; import { TableContainer, Table, TableHead, TableBody, TableRow, TableCell } from "@material-ui/core"; -import { Link } from "../common/Nav"; - +import { Link } from "components/common/Nav"; import OrderLinesList from "./OrderLinesList"; +import { OrderStatusButton, OrderActionButton } from "./OrderButtons"; function UserOrderRow({ order, actions }) { diff --git a/src/components/sales/ItemsTable.jsx b/src/components/sales/ItemsTable.jsx index 694f667..23863bb 100644 --- a/src/components/sales/ItemsTable.jsx +++ b/src/components/sales/ItemsTable.jsx @@ -1,9 +1,9 @@ -import React from 'react'; -import PropTypes from 'prop-types'; +import React from "react"; +import PropTypes from "prop-types"; -import { Box, TableContainer, Table, TableBody, TableRow, TableCell, TextField } from '@material-ui/core/'; -import { SkeletonTable } from '../common/Skeletons'; -import { formatPrice } from '../../utils'; +import { Box, TableContainer, Table, TableBody, TableRow, TableCell, TextField } from "@material-ui/core"; +import { SkeletonTable } from "components/common/Skeletons"; +import { formatPrice } from "utils/format"; export default function ItemsTable({ items, disabled, quantities, onQuantityChange, ...props }) { diff --git a/src/components/sales/SaleCard.jsx b/src/components/sales/SaleCard.jsx index 62f5a52..d1279de 100644 --- a/src/components/sales/SaleCard.jsx +++ b/src/components/sales/SaleCard.jsx @@ -1,11 +1,11 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { shorten } from '../../utils'; +import { shorten } from 'utils/format'; import { makeStyles } from '@material-ui/core/styles'; import { Grid, Card, CardContent, CardActions } from '@material-ui/core'; import { Skeleton } from '@material-ui/lab'; -import { NavButton } from '../common/Nav'; +import { NavButton } from 'components/common/Nav'; const useStyles = makeStyles(theme => ({ diff --git a/src/components/sales/SalesList.jsx b/src/components/sales/SalesList.jsx index 70e92a1..31c538c 100644 --- a/src/components/sales/SalesList.jsx +++ b/src/components/sales/SalesList.jsx @@ -3,8 +3,8 @@ import PropTypes from 'prop-types'; import { List, ListItem, ListItemSecondaryAction, ListItemText, IconButton } from '@material-ui/core'; import { NavListItem, NavIconButton } from '../common/Nav'; import { Add, Edit, Search } from '@material-ui/icons'; -import { SkeletonList } from '../../components/common/Skeletons'; -import { isEmpty } from '../../utils'; +import { SkeletonList } from 'components/common/Skeletons'; +import { isEmpty } from 'utils/helpers'; export default function SalesList({ sales, fetched, fetchMore, baseUrl, withEdit, assoId, ...props }) { diff --git a/src/components/users/AccountDetails.jsx b/src/components/users/AccountDetails.jsx index 427d3e5..e82340c 100644 --- a/src/components/users/AccountDetails.jsx +++ b/src/components/users/AccountDetails.jsx @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { makeStyles } from '@material-ui/core/styles'; import { TableContainer, Table, TableBody, TableCell, TableRow } from '@material-ui/core'; -import { capitalFirst } from '../../utils'; +import { capitalFirst } from 'utils/format'; const useStyles = makeStyles({ diff --git a/src/pages/LoginLogout.jsx b/src/pages/LoginLogout.jsx index f7b29bb..7326ae2 100644 --- a/src/pages/LoginLogout.jsx +++ b/src/pages/LoginLogout.jsx @@ -1,17 +1,17 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import Loader from '../components/common/Loader'; -import { API_URL } from '../constants'; +import React from "react"; +import PropTypes from "prop-types"; +import Loader from "components/common/Loader"; +import { API_URL } from "utils/constants"; export default function LoginLogout({ action }) { // Redirect to login/logout page - const callback = String(window.location).replace(/(.*)log(in|out)\/?$/, '$1'); + const callback = String(window.location).replace(/(.*)log(in|out)\/?$/, "$1"); window.location.replace(`${API_URL}/auth/${action}?redirect=${callback}`); return ; } LoginLogout.propTypes = { - action: PropTypes.oneOf(['login', 'logout']).isRequired, + action: PropTypes.oneOf(["login", "logout"]).isRequired, }; diff --git a/src/pages/admin/SaleDetail/QuantitiesSold.jsx b/src/pages/admin/SaleDetail/QuantitiesSold.jsx index 6783a8e..a927534 100644 --- a/src/pages/admin/SaleDetail/QuantitiesSold.jsx +++ b/src/pages/admin/SaleDetail/QuantitiesSold.jsx @@ -6,7 +6,8 @@ import { } from "@material-ui/core"; import { Done, Pause, ExpandLess, ExpandMore } from "@material-ui/icons"; import { Skeleton } from "@material-ui/lab"; -import { formatPrice, isEmpty } from "../../../utils"; +import { formatPrice } from "utils/format"; +import { isEmpty } from "utils/helpers"; export function ItemsSold({ items, ...props }) { if (!items) diff --git a/src/pages/admin/SaleDetail/TicketsList.jsx b/src/pages/admin/SaleDetail/TicketsList.jsx index ce8f99e..4ba00f7 100644 --- a/src/pages/admin/SaleDetail/TicketsList.jsx +++ b/src/pages/admin/SaleDetail/TicketsList.jsx @@ -1,8 +1,8 @@ import React from "react"; -import { useStoreAPIData } from "../../../redux/hooks"; -import APIDataTable from "../../../components/common/APIDataTable"; -import { VALID_ORDER_STATUS } from "../../../constants"; -import { arrayToMap } from "../../../utils"; +import { useStoreAPIData } from "redux/hooks"; +import APIDataTable from "components/common/APIDataTable"; +import { VALID_ORDER_STATUS } from "utils/constants"; +import { arrayToMap } from "utils/helpers"; export default function TicketsList({ saleId, items, itemgroups, ...props }) { // Get all fields for the moment, shouldn't be too much diff --git a/src/pages/admin/SaleDetail/index.jsx b/src/pages/admin/SaleDetail/index.jsx index 44018db..81fd7df 100644 --- a/src/pages/admin/SaleDetail/index.jsx +++ b/src/pages/admin/SaleDetail/index.jsx @@ -1,13 +1,13 @@ import React from "react"; -import { useStoreAPIData } from "../../../redux/hooks"; -import { formatDate } from "../../../utils"; +import { useStoreAPIData } from "redux/hooks"; +import { formatDate } from "utils/format"; import { Box, Container, Grid, Chip, Tabs, Tab } from "@material-ui/core"; import { PlayArrow, Pause, Public, Lock } from "@material-ui/icons"; -import Stat from "../../../components/common/Stat"; -import { Link } from "../../../components/common/Nav"; -import { CopyButton } from "../../../components/common/Buttons"; +import Stat from "components/common/Stat"; +import { Link } from "components/common/Nav"; +import { CopyButton } from "components/common/Buttons"; import QuantitiesSold from "./QuantitiesSold"; import OrdersTable from "./OrdersTable"; diff --git a/src/pages/admin/SaleEditor/DetailsEditor.jsx b/src/pages/admin/SaleEditor/DetailsEditor.jsx index c187af9..fd31626 100644 --- a/src/pages/admin/SaleEditor/DetailsEditor.jsx +++ b/src/pages/admin/SaleEditor/DetailsEditor.jsx @@ -1,44 +1,44 @@ -import React from 'react'; -import { Box, Paper, Grid, Button } from '@material-ui/core'; - -import { mergeClasses, useFormStyles } from '../../../styles'; -import { LoadingButton } from '../../../components/common/Buttons'; -import FieldGenerator from '../../../components/common/FieldGenerator'; - -export default function DetailsEditor({ disabled, editing, isCreator, ...props }) { - disabled = disabled || props.saving; - const classes = useFormStyles(); - const Field = new FieldGenerator(props.details, props.errors, props.onChange, 'details', { disabled }); - - const onlyCreate = { required: true, disabled: !isCreator || disabled }; - return ( - - - -

Description

- {Field.text('name', 'Nom', { required: true, autoFocus: true })} - {Field.text('id', 'ID', onlyCreate)} - {Field.select('association', 'Association', props.assos, onlyCreate)} - {Field.text('description', 'Description', { required: true, multiline: true, rows: 4 })} -
- - -

Disponibilité

- {Field.datetime('begin_at', 'Ouverture', { required: true })} - {Field.datetime('end_at', 'Fermeture', { required: true })} - {Field.boolean('is_active', 'Active')} - {Field.boolean('is_public', 'Publique')} - {Field.number('max_item_quantity', 'Quantité max')} -
-
- - - - { isCreator ? "Créer" : "Sauvegarder"} - - -
- ); -} +import React from 'react'; +import { Box, Paper, Grid, Button } from '@material-ui/core'; + +import { LoadingButton } from 'components/common/Buttons'; +import FieldGenerator from 'components/common/FieldGenerator'; +import { mergeClasses, useFormStyles } from 'utils/styles'; + +export default function DetailsEditor({ disabled, editing, isCreator, ...props }) { + disabled = disabled || props.saving; + const classes = useFormStyles(); + const Field = new FieldGenerator(props.details, props.errors, props.onChange, 'details', { disabled }); + + const onlyCreate = { required: true, disabled: !isCreator || disabled }; + return ( + + + +

Description

+ {Field.text('name', 'Nom', { required: true, autoFocus: true })} + {Field.text('id', 'ID', onlyCreate)} + {Field.select('association', 'Association', props.assos, onlyCreate)} + {Field.text('description', 'Description', { required: true, multiline: true, rows: 4 })} +
+ + +

Disponibilité

+ {Field.datetime('begin_at', 'Ouverture', { required: true })} + {Field.datetime('end_at', 'Fermeture', { required: true })} + {Field.boolean('is_active', 'Active')} + {Field.boolean('is_public', 'Publique')} + {Field.number('max_item_quantity', 'Quantité max')} +
+
+ + + + { isCreator ? "Créer" : "Sauvegarder"} + + +
+ ); +} diff --git a/src/pages/admin/SaleEditor/ItemsManager/GroupBlock.jsx b/src/pages/admin/SaleEditor/ItemsManager/GroupBlock.jsx index 26433fe..df58692 100644 --- a/src/pages/admin/SaleEditor/ItemsManager/GroupBlock.jsx +++ b/src/pages/admin/SaleEditor/ItemsManager/GroupBlock.jsx @@ -3,7 +3,7 @@ import { makeStyles } from '@material-ui/core/styles'; import { Box, Grid } from '@material-ui/core'; import { Skeleton } from '@material-ui/lab'; -import { isEmpty } from '../../../../utils'; +import { isEmpty } from 'utils/helpers'; import NoItems from './NoItems'; import ItemCard from './ItemCard'; diff --git a/src/pages/admin/SaleEditor/ItemsManager/ItemCard.jsx b/src/pages/admin/SaleEditor/ItemsManager/ItemCard.jsx index 5d622fb..e5c54c2 100644 --- a/src/pages/admin/SaleEditor/ItemsManager/ItemCard.jsx +++ b/src/pages/admin/SaleEditor/ItemsManager/ItemCard.jsx @@ -1,7 +1,7 @@ import React from 'react'; import { Card, CardContent } from '@material-ui/core'; -import { formatPrice } from '../../../../utils'; +import { formatPrice } from 'utils/format'; function displayQuantity(quantity, yesText, noText = null) { if (quantity) diff --git a/src/pages/admin/SaleEditor/ItemsManager/ItemEditor.jsx b/src/pages/admin/SaleEditor/ItemsManager/ItemEditor.jsx index c2d3e4b..774e4f5 100644 --- a/src/pages/admin/SaleEditor/ItemsManager/ItemEditor.jsx +++ b/src/pages/admin/SaleEditor/ItemsManager/ItemEditor.jsx @@ -1,110 +1,110 @@ -import React from 'react'; -import { - Box, Grid, Button, TableContainer, Table, - TableHead, TableBody, TableRow, TableCell -} from '@material-ui/core'; - - -import FieldGenerator from '../../../../components/common/FieldGenerator'; -import { useFormStyles } from '../../../../styles'; - - -function ItemFieldsTable({ Field, itemfields, fields, ...props}) { - return ( - -
- - - Champ - Editable - - - - - - - {itemfields.length ? ( - itemfields.map((itemfield, index) => ( - - - {(itemfield._isNew - ? Field.select(`itemfields.${index}.field`, null, fields, { required: true }) - : fields[itemfield.field].label - )} - - - {Field.boolean(`itemfields.${index}.editable`)} - - - - - - )) - ) : ( - - - Aucun champ ! Ajouter en avec le bouton ci-dessus. - - - )} - -
- - ); -} - -export default function ItemEditor({ item, ...props }) { - const classes = useFormStyles() - const Field = new FieldGenerator( - item, - props.errors, - props.onChange, - `items.${item.id}`, - { disabled: props.disabled || props.saving } - ); - - return ( - - - -

Description

- {Field.text('name', "Nom", { autoFocus: true })} - {Field.text('description', "Description", { multiline: true, rows: 2 })} - {Field.select('group', "Groupe", props.itemgroups, { default: 'null' })} - {Field.select('usertype', "Type d'acheteur", props.usertypes)} - {Field.number('price', "Prix")} -
- - -

Disponibilité

- {Field.boolean('is_active', "Actif")} - {Field.number('quantity', "Quantité")} - {Field.number('max_per_user', "Max par acheteur")} -
-
- - {!item._isNew && ( - -

Champs

- -
- )} -
- ); -} +import React from 'react'; +import { + Box, Grid, Button, TableContainer, Table, + TableHead, TableBody, TableRow, TableCell +} from '@material-ui/core'; + + +import FieldGenerator from 'components/common/FieldGenerator'; +import { useFormStyles } from 'utils/styles'; + + +function ItemFieldsTable({ Field, itemfields, fields, ...props}) { + return ( + + + + + Champ + Editable + + + + + + + {itemfields.length ? ( + itemfields.map((itemfield, index) => ( + + + {(itemfield._isNew + ? Field.select(`itemfields.${index}.field`, null, fields, { required: true }) + : fields[itemfield.field].label + )} + + + {Field.boolean(`itemfields.${index}.editable`)} + + + + + + )) + ) : ( + + + Aucun champ ! Ajouter en avec le bouton ci-dessus. + + + )} + +
+
+ ); +} + +export default function ItemEditor({ item, ...props }) { + const classes = useFormStyles() + const Field = new FieldGenerator( + item, + props.errors, + props.onChange, + `items.${item.id}`, + { disabled: props.disabled || props.saving } + ); + + return ( + + + +

Description

+ {Field.text('name', "Nom", { autoFocus: true })} + {Field.text('description', "Description", { multiline: true, rows: 2 })} + {Field.select('group', "Groupe", props.itemgroups, { default: 'null' })} + {Field.select('usertype', "Type d'acheteur", props.usertypes)} + {Field.number('price', "Prix")} +
+ + +

Disponibilité

+ {Field.boolean('is_active', "Actif")} + {Field.number('quantity', "Quantité")} + {Field.number('max_per_user', "Max par acheteur")} +
+
+ + {!item._isNew && ( + +

Champs

+ +
+ )} +
+ ); +} diff --git a/src/pages/admin/SaleEditor/ItemsManager/ItemGroupEditor.jsx b/src/pages/admin/SaleEditor/ItemsManager/ItemGroupEditor.jsx index 420631e..31fcf28 100644 --- a/src/pages/admin/SaleEditor/ItemsManager/ItemGroupEditor.jsx +++ b/src/pages/admin/SaleEditor/ItemsManager/ItemGroupEditor.jsx @@ -1,7 +1,7 @@ import React from 'react'; -import FieldGenerator from '../../../../components/common/FieldGenerator'; -import { useFormStyles } from '../../../../styles'; +import FieldGenerator from 'components/common/FieldGenerator'; +import { useFormStyles } from 'utils/styles'; export default function ItemGroupEditor({ itemgroup, ...props }) { diff --git a/src/pages/admin/SaleEditor/ItemsManager/ItemsDisplay.jsx b/src/pages/admin/SaleEditor/ItemsManager/ItemsDisplay.jsx index b46d49c..cb977bd 100644 --- a/src/pages/admin/SaleEditor/ItemsManager/ItemsDisplay.jsx +++ b/src/pages/admin/SaleEditor/ItemsManager/ItemsDisplay.jsx @@ -1,7 +1,7 @@ import React from 'react'; import { Box } from '@material-ui/core'; -import { isEmpty } from '../../../../utils'; +import { isEmpty } from 'utils/helpers'; import NoItems from './NoItems'; import GroupBlock from './GroupBlock'; @@ -17,7 +17,7 @@ export default function ItemsDisplay({ itemgroups, ...props }) { return ( {Object.values(itemgroups).map(itemgroup => ( - ); - + // Wrap editor with edition buttons const buttonProps = { name, value: id, disabled: editorProps.saving }; editor = ( @@ -104,7 +104,7 @@ function ItemsManager({ selected, ...props }) { {!isNew && ( +
+
+ +
+ ) + + ); +} From 9593397b1f4be63e5bb407248c5994d40d8a6ef5 Mon Sep 17 00:00:00 2001 From: heymanpi Date: Sun, 6 Dec 2020 19:22:09 +0100 Subject: [PATCH 13/17] Using custom fields and display error/success message to user --- src/pages/public/ContactForm.js | 250 ++++++++++++-------------------- 1 file changed, 90 insertions(+), 160 deletions(-) diff --git a/src/pages/public/ContactForm.js b/src/pages/public/ContactForm.js index b784f61..4703441 100644 --- a/src/pages/public/ContactForm.js +++ b/src/pages/public/ContactForm.js @@ -1,189 +1,119 @@ -import React, { useState } from 'react' -import {getButtonColoredVariant} from "../../styles"; -import { Container, Grid} from "@material-ui/core"; -import TextField from '@material-ui/core/TextField'; -import Button from '@material-ui/core/Button'; -import MenuItem from '@material-ui/core/MenuItem'; -import FormControl from '@material-ui/core/FormControl'; -import Select from '@material-ui/core/Select'; -import CloudUploadIcon from '@material-ui/icons/CloudUpload'; -import {useSelector} from "react-redux"; -import {apiAxios } from "../../redux/actions"; -import Loader from "../../components/common/Loader"; - -const styles = theme => ({ - title: { - fontSize: '4em', - margin: 0, - }, - subtitle: { - color: theme.palette.text.secondary, - fontWeight: 100, - }, - buttonEmpty: { - ...getButtonColoredVariant(theme, "warning", "outlined"), - margin: theme.spacing(1), - }, - buttonBuy: { - ...getButtonColoredVariant(theme, "success", "contained"), - margin: theme.spacing(1), - }, - alert: { - textAlign: 'center', - color: '#f50057', - }, - root: { - '& .MuiTextField-root': { - margin: theme.spacing(1), - width: '25ch', - }, - flexGrow: 1, - - }, - formControl: { - margin: theme.spacing(1), - minWidth: 120, - }, - selectEmpty: { - marginTop: theme.spacing(2), - }, - - paper: { - padding: theme.spacing(2), - textAlign: 'center', - color: theme.palette.text.secondary, - }, - column: { - display: 'flex', - flexDirection: 'column', - paddingTop: '0 !important', - paddingBottom: '0 !important', - minWidth: 220, - }, - controls: { - maxWidth: 280, - }, - editor: { - // borderWidth: 1, - // borderStyle: 'solid', - // borderColor: 'transparent', - padding: theme.spacing(2), - }, - editing: { - borderColor: 'yellow', - }, - textF: { - margin: 3, - padding: 5 - - }, - button: { - margin: theme.spacing(1), - }, -}); +import React from 'react' +import {useDispatch, useSelector} from "react-redux"; +import messagesActions from "redux/actions/messages"; +import {apiAxios} from "utils/api"; +import {useFormStyles} from "utils/styles"; +import {Box, Container, Grid, Paper, Button} from "@material-ui/core"; +import CloudUploadIcon from '@material-ui/icons/CloudUpload'; +import Loader from "components/common/Loader"; +import FieldGenerator from 'components/common/FieldGenerator'; export default function ContactForm() { - const user = useSelector(store => store.getAuthUser()); - const [reasonIndex, setReasonIndex] = useState(0); - const [reason, setReason] = useState(""); - const [message, setMessage] = useState(""); - const [loading, setLoading] = useState(0); - - async function handleSubmit(e){ - e.preventDefault(); - if(message.length < 0 || reasonIndex === 0) { - //TODO : validation - console.log("Données invalides") - return; + + const error = {}; + const initialStore = {choix: 0, reason: null, message: null, loading: false} + const [store, changeStore] = React.useState(initialStore); + const classes = useFormStyles(); + + const onChange = event => { + const {name, value} = event.target; + if (name === 'choix' && choicePossibilites[value - 1]) { + changeStore(prevState => ({...prevState, reason: choicePossibilites[value - 1].label})); } - const reasonToSend = reason - const subject = "[WOOLLY][FeedBack] - " + reason + " - " + user.name; - const sender = {name: user.name, id: user.id, email: user.email}; - const datas = {subject: subject, message: message, sender: sender, reason: reasonToSend}; - setLoading(1); - await apiAxios.post("/feedback", datas).then(() => { - setReasonIndex(0); - setReason(""); - setMessage(""); - setLoading(0); - }); + changeStore(prevState => ({...prevState, [name]: value})); } + const Field = new FieldGenerator(store, error, onChange); + + const choicePossibilites = [ + { + label: 'Création d\'une vente', value: '1' + }, + { + label: 'Gestion d\'une vente', value: '2' + }, + { + label: 'Commande passée', value: '3' + }, + { + label: 'Signaler un bug', value: '4' + }, + { + label: 'Autre', value: '5' + } + ]; + + const user = useSelector(store => store.getAuthUser()); + const dispatch = useDispatch(); + + async function handleSubmit() { + + const sender = {name: user.name, id: user.id, email: user.email}; + const datas = {message: store.message, sender: sender, reason: store.reason}; + changeStore(prevState => ({...prevState, loading: true})); + + try { + await apiAxios.post("/feedback", datas).then(() => { + changeStore(initialStore); + dispatch(messagesActions.pushMessage("Message envoyé !", "Votre message a bien été transmis au SiMDE", "success")); + }); + } catch (error) { + changeStore(prevState => ({...prevState, loading: false})); + dispatch(messagesActions.pushError(error, "Un ou plusieurs champs sont manquants")); + } + + } return ( - loading ? ( - + store.loading ? ( + ) : ( + + -

Contacter l'équipe

-
- +

Contacter le SiMDE

+ + + + - - - -

Veuillez sélectionner un motif

- -
+ +

Veuillez sélectionner un motif

+ {Field.select('choix', "Choix", choicePossibilites, {required: true})}
- - - - setMessage(e.target.value)} - /> - + +

Message

+ {Field.text('message', 'Message', { + required: true, + multiline: true, + rows: 6, + fullWidth: true + })}
+ +
+ + -
- + +
+
) - ); } From 81d7a85f894d27c14bec207a405e4e5fa44c95b4 Mon Sep 17 00:00:00 2001 From: Alexandre Brasseur Date: Sun, 13 Dec 2020 20:51:49 +0100 Subject: [PATCH 14/17] Add a timer on unopened sales --- src/components/sales/SaleCard.jsx | 174 ++++++++++++++++++------------ src/pages/public/Sales.jsx | 1 + src/utils.js | 141 ++++++++++++++++++++++++ src/utils/api.js | 30 ++++++ 4 files changed, 280 insertions(+), 66 deletions(-) create mode 100644 src/utils.js diff --git a/src/components/sales/SaleCard.jsx b/src/components/sales/SaleCard.jsx index d1279de..7309dfa 100644 --- a/src/components/sales/SaleCard.jsx +++ b/src/components/sales/SaleCard.jsx @@ -1,88 +1,130 @@ import React from 'react'; import PropTypes from 'prop-types'; import { shorten } from 'utils/format'; +import { getCountdown, saleIsOpen } from 'utils/api'; +import {isPast} from "date-fns"; import { makeStyles } from '@material-ui/core/styles'; -import { Grid, Card, CardContent, CardActions } from '@material-ui/core'; +import { Grid, Card, CardContent, CardActions, Chip } from '@material-ui/core'; import { Skeleton } from '@material-ui/lab'; import { NavButton } from 'components/common/Nav'; const useStyles = makeStyles(theme => ({ - card: { - height: '100%', - display: 'flex', - flexDirection: 'column', - '&:hover': { - boxShadow: theme.shadows[4], - }, - '&:hover .go-to-sale': { - color: theme.palette.primary.main, - }, - }, - content: { - flex: 1, - }, - title: { - fontSize: 24, - margin: 0, - }, - subtitle: { - fontStyle: 'italic', - }, - description: { - textAlign: 'justify', - }, + card: { + height: '100%', + display: 'flex', + flexDirection: 'column', + '&:hover': { + boxShadow: theme.shadows[4], + }, + '&:hover .go-to-sale': { + color: theme.palette.primary.main, + }, + }, + content: { + flex: 1, + }, + title: { + fontSize: 24, + margin: 0, + }, + subtitle: { + fontStyle: 'italic', + }, + description: { + textAlign: 'justify', + }, })); -export function SaleCardSkeleton() { - const classes = useStyles(); - return ( - - - - - - - - - +export function SaleCardSkeleton() { + const classes = useStyles(); + return ( + + + + + + + + + - - - - - - ); + + + + + + ); } -export default function SaleCard({ sale, ...props }) { - const classes = useStyles(); - return ( - - - -

- {sale.name} -

- + +export default function SaleCard({sale, ...props}) { + const classes = useStyles(); + + React.useEffect(() => { + const interval = setInterval(() => { + if(document.getElementById(sale.id)) { + const count = getCountdown(sale.begin_at) + if(!count) + window.location.reload(false); + document.getElementById(sale.id).children[0].textContent = count; + } + }, 1000); + return () => clearInterval(interval); + }); + + function currentSaleState() { + if (!sale) + return null; + if (sale.end_at && isPast(new Date(sale.end_at))) + return ['Terminée !', 'red']; + if (sale.begin_at && isPast(new Date(sale.begin_at))) + return ['En cours !', 'green']; + return ['Ouverte prochaine ... ', 'orange']; + } + + return ( + + + +

+ {sale.name} +

+ Par {sale.association && sale.association.shortname} -

- {shorten(sale.description, 150)} -

-
+

+ {shorten(sale.description, 150)} +

+ + + + + +
+ { isPast(new Date(sale.begin_at)) ? ( + + + Accéder à la vente + + + ) : ( + + ) + } - - - Accéder à la vente - - -
-
- ); + + + ); } SaleCard.propTypes = { - sale: PropTypes.object.isRequired, + sale: PropTypes.object.isRequired, }; diff --git a/src/pages/public/Sales.jsx b/src/pages/public/Sales.jsx index 145c9dd..6fae443 100644 --- a/src/pages/public/Sales.jsx +++ b/src/pages/public/Sales.jsx @@ -1,5 +1,6 @@ import React from "react"; import { Container, Box } from "@material-ui/core"; +import { getCountdown} from 'utils/api'; import APIDataTable from "components/common/APIDataTable"; import { Link } from "components/common/Nav"; diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..ed908ba --- /dev/null +++ b/src/utils.js @@ -0,0 +1,141 @@ +import React from 'react'; +import {formatDistanceToNow, formatDistanceToNowStrict, lightFormat, parseISO, isBefore, intervalToDuration} from 'date-fns' + +window.__localeId__ = 'fr' + +/* +|--------------------------------------------------------- +| Object utils +|--------------------------------------------------------- +*/ + +export function isList(object) { + return object && object.length !== undefined; +} + +export function isEmpty(object) { + return object == null || (object instanceof Object && Object.keys(object).length === 0); +} + +export function deepcopy(object) { + return JSON.parse(JSON.stringify(object)); +} + +export function stringToGetter(attr) { + return obj => obj[attr]; +} + +export function arrayToMap(array, getKey, getValue) { + if (typeof getKey === 'string') + getKey = stringToGetter(getKey); + if (typeof getValue === 'string') + getValue = stringToGetter(getValue); + + return array.reduce((map, obj) => { + map[getKey(obj)] = getValue ? getValue(obj) : obj; + return map; + }, {}); +} + + +function goDeep(path, ...args) { + return path ? path.split('.').reduce((dataArr, step) => ( + dataArr.map(data => data[step]) + ), args) : args; +} + +export function getDifferentChildren(prevData, nextData, path=null) { + if (path) + [prevData, nextData] = goDeep(path, prevData, nextData); + return Object.keys(nextData).reduce((store, key) => { + if (key in prevData) + store[prevData[key] === nextData[key] ? 'same' : 'updated'].push(key); + else + store.added.push(key); + return store; + }, { + added: [], + updated: [], + same: [], + deleted: Object.keys(prevData).filter(key => !(key in nextData)), + }); +} + +export function areDifferent(a, b, path=null) { + if (path) + [a, b] = goDeep(path, a, b); + return a !== b || JSON.stringify(a) !== JSON.stringify(b); +} + +export function dataToChoices(data, labelKey) { + return Object.values(data).reduce((choices, object) => { + choices[object.id] = { + value: object.id, + label: object[labelKey], + }; + return choices; + }, {}); +} + +/* +|--------------------------------------------------------- +| Text utils +|--------------------------------------------------------- +*/ + +export function shorten(text, limit) { + if (text && text.length > limit) + return text.slice(0,limit-3) + '...'; + return text; +} + +export function capitalFirst(text) { + return text.charAt(0).toLocaleUpperCase() + text.slice(1); +} + +export function textOrIcon(text, Icon, displayText) { + return displayText ? text : +} + +export const priceFormatter = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }); + +export function formatPrice(price, defaultValue = undefined) { + if (!price || price === '0') { + if (defaultValue !== undefined) + return defaultValue; + else + price = 0; + } + return priceFormatter.format(price); +} + +export function formatDate(date, variant = 'date') { + if (typeof date === 'string') + date = parseISO(date); + switch (variant) { + case 'date': + return lightFormat(date, 'dd/MM/yyyy') + case 'datetime': + return lightFormat(date, 'dd/MM/yyyy HH:mm') + case 'datetimesec': + return lightFormat(date, 'dd/MM/yyyy HH:mm:ss') + case 'fromNow': + return formatDistanceToNow(date); + case 'fromNowStrict': + return formatDistanceToNowStrict(date); + default: + throw Error(`Unknown format '${variant}'`) + } +} + +/* +|--------------------------------------------------------- +| Rights utils +|--------------------------------------------------------- +*/ + +export function hasManagerRights(auth, userAssos) { + return auth.authenticated && ( + auth.user.is_admin || !isEmpty(userAssos) + ); +} diff --git a/src/utils/api.js b/src/utils/api.js index d8e6c52..14be7c6 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -2,6 +2,7 @@ import axios from "axios"; import apiActions from "redux/actions/api"; import { API_URL } from "utils/constants"; import { isEmpty } from "utils/helpers"; +import { formatDistanceToNow, isBefore, intervalToDuration } from 'date-fns' /** * Default axios for the API @@ -69,3 +70,32 @@ export function getStatusActions(dispatch, history) { }; }; + +export function getCountdown(date) { + const today = new Date(); + const begin = new Date(date); + + let duration = intervalToDuration({ + start: begin, + end: today + }); + let result = null; + if(duration.days > 0) { + result = duration.days + " jours " ; + } + if(duration.hours > 0) { + result = result + duration.hours + " h " ; + } + if(duration.minutes > 0) { + result = result + duration.minutes + " min " ; + } + if(duration.seconds > 0) { + result = result + duration.seconds + " sec " ; + } + return result; +} + +export function saleIsOpen(sale) { + const today = new Date(); + return isBefore(new Date(sale.begin_at), today) && isBefore(today, new Date(sale.end_at)); +} From 4f2024bfe8413383f0fc9f7bc8bda9f7c6e1b05b Mon Sep 17 00:00:00 2001 From: Alexandre Brasseur Date: Sun, 13 Dec 2020 20:56:22 +0100 Subject: [PATCH 15/17] Improve the countdown and display a progress bar --- src/components/sales/SaleCard.jsx | 42 +++++++-- src/utils.js | 141 ------------------------------ src/utils/api.js | 22 +++-- 3 files changed, 50 insertions(+), 155 deletions(-) delete mode 100644 src/utils.js diff --git a/src/components/sales/SaleCard.jsx b/src/components/sales/SaleCard.jsx index 7309dfa..32509fd 100644 --- a/src/components/sales/SaleCard.jsx +++ b/src/components/sales/SaleCard.jsx @@ -5,7 +5,10 @@ import { getCountdown, saleIsOpen } from 'utils/api'; import {isPast} from "date-fns"; import { makeStyles } from '@material-ui/core/styles'; -import { Grid, Card, CardContent, CardActions, Chip } from '@material-ui/core'; +import { + Box, Grid, Card, CardContent, CardActions, + Chip, LinearProgress, Typography, +} from '@material-ui/core'; import { Skeleton } from '@material-ui/lab'; import { NavButton } from 'components/common/Nav'; @@ -59,16 +62,35 @@ export function SaleCardSkeleton() { } +function LinearProgressWithLabel(props) { + return ( + + + + + + + + {props.text} + + + + ); +} + export default function SaleCard({sale, ...props}) { const classes = useStyles(); + const [store, changeStore] = React.useState({progress: 0, timeLeft: "Chargement..."}); - React.useEffect(() => { + React.useEffect(() => { const interval = setInterval(() => { if(document.getElementById(sale.id)) { - const count = getCountdown(sale.begin_at) - if(!count) + const count = getCountdown(sale.begin_at); + console.log(count) + if(!count.timer) window.location.reload(false); - document.getElementById(sale.id).children[0].textContent = count; + + changeStore(prevState => ({...prevState, progress: count.nbSeconds, timeLeft: count.timer})); } }, 1000); return () => clearInterval(interval); @@ -92,8 +114,8 @@ export default function SaleCard({sale, ...props}) { {sale.name} - Par {sale.association && sale.association.shortname} - + Par {sale?.association?.shortname} +

{shorten(sale.description, 150)}

@@ -113,10 +135,12 @@ export default function SaleCard({sale, ...props}) { ) : ( - + + /* + />*/ ) } diff --git a/src/utils.js b/src/utils.js deleted file mode 100644 index ed908ba..0000000 --- a/src/utils.js +++ /dev/null @@ -1,141 +0,0 @@ -import React from 'react'; -import {formatDistanceToNow, formatDistanceToNowStrict, lightFormat, parseISO, isBefore, intervalToDuration} from 'date-fns' - -window.__localeId__ = 'fr' - -/* -|--------------------------------------------------------- -| Object utils -|--------------------------------------------------------- -*/ - -export function isList(object) { - return object && object.length !== undefined; -} - -export function isEmpty(object) { - return object == null || (object instanceof Object && Object.keys(object).length === 0); -} - -export function deepcopy(object) { - return JSON.parse(JSON.stringify(object)); -} - -export function stringToGetter(attr) { - return obj => obj[attr]; -} - -export function arrayToMap(array, getKey, getValue) { - if (typeof getKey === 'string') - getKey = stringToGetter(getKey); - if (typeof getValue === 'string') - getValue = stringToGetter(getValue); - - return array.reduce((map, obj) => { - map[getKey(obj)] = getValue ? getValue(obj) : obj; - return map; - }, {}); -} - - -function goDeep(path, ...args) { - return path ? path.split('.').reduce((dataArr, step) => ( - dataArr.map(data => data[step]) - ), args) : args; -} - -export function getDifferentChildren(prevData, nextData, path=null) { - if (path) - [prevData, nextData] = goDeep(path, prevData, nextData); - return Object.keys(nextData).reduce((store, key) => { - if (key in prevData) - store[prevData[key] === nextData[key] ? 'same' : 'updated'].push(key); - else - store.added.push(key); - return store; - }, { - added: [], - updated: [], - same: [], - deleted: Object.keys(prevData).filter(key => !(key in nextData)), - }); -} - -export function areDifferent(a, b, path=null) { - if (path) - [a, b] = goDeep(path, a, b); - return a !== b || JSON.stringify(a) !== JSON.stringify(b); -} - -export function dataToChoices(data, labelKey) { - return Object.values(data).reduce((choices, object) => { - choices[object.id] = { - value: object.id, - label: object[labelKey], - }; - return choices; - }, {}); -} - -/* -|--------------------------------------------------------- -| Text utils -|--------------------------------------------------------- -*/ - -export function shorten(text, limit) { - if (text && text.length > limit) - return text.slice(0,limit-3) + '...'; - return text; -} - -export function capitalFirst(text) { - return text.charAt(0).toLocaleUpperCase() + text.slice(1); -} - -export function textOrIcon(text, Icon, displayText) { - return displayText ? text : -} - -export const priceFormatter = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }); - -export function formatPrice(price, defaultValue = undefined) { - if (!price || price === '0') { - if (defaultValue !== undefined) - return defaultValue; - else - price = 0; - } - return priceFormatter.format(price); -} - -export function formatDate(date, variant = 'date') { - if (typeof date === 'string') - date = parseISO(date); - switch (variant) { - case 'date': - return lightFormat(date, 'dd/MM/yyyy') - case 'datetime': - return lightFormat(date, 'dd/MM/yyyy HH:mm') - case 'datetimesec': - return lightFormat(date, 'dd/MM/yyyy HH:mm:ss') - case 'fromNow': - return formatDistanceToNow(date); - case 'fromNowStrict': - return formatDistanceToNowStrict(date); - default: - throw Error(`Unknown format '${variant}'`) - } -} - -/* -|--------------------------------------------------------- -| Rights utils -|--------------------------------------------------------- -*/ - -export function hasManagerRights(auth, userAssos) { - return auth.authenticated && ( - auth.user.is_admin || !isEmpty(userAssos) - ); -} diff --git a/src/utils/api.js b/src/utils/api.js index 14be7c6..ee92fe9 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -80,19 +80,31 @@ export function getCountdown(date) { end: today }); let result = null; + let durationSec = 0; if(duration.days > 0) { - result = duration.days + " jours " ; + result = duration.days + "jours" ; + durationSec += duration.days * 24 * 3600; } if(duration.hours > 0) { - result = result + duration.hours + " h " ; + result = result + duration.hours + "h" ; + durationSec += duration.hours * 3600; } if(duration.minutes > 0) { - result = result + duration.minutes + " min " ; + result = result + duration.minutes + "min" ; + durationSec += duration.minutes * 60; } if(duration.seconds > 0) { - result = result + duration.seconds + " sec " ; + result = result + duration.seconds + "sec " ; + durationSec += duration.seconds; } - return result; + + if(durationSec > 3600) + durationSec = 0; + else { + durationSec = Math.round((3600/(durationSec + 3600))*100); + } + + return {timer: result, nbSeconds: durationSec}; } export function saleIsOpen(sale) { From 46ac0dce50723042275e0b55d3be7578936e27b3 Mon Sep 17 00:00:00 2001 From: Alexandre Brasseur Date: Sun, 13 Dec 2020 21:11:47 +0100 Subject: [PATCH 16/17] Move the timer to sale page --- src/components/sales/SaleCard.jsx | 64 +++--------------- src/pages/public/SaleDetail.jsx | 109 +++++++++++++++++++++++------- 2 files changed, 95 insertions(+), 78 deletions(-) diff --git a/src/components/sales/SaleCard.jsx b/src/components/sales/SaleCard.jsx index 32509fd..249977b 100644 --- a/src/components/sales/SaleCard.jsx +++ b/src/components/sales/SaleCard.jsx @@ -12,7 +12,6 @@ import { import { Skeleton } from '@material-ui/lab'; import { NavButton } from 'components/common/Nav'; - const useStyles = makeStyles(theme => ({ card: { height: '100%', @@ -62,48 +61,16 @@ export function SaleCardSkeleton() { } -function LinearProgressWithLabel(props) { - return ( - - - - - - - - {props.text} - - - - ); -} - export default function SaleCard({sale, ...props}) { const classes = useStyles(); - const [store, changeStore] = React.useState({progress: 0, timeLeft: "Chargement..."}); - - React.useEffect(() => { - const interval = setInterval(() => { - if(document.getElementById(sale.id)) { - const count = getCountdown(sale.begin_at); - console.log(count) - if(!count.timer) - window.location.reload(false); - - changeStore(prevState => ({...prevState, progress: count.nbSeconds, timeLeft: count.timer})); - } - }, 1000); - return () => clearInterval(interval); - }); - function currentSaleState() { if (!sale) return null; if (sale.end_at && isPast(new Date(sale.end_at))) - return ['Terminée !', 'red']; + return {label: 'Terminée !', color: 'red'}; if (sale.begin_at && isPast(new Date(sale.begin_at))) - return ['En cours !', 'green']; - return ['Ouverte prochaine ... ', 'orange']; + return {label: 'En cours !', color: 'green'}; + return {label: 'Ouverte prochaine ... ', color: 'orange'}; } return ( @@ -114,35 +81,26 @@ export default function SaleCard({sale, ...props}) { {sale.name} - Par {sale?.association?.shortname} + Par {sale.association?.shortname}

{shorten(sale.description, 150)}

- - { isPast(new Date(sale.begin_at)) ? ( - - - Accéder à la vente - - - ) : ( - + + + Accéder à la vente + + - /**/ - ) - } diff --git a/src/pages/public/SaleDetail.jsx b/src/pages/public/SaleDetail.jsx index 8b8b5dc..673e96b 100644 --- a/src/pages/public/SaleDetail.jsx +++ b/src/pages/public/SaleDetail.jsx @@ -8,6 +8,7 @@ import { apiAxios } from 'utils/api'; import { isPast } from 'date-fns'; import { formatDate } from 'utils/format'; +import { getCountdown } from 'utils/api'; import { getButtonColoredVariant } from 'utils/styles'; import Loader from 'components/common/Loader'; @@ -15,10 +16,37 @@ import ItemsTable from 'components/sales/ItemsTable'; import UnpaidOrderDialog from 'components/orders/UnpaidOrderDialog'; import { Link } from 'components/common/Nav'; -import { withStyles } from '@material-ui/core/styles'; -import { Container, Box, Grid, Button, Paper, FormControlLabel, Checkbox, Collapse } from '@material-ui/core'; -import { ShoppingCart, Delete } from '@material-ui/icons'; -import { Alert, AlertTitle } from '@material-ui/lab'; +import {withStyles} from '@material-ui/core/styles'; +import { + Container, Box, Grid, Button, Paper, FormControlLabel, Checkbox, + Collapse, LinearProgress,Typography, +} from '@material-ui/core'; +import {ShoppingCart, Delete} from '@material-ui/icons'; +import {Alert, AlertTitle} from '@material-ui/lab'; + + +function LinearProgressWithLabel(props) { + return ( + + {props.value ? ( + + + + + ) : ( + + + + + )} + + + {props.text} + + + + ); +} const connector = connect((store, props) => { @@ -38,6 +66,8 @@ class SaleDetail extends React.Component{ quantities: {}, buying: false, cgvAccepted: false, + progress: 0, + timeLeft: "Chargement...", } componentDidMount() { @@ -50,6 +80,16 @@ class SaleDetail extends React.Component{ if (!this.props.items) this.props.dispatch(apiActions.sales(saleId).items.all()); + + this.interval = setInterval(() => { + if (document.getElementById(this.props.sale.id)) { + const countdown = getCountdown(this.props.sale.begin_at); + if (!countdown.timer) + window.location.reload(false); + + this.setState(prevState => ({...prevState, progress: countdown.nbSeconds, timeLeft: countdown.timer})) + } + }, 1000); } componentDidUpdate(prevProps) { @@ -66,6 +106,10 @@ class SaleDetail extends React.Component{ } } + componentWillUnmount() { + clearInterval(this.interval); + } + // ----------------------------------------------------- // Order handlers // ----------------------------------------------------- @@ -297,17 +341,28 @@ class SaleDetail extends React.Component{ - - - {/* SAVE BUTTON, utile ?? + {saleState === 'NOT_BEGUN' ? ( + + + La vente n'a pas encore commencée, encore un peu de patience + ! + + + + + ) : ( + + + {/* SAVE BUTTON, utile ?? - + + + + + )} + From ce994cff935be9d282d472d27815a06f771b0c9e Mon Sep 17 00:00:00 2001 From: Alexandre Brasseur Date: Sun, 13 Dec 2020 23:43:15 +0100 Subject: [PATCH 17/17] refactor: Move getSaleStatus to utils --- src/components/sales/SaleCard.jsx | 36 ++----- src/pages/public/SaleDetail.jsx | 172 +++++++++++++----------------- src/pages/public/Sales.jsx | 1 - src/utils/api.js | 59 +++------- src/utils/constants.js | 8 ++ src/utils/format.js | 12 ++- 6 files changed, 113 insertions(+), 175 deletions(-) diff --git a/src/components/sales/SaleCard.jsx b/src/components/sales/SaleCard.jsx index 249977b..7e9dbec 100644 --- a/src/components/sales/SaleCard.jsx +++ b/src/components/sales/SaleCard.jsx @@ -1,14 +1,10 @@ import React from 'react'; import PropTypes from 'prop-types'; import { shorten } from 'utils/format'; -import { getCountdown, saleIsOpen } from 'utils/api'; -import {isPast} from "date-fns"; +import { getSaleState } from "utils/api"; import { makeStyles } from '@material-ui/core/styles'; -import { - Box, Grid, Card, CardContent, CardActions, - Chip, LinearProgress, Typography, -} from '@material-ui/core'; +import { Grid, Card, CardContent, CardActions, Chip } from '@material-ui/core'; import { Skeleton } from '@material-ui/lab'; import { NavButton } from 'components/common/Nav'; @@ -63,16 +59,7 @@ export function SaleCardSkeleton() { export default function SaleCard({sale, ...props}) { const classes = useStyles(); - function currentSaleState() { - if (!sale) - return null; - if (sale.end_at && isPast(new Date(sale.end_at))) - return {label: 'Terminée !', color: 'red'}; - if (sale.begin_at && isPast(new Date(sale.begin_at))) - return {label: 'En cours !', color: 'green'}; - return {label: 'Ouverte prochaine ... ', color: 'orange'}; - } - + const currentSaleState = getSaleState(sale); return ( @@ -81,27 +68,22 @@ export default function SaleCard({sale, ...props}) { {sale.name} - Par {sale.association?.shortname} + Par {sale.association?.shortname || "..."}

{shorten(sale.description, 150)}

- - - - - + Accéder à la vente - -
); diff --git a/src/pages/public/SaleDetail.jsx b/src/pages/public/SaleDetail.jsx index 673e96b..f6de53f 100644 --- a/src/pages/public/SaleDetail.jsx +++ b/src/pages/public/SaleDetail.jsx @@ -6,9 +6,9 @@ import apiActions from 'redux/actions/api'; import messagesActions from 'redux/actions/messages'; import { apiAxios } from 'utils/api'; -import { isPast } from 'date-fns'; +import { differenceInSeconds } from 'date-fns' import { formatDate } from 'utils/format'; -import { getCountdown } from 'utils/api'; +import { getSaleState } from 'utils/api'; import { getButtonColoredVariant } from 'utils/styles'; import Loader from 'components/common/Loader'; @@ -19,35 +19,13 @@ import { Link } from 'components/common/Nav'; import {withStyles} from '@material-ui/core/styles'; import { Container, Box, Grid, Button, Paper, FormControlLabel, Checkbox, - Collapse, LinearProgress,Typography, + Collapse, LinearProgress, } from '@material-ui/core'; import {ShoppingCart, Delete} from '@material-ui/icons'; import {Alert, AlertTitle} from '@material-ui/lab'; -function LinearProgressWithLabel(props) { - return ( - - {props.value ? ( - - - - - ) : ( - - - - - )} - - - {props.text} - - - - ); -} - +const COUNTDOWN_MAX = 12 * 3600; // Countdown 12h before const connector = connect((store, props) => { const saleId = props.match.params.sale_id; @@ -66,8 +44,10 @@ class SaleDetail extends React.Component{ quantities: {}, buying: false, cgvAccepted: false, - progress: 0, - timeLeft: "Chargement...", + countdown: undefined, + // progress: 0, + // timeLeft: "Chargement...", + saleState: null, } componentDidMount() { @@ -81,21 +61,16 @@ class SaleDetail extends React.Component{ if (!this.props.items) this.props.dispatch(apiActions.sales(saleId).items.all()); - this.interval = setInterval(() => { - if (document.getElementById(this.props.sale.id)) { - const countdown = getCountdown(this.props.sale.begin_at); - if (!countdown.timer) - window.location.reload(false); - - this.setState(prevState => ({...prevState, progress: countdown.nbSeconds, timeLeft: countdown.timer})) - } - }, 1000); + if (this.props.sale) + this.updateSaleState(); } componentDidUpdate(prevProps) { - const order = this.props.order; + if (prevProps.sale !== this.props.sale) + this.updateSaleState(); // Update quantities from current order + const order = this.props.order; if (prevProps.order !== order && order?.orderlines?.length) { this.setState({ quantities: order.orderlines.reduce((acc, orderline) => { @@ -106,9 +81,31 @@ class SaleDetail extends React.Component{ } } - componentWillUnmount() { - clearInterval(this.interval); - } + componentWillUnmount() { + if (this.interval) + clearInterval(this.interval); + } + + updateSaleState = () => { + if (this.interval) + clearInterval(this.interval); + + const saleState = getSaleState(this.props.sale); + this.setState({ saleState, countdown: undefined }); + + // Start countdown if not begun and less than 24 hours to go + const start = new Date(this.props.sale.begin_at); + if (saleState?.key === 'NOT_BEGUN' && differenceInSeconds(start, new Date()) < COUNTDOWN_MAX) { + this.interval = setInterval(() => { + const secondsLeft = differenceInSeconds(start, new Date()); + const countdown = (COUNTDOWN_MAX - secondsLeft) / COUNTDOWN_MAX * 100; + if (secondsLeft <= 0) + this.updateSaleState(); + else + this.setState(prevState => ({ ...prevState, countdown })); + }, 1000); + } + } // ----------------------------------------------------- // Order handlers @@ -217,17 +214,6 @@ class SaleDetail extends React.Component{ // Display // ----------------------------------------------------- - currentSaleState = () => { - const sale = this.props.sale; - if (!sale) - return null; - if (sale.end_at && isPast(new Date(sale.end_at))) - return 'FINISHED'; - if (sale.begin_at && isPast(new Date(sale.begin_at))) - return 'ONGOING'; - return 'NOT_BEGUN'; - } - hasUnpaidOrder = () => Boolean(this.props.order && this.props.order.status === 3) areItemsDisabled = () => Boolean(!this.props.authenticated || this.hasUnpaidOrder()) @@ -237,17 +223,16 @@ class SaleDetail extends React.Component{ canBuy = () => ( this.props.authenticated && this.state.cgvAccepted - && this.currentSaleState() === 'ONGOING' + && this.state.saleState?.key === 'ONGOING' && this.hasItemsInCart() ) render() { const { classes, sale } = this.props; - const { cgvAccepted } = this.state; + const { cgvAccepted, saleState } = this.state; if (!sale || this.props.fetchingSale) return - const saleState = this.currentSaleState() const CGVLink = props => return ( @@ -279,15 +264,21 @@ class SaleDetail extends React.Component{

Articles en ventes

- {saleState === 'NOT_BEGUN' && ( + {saleState?.key === 'NOT_BEGUN' && ( La vente n'a pas encore commencée - Revenez d'ici {sale.begin_at && formatDate(sale.begin_at, 'fromNowStrict')} pour pouvoir commander. + Encore un peu de patience ! + Revenez d'ici {sale.begin_at && formatDate(sale.begin_at, 'fromNowStrict')} pour pouvoir commander. + {this.state.countdown && ( + + + + )} )} - {saleState === 'FINISHED' && ( + {saleState?.key === 'FINISHED' && ( La vente est terminée @@ -295,7 +286,7 @@ class SaleDetail extends React.Component{ )} - {saleState === 'ONGOING' && ( + {saleState?.key === 'ONGOING' && ( - {saleState === 'NOT_BEGUN' ? ( - - - La vente n'a pas encore commencée, encore un peu de patience - ! - - - - - ) : ( - - - {/* SAVE BUTTON, utile ?? + - */} + {/* SAVE BUTTON, utile ?? - - - - )} + */} + +
- 0) { - result = duration.days + "jours" ; - durationSec += duration.days * 24 * 3600; - } - if(duration.hours > 0) { - result = result + duration.hours + "h" ; - durationSec += duration.hours * 3600; - } - if(duration.minutes > 0) { - result = result + duration.minutes + "min" ; - durationSec += duration.minutes * 60; - } - if(duration.seconds > 0) { - result = result + duration.seconds + "sec " ; - durationSec += duration.seconds; - } - - if(durationSec > 3600) - durationSec = 0; - else { - durationSec = Math.round((3600/(durationSec + 3600))*100); - } - - return {timer: result, nbSeconds: durationSec}; -} - -export function saleIsOpen(sale) { - const today = new Date(); - return isBefore(new Date(sale.begin_at), today) && isBefore(today, new Date(sale.end_at)); -} diff --git a/src/utils/constants.js b/src/utils/constants.js index ef884da..7acc20d 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -12,6 +12,14 @@ export const SLUG_REGEX = /^[a-zA-Z]([-_]?[a-zA-Z0-9])*$/; export const PAGE_SIZES = [10, 25, 50, 100]; export const DEFAULT_PAGE_SIZE = 10; +// Sales + +export const SALE_STATUS = { + NOT_BEGUN: {key: 'NOT_BEGUN', label: 'Ouverte prochaine... ', color: 'orange'}, + ONGOING: {key: 'ONGOING', label: 'En cours !', color: 'green'}, + FINISHED: {key: 'FINISHED', label: 'Terminée !', color: 'red'}, +} + // Orders export const VALID_ORDER_STATUS = [2, 4]; diff --git a/src/utils/format.js b/src/utils/format.js index 2bcac08..0838070 100644 --- a/src/utils/format.js +++ b/src/utils/format.js @@ -5,6 +5,7 @@ import { formatDistanceToNow, formatDistanceToNowStrict, } from "date-fns"; +import { fr } from "date-fns/locale"; window.__localeId__ = 'fr'; @@ -40,17 +41,18 @@ export function formatPrice(price, defaultValue = undefined) { export function formatDate(date, variant = "date") { if (typeof date === "string") date = parseISO(date); + const options = { locale: fr }; switch (variant) { case "date": - return lightFormat(date, "dd/MM/yyyy"); + return lightFormat(date, "dd/MM/yyyy", options); case "datetime": - return lightFormat(date, "dd/MM/yyyy HH:mm"); + return lightFormat(date, "dd/MM/yyyy HH:mm", options); case "datetimesec": - return lightFormat(date, "dd/MM/yyyy HH:mm:ss"); + return lightFormat(date, "dd/MM/yyyy HH:mm:ss", options); case "fromNow": - return formatDistanceToNow(date); + return formatDistanceToNow(date, options); case "fromNowStrict": - return formatDistanceToNowStrict(date); + return formatDistanceToNowStrict(date, options); default: throw Error(`Unknown format '${variant}'`); }