Compare commits

...

6 Commits

Author SHA1 Message Date
17e5f0d5dc Change size column to float type 2022-07-29 12:35:29 +02:00
7742235180 Change price input type 2022-07-29 12:23:30 +02:00
b9bf831001 Fix DDL.sql 2022-07-29 12:15:14 +02:00
b09e8fff28 Remove hard coded credentials 2022-07-29 11:54:09 +02:00
6ce3d64649 Validate forms 2022-07-29 11:33:54 +02:00
16b4d686e3 Make 'Product Add' page 2022-07-28 14:16:55 +02:00
19 changed files with 428 additions and 148 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.phan/

View File

@@ -1,3 +1,5 @@
DROP DATABASE IF EXISTS `products`;
CREATE DATABASE `products`;
USE `products`;
@@ -9,7 +11,7 @@ CREATE TABLE `product` (
`sku` varchar(100) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `sku` (`sku`)
)
);
CREATE TABLE `book` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
@@ -18,16 +20,16 @@ CREATE TABLE `book` (
PRIMARY KEY (`id`),
UNIQUE KEY `book_UN` (`product_id`),
CONSTRAINT `book_FK` FOREIGN KEY (`product_id`) REFERENCES `product` (`id`)
)
);
CREATE TABLE `dvd` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`product_id` int unsigned NOT NULL,
`size` int unsigned NOT NULL,
`size` float NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `dvd_UN` (`product_id`),
CONSTRAINT `dvd_FK` FOREIGN KEY (`product_id`) REFERENCES `product` (`id`)
)
);
CREATE TABLE `furniture` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
@@ -38,5 +40,4 @@ CREATE TABLE `furniture` (
PRIMARY KEY (`id`),
UNIQUE KEY `furniture_UN` (`product_id`),
CONSTRAINT `furniture_FK` FOREIGN KEY (`product_id`) REFERENCES `product` (`id`)
)
);

View File

@@ -1,16 +1,20 @@
<?php
require 'autoload.php';
ini_set('display_errors', true);
ini_set('error_log', '/tmp/php.log');
use ProductList\Http\Request;
use ProductList\Http\RequestHandler;
use ProductList\Http\Route;
$request = new Request($_SERVER);
$request = new Request($_SERVER, $_GET, $_POST);
$handler = new RequestHandler($request);
$handler->registerRoutes([
new Route('GET', 'products', ['ProductList\View\Product', 'list']),
new Route('DELETE', 'products', ['ProductList\View\Product', 'delete']),
new Route('GET', 'test', ['ProductList\View\Product', 'test']),
new Route('GET', 'product', ['ProductList\View\Product', 'get']),
new Route('DELETE', 'product', ['ProductList\View\Product', 'delete']),
new Route('POST', 'product', ['ProductList\View\Product', 'post']),
new Route('GET', 'add-product', function() { readfile('static/add-product.html'); }),
new Route('GET', '', function() { readfile('static/index.html'); }),
]);

View File

@@ -0,0 +1,4 @@
<?php
namespace ProductList\Exception;
class DuplicateException extends \Exception {}

View File

@@ -0,0 +1,11 @@
<?php
namespace ProductList\Exception;
class InvalidFieldException extends \Exception
{
public function __construct($field, $code = 0, Throwable $previous = null)
{
http_response_code(400);
parent::__construct("The field '$field' is invalid.", $code, $previous);
}
}

View File

@@ -5,7 +5,8 @@ class Request
{
private $method;
private $uri;
private $queryString;
private $queryParams;
private $formParams;
public function getMethod()
{
@@ -17,19 +18,25 @@ class Request
return $this->uri;
}
public function getQueryString()
public function getQueryParams()
{
return $this->queryString;
return $this->queryParams;
}
public function __construct(array $params)
public function getFormParams()
{
$uri_base = trim($params['REQUEST_URI'], '?'.$params['QUERY_STRING']);
return $this->formParams;
}
public function __construct(array $params, array $queryParams, array $formParams)
{
$uri_base = strtok($params['REQUEST_URI'], '?');
$uri_base = trim(urldecode($uri_base), '/');
$this->uri = explode('/', $uri_base);
$this->method = $params['REQUEST_METHOD'];
parse_str($params['QUERY_STRING'], $this->queryString);
$this->queryParams = $queryParams;
$this->formParams = $formParams;
}
}

