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/App.jsx b/src/App.jsx index e280a0e..5b1c742 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'; @@ -29,7 +29,7 @@ import Error404 from './pages/Error404'; // Public pages import Sales from './pages/public/Sales'; import SaleDetail from './pages/public/SaleDetail'; -import Orders from './pages/public/Orders'; +import UserOrders from './pages/public/UserOrders'; import OrderDetail from './pages/public/OrderDetail'; import ContactForm from "./pages/public/ContactForm"; @@ -58,7 +58,7 @@ class App extends React.Component { componentDidMount() { // Get connected user - store.dispatch(actions.auth().all()); + store.dispatch(apiActions.authUser.get()); } render() { @@ -81,7 +81,7 @@ class App extends React.Component { - + } /> } /> diff --git a/src/components/Header.jsx b/src/components/Header.jsx index 01535f1..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 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'; 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 => ({ @@ -30,12 +31,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.authUser(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/APIDataTable.jsx b/src/components/common/APIDataTable.jsx new file mode 100644 index 0000000..9e893ed --- /dev/null +++ b/src/components/common/APIDataTable.jsx @@ -0,0 +1,110 @@ +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 "utils/constants"; + +import MaterialTable from "material-table"; +import { + Add, Check, Clear, Delete, Edit, GetApp, FilterList, ArrowUpward, + FirstPage, LastPage, NavigateNext, NavigateBefore, Search, ViewColumn +} 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, +} +*/ +// TODO Add sort, search, filtering functionalities +export function paginateResource(resource, transformData = 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: transformData ? transformData(data) : 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: transformData ? transformData(data) : data, + page: query.page, + totalCount: pagination ? pagination.count : data.length, + }; + } + } + return paginateData; +} + +export default function APIDataTable({ path, queryParams = {}, apiOptions = {}, 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, apiOptions); + + return ( + + ); +} + +APIDataTable.propTypes = { + path: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.array, + ]).isRequired, + queryParams: PropTypes.object, + apiOptions: PropTypes.object, + transformData: PropTypes.func, + options: PropTypes.object, +}; diff --git a/src/components/common/DetailsTable.jsx b/src/components/common/DetailsTable.jsx index 779c329..ff5faea 100644 --- a/src/components/common/DetailsTable.jsx +++ b/src/components/common/DetailsTable.jsx @@ -1,63 +1,66 @@ -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 { SkeletonTable } from './Skeletons'; - +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 { SkeletonTable } from "./Skeletons"; const useStyles = makeStyles({ - topborder: { - borderTop: '1px solid rgba(224, 224, 224, 1)', - }, - label: { - fontWeight: 500, - fontSize: '1em', - paddingRight: '1em', - }, - value: { - fontWeight: 200, - }, + topborder: { + borderTop: "1px solid rgba(224, 224, 224, 1)", + }, + label: { + fontWeight: 500, + fontSize: "1em", + paddingRight: "1em", + }, + value: { + fontWeight: 200, + }, }); function renderDetailValue(value) { - if (typeof value === "object") - return JSON.stringify(value); + if (typeof value === "object") { + return JSON.stringify(value); + } - return value; + return value; } -export default function DetailsTable({ data, labels, renderValue }) { - const classes = useStyles(); - if (!data) - 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/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 a9cbd54..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 = { @@ -10,20 +10,20 @@ 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); }, }; 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/OrderButtons.jsx b/src/components/orders/OrderButtons.jsx new file mode 100644 index 0000000..0c95f51 --- /dev/null +++ b/src/components/orders/OrderButtons.jsx @@ -0,0 +1,75 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { Chip, Button, IconButton, CircularProgress } from "@material-ui/core"; + +import { ORDER_STATUS } from "utils/constants"; + + +export function OrderStatusButton({ status, updateStatus, variant, updating, ...props }) { + if (typeof status === "number") + status = ORDER_STATUS[status] || {}; + + const Component = { + chip: Chip, + button: Button, + text: "span", + }[variant]; + + let _props = { + onClick: updateStatus, + ...props, + style: { + backgroundColor: variant === "chip" ? status?.color : undefined, + color: variant === "chip" ? "#fff" : status?.color, + cursor: updateStatus ? "pointer" : null, + ...props.style, + }, + [variant === "chip" ? "label" : "children"]: status?.label || "Inconnu", + }; + + const icon = updating ? : null; + if (variant === "chip") { + _props.icon = icon; + _props.clickable = Boolean(updateStatus); + } else if (variant === "button") { + _props.startIcon = icon; + } + + return ; +} + +OrderStatusButton.propTypes = { + status: PropTypes.oneOfType([ + PropTypes.object, + PropTypes.number, + ]).isRequired, + variant: PropTypes.oneOf(["button", "chip", "text"]), + updateStatus: PropTypes.func, + updating: PropTypes.bool, +}; + +OrderStatusButton.defaultProps = { + variant: "text", +}; + + +export function OrderActionButton({ order, text, Icon, onClick, ...props }) { + return ( + + + + ); +} + +OrderActionButton.propTypes = { + order: PropTypes.object.isRequired, + text: PropTypes.string.isRequired, + Icon: PropTypes.object.isRequired, + onClick: PropTypes.func.isRequired, +}; 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 7039545..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 React from "react" +import PropTypes from "prop-types"; +import { List, ListItem, ListItemText } from "@material-ui/core"; +import { SkeletonList } from "components/common/Skeletons"; +import { isEmpty } from "utils/helpers"; export default function OrderLinesList({ orderlines, items, prefix, empty, ...props }) { @@ -12,20 +12,12 @@ export default function OrderLinesList({ orderlines, items, prefix, empty, ...pr if (isEmpty(orderlines)) return empty; - function getItem(item) { - if (item.name) - return item.name; - else if (items && items.hasOwnProperty(item)) - return items[item].name; - return '...'; - } - return ( - {Object.values(orderlines).map(orderline => ( - + {Object.values(orderlines).map(({ id, quantity, item }, index) => ( + - {prefix}{orderline.quantity} × {getItem(orderline.item)} + {prefix}{quantity} × {item?.name || item?.[item]?.name || "..."} ))} @@ -41,6 +33,6 @@ OrderLinesList.propTypes = { }; OrderLinesList.defaultProps = { - empty: 'Aucun article', - prefix: '', + empty: "Aucun article", + prefix: "", }; diff --git a/src/components/orders/OrdersTable.jsx b/src/components/orders/OrdersTable.jsx new file mode 100644 index 0000000..4c31625 --- /dev/null +++ b/src/components/orders/OrdersTable.jsx @@ -0,0 +1,137 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { useHistory } from "react-router"; +import { useDispatch } from "react-redux"; + +import { formatDate } from "utils/format"; +import { updateOrderStatus, getStatusActions } from "utils/api"; +import { ORDER_STATUS, ORDER_ACTIONS } from "utils/constants"; + +import APIDataTable from "components/common/APIDataTable"; +import { Link } from "components/common/Nav"; +import { OrderStatusButton, OrderActionButton } from "./OrderButtons"; +import OrderLinesList from "./OrderLinesList"; + + +export default function OrdersTable({ items, show, queryParams = {}, ...props }) { + const dispatch = useDispatch(); + const history = useHistory(); + const statusActions = getStatusActions(dispatch, history); + + // Specify columns + const showMap = new Set(show); + const columns = [ + { + title: "N°", + field: "id", + render: (order) => ( + {order.id} + ), + }, + { + title: "Acheteur", + field: "owner", + hidden: !showMap.has("owner"), + }, + { + title: "Vente", + field: "sale", + hidden: !showMap.has("sale"), + render: (order) => ( + {order.sale?.name} + ), + }, + { + title: "Status", + field: "status", + render: (order) => ( + updateOrderStatus(dispatch, order.id, { fetch: true })} + variant="button" + /> + ), + }, + { + title: "Action", + field: "status", + searchable: false, + hidden: !showMap.has("actions"), + render: (order) => ( + + {order.status?.actions?.map(key => ( + + ))} + + ), + }, + { + title: "Mise à jour", + field: "updated_at", + searchable: false, + render: (order) => {formatDate(order.updated_at, "datetime")}, + }, + { + title: "Articles", + field: "orderlines", + searchable: false, + render: (order) => ( + + ), + }, + ]; + + // Configure include query params + let include = new Set((queryParams.include || '').split('')) + if (showMap.has("items") && items === undefined) + include.add("orderlines").add("orderlines__item") + if (showMap.has("sale")) + include.add("sale") + if (showMap.has("owner")) + include.add("owner") + const _queryParams = { ...queryParams, include: Array.from(include).join(',') }; + + return ( + orders.map((order) => ({ + id: order.id, + sale: order.sale, + owner: order.owner ? `${order.owner.first_name} ${order.owner.last_name}` : null, + updated_at: order.updated_at, + status: ORDER_STATUS[order.status] || {}, + orderlines: order.orderlines, + }))} + {...props} + /> + ); +} + +OrdersTable.propTypes = { + items: PropTypes.object, + show: PropTypes.arrayOf( + PropTypes.oneOf([ + "sale", + "owner", + "items", + "actions", + ]) + ), +}; + +OrdersTable.defaultProps = { + items: undefined, + show: [], +}; diff --git a/src/components/orders/UserOrdersList.jsx b/src/components/orders/UserOrdersList.jsx index 69927fd..1c963c6 100644 --- a/src/components/orders/UserOrdersList.jsx +++ b/src/components/orders/UserOrdersList.jsx @@ -1,78 +1,20 @@ -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 React from "react"; +import PropTypes from "prop-types"; +import { useHistory } from "react-router"; +import { useDispatch } from "react-redux"; +import { getStatusActions } from "utils/api"; +import { ORDER_STATUS, ORDER_ACTIONS } from "utils/constants"; import { TableContainer, Table, TableHead, TableBody, - TableRow, TableCell, Button, IconButton -} from '@material-ui/core'; -import { Link } from '../common/Nav'; + TableRow, TableCell +} from "@material-ui/core"; +import { Link } from "components/common/Nav"; +import OrderLinesList from "./OrderLinesList"; +import { OrderStatusButton, OrderActionButton } from "./OrderButtons"; -import OrderLinesList from './OrderLinesList'; -import { ORDER_STATUS, ORDER_ACTIONS, API_URL } from '../../constants'; - -function getStatusActions(dispatch, history, fetchOrders) { - // TODO fetch the right order instead of fetching all orders - return { - download(event) { - const orderId = event.currentTarget.getAttribute('data-order-id'); - window.open(`${API_URL}/orders/${orderId}/pdf?download`, '_blank'); - }, - - modify(event) { - const orderId = event.currentTarget.getAttribute('data-order-id'); - history.push(`/orders/${orderId}`); - }, - - pay(event) { - const saleId = event.currentTarget.getAttribute('data-sale-id'); - history.push(`/sales/${saleId}`); - }, - - cancel(event) { - const orderId = event.currentTarget.getAttribute('data-order-id'); - const action = actions.orders(orderId).delete(); - dispatch(action); - action.payload.finally(fetchOrders); - }, - - updateStatus(event) { - const orderId = event.currentTarget.getAttribute('data-order-id'); - apiAxios.get(`/orders/${orderId}/status`).then(resp => { - if (resp.updated) - fetchOrders(); - }); - }, - }; -}; - - -function ActionButton({ order, text, Icon, onClick }) { - return ( - - - - ); -} - -ActionButton.propTypes = { - order: PropTypes.object.isRequired, - text: PropTypes.string.isRequired, - Icon: PropTypes.object.isRequired, - onClick: PropTypes.func.isRequired, -}; - - -function ActionOrderRow({ order, actions }) { +function UserOrderRow({ order, actions }) { const status = ORDER_STATUS[order.status] || {}; return ( @@ -80,20 +22,18 @@ function ActionOrderRow({ order, actions }) { {order.id} - {order.sale && order.sale.name} + {order.sale?.name} - + /> {status.actions.map(key => ( - @@ -136,7 +76,7 @@ export default function UserOrdersList({ orders, fetchOrders, ...props }) { {Object.values(orders).map(order => ( - ({ +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 => { + 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]?.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 ( @@ -39,7 +50,9 @@ export default function AssoSalesList({ assos, sales, ...props}) { - + - ))} 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 fcdd64b..15c5bcc 100644 --- a/src/components/sales/SaleCard.jsx +++ b/src/components/sales/SaleCard.jsx @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +<<<<<<< HEAD import {shorten } from '../../utils'; import {makeStyles} from '@material-ui/core/styles'; import {Grid, Card, CardContent, CardActions} from '@material-ui/core'; @@ -7,6 +8,15 @@ import {Skeleton} from '@material-ui/lab'; import {NavButton} from '../common/Nav'; import {isPast} from "date-fns"; import Chip from '@material-ui/core/Chip'; +======= +import { shorten } from 'utils/format'; +import { getSaleState } from "utils/api"; + +import { makeStyles } from '@material-ui/core/styles'; +import { Grid, Card, CardContent, CardActions, Chip } from '@material-ui/core'; +import { Skeleton } from '@material-ui/lab'; +import { NavButton } from 'components/common/Nav'; +>>>>>>> ce994cff935be9d282d472d27815a06f771b0c9e const useStyles = makeStyles(theme => ({ card: { @@ -56,6 +66,7 @@ export function SaleCardSkeleton() { ); } +<<<<<<< HEAD export default function SaleCard({sale, ...props}) { const classes = useStyles(); @@ -100,6 +111,36 @@ export default function SaleCard({sale, ...props}) { +======= + +export default function SaleCard({sale, ...props}) { + const classes = useStyles(); + const currentSaleState = getSaleState(sale); + return ( + + + +

+ {sale.name} +

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

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

+ +
+ + + Accéder à la vente + + +>>>>>>> ce994cff935be9d282d472d27815a06f771b0c9e
); diff --git a/src/components/sales/SalesList.jsx b/src/components/sales/SalesList.jsx index c0388f7..31c538c 100644 --- a/src/components/sales/SalesList.jsx +++ b/src/components/sales/SalesList.jsx @@ -1,15 +1,15 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { List, ListItem, ListItemSecondaryAction, ListItemText } from '@material-ui/core'; +import { List, ListItem, ListItemSecondaryAction, ListItemText, IconButton } from '@material-ui/core'; import { NavListItem, NavIconButton } from '../common/Nav'; -import { Add, Edit } from '@material-ui/icons'; -import { SkeletonList } from '../../components/common/Skeletons'; -import { isEmpty } from '../../utils'; +import { Add, Edit, Search } from '@material-ui/icons'; +import { SkeletonList } from 'components/common/Skeletons'; +import { isEmpty } from 'utils/helpers'; -export default function SalesList({ sales, baseUrl, withEdit, assoId, ...props }) { +export default function SalesList({ sales, fetched, fetchMore, 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) ? ( @@ -40,6 +40,16 @@ export default function SalesList({ sales, baseUrl, withEdit, assoId, ...props } )) )} + {fetchMore && ( + + + + + + + + + )} {withEdit && ( @@ -50,12 +60,14 @@ export default function SalesList({ sales, baseUrl, withEdit, assoId, ...props } )} - + ); } SalesList.propTypes = { sales: PropTypes.object, + fetched: PropTypes.bool, + fetchMore: PropTypes.func, baseUrl: PropTypes.string, withEdit: PropTypes.bool, assoId: PropTypes.string, @@ -63,6 +75,8 @@ SalesList.propTypes = { SalesList.defaultProps = { sales: null, + fetched: false, + fetchMore: undefined, baseUrl: '', withEdit: false, assoId: null, 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/Account.jsx b/src/pages/Account.jsx index 202dcc0..12a15af 100644 --- a/src/pages/Account.jsx +++ b/src/pages/Account.jsx @@ -1,17 +1,34 @@ -import React from 'react'; -import { useSelector } from 'react-redux'; -import { useUserOrders } from '../redux/hooks'; +import React from "react"; +import { useDispatch, useSelector } from "react-redux"; +import apiActions from "../redux/actions/api"; -import { Container, Grid } from '@material-ui/core'; -import Loader from '../components/common/Loader'; +import { Container, Grid, Box } from "@material-ui/core"; +import { NavButton } from "../components/common/Nav"; +import Loader from "../components/common/Loader"; -import AccountDetails from '../components/users/AccountDetails'; -import UserOrdersList from '../components/orders/UserOrdersList'; +import AccountDetails from "../components/users/AccountDetails"; +import UserOrdersList from "../components/orders/UserOrdersList"; export default function Account(props) { - const user = useSelector(store => store.getAuthUser()); - const { orders, fetchOrders } = useUserOrders(); + const dispatch = useDispatch(); + const user = useSelector(store => store.api.getAuthUser()); + 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/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/AdminNav.jsx b/src/pages/admin/AdminNav.jsx index f48e3e1..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 }; } @@ -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/AssoDashboard.jsx b/src/pages/admin/AssoDashboard.jsx index 34ad262..6649add 100644 --- a/src/pages/admin/AssoDashboard.jsx +++ b/src/pages/admin/AssoDashboard.jsx @@ -1,51 +1,56 @@ -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"]); + + const { lastFetched, nbPages } = sales.pagination; + const fetchMore = (lastFetched < nbPages) ? () => sales.fetchData(lastFetched + 1) : null; + 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 4155fc7..61ced38 100644 --- a/src/pages/admin/Dashboard.jsx +++ b/src/pages/admin/Dashboard.jsx @@ -1,18 +1,26 @@ -import React from 'react' -import actions from '../../redux/actions'; -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.getAuthRelatedData('associations', {})); - const sales = useSelector(store => store.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 handleFetchSales(assoId) { - dispatch(actions.associations(assoId).sales.all({ include_inactive: true })); + function fetchSales(assoId, page = 1) { + dispatch(apiActions.associations(assoId).sales.all({ page, page_size: 1, include_inactive: true })); } return ( @@ -28,7 +36,7 @@ export default function Dashboard(props) { diff --git a/src/pages/admin/SaleDetail/OrdersList.jsx b/src/pages/admin/SaleDetail/OrdersList.jsx deleted file mode 100644 index d5936fe..0000000 --- a/src/pages/admin/SaleDetail/OrdersList.jsx +++ /dev/null @@ -1,78 +0,0 @@ -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' - - -const queryParams = { - include: 'owner,orderlines', -}; - -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, - }} - - /> - ); -} 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/QuantitiesSold.jsx b/src/pages/admin/SaleDetail/QuantitiesSold.jsx index 45d4b15..a927534 100644 --- a/src/pages/admin/SaleDetail/QuantitiesSold.jsx +++ b/src/pages/admin/SaleDetail/QuantitiesSold.jsx @@ -1,105 +1,110 @@ -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 } from "utils/format"; +import { isEmpty } from "utils/helpers"; 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..4ba00f7 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 "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 + 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..81fd7df 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/format"; -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 OrdersTable from "./OrdersTable"; +import TicketsList from "./TicketsList"; export default function SaleDetail(props) { - const [tab, setTab] = React.useState('quantities'); + const [tab, setTab] = React.useState("quantities"); 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 === 'tickets' && ( + )) || (tab === "tickets" && ( - )) || (tab === 'chart' && ( + )) || (tab === "chart" && (

À venir...

))}
diff --git a/src/pages/admin/SaleEditor/DetailsEditor.jsx b/src/pages/admin/SaleEditor/DetailsEditor.jsx index 3489cd9..8266550 100644 --- a/src/pages/admin/SaleEditor/DetailsEditor.jsx +++ b/src/pages/admin/SaleEditor/DetailsEditor.jsx @@ -1,9 +1,9 @@ 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'; +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; @@ -20,12 +20,6 @@ export default function DetailsEditor({ disabled, editing, isCreator, ...props } {Field.text('id', 'ID', onlyCreate)} {Field.select('association', 'Association', props.assos, onlyCreate)} {Field.text('description', 'Description', { required: true, multiline: true, rows: 4 })} - -{/* - {Field.color('color', 'QCouleuuur')} -*/} - -
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 c40479b..774e4f5 100644 --- a/src/pages/admin/SaleEditor/ItemsManager/ItemEditor.jsx +++ b/src/pages/admin/SaleEditor/ItemsManager/ItemEditor.jsx @@ -1,109 +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 && ( + - {/* SAVE BUTTON, utile ?? + )} + /> + + + + + Pour acheter + + {!this.props.authenticated && ( +
  • + Veuillez vous connecter pour acheter. +
  • + )} + {!this.state.cgvAccepted && ( +
  • + Veuillez accepter les CGV ci-dessus. +
  • + )} +
    +
    +
    +
    + + )} + + + + + + + + + {/* SAVE BUTTON, utile ?? + + */} + - */} - - - - - )} - -
    - - - - -
    - - ) - } + + + + + + + + + ) + } } SaleDetail.propTypes = { diff --git a/src/pages/public/Sales.jsx b/src/pages/public/Sales.jsx index db0fa88..827d532 100644 --- a/src/pages/public/Sales.jsx +++ b/src/pages/public/Sales.jsx @@ -1,25 +1,58 @@ -import React from 'react' -import { useStoreAPIData } from '../../redux/hooks'; +import React from "react"; +import { Container, Box } from "@material-ui/core"; -import { Container, Grid } from '@material-ui/core'; -import SaleCard, { SaleCardSkeleton } from '../../components/sales/SaleCard'; +import APIDataTable from "components/common/APIDataTable"; +import { Link } from "components/common/Nav"; +import { PORTAIL_URL } from "utils/constants"; +import { formatDate } from "utils/format"; +import { useStoreAPIData } from "redux/hooks"; export default function Sales(props) { const sales = useStoreAPIData('sales', { queryParams: { include: 'association', order_by: 'begin_at' }}); - return ( - -

    Liste des ventes

    - - - {sales ? ( - Object.values(sales).map(sale => ) - ) : ( - [...Array(3).keys()].map(index => ) - )} - -
    + + + 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 new file mode 100644 index 0000000..eb95897 --- /dev/null +++ b/src/pages/public/UserOrders.jsx @@ -0,0 +1,24 @@ +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` }} + /> + + + + ); +} 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 && ( + + + + )} +
    + ); +} diff --git a/src/redux/actions.js b/src/redux/actions.js deleted file mode 100644 index 811b508..0000000 --- a/src/redux/actions.js +++ /dev/null @@ -1,323 +0,0 @@ -/** - * Création et gestion automatique des actions que l'on dispatch via redux - * - * @author Samy Nastuzzi - * @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'; - -// Default axios for the api -export const apiAxios = axios.create({ - baseURL: API_URL, - xsrfHeaderName: 'X-CSRFToken', - 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 }, - }), -}; - -/* -|--------------------------------------------------------- -| Action Methods -|--------------------------------------------------------- -*/ - -// Methods calling the API with alliases -export const API_METHODS = { - all: { - type: 'ALL', - method: 'get', - action: 'updateAll', - }, - find: { - type: 'FIND', - method: 'get', - action: 'update', - }, - create: { - type: 'CREATE', - method: 'post', - action: 'insert', - }, - update: { - type: 'UPDATE', - method: 'put', - action: 'update', - }, - remove: { - type: 'DELETE', - method: 'delete', - action: 'delete', - }, - 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, actionHandler); - }, - - /** Define the path for the resource in the store */ - defineUri: action => uri => { - action.uri = uri; - return new Proxy(action, actionHandler); - }, - - setUriFromPath: action => path => { - action.path = path.slice(); - action.uri = path.join('/'); - action.idIsGiven = path.length % 2 === 0; - return new Proxy(action, actionHandler); - }, - - /** Add a valid status */ - addValidStatus: action => validStatus => { - action.validStatus.push(validStatus); - return new Proxy(action, actionHandler); - }, - - /** Define the valid status */ - defineValidStatus: action => validStatus => { - action.validStatus = validStatus; - return new Proxy(action, actionHandler); - }, - - /** Set Action options */ - setOptions: action => options => { - action.options = { ...action.options, ...options }; - return new Proxy(action, actionHandler); - }, - - - // TODO Custom methods - auth: action => (authId = null) => { - action.path = ['auth']; - action.uri = authId ? `/users/${authId}` : 'auth/me'; - return new Proxy(action, actionHandler); - }, -}; - - -/* -|--------------------------------------------------------- -| Handler and APIAction class -|--------------------------------------------------------- -*/ - -// Gestionnaire d'actions (crée dynamiquement les routes api à appeler et où stocker les données) -export const actionHandler = { - get(action, attr) { - // Access instance - if (attr === '_instance') - return action; - - // Real attribute of 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); - - // Build the API query method - const apiMethod = (...args) => { - let id, queryParams, jsonData; - - // 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; - 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); - } - - // 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); - } - - // Not an HTTP Method (ex: actions.users(1)) - if (args.length === 1) - action.addId(args[0]); - return new Proxy(action, actionHandler); - }; - - // 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] }); - }, -}; - -// REST Action management class -export class APIAction { - constructor(axios_instance = apiAxios) { - this.axios = axios_instance; - this.uri = ''; - this.idIsGiven = false; - this.path = []; - this.pathLocked = false; - this.actions = API_METHODS; - this.validStatus = [200, 201, 202, 203, 204, 416]; - this.options = { - type: undefined, - axios: {}, - meta: {}, - action: {}, - }; - - return new Proxy(this, actionHandler); - } - - addUri(step) { - this.uri += `/${step}`; - - if (!this.pathLocked) { - this.path.push(step); - this.idIsGiven = false; - } - } - - addId(id) { - this.uri += `/${id}`; - - if (!this.pathLocked) { - this.path.push(id); - this.idIsGiven = true; - } - } - - generateQueries(queryParams, prefix) { - const queries = []; - - for (const key in queryParams) { - if (queryParams.hasOwnProperty(key)) { - const value = queryParams[key]; - - if (value !== undefined) { - if (Object.is(value)) - queries.push(this.generateQueries(value, true)); - else - queries.push( - `${encodeURIComponent(prefix ? `[${key}]` : key)}=${encodeURIComponent(value)}` - ); - } - } - } - return queries.join('&'); - } - - generateUri(uri, queryParams) { - const queries = this.generateQueries(queryParams); - return uri + (queries.length === 0 ? '' : `?${queries}`); - } - - generateType(action) { - return [ API_PREFIX, this.actions[action].type, ...this.path ].join('_').toUpperCase(); - } - - generateAction(action, queryParams = {}, jsonData = {}) { - const actionData = this.actions[action]; - return { - type: this.options.type || this.generateType(action), - meta: { - action: actionData.action, - validStatus: this.validStatus, - path: this.path, - timestamp: Date.now(), - ...this.options.meta, - }, - payload: this.axios.request({ - url: this.generateUri(this.uri, queryParams), - method: actionData.method, - data: jsonData, - withCredentials: true, - ...this.options.axios, - }), - ...this.options.action, - }; - } -} - -/** - * Actions are created dynamically (each use returns a new APIAction instance) - * Examples: - * - actions.users.all() - * - actions.users(1).orders.create(null, { status: 'ok' }) - */ -export const actions = new Proxy(axios_instance => new APIAction(axios_instance), { - get(target, attr) { - return new APIAction()[attr]; - }, -}); - -export default actions; diff --git a/src/redux/actions/api.js b/src/redux/actions/api.js new file mode 100644 index 0000000..ab587ba --- /dev/null +++ b/src/redux/actions/api.js @@ -0,0 +1,247 @@ +/** + * Création et gestion automatique des actions que l'on dispatch via redux + * + * @author Samy Nastuzzi + * @author Alexandre Brasseur + * + * @copyright Copyright (c) 2018, SiMDE-UTC + * @license GNU GPL-3.0 + */ +import { apiAxios } from 'utils/api'; +import { isObject } from 'utils/helpers'; +import { API_REDUX_PREFIX, DATA_CHANGES, DATA_SCOPES } from 'redux/constants'; + + +/** + * Methods calling the API with alliases + */ +export const API_METHODS = { + all: { + type: 'ALL', + httpMethod: 'get', + dataChange: DATA_CHANGES.ASSIGN, + dataScope: DATA_SCOPES.MULTIPLE, + takesId: false, + }, + find: { + type: 'FIND', + httpMethod: 'get', + dataChange: DATA_CHANGES.ASSIGN, + dataScope: DATA_SCOPES.ONE, + takesId: true, + }, + create: { + type: 'CREATE', + httpMethod: 'post', + dataChange: DATA_CHANGES.ASSIGN, + dataScope: DATA_SCOPES.ONE, + takesId: false, + }, + update: { + type: 'UPDATE', + httpMethod: 'put', + dataChange: DATA_CHANGES.ASSIGN, + dataScope: DATA_SCOPES.ONE, + takesId: true, + }, + delete: { + type: 'DELETE', + httpMethod: 'delete', + dataChange: DATA_CHANGES.REMOVE, + dataScope: DATA_SCOPES.ONE, + takesId: true, + }, + get: { + type: 'GET', + httpMethod: 'get', + dataChange: DATA_CHANGES.ASSIGN, + dataScope: DATA_SCOPES.FULL, + takesId: false, + }, +}; + +/** + * 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] }); + }, +}; + +/** + * Action handler that dynamically creates the URI path to the resource + */ +export const apiActionHandler = { + get(action, attr) { + // Access instance + if (attr === '_instance') + return action; + + // Access a real attribute of this Action + if (action[attr] !== undefined) + return action[attr].bind(action); + + if (attr in apiShortcuts) + return apiShortcuts[attr](action); + + // HTTP Action (ex: `actions.api.users.all()`) + if (attr in API_METHODS) { + return function (...args) { + const methodData = API_METHODS[attr]; + + 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(methodData, queryParams, jsonData); + } + } + + // At this point, we dynamically build the URI + // Example: `actions.api.users` build the URI /users + action.addUri(attr); + + // 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); + } + + return new Proxy(resourceSpecifier, { get: (func, key) => func()[key] }); + }, +}; + +/** + * API Action generator + */ +export class APIAction { + constructor(axiosInstance = apiAxios) { + this.axios = axiosInstance; + this.uri = ''; + this.idIsGiven = false; + this.path = []; + this.pathLocked = false; + this.options = { + type: undefined, + axios: {}, + meta: {}, + }; + + return new Proxy(this, apiActionHandler); + } + + configure(modify) { + if (modify) + modify(this); + return new Proxy(this, apiActionHandler); + } + + addUri(step) { + this.uri += `/${step}`; + this.idIsGiven = false; + + if (!this.pathLocked) + this.path.push(step); + } + + addId(id) { + this.uri += `/${id}`; + this.idIsGiven = true; + + if (!this.pathLocked) + this.path.push(id); + } + + /** + * 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) { + if (queryParams.hasOwnProperty(key)) { + const value = queryParams[key]; + + if (value !== undefined) { + const _key = encodeURIComponent(key); + const prefixedKey = prefix ? `${prefix}[${_key}]` : _key; + if (isObject(value)) + queries.push(this.stringifyQueryParams(value, prefixedKey)); + else + queries.push(`${prefixedKey}=${encodeURIComponent(value)}`); + } + } + } + return queries.join('&'); + } + + generateUri(uri, queryParams = {}) { + const queries = this.stringifyQueryParams(queryParams); + return uri + (queries.length > 0 ? `?${queries}`: ''); + } + + generateType(methodType) { + return [ API_REDUX_PREFIX, methodType, ...this.path ].join('_').toUpperCase(); + } + + generateAction(methodData, queryParams = {}, jsonData = {}) { + return { + type: this.options.type || this.generateType(methodData.type), + meta: { + 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: methodData.httpMethod, + data: jsonData, + withCredentials: true, + ...this.options.axios, + }), + }; + } +} + +/** + * Actions are created dynamically (each use returns a new APIAction instance) + * Examples: + * - actions.api.users.all() + * - actions.api.users(1).orders.create(null, { status: 'ok' }) + */ +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 apiActions; 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..c58ec68 --- /dev/null +++ b/src/redux/actions/messages.js @@ -0,0 +1,37 @@ +import { MESSAGE_REDUX_PREFIX } from "redux/constants"; + +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_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 messagesActions; diff --git a/src/redux/constants.js b/src/redux/constants.js new file mode 100644 index 0000000..b69b9e0 --- /dev/null +++ b/src/redux/constants.js @@ -0,0 +1,28 @@ +/** + * Type prefixes for each reducer + */ +export const API_REDUX_PREFIX = "API"; +export const MESSAGE_REDUX_PREFIX = "MESSAGE"; + +/** + * Redux promises suffixes + */ +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/hooks.js b/src/redux/hooks.js index 354f76e..1e94e82 100644 --- a/src/redux/hooks.js +++ b/src/redux/hooks.js @@ -1,90 +1,35 @@ -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'; - - -const USE_API_STORE_DEFAULT_OPTIONS = { - action: 'get', - queryParams: undefined, - jsonData: undefined, - - raiseError: true, - fetchingValue: undefined, -}; - -// FIXME Process pagination -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'; - - // Get data from redux store - const dispatch = useDispatch(); - const resource = useSelector(store => store.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]); - - if (!resource.fetched || resource.fetching) - return options.fetchingValue; - - return resource.data; -} - -export async function useUpdateOrderStatus(orderId, auto = { fetch: false, redirect: false }) { - const dispatch = useDispatch(); - const resp = (await apiAxios.get(`/orders/${orderId}/status`)).data - - const fetchOrder = () => dispatch(actions.orders.find(orderId)); - const redirectToPayment = () => resp.redirect_url ? window.location.href = resp.redirect_url : null; - - if (auto.fetch && resp.updated) - fetchOrder(); - if (auto.redirect && resp.redirect_url) - redirectToPayment(); - - return { resp, fetchOrder, redirectToPayment }; -} - -function fetchOrders(dispatch, userId) { - dispatch( - actions - .defineUri(`users/${userId}/orders`) - .definePath(['auth', 'orders']) - .all({ - order_by: '-id', - include: 'sale,orderlines,orderlines__item,orderlines__orderlineitems', - }) +import useDeepCompareEffect from "use-deep-compare-effect"; +import { useSelector, useDispatch } from "react-redux"; +import { pathToArray } from "./reducers/api"; +import { APIAction, API_METHODS } from "./actions/api"; + +/** + * 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 || ( + options.singleElement ? API_METHODS.find : API_METHODS.all ); -} -export function useUserOrders() { + // Get data from redux store const dispatch = useDispatch(); - const userId = useSelector(store => store.getAuthUser('id', null)); - const orders = useSelector(store => store.getAuthRelatedData('orders', undefined)); - - useEffect(() => { - if (userId) - fetchOrders(dispatch, userId); - }, [dispatch, userId]); - - return { userId, orders, fetchOrders: () => fetchOrders(dispatch, userId) }; + const resource = useSelector(store => store.api.get(path)); + + function fetchData(additionalQuery = null, returnAction = false) { + const actionGen = new APIAction(); + actionGen.path = path; + actionGen.uri = options.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, options, dispatch]); + + return { ...resource, fetchData }; } diff --git a/src/redux/reducers/api.js b/src/redux/reducers/api.js new file mode 100644 index 0000000..d7b7e9b --- /dev/null +++ b/src/redux/reducers/api.js @@ -0,0 +1,336 @@ +import produce from 'immer'; +import { DEFAULT_PAGE_SIZE } from 'utils/constants'; +import { deepcopy, isEmpty } from 'utils/helpers'; +import { API_REDUX_PREFIX, ASYNC_SUFFIXES, DATA_CHANGES, DATA_SCOPES } from 'redux/constants'; + +/* +|--------------------------------------------------------- +| 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 []; +} + + +/* +|--------------------------------------------------------- +| 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 DEFAULT_API_STORE = { + + // 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 so simply access it + 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; + }, + + // Custom methods to retrieve the authenticated user resources + 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 +|--------------------------------------------------------- +*/ + +/** + * 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 (meta.idIsGiven) + id = path.pop(); + + return { path, id }; +} + +/** + * 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); +} + +/** + * Make a resource place successful + */ +function makeResourceSuccessful(resource, timestamp, status) { + resource.fetching = false + resource.fetched = true + resource.error = null + resource.failed = false + resource.lastUpdate = timestamp + resource.status = status +} + +/** + * 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}`) + + 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}`) + } +} + +/** + * 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')) || 1, + pageSize: parseInt(params.get('page_size')) || undefined, + }; +} + +/** + * Process pagination in place and return results + */ +export function processPagination(data, place = undefined) { + // No pagination + if (!data.hasOwnProperty('results')) { + if (place) + place.pagination = null; + return { data, pagination: null }; + } + + const { results, ...pagination } = data; + const prevPagination = (place && 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 + const resetPagination = place && prevPagination.pageSize && prevPagination.pageSize !== pageSize; + if (resetPagination) { + place.data = {}; + place.resources = {}; + place.pagination = {}; + } + + const newPagination = { + count: pagination.count, + pageSize, + fetchedPages: { + ...(!resetPagination ? prevPagination.fetchedPages || {} : {}), + [currentPage]: Object.values(results).map(result => result.id), + }, + lastFetched: currentPage, + nbPages: Math.ceil(pagination.count / pageSize), + }; + + if (place) + place.pagination = newPagination; + + return { data: results, pagination: newPagination }; +} + +/** + * 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 (action.type && action.type.startsWith(API_REDUX_PREFIX)) { + return produce(state, draft => { + + // place is the resource place where to store the data + const { path, id } = getPathFromMeta(action.meta); + let place = buildPathInStore(draft, path); + + const callStatus = action.type.split('_').pop(); + switch (callStatus) { + + case ASYNC_SUFFIXES.loading: + place.fetching = true; + place.status = null; + return draft; + + case ASYNC_SUFFIXES.error: + place.fetching = false; + place.error = action.payload; + place.failed = true; + place.status = getStatus(action.payload); + return draft; + + case ASYNC_SUFFIXES.success: + // Get all data need from action + const { data } = processPagination(action.payload.data, place); + const { dataScope, dataChange, timestamp } = action.meta; + + const status = getStatus(action.payload); + 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: + delete place.data; + delete place.resources; + delete place.pagination; + break; + + default: + break; + } + break; + + case DATA_SCOPES.MULTIPLE: + Object.values(data).forEach((element, index) => { + const elementId = element.id || index; + changeOneElement(place, dataChange, elementId, element, timestamp, status); + }); + break; + + default: + break; + } + + return draft; + + default: + 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..3ac1071 --- /dev/null +++ b/src/redux/reducers/messages.jsx @@ -0,0 +1,21 @@ +import { MESSAGE_REDUX_PREFIX } from "redux/constants"; + +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..51eeac2 100644 --- a/src/redux/store.js +++ b/src/redux/store.js @@ -1,399 +1,43 @@ -/** - * 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 -import thunk from 'redux-thunk'; +// Middlewares 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( - thunk, +const middlewares = [ 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; diff --git a/src/styles.js b/src/styles.js deleted file mode 100644 index 276221c..0000000 --- a/src/styles.js +++ /dev/null @@ -1,82 +0,0 @@ -import { makeStyles } from '@material-ui/core/styles'; -import { fade } from "@material-ui/core/styles/colorManipulator"; - - -// Utils - -export function mergeClasses(classes, ...names) { - return names.reduce((merged, name) => ( - name in classes ? `${merged} ${classes[name]}` : merged - ), '').slice(1); -} - -export function getButtonColoredVariant(theme, color, variant) { - const colors = theme.palette[color]; - switch (variant) { - case 'outlined': - return { - color: colors.main, - border: `1px solid ${fade(colors.main, 0.5)}`, - "&:hover": { - border: `1px solid ${colors.main}`, - backgroundColor: fade( - colors.main, - theme.palette.action.hoverOpacity - ), - "@media (hover: none)": { - backgroundColor: "transparent" - } - } - }; - case 'contained': - return { - color: colors.contrastText, - backgroundColor: colors.main, - "&:hover": { - backgroundColor: colors.dark, - "@media (hover: none)": { - backgroundColor: colors.main - } - } - }; - default: - case 'text': - return { - color: colors.main, - "&:hover": { - backgroundColor: fade(colors.main, theme.palette.action.hoverOpacity), - "@media (hover: none)": { - backgroundColor: "transparent" - } - } - }; - } -} - - -// Style decorators - -export const useFormStyles = makeStyles(theme => ({ - 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', - }, - error: { - borderColor: 'red', - }, -})); 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, + }, + }, + }, }); diff --git a/src/utils/api.js b/src/utils/api.js new file mode 100644 index 0000000..6d7f7a0 --- /dev/null +++ b/src/utils/api.js @@ -0,0 +1,84 @@ +import axios from "axios"; +import apiActions from "redux/actions/api"; +import { API_URL, SALE_STATUS } from "utils/constants"; +import { isEmpty } from "utils/helpers"; +import { isPast } from "date-fns"; + +/** + * Default axios for the API + */ +export const apiAxios = axios.create({ + baseURL: API_URL, + xsrfHeaderName: "X-CSRFToken", + xsrfCookieName: "csrftoken", +}); + +/** + * Check that a user a manager rights + */ +export function hasManagerRights(auth, userAssos) { + return auth.authenticated && (auth.user.is_admin || !isEmpty(userAssos)); +} + +/** + * Get the state of a sale + */ +export function getSaleState(sale) { + if (!sale) + return null; + if (sale.end_at && isPast(new Date(sale.end_at))) + return SALE_STATUS.FINISHED; + if (sale.begin_at && isPast(new Date(sale.begin_at))) + return SALE_STATUS.ONGOING; + return SALE_STATUS.NOT_BEGUN; +} + +/** + * 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 })); + }, + }; +}; diff --git a/src/constants.js b/src/utils/constants.js similarity index 73% rename from src/constants.js rename to src/utils/constants.js index 3a47fd6..7acc20d 100644 --- a/src/constants.js +++ b/src/utils/constants.js @@ -1,16 +1,24 @@ -import { - Add, Check, Clear, Delete, Edit, GetApp, FilterList, ArrowUpward, - FirstPage, LastPage, NavigateNext, NavigateBefore, Search, Payment -} from '@material-ui/icons'; +import { Clear, Edit, GetApp, Payment } from '@material-ui/icons'; // Environment variables export const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000'; +export const PORTAIL_URL = 'https://assos.utc.fr'; // Regex -export const REGEX_SLUG = /^[a-zA-Z]([-_]?[a-zA-Z0-9])*$/; +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 @@ -24,14 +32,14 @@ export const ORDER_STATUS = { 4: { color: '#008805', actions: ['download', 'modify'], label: 'Payée' }, 5: { color: '#e00000', actions: [], label: 'Expirée' }, 6: { color: '#e00000', actions: [], label: 'Annulée' }, -} +}; export const ORDER_ACTIONS = { - download: { text: "Télécharger les billets", Icon: GetApp, }, - modify: { text: "Modifier la commande", Icon: Edit, }, + download: { text: "Télécharger les billets", Icon: GetApp, }, + modify: { text: "Modifier la commande", Icon: Edit, }, pay: { text: "Payer la commande", Icon: Payment, }, - cancel: { text: "Annuler la commande", Icon: Clear, }, -} + cancel: { text: "Annuler la commande", Icon: Clear, }, +}; export const STATUS_MESSAGES = { 0: { severity: 'info', message: "Vous pouvez la complèter en cliquant sur le lien suivant.", link: "Finaliser ma commande" }, @@ -41,7 +49,7 @@ export const STATUS_MESSAGES = { 4: { severity: 'success', message: "Vous pouvez télécharger vos billets en utilisant le bouton en base de page ou bien modifier ceux qui sont éditables en cliquant sur les différents champs." }, 5: { severity: 'error', message: "Vous pouvez effectuer une autre commande sur la même vente en cliquant sur le lien suivant.", link: "Effectuer une autre commande" }, 6: { severity: 'error', message: "Vous pouvez effectuer une autre commande sur la même vente en cliquant sur le lien suivant.", link: "Effectuer une autre commande" }, -} +}; // Blank data @@ -89,25 +97,3 @@ export const BLANK_ITEMFIELD = { field: null, editable: true, }; - -// 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, -}; diff --git a/src/utils/format.js b/src/utils/format.js new file mode 100644 index 0000000..0838070 --- /dev/null +++ b/src/utils/format.js @@ -0,0 +1,59 @@ +import React from "react"; +import { + parseISO, + lightFormat, + formatDistanceToNow, + formatDistanceToNowStrict, +} from "date-fns"; +import { fr } from "date-fns/locale"; + +window.__localeId__ = 'fr'; + + +export function textOrIcon(text, Icon, displayText) { + return displayText ? text : +} + +export function shorten(text, limit) { + if (text?.length > limit) + return text.slice(0, limit - 3) + "..."; + return text; +} + +export function capitalFirst(text) { + return text.charAt(0).toLocaleUpperCase() + text.slice(1); +} + +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); + const options = { locale: fr }; + switch (variant) { + case "date": + return lightFormat(date, "dd/MM/yyyy", options); + case "datetime": + return lightFormat(date, "dd/MM/yyyy HH:mm", options); + case "datetimesec": + return lightFormat(date, "dd/MM/yyyy HH:mm:ss", options); + case "fromNow": + return formatDistanceToNow(date, options); + case "fromNowStrict": + return formatDistanceToNowStrict(date, options); + default: + throw Error(`Unknown format '${variant}'`); + } +} diff --git a/src/utils/helpers.js b/src/utils/helpers.js new file mode 100644 index 0000000..dc58c1c --- /dev/null +++ b/src/utils/helpers.js @@ -0,0 +1,79 @@ + +export function isObject(object) { + return typeof object === 'object' && object !== null && !isList(object); +} + +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)); +} + +// ------------------------------------------------- +// Mappers +// ------------------------------------------------- + +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; + }, {}); +} + +export function dataToChoices(data, labelKey) { + return Object.values(data).reduce((choices, object) => { + choices[object.id] = { + value: object.id, + label: object[labelKey], + }; + return choices; + }, {}); +} + +// ------------------------------------------------- +// Comparison +// ------------------------------------------------- + +export 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); +} diff --git a/src/utils/styles.js b/src/utils/styles.js new file mode 100644 index 0000000..daed87c --- /dev/null +++ b/src/utils/styles.js @@ -0,0 +1,76 @@ +import { makeStyles } from "@material-ui/core/styles"; +import { fade } from "@material-ui/core/styles/colorManipulator"; + + +export function mergeClasses(classes, ...names) { + return names.reduce((merged, name) => ( + name in classes ? `${merged} ${classes[name]}` : merged + ), "").slice(1); +} + +export function getButtonColoredVariant(theme, color, variant) { + const colors = theme.palette[color]; + switch (variant) { + case "outlined": + return { + color: colors.main, + border: `1px solid ${fade(colors.main, 0.5)}`, + "&:hover": { + border: `1px solid ${colors.main}`, + backgroundColor: fade(colors.main, theme.palette.action.hoverOpacity), + "@media (hover: none)": { + backgroundColor: "transparent", + }, + }, + }; + case "contained": + return { + color: colors.contrastText, + backgroundColor: colors.main, + "&:hover": { + backgroundColor: colors.dark, + "@media (hover: none)": { + backgroundColor: colors.main, + }, + }, + }; + default: + case "text": + return { + color: colors.main, + "&:hover": { + backgroundColor: fade(colors.main, theme.palette.action.hoverOpacity), + "@media (hover: none)": { + backgroundColor: "transparent", + }, + }, + }; + } +} + +// Style decorators + +export const useFormStyles = makeStyles((theme) => ({ + 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", + }, + error: { + borderColor: "red", + }, +}));