From 553d795580195c9ff97aa7a31244d447e373dff5 Mon Sep 17 00:00:00 2001 From: Glauber Costa Vila Verde Date: Thu, 9 Nov 2023 16:23:09 -0300 Subject: [PATCH 1/4] A product editing workflow was created. #205 --- backend/core/admin.py | 3 +- .../migrations/0027_product_updated_at.py | 18 ++ ...productfile_created_productfile_updated.py | 25 +++ backend/core/models/product.py | 6 + backend/core/models/product_file.py | 5 +- backend/core/serializers/product.py | 8 +- frontend/components/ProductDetail.js | 21 +- frontend/components/ProductGrid.js | 62 ++++-- frontend/pages/product/edit/[pid].js | 205 ++++++++++++++++++ frontend/services/product.js | 11 + 10 files changed, 340 insertions(+), 24 deletions(-) create mode 100644 backend/core/migrations/0027_product_updated_at.py create mode 100644 backend/core/migrations/0028_productfile_created_productfile_updated.py create mode 100644 frontend/pages/product/edit/[pid].js diff --git a/backend/core/admin.py b/backend/core/admin.py index cc2c12e..fe802c5 100644 --- a/backend/core/admin.py +++ b/backend/core/admin.py @@ -40,6 +40,7 @@ class ProductAdmin(admin.ModelAdmin): "official_product", "pz_code", "created_at", + "updated_at", "status", ) @@ -69,7 +70,7 @@ def has_delete_permission(self, request, obj=None): @admin.register(ProductFile) class ProductFileAdmin(admin.ModelAdmin): list_display = ("id", "product", "file", "role", - "type", "size", "extension") + "type", "size", "extension", "created", "updated") def has_add_permission(self, request): return False diff --git a/backend/core/migrations/0027_product_updated_at.py b/backend/core/migrations/0027_product_updated_at.py new file mode 100644 index 0000000..1d40326 --- /dev/null +++ b/backend/core/migrations/0027_product_updated_at.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.2 on 2023-11-09 13:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0026_productcontent_alias'), + ] + + operations = [ + migrations.AddField( + model_name='product', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/backend/core/migrations/0028_productfile_created_productfile_updated.py b/backend/core/migrations/0028_productfile_created_productfile_updated.py new file mode 100644 index 0000000..556a4b7 --- /dev/null +++ b/backend/core/migrations/0028_productfile_created_productfile_updated.py @@ -0,0 +1,25 @@ +# Generated by Django 4.0.2 on 2023-11-09 13:45 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0027_product_updated_at'), + ] + + operations = [ + migrations.AddField( + model_name='productfile', + name='created', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='productfile', + name='updated', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/backend/core/models/product.py b/backend/core/models/product.py index abbf891..b576d10 100644 --- a/backend/core/models/product.py +++ b/backend/core/models/product.py @@ -40,6 +40,7 @@ class Product(models.Model): pz_code = models.CharField(max_length=55, null=True, blank=True) description = models.TextField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) status = models.IntegerField( verbose_name="Status", default=ProductStatus.REGISTERING, @@ -70,3 +71,8 @@ def can_delete(self, user) -> bool: if self.user.id == user.id or user.profile.is_admin(): return True return False + + def can_update(self, user) -> bool: + if self.user.id == user.id or user.profile.is_admin(): + return True + return False \ No newline at end of file diff --git a/backend/core/models/product_file.py b/backend/core/models/product_file.py index f8cec2a..a9b61cb 100644 --- a/backend/core/models/product_file.py +++ b/backend/core/models/product_file.py @@ -1,6 +1,7 @@ +import os + from core.models import Product from django.db import models -import os def upload_product_files(instance, filename): @@ -34,6 +35,8 @@ class ProductFile(models.Model): extension = models.CharField( verbose_name="Extension", max_length=10, null=True, blank=True ) + created = models.DateTimeField(auto_now_add=True, blank=True) + updated = models.DateTimeField(auto_now=True) def __str__(self): return f"{self.product.display_name} - {os.path.basename(self.file.name)}" diff --git a/backend/core/serializers/product.py b/backend/core/serializers/product.py index 1c6bdde..2e2e3c6 100644 --- a/backend/core/serializers/product.py +++ b/backend/core/serializers/product.py @@ -22,6 +22,8 @@ class ProductSerializer(serializers.ModelSerializer): can_delete = serializers.SerializerMethodField() + can_update = serializers.SerializerMethodField() + class Meta: model = Product read_only_fields = ("internal_name", "is_owner") @@ -48,4 +50,8 @@ def get_is_owner(self, obj): def get_can_delete(self, obj): current_user = self.context["request"].user - return obj.can_delete(current_user) \ No newline at end of file + return obj.can_delete(current_user) + + def get_can_update(self, obj): + current_user = self.context["request"].user + return obj.can_update(current_user) \ No newline at end of file diff --git a/frontend/components/ProductDetail.js b/frontend/components/ProductDetail.js index 80b4d70..cef95d4 100644 --- a/frontend/components/ProductDetail.js +++ b/frontend/components/ProductDetail.js @@ -2,11 +2,10 @@ import ShareIcon from '@mui/icons-material/Share' import VerifiedIcon from '@mui/icons-material/Verified' import LoadingButton from '@mui/lab/LoadingButton' import { - Tabs, - Tab, Box, Card, CardContent, + CardMedia, Chip, Divider, Grid, @@ -17,13 +16,16 @@ import { Paper, Snackbar, Stack, - Typography, - CardMedia + Tab, + Tabs, + Typography } from '@mui/material' import Alert from '@mui/material/Alert' import ProductShare from './ProductShare' +import EditIcon from '@mui/icons-material/Edit' import moment from 'moment' +import { useRouter } from 'next/router' import prettyBytes from 'pretty-bytes' import PropTypes from 'prop-types' import React from 'react' @@ -37,8 +39,8 @@ import { getProducts } from '../services/product' import useStyles from '../styles/pages/product' - export default function ProductDetail({ productId, internalName }) { + const router = useRouter() const classes = useStyles() const [product, setProduct] = React.useState(null) @@ -170,6 +172,10 @@ export default function ProductDetail({ productId, internalName }) { }) } + const handleEdit = row => { + router.push(`/product/edit/${product.internal_name}`) + } + const createFileFields = file => { // Se o nome do arquivo for grande, // exibe só os primeiros caracteres + extensao. @@ -250,6 +256,11 @@ export default function ProductDetail({ productId, internalName }) { )} + {product.can_update === true && ( + + + + )} {product.official_product === true && ( { + router.push(`/product/edit/${row.internal_name}`) + } + + return [ // Hide Id Column ISSUE #123 // { field: 'id', headerName: 'ID', width: 90, sortable: true }, @@ -175,6 +182,29 @@ export default function ProductGrid(props) { ) + }, + { + field: 'can_update', + headerName: 'Edit', + width: 120, + sortable: false, + renderCell: params => ( + +
+ } + onClick={() => handleEdit(params.row)} + disabled={!params.row.can_update} + /> +
+
+ ) } ] }, [getProductUrl, router]) diff --git a/frontend/pages/product/edit/[pid].js b/frontend/pages/product/edit/[pid].js new file mode 100644 index 0000000..a015c46 --- /dev/null +++ b/frontend/pages/product/edit/[pid].js @@ -0,0 +1,205 @@ +import VerifiedIcon from '@mui/icons-material/Verified' +import { + Container, + Typography +} from '@mui/material' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Card from '@mui/material/Card' +import CardActions from '@mui/material/CardActions' +import CardContent from '@mui/material/CardContent' + +import FormControl from '@mui/material/FormControl' +import Grid from '@mui/material/Grid' +import Snackbar from '@mui/material/Snackbar' +import Stack from '@mui/material/Stack' +import TextField from '@mui/material/TextField' +import moment from 'moment' +import { useRouter } from 'next/router' +import { parseCookies } from 'nookies' +import React from 'react' +import Loading from '../../../components/Loading' +import { getProductByInternalName, patchProduct } from '../../../services/product' +import useStyles from '../../../styles/pages/newproduct' +export default function EditProduct() { + const classes = useStyles() + const router = useRouter() + const { pid } = router.query + + const [isOpen, setIsOpen] = React.useState(false) + const [originalProduct, setOriginalProduct] = React.useState(undefined) + const [product, setProduct] = React.useState(undefined) + const [isLoading, setIsLoading] = React.useState(false) + + + const loadProduct = React.useCallback(async () => { + setIsLoading(true) + getProductByInternalName(pid) + .then(res => { + // Apresenta a interface de Produtos + setOriginalProduct(res) + setProduct(res) + setIsLoading(false) + }) + .catch(res => { + // Retorna error 404 + // TODO: Tratar os errors e apresentar. + setIsLoading(false) + }) + }, [pid]) + + React.useEffect(() => { + if (pid) { + loadProduct() + } + }, [pid]) + + const handleUpdate = () => { + + patchProduct(product) + .then(res => { + if (res.status === 200) { + setIsLoading(false) + const data = res.data + setProduct(data) + setOriginalProduct(data) + setIsOpen(true) + } + }) + .catch(res => { + if (res.response.status === 400) { + // Tratamento para erro nos campos + // handleFieldsErrors(res.response.data) + } + if (res.response.status === 500) { + // Tratamento erro no backend + // catchFormError(res.response.data) + } + setIsLoading(false) + }) + } + + return ( + + {isLoading && } + + + Edit Product + + + {(product !== undefined) && ( + + + + + + {product.display_name} + {product.official_product === true && ( + } + /> + )} + + + + Created at:{' '} + {moment(product.created_at).format('L LTS')} + + + Uploaded by: {product.uploaded_by} + + + + + {product.release_name} - {product.product_type_name} + + + + setProduct(prev => { + return { + ...prev, + description: e.target.value + } + })} + // onBlur={handleInputValue} + // error={!!fieldErrors.description} + // helperText={fieldErrors.description} + /> + + + + + + + + + + )} + + setIsOpen(false)} + message="Product has been updated" + /> + + + ) +} + +export const getServerSideProps = async ctx => { + const { 'pzserver.access_token': token } = parseCookies(ctx) + + // A better way to validate this is to have + // an endpoint to verify the validity of the token: + if (!token) { + return { + redirect: { + destination: '/login', + permanent: false + } + } + } + + return { + props: {} + } +} diff --git a/frontend/services/product.js b/frontend/services/product.js index 89f52ff..daa3374 100644 --- a/frontend/services/product.js +++ b/frontend/services/product.js @@ -137,6 +137,17 @@ export const getProduct = product_id => { return api.get(`/api/products/${product_id}/`).then(res => res.data) } +export const getProductByInternalName = (internalName) => { + return api.get(`/api/products/`, { params: { internal_name: internalName } }).then((res) => { + if (res.data.count == 1) { + return res.data.results[0] + } else { + return undefined + } + }); + +} + export const fetchProductData = ({ queryKey }) => { const [_, params] = queryKey const { productId, page, pageSize: page_size } = params From b146027d76ffc5b21c72331822178f82c92b4b0f Mon Sep 17 00:00:00 2001 From: glaubervila Date: Mon, 13 Nov 2023 18:21:12 -0300 Subject: [PATCH 2/4] An option to delete and send files to an existing product has been implemented. #205 --- frontend/components/FileUploader.js | 4 +- frontend/components/ProductFileTextField.js | 82 ++++++++ frontend/components/ProductGrid.js | 34 ++-- frontend/components/newProduct/Step2.js | 46 ++--- frontend/pages/product/edit/[pid].js | 208 +++++++++++++++++--- frontend/services/product.js | 2 + 6 files changed, 305 insertions(+), 71 deletions(-) create mode 100644 frontend/components/ProductFileTextField.js diff --git a/frontend/components/FileUploader.js b/frontend/components/FileUploader.js index 911841a..b3ab978 100644 --- a/frontend/components/FileUploader.js +++ b/frontend/components/FileUploader.js @@ -1,7 +1,7 @@ -import React from 'react' -import { Input, Button } from '@mui/material' +import { Button, Input } from '@mui/material' import prettyBytes from 'pretty-bytes' import PropTypes from 'prop-types' +import React from 'react' export default function FileUploader(props) { const { diff --git a/frontend/components/ProductFileTextField.js b/frontend/components/ProductFileTextField.js new file mode 100644 index 0000000..6104fed --- /dev/null +++ b/frontend/components/ProductFileTextField.js @@ -0,0 +1,82 @@ +import CloseIcon from '@mui/icons-material/Close' +import FormGroup from '@mui/material/FormGroup' +import IconButton from '@mui/material/IconButton' +import InputAdornment from '@mui/material/InputAdornment' +import TextField from '@mui/material/TextField' +import prettyBytes from 'pretty-bytes' +import PropTypes from 'prop-types' +import React from 'react' +import { deleteProductFile } from '../services/product' + +export default function ProductFileTextField(props) { + const { id, role, name, size, readOnly, onDelete } = props + + const handleRemoveFile = () => { + deleteProductFile(id) + .then(() => { + // Forcar um reload dos arquivos + onDelete(id) + }) + .catch(res => { + if (res.response.status === 500) { + // TODO: Tratamento erro no backend + } + }) + } + + const getLabelByRole = role => { + let label = '' + switch (role) { + case 0: + label = 'Main File' + break + case 1: + label = 'Description File' + break + case 2: + label = 'Auxiliary File' + break + } + return label + } + + return ( + + + + + + + ) + } + : {} + } + helperText={prettyBytes(Number(size))} + /> + + ) +} +ProductFileTextField.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + role: PropTypes.number.isRequired, + onDelete: PropTypes.func.isRequired, + readOnly: PropTypes.bool +} +ProductFileTextField.defaultProps = { + readOnly: false +} diff --git a/frontend/components/ProductGrid.js b/frontend/components/ProductGrid.js index 07c0023..3baf70c 100644 --- a/frontend/components/ProductGrid.js +++ b/frontend/components/ProductGrid.js @@ -1,24 +1,23 @@ /* eslint-disable multiline-ternary */ -import DeleteIcon from '@mui/icons-material/Delete'; -import DownloadIcon from '@mui/icons-material/Download'; -import EditIcon from '@mui/icons-material/Edit'; -import ShareIcon from '@mui/icons-material/Share'; -import Alert from '@mui/material/Alert'; -import Link from '@mui/material/Link'; -import Snackbar from '@mui/material/Snackbar'; -import Tooltip from '@mui/material/Tooltip'; -import { DataGrid, GridActionsCellItem } from '@mui/x-data-grid'; -import moment from 'moment'; -import { useRouter } from 'next/router'; -import PropTypes from 'prop-types'; -import * as React from 'react'; -import { getProducts } from '../services/product'; +import DeleteIcon from '@mui/icons-material/Delete' +import DownloadIcon from '@mui/icons-material/Download' +import EditIcon from '@mui/icons-material/Edit' +import ShareIcon from '@mui/icons-material/Share' +import Alert from '@mui/material/Alert' +import Link from '@mui/material/Link' +import Snackbar from '@mui/material/Snackbar' +import Tooltip from '@mui/material/Tooltip' +import { DataGrid, GridActionsCellItem } from '@mui/x-data-grid' +import moment from 'moment' +import { useRouter } from 'next/router' +import PropTypes from 'prop-types' +import * as React from 'react' +import { getProducts } from '../services/product' -import ProductRemove from '../components/ProductRemove'; -import ProductShare from './ProductShare'; +import ProductRemove from '../components/ProductRemove' +import ProductShare from './ProductShare' export default function ProductGrid(props) { - const router = useRouter() const [rows, setRows] = React.useState([]) const [rowCount, setRowCount] = React.useState(0) @@ -91,7 +90,6 @@ export default function ProductGrid(props) { router.push(`/product/edit/${row.internal_name}`) } - return [ // Hide Id Column ISSUE #123 // { field: 'id', headerName: 'ID', width: 90, sortable: true }, diff --git a/frontend/components/newProduct/Step2.js b/frontend/components/newProduct/Step2.js index 038e44a..ff9020e 100644 --- a/frontend/components/newProduct/Step2.js +++ b/frontend/components/newProduct/Step2.js @@ -1,29 +1,30 @@ -import React, { useState, useEffect } from 'react' +import CloseIcon from '@mui/icons-material/Close' +import UploadIcon from '@mui/icons-material/Upload' import { + Alert, + Box, + Button, + FormGroup, Grid, - Typography, + Stack, TextField, - FormGroup, - Button, - Box, - Alert, - Stack + Typography } from '@mui/material' -import FileUploader from '../FileUploader' -import Loading from '../Loading' -import LinearProgressWithLabel from '../LinearProgressWithLabel' +import IconButton from '@mui/material/IconButton' +import InputAdornment from '@mui/material/InputAdornment' +import prettyBytes from 'pretty-bytes' +import PropTypes from 'prop-types' +import React, { useEffect, useState } from 'react' import { - getProductFiles, - deleteProductFile, + MAX_UPLOAD_SIZE, createProductFile, + deleteProductFile, + getProductFiles, registryProduct } from '../../services/product' -import InputAdornment from '@mui/material/InputAdornment' -import CloseIcon from '@mui/icons-material/Close' -import UploadIcon from '@mui/icons-material/Upload' -import IconButton from '@mui/material/IconButton' -import prettyBytes from 'pretty-bytes' -import PropTypes from 'prop-types' +import FileUploader from '../FileUploader' +import LinearProgressWithLabel from '../LinearProgressWithLabel' +import Loading from '../Loading' export default function NewProductStep2({ productId, onNext, onPrev }) { const [mainFile, setMainFile] = useState(false) const [mainFileError, setMainFileError] = useState('') @@ -34,7 +35,6 @@ export default function NewProductStep2({ productId, onNext, onPrev }) { const [isLoading, setLoading] = useState(false) const [progress, setProgress] = useState(null) const [formError, setFormError] = React.useState('') - const maxUploadSize = 200 const loadFiles = React.useCallback(async () => { setFormError('') @@ -241,8 +241,8 @@ export default function NewProductStep2({ productId, onNext, onPrev }) { the upload button as many times as necessary). - The maximum upload size is {maxUploadSize}MB. For text files, e.g., CSV, - all commented lines are ignored. Index column is optional. + The maximum upload size is {MAX_UPLOAD_SIZE}MB. For text files, e.g., + CSV, all commented lines are ignored. Index column is optional. For text files, the header is optional (multiline headers are ignored). @@ -267,7 +267,7 @@ export default function NewProductStep2({ productId, onNext, onPrev }) { onFileSelectError={e => { handleFileError(0, e.error) }} - maxSize={maxUploadSize} // 200 MB + maxSize={MAX_UPLOAD_SIZE} // 200 MB buttonProps={{ color: 'primary', disabled: progress !== null, @@ -325,7 +325,7 @@ export default function NewProductStep2({ productId, onNext, onPrev }) { onFileSelectError={e => { handleFileError(2, e.error) }} - maxSize={maxUploadSize} // 200 MB + maxSize={MAX_UPLOAD_SIZE} // 200 MB buttonProps={{ startIcon: , disabled: progress !== null, diff --git a/frontend/pages/product/edit/[pid].js b/frontend/pages/product/edit/[pid].js index a015c46..84fa4ce 100644 --- a/frontend/pages/product/edit/[pid].js +++ b/frontend/pages/product/edit/[pid].js @@ -1,36 +1,62 @@ +import UploadIcon from '@mui/icons-material/Upload' import VerifiedIcon from '@mui/icons-material/Verified' -import { - Container, - Typography -} from '@mui/material' + +import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' import Button from '@mui/material/Button' import Card from '@mui/material/Card' import CardActions from '@mui/material/CardActions' import CardContent from '@mui/material/CardContent' - +import Chip from '@mui/material/Chip' +import Container from '@mui/material/Container' import FormControl from '@mui/material/FormControl' import Grid from '@mui/material/Grid' import Snackbar from '@mui/material/Snackbar' import Stack from '@mui/material/Stack' import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' import moment from 'moment' import { useRouter } from 'next/router' import { parseCookies } from 'nookies' -import React from 'react' +import React, { useState } from 'react' +import FileUploader from '../../../components/FileUploader' +import LinearProgressWithLabel from '../../../components/LinearProgressWithLabel' import Loading from '../../../components/Loading' -import { getProductByInternalName, patchProduct } from '../../../services/product' -import useStyles from '../../../styles/pages/newproduct' +import ProductFileTextField from '../../../components/ProductFileTextField' +import { + MAX_UPLOAD_SIZE, + createProductFile, + getProductByInternalName, + getProductFiles, + patchProduct +} from '../../../services/product' + export default function EditProduct() { - const classes = useStyles() const router = useRouter() const { pid } = router.query const [isOpen, setIsOpen] = React.useState(false) const [originalProduct, setOriginalProduct] = React.useState(undefined) const [product, setProduct] = React.useState(undefined) + const [files, setFiles] = React.useState([]) const [isLoading, setIsLoading] = React.useState(false) + const [fileError, setFileError] = React.useState(undefined) + const [progress, setProgress] = useState(null) + const loadFiles = React.useCallback(async () => { + setIsLoading(true) + getProductFiles(product.id) + .then(res => { + setFiles(res.results) + setIsLoading(false) + }) + .catch(res => { + if (res.response.status === 500) { + // TODO: Tratamento erro no backend + } + setIsLoading(false) + }) + }, [product]) const loadProduct = React.useCallback(async () => { setIsLoading(true) @@ -52,10 +78,15 @@ export default function EditProduct() { if (pid) { loadProduct() } - }, [pid]) + }, [pid, loadProduct]) - const handleUpdate = () => { + React.useEffect(() => { + if (product) { + loadFiles() + } + }, [product, loadFiles]) + const handleUpdate = () => { patchProduct(product) .then(res => { if (res.status === 200) { @@ -79,8 +110,78 @@ export default function EditProduct() { }) } + const handleOnDeleteFile = fileId => { + loadFiles() + } + + const renderDisplayFile = file => { + return ( + + ) + } + + const onProgress = progressEvent => { + const progress = Math.round( + (progressEvent.loaded * 100) / progressEvent.total + ) + setProgress(progress) + } + + const checkFileName = (name, files) => { + let isOk = true + // Verifica se já existe um arquivo com mesmo nome. + files.forEach(f => { + if (f.name === name) { + isOk = false + } + }) + return isOk + } + + const handleUploadFile = file => { + // Os arquivos uploaded nesta página sempre serão auxiliares + const role = 2 + + if (!checkFileName(file.name, files)) { + setFileError('A file with the same name already exists') + return + } + + createProductFile(product.id, file, role, onProgress) + .then(res => { + if (res.status === 201) { + setProgress(null) + loadFiles() + } + }) + .catch(res => { + if (res.response.status === 400) { + // Tratamento erro no backend regra de negocio dos arquivos enviados + if ('file' in res.response.data) { + setFileError(res.response.data.file[0]) + } else { + setFileError(res.response.data.error) + } + } + if (res.response.status === 500) { + // catchFormError(res.response.data) + setFileError(res.response.data.error) + } + setProgress(null) + loadFiles() + }) + } + return ( - + {isLoading && } @@ -96,7 +197,7 @@ export default function EditProduct() { alignItems="center" justifyContent="center" > - {(product !== undefined) && ( + {product !== undefined && ( - + - {product.display_name} + + {product.display_name} + {product.official_product === true && ( Uploaded by: {product.uploaded_by} + + Last update: {moment(product?.updated_at).format('L LTS')} + {product.release_name} - {product.product_type_name} @@ -149,15 +255,17 @@ export default function EditProduct() { label="Description" multiline minRows={6} - onChange={(e) => setProduct(prev => { - return { - ...prev, - description: e.target.value - } - })} - // onBlur={handleInputValue} - // error={!!fieldErrors.description} - // helperText={fieldErrors.description} + onChange={e => + setProduct(prev => { + return { + ...prev, + description: e.target.value + } + }) + } + // onBlur={handleInputValue} + // error={!!fieldErrors.description} + // helperText={fieldErrors.description} /> @@ -165,12 +273,56 @@ export default function EditProduct() { + disabled={ + originalProduct?.description === product?.description + } + onClick={handleUpdate} + > + Update + + + + + + {files.map(row => { + return renderDisplayFile(row) + })} + + {progress && } + + { + handleUploadFile(file) + }} + onFileSelectError={e => { + setFileError(e.error) + }} + maxSize={MAX_UPLOAD_SIZE} // 200 MB + buttonProps={{ + startIcon: , + disabled: progress !== null, + fullWidth: true + }} + /> + {fileError !== undefined && ( + { + setFileError(undefined) + }} + > + {fileError} + + )} + + + + )} @@ -181,7 +333,7 @@ export default function EditProduct() { message="Product has been updated" /> - + ) } diff --git a/frontend/services/product.js b/frontend/services/product.js index daa3374..9bd2cab 100644 --- a/frontend/services/product.js +++ b/frontend/services/product.js @@ -18,6 +18,8 @@ export const downloadProduct = (id, internalName) => { }) } +export const MAX_UPLOAD_SIZE = 200 + // Exemplo de como enviar arquivo via upload: https://dev.to/thomz/uploading-images-to-django-rest-framework-from-forms-in-react-3jhj export const createProduct = data => { const formData = new FormData() From 29bc0198093d29082e572cf92c5dff8da1b11e5b Mon Sep 17 00:00:00 2001 From: glaubervila Date: Mon, 13 Nov 2023 18:34:06 -0300 Subject: [PATCH 3/4] Updated backend tests. Closed #205 --- backend/core/test/test_product.py | 2 ++ backend/core/test/test_product_file.py | 5 ++++- frontend/pages/product/edit/[pid].js | 3 --- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/core/test/test_product.py b/backend/core/test/test_product.py index 8e82eb5..8177516 100644 --- a/backend/core/test/test_product.py +++ b/backend/core/test/test_product.py @@ -289,6 +289,7 @@ def test_product_serialized_format(self): "uploaded_by": self.user.username, "is_owner": True, "can_delete": True, + "can_update": True, "internal_name": self.product.internal_name, "display_name": self.product.display_name, "official_product": self.product.official_product, @@ -296,6 +297,7 @@ def test_product_serialized_format(self): "description": self.product.description, "created_at": self.product.created_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), "status": self.product.status, + "updated_at": self.product.updated_at.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), } response = self.client.get(self.url) diff --git a/backend/core/test/test_product_file.py b/backend/core/test/test_product_file.py index c3087c8..916f4ae 100644 --- a/backend/core/test/test_product_file.py +++ b/backend/core/test/test_product_file.py @@ -9,7 +9,8 @@ from django.contrib.auth.models import User from django.urls import reverse from rest_framework.authtoken.models import Token -from rest_framework.test import APIRequestFactory, APITestCase, force_authenticate +from rest_framework.test import (APIRequestFactory, APITestCase, + force_authenticate) class ProductFileListCreateAPIViewTestCase(APITestCase): @@ -218,6 +219,8 @@ def test_product_file_serialized_format(self): ], "size": self.product_file.file.size, "extension": os.path.splitext(self.product_file.file.name)[1], + "created": self.product_file.created.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + "updated": self.product_file.updated.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), } response = self.client.get(self.url) diff --git a/frontend/pages/product/edit/[pid].js b/frontend/pages/product/edit/[pid].js index 84fa4ce..bcd2e67 100644 --- a/frontend/pages/product/edit/[pid].js +++ b/frontend/pages/product/edit/[pid].js @@ -263,9 +263,6 @@ export default function EditProduct() { } }) } - // onBlur={handleInputValue} - // error={!!fieldErrors.description} - // helperText={fieldErrors.description} /> From 454fd18fb0cac7e3ec2a435dd97ca245c0701907 Mon Sep 17 00:00:00 2001 From: Jandson Vitorino Date: Wed, 29 Nov 2023 14:58:54 -0300 Subject: [PATCH 4/4] I implemented an arrow to make it easier to return to the previous page and improved the visibility of the refresh button, making it more prominent. --- frontend/pages/product/edit/[pid].js | 30 +++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/frontend/pages/product/edit/[pid].js b/frontend/pages/product/edit/[pid].js index bcd2e67..0659575 100644 --- a/frontend/pages/product/edit/[pid].js +++ b/frontend/pages/product/edit/[pid].js @@ -1,5 +1,6 @@ import UploadIcon from '@mui/icons-material/Upload' import VerifiedIcon from '@mui/icons-material/Verified' +import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos' import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' @@ -184,8 +185,29 @@ export default function EditProduct() { {isLoading && } - - Edit Product + + + { + router.back() + }} + sx={{ cursor: 'pointer' }} + /> + Edit Product +