View File

@@ -20,7 +20,12 @@ class RequestHandler
{
foreach ($this->routes as $route) {
if ($route->matches($this->request)) {
$route->execute($this->request);
try {
$route->execute($this->request);
} catch (\Exception $e) {
http_response_code(500);
echo $e->getMessage();
}
return;
}
}

View File

@@ -1,6 +1,8 @@
<?php
namespace ProductList\Model;
use ProductList\Exception\InvalidFieldException;
class Book extends Product
{
private $weight;
@@ -14,6 +16,11 @@ class Book extends Product
$variationId = null
) {
parent::__construct($sku, $name, $price, $productId, $variationId);
if (empty($weight) || !is_numeric($weight)) {
throw new InvalidFieldException('Weight');
}
$this->weight = $weight;
}
@@ -24,8 +31,8 @@ class Book extends Product
$row['name'],
$row['price'],
$row['weight'],
$row['product_id'],
$row['variation_id']
$row['productId'],
$row['variationId']
);
}
@@ -42,8 +49,15 @@ class Book extends Product
private static function getSelectAllQuery() : string
{
return 'SELECT '.PRODUCT.'.*, '.BOOK.'.id as variation_id, size
FROM '.PRODUCT.' LEFT JOIN '.BOOK.' ON '.PRODUCT.'.id = '.BOOK.'.product_id';
return '
SELECT
'.PRODUCT.'.*,
'.BOOK.'.id as variationId,
weight
FROM
'.PRODUCT.'
LEFT JOIN
'.BOOK.' ON '.PRODUCT.'.id = '.BOOK.'.product_id';
}
public function insert($conn = null) : int
@@ -81,7 +95,7 @@ class Book extends Product
$stmt->bind_param('i', $variationId);
if ($stmt->execute() === false) {
throw new \Exception("Unable to delete product with id '$id'");
throw new \Exception("Unable to delete product with id '$variationId'");
}
parent::delete($conn);

View File

@@ -1,6 +1,8 @@
<?php
namespace ProductList\Model;
use ProductList\Exception\InvalidFieldException;
class DVD extends Product
{
private $size;
@@ -14,6 +16,11 @@ class DVD extends Product
$variationId = null
) {
parent::__construct($sku, $name, $price, $productId, $variationId);
if (empty($size) || !is_numeric($size)) {
throw new InvalidFieldException('Size');
}
$this->size = $size;
}
@@ -24,8 +31,8 @@ class DVD extends Product
$row['name'],
$row['price'],
$row['size'],
$row['product_id'],
$row['variation_id']
$row['productId'],
$row['variationId']
);
}
@@ -42,8 +49,15 @@ class DVD extends Product
private static function getSelectAllQuery() : string
{
return 'SELECT '.PRODUCT.'.*, '.DVD.'.id as variation_id, size
FROM '.PRODUCT.' LEFT JOIN '.DVD.' ON '.PRODUCT.'.id = '.DVD.'.product_id';
return '
SELECT
'.PRODUCT.'.*,
'.DVD.'.id as variationId,
size
FROM
'.PRODUCT.'
LEFT JOIN
'.DVD.' ON '.PRODUCT.'.id = '.DVD.'.product_id';
}
public function insert($conn = null) : int
@@ -60,13 +74,13 @@ class DVD extends Product
$size = $this->getSize();
$stmt = $conn->prepare("INSERT INTO ".DVD." (product_id, size) VALUES (?, ?);");
$stmt->bind_param('ii', $productId, $size);
$stmt->bind_param('id', $productId, $size);
if ($stmt->execute() === true) {
$this->setVariationId($conn->insert_id);
return $conn->insert_id;
} else {
throw new Exception("Unable to insert object");
throw new \Exception("Unable to insert object");
}
}
@@ -81,7 +95,7 @@ class DVD extends Product
$stmt->bind_param('i', $variationId);
if ($stmt->execute() === false) {
throw new \Exception("Unable to delete product with id '$id'");
throw new \Exception("Unable to delete product with id '$variationId'");
}
parent::delete($conn);

