Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add New Drive View #584

Merged
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Convert to LF line endings on checkout.
*.sh text eol=lf
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

29 changes: 23 additions & 6 deletions backend/fleet_management/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,35 @@ class CountryFilter(admin.SimpleListFilter):
title = _("Country")
parameter_name = "country"

def get_parameter(self, obj):
for part in self.parameter_name.split("__"):
obj = getattr(obj, part)
return obj

def lookups(self, request, model_admin):
objects = model_admin.model.objects.distinct(self.parameter_name)
countries = [(o.country.code, o.country.name) for o in objects]
countries = sorted(countries, key=lambda c: c[1]) # sort by name, A-Z
countries = [self.get_parameter(o) for o in set(objects)]
countries = [(c.code, c.name) for c in countries]
countries.sort(key=lambda c: c[1]) # sort by name, A-Z
return [("ALL", _("Global"))] + countries

def queryset(self, request, queryset):
value = self.value()

# "ALL" is special value used for showing global users (with empty country)
# no query was applied, skip filtering
if value is None:
return queryset

# "ALL" is special value used for showing
# global users (those with empty country)
if value == "ALL":
value = ""

if value is not None:
return queryset.filter(**{self.parameter_name: value})
return queryset.filter(**{self.parameter_name: value})

return queryset

class CarCountryFilter(CountryFilter):
parameter_name = "car__country"


class DriveResource(resources.ModelResource):
Expand Down Expand Up @@ -133,13 +145,18 @@ class RefuelAdmin(admin.ModelAdmin):
list_filter = (
"driver",
"car",
CarCountryFilter,
)
list_display = (
"driver",
"car",
"car__country",
"date",
"current_mileage",
"refueled_liters",
"price_per_liter",
"total_cost",
)

def car__country(self, refuel):
return refuel.car.country.name
4 changes: 3 additions & 1 deletion frontend-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@transifex/react": "^0.11.2",
"axios": "^0.21.0",
"case-converter": "^1.0.1",
"formik": "^2.2.6",
"history": "^5.0.0",
"js-cookie": "^2.2.1",
"jss": "^10.6.0",
Expand All @@ -28,7 +29,8 @@
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.1",
"react-world-flags": "^1.4.0",
"web-vitals": "^0.2.4"
"web-vitals": "^0.2.4",
"yup": "^0.32.9"
},
"devDependencies": {
"@babel/cli": "^7.12.10",
Expand Down
6 changes: 6 additions & 0 deletions frontend-react/src/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const routeKeys = {
LOGOUT: 'logout',
NOTFOUND: 'notfound',
DEFAULT: 'default',
DRIVE: 'drive'
}

const routes = [
Expand All @@ -70,6 +71,11 @@ const routes = [
key: routeKeys.NOTFOUND,
component: lazy(() => import('./views/NotFound'))
},
{
path: '/drive',
key: routeKeys.DRIVE,
component: lazy(() => import('./views/Drive'))
},
{
path: '*',
key: routeKeys.DEFAULT,
Expand Down
8 changes: 7 additions & 1 deletion frontend-react/src/theme/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,17 @@ const themeObject = {
},
background: {
default: '#fff',
grayLight: '#e9ecef',
},
sidebar: {
fg: '#fff',
bg: '#338ec9',
}
},
errorMessage: {
fg: '#721c24',
bg: '#f8d7da',
border: '#f5c6cb'
},
},
};

Expand Down
211 changes: 211 additions & 0 deletions frontend-react/src/views/Drive/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import React, { useState, useEffect } from 'react';
import Page from '../../components/Page';
import {
FormControl,
Box,
TextField,
InputLabel,
Select,
MenuItem,
Container,
Button,
Typography,
} from '@material-ui/core';

import useT from '../../utils/translation';
import { useFormik } from 'formik';
import * as yup from 'yup';

// styling
import { useStyles, WhiteBox, ButtonsContainer } from './styles';

