Commit 4cfe777a authored by Florent Chehab's avatar Florent Chehab

Redesign(front theme) 馃帹馃帀 & tweaks (routing, etc.)

* Complete redesign of the frontend theme => mobile friendly++ 馃帹馃帀
* Redesigned how the theme can be customized
* (adapted the backend to store the theme correctly + testing)
* Added a default theme for the app
* Centralized routing in the APP for consistency
* Quick fix to prevent rerendering on layout change in the university page

(backend migration required)

Fixes #19 #20
parent 84ffef76
Pipeline #38656 passed with stages
in 3 minutes and 15 seconds
# Generated by Django 2.1.7 on 2019-04-21 09:14
import backend_app.fields
import backend_app.utils
import backend_app.validation.validators
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("backend_app", "0003_auto_20190417_2135")]
operations = [
migrations.RemoveField(model_name="userdata", name="config"),
migrations.RemoveField(model_name="userdata", name="other_data"),
migrations.AddField(
model_name="userdata",
name="theme",
field=backend_app.fields.JSONField(
default=backend_app.utils.get_default_theme_settings,
validators=[backend_app.validation.validators.ThemeValidator()],
),
),
]
from django.db import models
from rest_framework import serializers
from rest_framework.response import Response
from backend_app.fields import JSONField
from backend_app.models.abstract.base import (
......@@ -7,7 +8,8 @@ from backend_app.models.abstract.base import (
BaseModelSerializer,
BaseModelViewSet,
)
from backend_app.utils import get_user_level
from backend_app.utils import get_user_level, get_default_theme_settings
from backend_app.validation.validators import ThemeValidator
from base_app.models import User
......@@ -15,8 +17,7 @@ class UserData(BaseModel):
moderation_level = 0
owner = models.OneToOneField(User, on_delete=models.CASCADE, primary_key=True)
config = JSONField(default=dict)
other_data = JSONField(default=dict)
theme = JSONField(default=get_default_theme_settings, validators=[ThemeValidator()])
class UserDataSerializer(BaseModelSerializer):
......@@ -69,6 +70,10 @@ class UserDataViewSet(BaseModelViewSet):
serializer_class = UserDataSerializer
end_point_route = "userData"
def list(self, request, *args, **kwargs):
# Prevent the querying of all objects.
return Response(list())
def get_queryset(self):
def get_for_querier():
return UserData.objects.filter(
......
......@@ -13,15 +13,14 @@ class UserDataTestCase(WithUserTestCase):
def setUpMoreTestData(cls):
cls.api_user_data = "/api/{}/".format(UserDataViewSet.end_point_route)
def test_only_one_is_returned(self):
def test_none_is_returned(self):
# make sure there is data in the db
self.assertNotEqual((UserData.objects.all()), 0)
# get the data from the apo
response = self.staff_client.get(self.api_user_data)
content = json.loads(response.content)
# make sure only one is returned ant that it is the one corresponding to the requester
self.assertEqual(len(content), 1)
self.assertEqual(content[0]["id"], self.staff_user.pk)
self.assertEqual(len(content), 0)
def test_get_working(self):
"""
......
......@@ -7,6 +7,7 @@ from backend_app.validation.validators import (
TagNameValidator,
PhotosValidator,
TaggedItemValidator,
ThemeValidator,
)
......@@ -37,6 +38,48 @@ class TestUsefulLinksValidator(object):
self.validator(data)
class TestThemeValidator(object):
validator = ThemeValidator()
@staticmethod
def build(mode, ligth_primary, light_secondary, dark_primary, dark_secondary):
return dict(
mode=mode,
light=dict(primary=ligth_primary, secondary=light_secondary),
dark=dict(primary=dark_primary, secondary=dark_secondary),
)
def test_ok(self):
data = self.build("dark", "#123456", "#123456", "#123456", "#123456")
self.validator(data)
def test_ok_2(self):
data = self.build("light", "#123456", "#123456", "#123456", "#123456")
self.validator(data)
def test_not_ok_mode(self):
data = self.build("darkos", "#123456", "#123456", "#123456", "#123456")
with pytest.raises(ValidationError):
self.validator(data)
def test_not_ok_color(self):
data = self.build("dark", "#1zzzzz", "#123456", "#123456", "#123456")
with pytest.raises(ValidationError):
self.validator(data)
def test_not_ok_extra_1(self):
data = self.build("dark", "#123456", "#123456", "#123456", "#123456")
data["lol"] = "lolilol"
with pytest.raises(ValidationError):
self.validator(data)
def test_not_ok_extra_in_palette(self):
data = self.build("dark", "#123456", "#123456", "#123456", "#123456")
data["light"]["lol"] = "lolilol"
with pytest.raises(ValidationError):
self.validator(data)
class TestPhotosValidator(object):
validator = PhotosValidator()
......
import json
import re
from os.path import join
from backend_app.settings.defaults import OBJ_MODERATION_PERMISSIONS
from base_app.settings.dir_locations import REPO_ROOT_DIR
def get_user_level(user) -> int:
......@@ -40,3 +43,8 @@ def clean_route(route):
# Clean the string
out = out.replace("//", "/")
return out.rstrip("/")
def get_default_theme_settings():
with open(join(REPO_ROOT_DIR, "frontend/src/config/defaultTheme.json"), "r") as f:
return json.load(f)
......@@ -60,6 +60,26 @@
],
"additionalProperties": false
}
},
"palette": {
"type": "object",
"properties": {
"primary": {
"$ref": "definitions.json#/definitions/hex-color"
},
"secondary": {
"$ref": "definitions.json#/definitions/hex-color"
}
},
"required": [
"primary",
"secondary"
],
"additionalProperties": false
},
"hex-color": {
"pattern": "^#[0-9a-f]{6}$",
"type": "string"
}
}
}
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Theme field",
"description": "Schema to validate the theme field (used in the frontend)",
"type": "object",
"properties": {
"mode": {
"description": "What is the selected mode of the theme",
"type": "string",
"enum": [
"light",
"dark"
]
},
"light": {
"description": "Settings in light mode",
"$ref": "definitions.json#/definitions/palette"
},
"dark": {
"description": "Settings in dark mode",
"$ref": "definitions.json#/definitions/palette"
}
},
"required": [
"mode",
"light",
"dark"
],
"additionalProperties": false
}
......@@ -18,6 +18,10 @@ with open(join(schema_dir, "definitions.json"), "r") as f:
DEFINITIONS_SCHEMA = json.load(f)
Draft7Validator.check_schema(DEFINITIONS_SCHEMA)
with open(join(schema_dir, "theme.json"), "r") as f:
THEME_SCHEMA = json.load(f)
Draft7Validator.check_schema(THEME_SCHEMA)
with open(join(schema_dir, "tags_schemas_collection.json"), "r") as f:
TAGS_SCHEMA_COLLECTION = json.load(f)
for key, schema in TAGS_SCHEMA_COLLECTION.items():
......
......@@ -8,6 +8,7 @@ from backend_app.validation.schemas import (
USEFUL_LINKS_SCHEMA,
PHOTOS_SCHEMA,
TAGS_SCHEMA_COLLECTION,
THEME_SCHEMA,
)
from backend_app.validation.utils import DEFINITIONS_RESOLVER, get_schema_for_tag
......@@ -21,14 +22,19 @@ class JsonValidator(object):
"""
# Value to override
validator = None
schema = None
def __init__(self):
self.validator = validator_for(self.schema)(
self.schema, resolver=DEFINITIONS_RESOLVER, format_checker=FORMAT_CHECKER
)
def __call__(self, value):
"""
Perform validation
:param value: Value to validate
:type value: list
:type value: list, dict
:raises: ValidationErrort
"""
try:
......@@ -43,11 +49,7 @@ class UsefulLinksValidator(JsonValidator):
Validator to be used on a JSON field that is suppose to store Useful links
"""
validator = validator_for(USEFUL_LINKS_SCHEMA)(
USEFUL_LINKS_SCHEMA,
resolver=DEFINITIONS_RESOLVER,
format_checker=FORMAT_CHECKER,
)
schema = USEFUL_LINKS_SCHEMA
@deconstructible()
......@@ -56,9 +58,16 @@ class PhotosValidator(JsonValidator):
Validator to be used on a JSON field that is suppose to store photos
"""
validator = validator_for(PHOTOS_SCHEMA)(
PHOTOS_SCHEMA, resolver=DEFINITIONS_RESOLVER, format_checker=FORMAT_CHECKER
)
schema = PHOTOS_SCHEMA
@deconstructible()
class ThemeValidator(JsonValidator):
"""
Validator to be used on a JSON field that is suppose to store the theme data
"""
schema = THEME_SCHEMA
@deconstructible()
......
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 504.142 504.142" style="enable-background:new 0 0 504.142 504.142;" xml:space="preserve">
<g>
<path style="fill:#EB7A0C;" d="M176.797,411.547l99.446-66.298"/>
<g>
<polygon style="fill:#A4C2F7;" points="210.371,302.432 176.797,411.547 276.243,345.247 "/>
<polygon style="fill:#A4C2F7;" points="210.371,302.432 177.334,411.547 151.617,268.858 495.748,92.596 "/>
<path style="fill:#A4C2F7;" d="M487.705,107.971c-3.004,0.098-5.814-1.481-7.293-4.098L210.371,302.432L378.24,411.547
l112.037-304.098C489.45,107.75,488.583,107.926,487.705,107.971z"/>
</g>
<polygon style="fill:#E3E7F2;" points="445.387,126.17 210.371,302.432 361.453,394.76 462.174,117.776 "/>
<path style="fill:#FFFFFF;" d="M468.41,98.245L8.394,193.317l143.222,75.541l323.041-165.461
C471.805,102.919,469.423,100.955,468.41,98.245z"/>
<path style="fill:#E3E7F2;" d="M445.387,117.776L34.109,210.104l117.508,58.754l300.017-145.93
C448.781,122.449,446.4,120.486,445.387,117.776z"/>
<g>
<path style="fill:#428DFF;" d="M378.24,419.94c-1.623,0.001-3.212-0.469-4.574-1.352L205.797,309.473
c-2.312-1.501-3.74-4.041-3.82-6.796c-0.08-2.755,1.197-5.374,3.418-7.007l201.385-148.074l-251.336,128.73
c-2.434,1.249-5.325,1.234-7.746-0.041L4.477,200.743c-3.134-1.654-4.892-5.097-4.394-8.605c0.498-3.508,3.145-6.326,6.615-7.042
L493.895,84.407c3.357-0.756,6.835,0.612,8.779,3.451c0.177,0.251,0.336,0.514,0.475,0.787c0.785,1.46,1.116,3.121,0.951,4.771
c-0.04,0.435-0.117,0.865-0.23,1.287c-0.076,0.3-0.169,0.596-0.279,0.885L386.117,414.448
C384.904,417.75,381.758,419.944,378.24,419.94z M225.117,302.006l148.885,96.771l104.213-282.869L225.117,302.006z M32.994,196.8
l118.68,62.598L436.83,113.342L32.994,196.8z"/>
<path style="fill:#428DFF;" d="M176.805,419.94c-3.7,0.003-6.966-2.417-8.04-5.957c-1.074-3.541,0.297-7.367,3.376-9.42
l99.443-66.295c2.495-1.689,5.704-1.911,8.408-0.582c2.704,1.329,4.488,4.005,4.675,7.012c0.187,3.007-1.253,5.884-3.771,7.537
l-99.443,66.295C180.077,419.448,178.46,419.939,176.805,419.94L176.805,419.94z"/>
<path style="fill:#428DFF;" d="M177.33,419.94c-0.156,0-0.32-0.008-0.484-0.017c-3.873-0.222-7.089-3.068-7.779-6.885
l-25.713-142.689c-0.656-3.637,1.145-7.275,4.435-8.959L491.92,85.128c3.893-1.994,8.664-0.665,10.965,3.054
c2.301,3.719,1.36,8.582-2.162,11.175L217.592,307.538l-32.229,106.442C184.292,417.521,181.028,419.942,177.33,419.94
L177.33,419.94z M160.977,273.489l18.426,102.246l22.935-75.737c0.524-1.735,1.597-3.254,3.057-4.328l201.385-148.074
L160.977,273.489z"/>
<path style="fill:#428DFF;" d="M176.797,419.94c-2.664,0-5.169-1.264-6.752-3.406c-1.583-2.142-2.055-4.909-1.273-7.455
l33.574-109.115c0.774-2.518,2.684-4.526,5.16-5.425c2.476-0.899,5.23-0.584,7.439,0.851l65.869,42.82
c2.368,1.539,3.803,4.166,3.82,6.99c0.017,2.824-1.388,5.467-3.738,7.034l-99.443,66.295
C180.075,419.451,178.454,419.942,176.797,419.94z M215.125,315.53l-23.377,75.959l69.229-46.147L215.125,315.53z"/>
</g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>
Credits
=================
- Favicon: Icon made by [Smashicons](https://www.flaticon.com/authors/smashicons) from [flaticon.com](https://www.flaticon.com/). It is licensed under the [CC 3.0 BY](http://creativecommons.org/licenses/by/3.0/) license.
- Favicon & website logo: Icon made by [Smashicons](https://www.flaticon.com/authors/smashicons) from [flaticon.com](https://www.flaticon.com/). It is licensed under the [CC 3.0 BY](http://creativecommons.org/licenses/by/3.0/) license.
/** General app JS entry
* Inspired by https://github.com/mui-org/material-ui/tree/master/docs/src/pages/page-layout-examples/dashboard
*/
import React from "react";
import PropTypes from "prop-types";
import classNames from "classnames";
import withStyles from "@material-ui/core/styles/withStyles";
import CssBaseline from "@material-ui/core/CssBaseline";
import Drawer from "@material-ui/core/Drawer";
import List from "@material-ui/core/List";
import Divider from "@material-ui/core/Divider";
import IconButton from "@material-ui/core/IconButton";
import MenuIcon from "@material-ui/icons/Menu";
import Chip from "@material-ui/core/Chip";
import Avatar from "@material-ui/core/Avatar";
import ChevronLeftIcon from "@material-ui/icons/ChevronLeft";
import SchoolIcon from "@material-ui/icons/School";
import {mainListItems, secondaryListItems, thirdListItems} from "./listItems";
import FullScreenDialog from "./FullScreenDialog";
import {connect} from "react-redux";
import CustomComponentForAPI from "../common/CustomComponentForAPI";
import {compose} from "recompose";
import {withErrorBoundary} from "../common/ErrorBoundary";
import {Redirect, Route} from "react-router-dom";
import {Route} from "react-router-dom";
import getActions from "../../redux/api/getActions";
......@@ -31,12 +18,13 @@ import PageMap from "../pages/PageMap";
import PageHome from "../pages/PageHome";
import PageUniversity from "../pages/PageUniversity";
import PageSearch from "../pages/PageSearch";
import PageSettings from "../pages/PageSettings";
import PageSettings from "../pages/PageThemeSettings";
import PageUser from "../pages/PageUser";
import PageLists from "../pages/PageLists";
const DRAWER_WIDTH = 240;
import AppFrame from "./AppFrame";
import {APP_ROUTES} from "../../config/appRoutes";
import PageAboutProject from "../pages/PageAboutProject";
import PageAboutConditions from "../pages/PageAboutConditions";
/**
* @class App
......@@ -44,95 +32,24 @@ const DRAWER_WIDTH = 240;
* @extends React.Component
*/
class App extends CustomComponentForAPI {
state = {
open: true,
};
handleDrawerOpen = () => {
this.setState({ open: true });
};
handleDrawerClose = () => {
this.setState({ open: false });
};
customRender() {
const { classes } = this.props;
const {classes} = this.props;
return (
<>
<CssBaseline />
<div className={classes.root}>
<Drawer
variant="permanent"
classes={{
paper: classNames(classes.drawerPaper, !this.state.open && classes.drawerPaperClose),
}}
open={this.state.open}
>
<div className={classes.toolbarIcon}>
<div className={!this.state.open ? classes.hideIt : classes.null}>
<Chip
avatar={<Avatar> <SchoolIcon /> </Avatar>}
label="Outgoing REX"
className={classes.chip}
color="primary"
/>
</div>
<IconButton
onClick={this.handleDrawerOpen}
className={
classNames(
classes.menuButton,
this.state.open ? classes.hideIt : classes.null
)}
>
<MenuIcon />
</IconButton>
<IconButton
onClick={this.handleDrawerClose}
className={
classNames(
classes.menuButton,
!this.state.open ? classes.hideIt : classes.null
)}
>
<ChevronLeftIcon />
</IconButton>
</div>
<Divider />
<List>{mainListItems}</List>
<Divider />
<List>{secondaryListItems}</List>
<Divider />
<List>{thirdListItems}</List>
</Drawer>
<FullScreenDialog />
<main className={classNames(classes.content, classes.noPaddingTop)}>
<div className={classes.paddingTop}>
<Route path="/app/" exact={true} component={PageHome} />
<Route path="/app/search" component={PageSearch} />
<Route path="/app/map" component={PageMap} />
<Route path="/app/settings" component={PageSettings} />
<Route path="/app/lists" component={PageLists} />
<Route
exact
path="/app/university/"
render={() => (<Redirect to="/app/university/undefined/" />)}
/>
<Route path="/app/university/:univId/:tabName?" component={PageUniversity} />
<Route path="/app/user/:userId" component={PageUser} />
</div>
<AppFrame>
<FullScreenDialog/>
<main className={classes.content}>
<Route exact path={APP_ROUTES.base} component={PageHome}/>
<Route path={APP_ROUTES.search} component={PageSearch}/>
<Route path={APP_ROUTES.map} component={PageMap}/>
<Route path={APP_ROUTES.themeSettings} component={PageSettings}/>
<Route path={APP_ROUTES.listsWithParams} component={PageLists}/>
<Route path={APP_ROUTES.universityWithParams} component={PageUniversity}/>
<Route path={APP_ROUTES.userWithParams} component={PageUser}/>
<Route path={APP_ROUTES.aboutProject} component={PageAboutProject}/>
<Route path={APP_ROUTES.aboutConditions} component={PageAboutConditions}/>
</main>
</div>
</AppFrame>
</>
);
}
......@@ -163,73 +80,18 @@ const mapDispatchToProps = (dispatch) => {
const styles = theme => ({
root: {
display: "flex",
},
toolbar: {
paddingRight: 24, // keep right padding when drawer closed
},
toolbarIcon: {
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
padding: "0 8px",
...theme.mixins.toolbar,
},
chip: {
margin: theme.spacing.unit,
},
menuButton: {
marginRight: 4,
},
hideIt: {
display: "none",
},
title: {
flexGrow: 1,
},
drawerPaper: {
position: "relative",
whiteSpace: "nowrap",
width: DRAWER_WIDTH,
transition: theme.transitions.create("width", {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
},
drawerPaperClose: {
overflowX: "hidden",
transition: theme.transitions.create("width", {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
width: theme.spacing.unit * 7,
[theme.breakpoints.up("sm")]: {
width: theme.spacing.unit * 9,
},
},
content: {
flexGrow: 1,
[theme.breakpoints.up("md")]: {
padding: theme.spacing.unit * 3,
height: "100vh",
overflow: "auto",
paddingTop: "0px"
},
paddingTop: {
paddingTop: "24px"
},
chartContainer: {
marginLeft: -22,
[theme.breakpoints.down("sm")]: {
padding: 0,
},
tableContainer: {
height: 320,
},
null: {}
});
export default compose(
connect(mapStateToProps, mapDispatchToProps),
withStyles(styles, { withTheme: true }),
withStyles(styles, {withTheme: true}),
withErrorBoundary(),
)(App);
import React from "react";
import AppBar from "@material-ui/core/AppBar";
import Toolbar from "@material-ui/core/Toolbar";
import IconButton from "@material-ui/core/IconButton";
import MenuIcon from "@material-ui/icons/Menu";
import SettingsIcon from "@material-ui/icons/Settings";
import InfoIcon from "@material-ui/icons/Info";
import Chip from "@material-ui/core/Chip";
import Avatar from "@material-ui/core/Avatar";
import Typography from "@material-ui/core/Typography";
import Button from "@material-ui/core/Button";
import {mainMenuItems, secondaryMenuItems} from "./menuItems";
import withStyles from "@material-ui/core/styles/withStyles";
import PropTypes from "prop-types";
import Fab from "@material-ui/core/Fab";
import IconWithMenu from "../common/IconWithMenu";
import DrawerMenu from "./DrawerMenu";
import {APP_ROUTES} from "../../config/appRoutes";
import CustomNavLink from "../common/CustomNavLink";
import {classNames} from "../../utils/classNames";
const styles = theme => ({
root: {
flexGrow: 1,
},
appBar: {
width: "100%",
},
toolBar: {
width: "100%",
maxWidth: 1920,
display: "flex",
},
content: {
maxWidth: 1920,
},
siteName: {
fontWeight: 900,
},
logoContainer: {
position: "relative",
width: "fit-content",
},
iconChip: {
backgroundColor: "transparent",
borderRadius: 0,
paddingLeft: 1,
paddingRight: 1,
"&:hover": {
cursor: "pointer"
}
},
logoUnderline: {
position: "absolute",
left: 0,
bottom: 2,
height: 9,
backgroundColor: theme.palette.secondary.main,
width: "100%",
zIndex: -1,
},
iconAvatar: {
width: "2.5em",
height: "2.5em",
overflow: "initial",
backgroundColor: "transparent",
right: -4
},
menuButton: {
[theme.breakpoints.up("lg")]: {
display: "none",
},
marginLeft: -12,
marginRight: 20,
},
menuButtonIcon: {
fontSize: "1.5em",
},
mainMenuButton: {
fontSize: "1.2rem",
fontWeight: 700,
[theme.breakpoints.down("md")]: {
display: "none"
},