View File

@@ -8,15 +8,10 @@ define("FURNITURE", "furniture");
class Database
{
const SERVERNAME = "127.0.0.1";
const DATABASE = "scandiweb";
const USERNAME = "root";
const PASSWORD = "root";
public static function connect()
{
$conn = new \mysqli(self::SERVERNAME, self::USERNAME, self::PASSWORD);
$conn->select_db(self::DATABASE);
$conn = new \mysqli(getenv('SERVERNAME'), getenv('USERNAME'), getenv('PASSWORD'));
$conn->select_db(getenv('DATABASE'));
return $conn;
}
}

View File

@@ -1,6 +1,8 @@
<?php
namespace ProductList\Model;
use ProductList\Exception\InvalidFieldException;
class Furniture extends Product
{
private $height;
@@ -8,7 +10,7 @@ class Furniture extends Product
private $length;
public function __construct(
$SKU,
$sku,
$name,
$price,
$height,
@@ -17,7 +19,20 @@ class Furniture extends Product
$productId = null,
$variationId = null
) {
parent::__construct($SKU, $name, $price, $productId, $variationId);
parent::__construct($sku, $name, $price, $productId, $variationId);
if (empty($height) || !is_numeric($height)) {
throw new InvalidFieldException('Height');
}
if (empty($width) || !is_numeric($width)) {
throw new InvalidFieldException('Width');
}
if (empty($length) || !is_numeric($length)) {
throw new InvalidFieldException('Length');
}
$this->height = $height;
$this->width = $width;
$this->length = $length;
@@ -32,8 +47,8 @@ class Furniture extends Product
$row['height'],
$row['width'],
$row['length'],
$row['product_id'],
$row['variation_id']
$row['productId'],
$row['variationId']
);
}
@@ -62,9 +77,17 @@ class Furniture extends Product
private static function getSelectAllQuery() : string
{
return 'SELECT '.PRODUCT.'.*, '.FURNITURE.'.id as variation_id, size
FROM '.PRODUCT.'
LEFT JOIN '.FURNITURE.' ON '.PRODUCT.'.id = '.FURNITURE.'.product_id';
return '
SELECT
'.PRODUCT.'.*,
'.FURNITURE.'.id as variationId,
height,
width,
length
FROM
'.PRODUCT.'
LEFT JOIN '.FURNITURE.' ON
'.PRODUCT.'.id = '.FURNITURE.'.product_id';
}
public function insert($conn = null) : int
@@ -107,7 +130,7 @@ class Furniture extends Product
$stmt->bind_param('i', $variationId);
if ($stmt->execute() === false) {
throw new \Exception("Unable to delete product with id '$id'");
throw new \Exception("Unable to delete product with id '$variationId'");
}
parent::delete($conn);

View File