const DriveView = () => {
// local state
const [traveled, setTraveled] = useState(0);

// Translated labels
const title = useT('Add new drive');
const errorTitle = useT('Please correct the following error(s):');
const traveledT = useT('traveled');
const submit = useT('Submit');
const reset = useT('Reset');

// separate object to generate fields dynamically
const translatedFieldsLabels = {
date: useT('Date'),
startLocation: useT('Start location'),
mileageStart: useT('Starting mileage'),
project: useT('Project'),
car: useT('Choose a car'),
passenger: useT('Passenger'),
description: useT('Description'),
endLocation: useT('End location'),
mileageEnd: useT('Ending mileage'),
};

const validationSchema = yup.object().shape({
startLocation: yup.string().required(useT('Start location is required')),
mileageStart: yup.number().required(useT('Starting mileage is required')),
project: yup.string().required(useT('Project is required')),
car: yup.string().required(useT('Car is required')),
passenger: yup.string().required(useT('Start location is required')),
endLocation: yup.string().required(useT('End location is required')),
mileageEnd: yup.number().required(useT('Ending mileage is required')),
});

const formik = useFormik({
initialValues: {
date: new Date().toISOString().split('T')[0],
startLocation: '',
mileageStart: 0,
project: '',
car: '',
passenger: '',
description: '',
endLocation: '',
mileageEnd: 0,
},
validateOnChange: false,
validationSchema: validationSchema,
onSubmit: (values) => {
alert(JSON.stringify(values, null, 2));
},
});

useEffect(() => {
const { mileageStart, mileageEnd } = formik.values;
const mileage = mileageEnd - mileageStart;

if (mileage !== traveled && mileage >= 0) {
setTraveled(mileage);
}
}, [formik.values, traveled]);

// values to generate MenuItems in select fields. Will be converted to redux slice.
const selectItems = {
project: ['TestProject1', 'TestProject2', 'TestProject3'],
car: ['Audi', 'Opel', 'Lamborghini'],
passenger: ['Passenger1', 'Passenger2', 'Passenger3'],
};

const selectItemsNames = Object.keys(selectItems);

// generate fieldList array dynamically
const fieldList = Object.keys(formik.values).map((label) => {
let type = 'text';

// set type based on label
if (label.includes('mileage')) {
type = 'number';
} else if (label === 'date') {
type = 'date';
}

const isSelect = selectItemsNames.includes(label);

const result = {
isTextField: !isSelect,
translatedLabel: translatedFieldsLabels[label],
labelName: label,
type,
errorText: formik.errors[label],
};

if (isSelect) {
result.items = selectItems[label];
}

return result;
});

const classes = useStyles();

return (
<Page title="Drive" className={classes.root}>
<Container className={classes.container} maxWidth="md">
{Object.keys(formik.errors).length > 0 ? (
<Box className={classes.errorContainer}>
<Typography
variant="h4"
component="h4"
className={classes.errorTitle}
>
{errorTitle}
</Typography>
<ul>
{Object.values(formik.errors).map((errorText) => (
<li key={errorText}>{errorText}</li>
))}
</ul>
</Box>
) : null}
<form className={classes.formContainer} onSubmit={formik.handleSubmit}>
<Typography variant="h2" component="h2" className={classes.title}>
{title}
</Typography>
<Box display="flex" flexDirection="column">
{fieldList.map((field) => {
if (!field.isTextField) {
const { translatedLabel, labelName, items, errorText } = field;
return (
<WhiteBox key={`${labelName}-select-container`}>
<FormControl variant="outlined" fullWidth>
<InputLabel id={`drive-${labelName}-label`}>
{translatedLabel}
</InputLabel>
<Select
id={`drive-${labelName}`}
name={labelName}
labelId={`drive-${labelName}-label`}
label={translatedLabel}
value={formik.values[labelName]}
onChange={formik.handleChange}
error={!!errorText}
>
{items.map((item) => (
<MenuItem key={item} value={item}>
{item}
</MenuItem>
))}
</Select>
</FormControl>
</WhiteBox>
);
} else {
const { translatedLabel, labelName, type, errorText } = field;
return (
<WhiteBox key={`${labelName}-container`}>
<TextField
id={`drive-${labelName}`}
name={labelName}
variant="outlined"
fullWidth
label={translatedLabel}
type={type}
value={formik.values[labelName]}
onChange={formik.handleChange}
error={!!errorText}
/>
</WhiteBox>
);
}
})}
</Box>
<p>
{traveled} km {traveledT}
</p>
<ButtonsContainer>
<Button type="submit" variant="contained" color="primary">
{submit}
</Button>
<Button variant="contained" onClick={formik.resetForm}>
{reset}
</Button>
</ButtonsContainer>
</form>
</Container>
</Page>
);
};

export default DriveView;
57 changes: 57 additions & 0 deletions frontend-react/src/views/Drive/styles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { makeStyles, styled } from '@material-ui/styles';
import { Box } from '@material-ui/core';

export const useStyles = makeStyles(({ palette }) => ({
root: {
flexGrow: 1,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100%',
},
container: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
background: palette.background.grayLight,
padding: '4rem 2rem',
margin: '0.5rem',
borderRadius: '0.3rem',
},
formContainer: {
minWidth: '60%',
},
title: {
fontSize: '2rem',
fontWeight: 'normal',
marginBottom: '2rem',
},
errorContainer: {
minWidth: '60%',
padding: '.75rem 1.25rem',
marginBottom: '1rem',
fontSize: '1rem',
color: palette.errorMessage.fg,
backgroundColor: palette.errorMessage.bg,
border: `1px solid ${palette.errorMessage.border}`,
borderRadius: '.25rem',
},
errorTitle: {
fontSize: '1.125rem',
fontWeight: 'bold',
},
}));

export const WhiteBox = styled(Box)({
backgroundColor: 'white',
marginBottom: '1.5rem',
'&:last-of-type': {
marginBottom: 'unset',
},
});

export const ButtonsContainer = styled(Box)({
display: 'flex',
justifyContent: 'space-between',
});
Loading