@@ -2,33 +2,49 @@
namespace ProductList\Model;
use ProductList\Exception\NotFoundException;
use ProductList\Exception\InvalidFieldException;
use ProductList\Exception\DuplicateException;
abstract class Product implements \JsonSerializable
{
use Model;
private $variationId;
private $SKU;
private $sku;
private $name;
private $price;
private $productId;
public function __construct(
$SKU,
$sku,
$name,
$price,
$productId = null,
$variationId = null
) {
if (empty($sku)) {
throw new InvalidFieldException('SKU');
}
if (empty($name)) {
throw new InvalidFieldException('Name');
}
if (empty($price) || !is_numeric($price)) {
throw new InvalidFieldException('Price');
}
$this->productId = $productId;
$this->variationId = $variationId;
$this->SKU = $SKU;
$this->sku = $sku;
$this->name = $name;
$this->price = $price;
}
public function getSKU()
{
return $this->SKU;
return $this->sku;
}
public function getName()
@@ -74,7 +90,7 @@ abstract class Product implements \JsonSerializable
$stmt->bind_param('i', $productId);
if ($stmt->execute() === false) {
throw new \Exception("Unable to delete product with id '$id'");
throw new \Exception("Unable to delete product with id '$productId'");
}
}
@@ -102,26 +118,45 @@ abstract class Product implements \JsonSerializable
public static function fromRow($row) : self
{
if ($row['size'] !== null) {
return DVD::fromRow($row);
} elseif ($row['weight'] !== null) {
return Book::fromRow($row);
} elseif ($row['height'] !== null) {
return Furniture::fromRow($row);
} else {
throw new \Exception("Product without a type");
switch ($row['productType']) {
case 'dvd':
return DVD::fromRow($row);
case 'book':
return Book::fromRow($row);
case 'furniture':
return Furniture::fromRow($row);
default:
throw new \Exception("Product without a type");
}
}
private static function getSelectAllQuery() : string
{
return 'SELECT '.PRODUCT.'.id as product_id,
COALESCE('.DVD.'.id, '.BOOK.'.id, '.FURNITURE.'.id) as variation_id,
name, sku, price, size, weight, width, height, length
FROM '.PRODUCT.'
LEFT JOIN '.DVD.' ON '.PRODUCT.'.id = '.DVD.'.product_id
LEFT JOIN '.BOOK.' ON '.PRODUCT.'.id = '.BOOK.'.product_id
LEFT JOIN '.FURNITURE.' ON '.PRODUCT.'.id = '.FURNITURE.'.product_id';
return '
SELECT
'.PRODUCT.'.id as productId,
name,
sku,
price,
size,
weight,
width,
height,
length,
COALESCE('.DVD.'.id, '.BOOK.'.id, '.FURNITURE.'.id) as variationId,
CASE
WHEN '.DVD.'.id IS NOT NULL THEN "dvd"
WHEN '.BOOK.'.id IS NOT NULL THEN "book"
WHEN '.FURNITURE.'.id IS NOT NULL THEN "furniture"
END as productType
FROM
'.PRODUCT.'
LEFT JOIN '.DVD.' ON
'.PRODUCT.'.id = '.DVD.'.product_id
LEFT JOIN '.BOOK.' ON
'.PRODUCT.'.id = '.BOOK.'.product_id
LEFT JOIN '.FURNITURE.' ON
'.PRODUCT.'.id = '.FURNITURE.'.product_id';
}
public function insert($conn = null) : int
@@ -140,11 +175,19 @@ abstract class Product implements \JsonSerializable
);
$stmt->bind_param('ssd', $SKU, $name, $price);
if ($stmt->execute() === true) {
$this->setProductId($conn->insert_id);
return $conn->insert_id;
} else {
throw new \Exception("Unable to insert object");
try {
if ($stmt->execute() === true) {
$this->setProductId($conn->insert_id);
return $conn->insert_id;
} else {
throw new \Exception("Unable to insert object");
}
} catch (\mysqli_sql_exception $e) {
if ($e->getCode() === 1062) {
throw new DuplicateException('The provided SKU is already registered.');
} else {
throw $e;
}
}
}

View File

@@ -5,19 +5,20 @@ use ProductList\Http\Request;
use ProductList\Model\Product as ProductModel;
use ProductList\Exception\NotFoundException;
class Product
class Product extends View
{
public static function list(Request $request)
public static function get(Request $request)
{
header('Content-Type: application/json');
echo json_encode(ProductModel::selectAll());
}
public static function delete(Request $request)
{
$queryString = $request->getQueryString();
$queryParams = $request->getQueryParams();
if (array_key_exists('id', $queryString)) {
$ids = explode(',', $queryString['id']);
if (array_key_exists('id', $queryParams)) {
$ids = explode(',', $queryParams['id']);
$ids = array_map('intval', $ids);
foreach($ids as $id) {
@@ -34,7 +35,7 @@ class Product
} catch (NotFoundException $e) {
http_response_code(404);
echo $e->getMessage();
echo "The selected(s) object(s) is(are) not available anymore.";
return;
}
}
@@ -44,7 +45,27 @@ class Product
}
}
public static function add(Request $request)
public static function post(Request $request)
{
$params= $request->getFormParams();
$expected = [
'sku',
'name',
'price',
'productType',
'weight',
'size',
'height',
'width',
'length'
];
if (self::expectArgs($expected, $params)) {
$params['productId'] = null;
$params['variationId'] = null;
$product = ProductModel::fromRow($params);
$product->insert();
}
}
}

22
src/View/View.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
namespace ProductList\View;
abstract class View
{
protected static function expectArgs(array $args, array $provided) {
$keys = array_keys($provided);
$missing = array_diff($args, $keys);
if (count($missing) > 0) {
http_response_code(400);
$missing = join("', '", $missing);
echo "Missing parameters '$missing'";
return false;
}
return true;
}
}

View File

@@ -1,16 +1,83 @@
<html>
<head>
<meta charset="utf-8"/>
<link rel="stylesheet" href="static/index.css"/>
<title>Product Add</title>
</head>
<body>
<div id="header">
<h1 id="title">Product Add</h1>
<div id="buttons">
<a href="/"><button>Save</button></a>
<a href="/"><button>Cancel</button></a>
</div>
</div>
</body>
<head>
<meta charset="utf-8"/>
<link rel="stylesheet" href="static/stylesheet.css"/>
<title>Product Add</title>
</head>
<body>
<div id="header">
<h1 id="title">Product Add</h1>
<div id="buttons">
<div class="header-button"><button type="submit" form="product_form" id="save">Save</button></div>
<div class="header-button"><a href="/"><button>Cancel</button></a></div>
</div>
</div>
<div id="main">
<form id="product_form">
<div id="basic-info" class="form-section">
<table>
<tr>
<td><label for="sku">SKU</label></td>
<td><input type="text" id="sku" name="sku" required></td>
</tr>
<tr>
<td><label for="name">Name</label></td>
<td><input type="text" id="name" name="name" required></td>
</tr>
<tr>
<td><label for="price">Price ($)</label></td>
<td><input type="text" id="price" name="price" required></td>
</tr>
</table>
</div>
<div id="type-switcher" class="form-section">
<label for="productType">Type Switcher</label>
<select name="productType" id="productType">
<option value="dvd">DVD</option>
<option value="furniture">Furniture</option>
<option value="book">Book</option>
</select>
</div>
<div id="attribute" class="form-section">
<div id="dvd" class="hidden">
<table>
<tr>
<td><label for="size">Size (MB)</label></td>
<td><input type="text" id="size" name="size"></td>
</tr>
</table>
Please, provide size
</div>
<div id="furniture" class="hidden">
<table>
<tr>
<td><label for="height">Height (CM)</label></td>
<td><input type="text" id="height" name="height"></td>
</tr>
<tr>
<td><label for="width">Width (CM)</label></td>
<td><input type="text" id="width" name="width"></td>
</tr>
<tr>
<td><label for="length">Length (CM)</label></td>
<td><input type="text" id="length" name="length"></td>
</tr>
</table>
Please, provide dimensions
</div>
<div id="book" class="hidden">
<table>
<tr>
<td><label for="weight">Weight (KG)</label></td>
<td><input type="text" id="weight" name="weight"></td>
</tr>
</table>
Please, provide weight
</div>
</div>
</form>
</div>
</body>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script src="static/add-product.js"></script>
</html>

29
static/add-product.js Normal file
View File

@@ -0,0 +1,29 @@
let currentTypeForm = document.getElementById($('#productType').val());
currentTypeForm.querySelectorAll('input').forEach(input => input.required = true);
currentTypeForm.classList.remove('hidden');
$('#productType').on('change', e => {
const newTypeForm = document.getElementById(e.target.value);
currentTypeForm.classList.add('hidden');
newTypeForm.classList.remove('hidden');
currentTypeForm.querySelectorAll('input').forEach(input => input.required = false);
newTypeForm.querySelectorAll('input').forEach(input => input.required = true);
currentTypeForm = newTypeForm;
});
$('#product_form').on('submit', e=> {
e.preventDefault();
$.ajax(
'product',
{
method: 'POST',
data: $('#product_form').serializeArray(),
success: _ => window.location.href = '/',
error: jqXHR => alert(jqXHR.responseText),
}
);
});

View File

@@ -1,20 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<link rel="stylesheet" href="static/index.css"/>
<title>Product List</title>
</head>
<body>
<div id="header">
<h1 id="title">Product List</h1>
<div id="buttons">
<a href="add-product"><button>ADD</button></a>
<button id="delete-product-btn">MASS DELETE</button>
</div>
</div>
<div id="products">
</div>
</body>
<script src="static/index.js"></script>
<head>
<meta charset="utf-8"/>
<link rel="stylesheet" href="static/stylesheet.css"/>
<title>Product List</title>
</head>
<body>
<div id="header">
<h1 id="title">Product List</h1>
<div id="buttons">
<div class="header-button"><a href="add-product"><button>ADD</button></a></div>
<div class="header-button"><button id="delete-product-btn">MASS DELETE</button></div>
</div>
</div>
<div id="products">
</div>
</body>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script src="static/index.js"></script>
</html>

View File

@@ -1,45 +1,44 @@
const productsList = document.getElementById('products');
const loadItems = () => {
const xhttp = new XMLHttpRequest();
$.ajax(
'product',
{
success: data => {
const boxes = data.map(product =>
`<div class="product">
<input type="checkbox" class="delete-checkbox" value="${product.id}">
<p>
${product.sku}<br>
${product.name}<br>
${product.price} $<br>
${product.attribute}
</p>
</div>`
);
xhttp.onload = function() {
const products = JSON.parse(this.responseText);
const boxes = products.map(product =>
`<div class="product">
<input type="checkbox" class="delete-checkbox" value="${product.id}">
<p>
${product.sku}<br>
${product.name}<br>
${product.price} $<br>
${product.attribute}
</p>
</div>`
)
productsList.innerHTML = boxes.join('\n');
}
xhttp.open('GET', 'products', true);
xhttp.send();
$('#products').html(boxes.join('\n'));
},
error: jqXHR => alert(jqXHR.responseText),
}
)
}
loadItems();
const deleteSelected = () => {
const checkboxes = document.querySelectorAll('input[class="delete-checkbox"]:checked');
let values = [];
checkboxes.forEach(checkbox => values.push(checkbox.value));
$('#delete-product-btn').on('click', () => {
let values = [];
const checkboxes = document.querySelectorAll('input[class="delete-checkbox"]:checked');
checkboxes.forEach(checkbox => values.push(checkbox.value));
const xhttp = new XMLHttpRequest();
xhttp.onload = function() {
loadItems();
}
xhttp.open('DELETE', `products?id=${values.join(',')}`, true);
xhttp.send();
}
const deleteButton = document.getElementById('delete-product-btn');
deleteButton.addEventListener('click', deleteSelected);
if(values.length) {
$.ajax(
`product?id=${values.join(',')}`,
{
method: 'DELETE',
success: loadItems,
error: jqXHR => alert(jqXHR.responseText),
}
)
} else {
alert('Please select a product.');
}
});

View File

@@ -14,16 +14,15 @@ body {
margin: 0 1em;
}
#buttons a {
.header-button {
align-self: center;
margin: 0 2em;
}
#buttons button {
font-family: cursive;
padding: .5em 1em;
font-size: 1rem;
margin: 0 2em;
align-self: center;
background-color: white;
border: 2px solid black;
box-shadow: 2px 2px black;
@@ -56,3 +55,23 @@ body {
position: absolute;
top: 0px;
}
.hidden {
display: none;
}
#main {
margin: 2em;
}
.form-section {
margin: 2em;
}
td:nth-child(2) {
padding-left: 2em;
}
#productType {
margin-left: 2em;
}