Add image editing to position dialog

This commit is contained in:
Bartek Fabiszewski 2020-06-02 22:29:16 +02:00
parent 62729088c5
commit 74eef23492
9 changed files with 251 additions and 32 deletions

View File

@ -1,5 +1,4 @@
<?php <?php
use PHPUnit\Framework\TestCase;
require_once(__DIR__ . "/../lib/UloggerDatabaseTestCase.php"); require_once(__DIR__ . "/../lib/UloggerDatabaseTestCase.php");
require_once(__DIR__ . "/../../helpers/track.php"); require_once(__DIR__ . "/../../helpers/track.php");
@ -118,7 +117,7 @@ class PositionTest extends UloggerDatabaseTestCase {
$this->assertEquals(2, $this->getConnection()->getRowCount('tracks'), "Wrong row count"); $this->assertEquals(2, $this->getConnection()->getRowCount('tracks'), "Wrong row count");
$this->assertEquals(4, $this->getConnection()->getRowCount('positions'), "Wrong row count"); $this->assertEquals(4, $this->getConnection()->getRowCount('positions'), "Wrong row count");
$posArr = uPosition::getLastAllUsers(); $posArr = uPosition::getLastAllUsers();
$this->assertEquals(2, count($posArr), "Wrong row count"); $this->assertCount(2, $posArr, "Wrong row count");
foreach ($posArr as $position) { foreach ($posArr as $position) {
/** @var uPosition $position */ /** @var uPosition $position */
switch ($position->id) { switch ($position->id) {
@ -152,15 +151,15 @@ class PositionTest extends UloggerDatabaseTestCase {
$this->assertEquals(3, $this->getConnection()->getRowCount('positions'), "Wrong row count"); $this->assertEquals(3, $this->getConnection()->getRowCount('positions'), "Wrong row count");
$posArr = uPosition::getAll(); $posArr = uPosition::getAll();
$this->assertEquals(3, count($posArr), "Wrong row count"); $this->assertCount(3, $posArr, "Wrong row count");
$posArr = uPosition::getAll($userId); $posArr = uPosition::getAll($userId);
$this->assertEquals(2, count($posArr), "Wrong row count"); $this->assertCount(2, $posArr, "Wrong row count");
$posArr = uPosition::getAll($userId, $trackId); $posArr = uPosition::getAll($userId, $trackId);
$this->assertEquals(1, count($posArr), "Wrong row count"); $this->assertCount(1, $posArr, "Wrong row count");
$posArr = uPosition::getAll(NULL, $trackId); $posArr = uPosition::getAll(NULL, $trackId);
$this->assertEquals(1, count($posArr), "Wrong row count"); $this->assertCount(1, $posArr, "Wrong row count");
$posArr = uPosition::getAll($userId3); $posArr = uPosition::getAll($userId3);
$this->assertEquals(0, count($posArr), "Wrong row count"); $this->assertCount(0, $posArr, "Wrong row count");
} }
public function testDistanceTo() { public function testDistanceTo() {
@ -169,7 +168,7 @@ class PositionTest extends UloggerDatabaseTestCase {
$pos1 = $this->addTestPosition($userId, $trackId, $this->testTimestamp, 0, 0); $pos1 = $this->addTestPosition($userId, $trackId, $this->testTimestamp, 0, 0);
$pos2 = $this->addTestPosition($userId, $trackId, $this->testTimestamp, 0, 1); $pos2 = $this->addTestPosition($userId, $trackId, $this->testTimestamp, 0, 1);
$posArr = uPosition::getAll(); $posArr = uPosition::getAll();
$this->assertEquals(2, count($posArr), "Wrong row count"); $this->assertCount(2, $posArr, "Wrong row count");
$this->assertEquals(111195, round($posArr[0]->distanceTo($posArr[1])), "Wrong distance"); $this->assertEquals(111195, round($posArr[0]->distanceTo($posArr[1])), "Wrong distance");
} }
@ -179,7 +178,7 @@ class PositionTest extends UloggerDatabaseTestCase {
$pos1 = $this->addTestPosition($userId, $trackId, $this->testTimestamp); $pos1 = $this->addTestPosition($userId, $trackId, $this->testTimestamp);
$pos2 = $this->addTestPosition($userId, $trackId, $this->testTimestamp + 1); $pos2 = $this->addTestPosition($userId, $trackId, $this->testTimestamp + 1);
$posArr = uPosition::getAll(); $posArr = uPosition::getAll();
$this->assertEquals(2, count($posArr), "Wrong row count"); $this->assertCount(2, $posArr, "Wrong row count");
$this->assertEquals(-1, $posArr[0]->secondsTo($posArr[1]), "Wrong time difference"); $this->assertEquals(-1, $posArr[0]->secondsTo($posArr[1]), "Wrong time difference");
} }

View File

@ -507,6 +507,10 @@ button > * {
margin: 0 5px; margin: 0 5px;
} }
.hidden {
display: none;
}
/* alert */ /* alert */
.alert { .alert {

View File

@ -194,9 +194,7 @@ require_once(ROOT_DIR . "/helpers/upload.php");
$query = "DELETE FROM " . self::db()->table('positions') . " WHERE id = ?"; $query = "DELETE FROM " . self::db()->table('positions') . " WHERE id = ?";
$stmt = self::db()->prepare($query); $stmt = self::db()->prepare($query);
$stmt->execute([ $this->id ]); $stmt->execute([ $this->id ]);
if ($this->hasImage()) { $this->removeImage();
uUpload::delete($this->image);
}
$ret = true; $ret = true;
$this->id = NULL; $this->id = NULL;
$this->isValid = false; $this->isValid = false;
@ -373,15 +371,9 @@ require_once(ROOT_DIR . "/helpers/upload.php");
*/ */
public static function removeImages($userId, $trackId = NULL) { public static function removeImages($userId, $trackId = NULL) {
if (($positions = self::getAllWithImage($userId, $trackId)) !== false) { if (($positions = self::getAllWithImage($userId, $trackId)) !== false) {
/** @var uUpload $position */
foreach ($positions as $position) { foreach ($positions as $position) {
try { try {
$query = "UPDATE " . self::db()->table('positions') . " $position->removeImage();
SET image = NULL WHERE id = ?";
$stmt = self::db()->prepare($query);
$stmt->execute([ $position->id ]);
// ignore unlink errors
uUpload::delete($position->image);
} catch (PDOException $e) { } catch (PDOException $e) {
// TODO: handle exception // TODO: handle exception
syslog(LOG_ERR, $e->getMessage()); syslog(LOG_ERR, $e->getMessage());
@ -392,6 +384,38 @@ require_once(ROOT_DIR . "/helpers/upload.php");
return true; return true;
} }
/**
* Add uploaded image
* @param array $imageMeta File metadata array
*/
public function setImage($imageMeta) {
if (!empty($imageMeta)) {
if ($this->hasImage()) {
$this->removeImage();
}
$this->image = uUpload::add($imageMeta, $this->trackId);
$query = "UPDATE " . self::db()->table('positions') . "
SET image = ? WHERE id = ?";
$stmt = self::db()->prepare($query);
$stmt->execute([ $this->image, $this->id ]);
}
}
/**
* Delete image
*/
public function removeImage() {
if ($this->hasImage()) {
$query = "UPDATE " . self::db()->table('positions') . "
SET image = NULL WHERE id = ?";
$stmt = self::db()->prepare($query);
$stmt->execute([ $this->id ]);
// ignore unlink errors
uUpload::delete($this->image);
$this->image = null;
}
}
/** /**
* Calculate distance to target point using haversine formula * Calculate distance to target point using haversine formula
* *

View File

@ -210,7 +210,7 @@ export default class MapViewModel extends ViewModel {
if (pos.hasImage()) { if (pos.hasImage()) {
const image = node.querySelector('#pimage img'); const image = node.querySelector('#pimage img');
image.onclick = () => { image.onclick = () => {
const modal = new uDialog(`<img src="uploads/${pos.image}" alt="image">`); const modal = new uDialog(`<img src="${pos.getImagePath()}" alt="image">`);
const closeEl = modal.element.querySelector('#modal-close'); const closeEl = modal.element.querySelector('#modal-close');
closeEl.onclick = () => modal.destroy(); closeEl.onclick = () => modal.destroy();
modal.element.classList.add('image'); modal.element.classList.add('image');

View File

@ -85,6 +85,17 @@ export default class uPosition {
return (this.image != null && this.image.length > 0); return (this.image != null && this.image.length > 0);
} }
/**
* @return {?string}
*/
getImagePath() {
return this.hasImage() ? `uploads/${this.image}` : null;
}
/**
* Get total speed in m/s
* @return {number}
*/
get totalSpeed() { get totalSpeed() {
return this.totalSeconds ? this.totalMeters / this.totalSeconds : 0; return this.totalSeconds ? this.totalMeters / this.totalSeconds : 0;
} }
@ -110,10 +121,37 @@ export default class uPosition {
}); });
} }
/**
* @return {Promise<void, Error>}
*/
imageDelete() {
return uPosition.update({
action: 'imagedel',
posid: this.id
}).then(() => { this.image = null; });
}
/**
* @param {File} imageFile
* @return {Promise<void, Error>}
*/
imageAdd(imageFile) {
const data = new FormData();
data.append('image', imageFile);
data.append('action', 'imageadd');
data.append('posid', this.id);
return uPosition.update(data).then(
/**
* @param {Object} result
* @param {string} result.image
*/
(result) => { this.image = result.image; });
}
/** /**
* Save track data * Save track data
* @param {Object} data * @param {Object} data
* @return {Promise<void, Error>} * @return {Promise<void|Object, Error>}
*/ */
static update(data) { static update(data) {
return uAjax.post('utils/handleposition.php', data); return uAjax.post('utils/handleposition.php', data);

View File

@ -17,13 +17,15 @@
* along with this program; if not, see <http://www.gnu.org/licenses/>. * along with this program; if not, see <http://www.gnu.org/licenses/>.
*/ */
import { lang as $ } from './initializer.js'; import { lang as $, config } from './initializer.js';
import ViewModel from './viewmodel.js'; import ViewModel from './viewmodel.js';
import uAlert from './alert.js'; import uAlert from './alert.js';
import uDialog from './dialog.js'; import uDialog from './dialog.js';
import uObserve from './observe.js'; import uObserve from './observe.js';
import uUtils from './utils.js'; import uUtils from './utils.js';
const hiddenClass = 'hidden';
/** /**
* @class PositionDialogModel * @class PositionDialogModel
*/ */
@ -38,14 +40,22 @@ export default class PositionDialogModel extends ViewModel {
onPositionDelete: null, onPositionDelete: null,
onPositionUpdate: null, onPositionUpdate: null,
onCancel: null, onCancel: null,
comment: '' comment: null,
image: null,
onImageDelete: null
}); });
this.state = state; this.state = state;
this.positionIndex = positionIndex; this.positionIndex = positionIndex;
this.position = this.state.currentTrack.positions[positionIndex]; this.position = this.state.currentTrack.positions[positionIndex];
this.model.comment = this.position.hasComment() ? this.position.comment : '';
this.model.image = this.position.image;
this.model.onPositionDelete = () => this.onPositionDelete(); this.model.onPositionDelete = () => this.onPositionDelete();
this.model.onPositionUpdate = () => this.onPositionUpdate(); this.model.onPositionUpdate = () => this.onPositionUpdate();
this.model.onCancel = () => this.onCancel(); this.model.onCancel = () => this.onCancel();
this.model.onImageDelete = () => this.onImageDelete();
this.onChanged('image', (image) => {
if (image && image !== this.position.image) { this.readImage(); }
});
} }
init() { init() {
@ -53,6 +63,61 @@ export default class PositionDialogModel extends ViewModel {
this.dialog = new uDialog(html); this.dialog = new uDialog(html);
this.dialog.show(); this.dialog.show();
this.bindAll(this.dialog.element); this.bindAll(this.dialog.element);
this.previewEl = this.getBoundElement('imagePreview');
this.fileEl = this.getBoundElement('image');
this.imageDeleteEl = this.getBoundElement('onImageDelete');
this.initReader();
}
initReader() {
this.reader = new FileReader();
this.reader.addEventListener('load', () => {
this.showThumbnail();
}, false);
this.reader.addEventListener('error', () => {
this.model.image = this.position.image;
}, false);
}
readImage() {
const file = this.fileEl.files[0];
if (file) {
if (file.size > config.uploadMaxSize) {
uAlert.error($._('isizefailure', config.uploadMaxSize));
this.model.image = this.position.image;
return;
}
this.reader.readAsDataURL(file);
}
}
showThumbnail() {
this.previewEl.onload = () => this.toggleImage();
this.previewEl.onerror = () => {
uAlert.error($._('iuploadfailure'));
this.model.image = this.position.image;
};
this.previewEl.src = this.reader.result;
}
/**
* Toggle image visibility
*/
toggleImage() {
if (this.previewEl.classList.contains(hiddenClass)) {
this.previewEl.classList.remove(hiddenClass);
this.imageDeleteEl.classList.remove(hiddenClass);
this.fileEl.classList.add(hiddenClass);
} else {
this.previewEl.classList.add(hiddenClass);
this.imageDeleteEl.classList.add(hiddenClass);
this.fileEl.classList.remove(hiddenClass);
}
}
onImageDelete() {
this.model.image = null;
this.toggleImage();
} }
/** /**
@ -64,7 +129,13 @@ export default class PositionDialogModel extends ViewModel {
<div style="clear: both; padding-bottom: 1em;"></div> <div style="clear: both; padding-bottom: 1em;"></div>
<form id="positionForm"> <form id="positionForm">
<label><b>${$._('comment')}</b></label><br> <label><b>${$._('comment')}</b></label><br>
<textarea style="width:100%;" maxlength="255" rows="5" placeholder="${$._('comment')}" name="comment" data-bind="comment" autofocus>${this.position.hasComment() ? uUtils.htmlEncode(this.position.comment) : ''}</textarea> <textarea style="width:100%;" maxlength="255" rows="5" placeholder="${$._('comment')}" name="comment"
data-bind="comment" autofocus>${uUtils.htmlEncode(this.model.comment)}</textarea>
<br><br>
<label><b>${$._('image')}</b></label><br>
<input type="file" name="image" data-bind="image" accept="image/png, image/jpeg, image/gif, image/bmp"${this.position.hasImage() ? ' class="hidden"' : ''}>
<img style="max-width:50px; max-height:50px" data-bind="imagePreview" ${this.position.hasImage() ? `src="${this.position.getImagePath()}"` : 'class="hidden"'}>
<a data-bind="onImageDelete" ${this.position.hasImage() ? '' : ' class="hidden"'}>${$._('delimage')}</a>
<div class="buttons"> <div class="buttons">
<button class="button-reject" data-bind="onCancel" type="button">${$._('cancel')}</button> <button class="button-reject" data-bind="onCancel" type="button">${$._('cancel')}</button>
<button class="button-resolve" data-bind="onPositionUpdate" type="submit">${$._('submit')}</button> <button class="button-resolve" data-bind="onPositionUpdate" type="submit">${$._('submit')}</button>
@ -86,10 +157,27 @@ export default class PositionDialogModel extends ViewModel {
} }
} }
/**
* @return {Promise<void>}
*/
updateImage() {
let promise = Promise.resolve();
if (this.model.image !== this.position.image) {
if (this.model.image === null) {
promise = this.position.imageDelete();
} else {
promise = this.position.imageAdd(this.fileEl.files[0]);
}
}
return promise;
}
onPositionUpdate() { onPositionUpdate() {
this.model.comment.trim();
if (this.validate()) { if (this.validate()) {
this.position.comment = this.model.comment; this.position.comment = this.model.comment;
this.position.save() this.updateImage()
.then(() => this.position.save())
.then(() => { .then(() => {
uObserve.forceUpdate(this.state, 'currentTrack'); uObserve.forceUpdate(this.state, 'currentTrack');
this.dialog.destroy() this.dialog.destroy()
@ -107,6 +195,7 @@ export default class PositionDialogModel extends ViewModel {
* @return {boolean} True if valid * @return {boolean} True if valid
*/ */
validate() { validate() {
return this.model.comment !== this.position.comment; return !(this.model.comment === this.position.comment && this.model.image === this.position.image);
} }
} }

View File

@ -241,6 +241,43 @@ describe('Position tests', () => {
expect(uPosition.update).toHaveBeenCalledWith({ action: 'update', posid: posId, comment: comment }); expect(uPosition.update).toHaveBeenCalledWith({ action: 'update', posid: posId, comment: comment });
}); });
it('should delete image on server', (done) => {
// given
spyOn(uPosition, 'update').and.returnValue(Promise.resolve());
const position = uPosition.fromJson(jsonPosition);
// when
position.imageDelete()
// then
setTimeout(() => {
expect(uPosition.update).toHaveBeenCalledWith({ action: 'imagedel', posid: posId });
expect(position.image).toBeNull();
done();
}, 100);
});
it('should add image on server', (done) => {
// given
const newImage = 'new_image.jpg';
const imageFile = 'imageFile';
spyOn(uPosition, 'update').and.returnValue(Promise.resolve({ image: newImage }));
const position = uPosition.fromJson(jsonPosition);
// when
position.imageAdd(imageFile);
// then
setTimeout(() => {
expect(uPosition.update).toHaveBeenCalledWith(jasmine.any(FormData));
/** @var {FormData} */
const data = uPosition.update.calls.mostRecent().args[0];
expect(data.get('image')).toBe(imageFile);
expect(data.get('action')).toBe('imageadd');
expect(data.get('posid')).toBe(posId.toString());
expect(position.image).toBe(newImage);
done();
}, 100);
});
it('should call ajax post with url and params', () => { it('should call ajax post with url and params', () => {
// given // given
const url = 'utils/handleposition.php'; const url = 'utils/handleposition.php';

View File

@ -116,7 +116,9 @@ $lang["edittrack"] = "Edit track";
$lang["positiondelwarn"] = "Warning!\n\nYou are going to permanently delete position %d of track %s.\n\nAre you sure?"; // substitutes position index and track name $lang["positiondelwarn"] = "Warning!\n\nYou are going to permanently delete position %d of track %s.\n\nAre you sure?"; // substitutes position index and track name
$lang["editingposition"] = "You are editing position #%d of track %s"; // substitutes position index and track name $lang["editingposition"] = "You are editing position #%d of track %s"; // substitutes position index and track name
$lang["delposition"] = "Remove position"; $lang["delposition"] = "Remove position";
$lang["delimage"] = "Remove image";
$lang["comment"] = "Comment"; $lang["comment"] = "Comment";
$lang["image"] = "Image";
$lang["editposition"] = "Edit position"; $lang["editposition"] = "Edit position";
$lang["passlenmin"] = "Password must be at least %d characters"; // substitutes password minimum length $lang["passlenmin"] = "Password must be at least %d characters"; // substitutes password minimum length
$lang["passrules_1"] = "It should contain at least one lower case letter, one upper case letter"; $lang["passrules_1"] = "It should contain at least one lower case letter, one upper case letter";

View File

@ -25,9 +25,9 @@ require_once(ROOT_DIR . "/helpers/config.php");
$auth = new uAuth(); $auth = new uAuth();
$action = uUtils::postString('action'); $action = uUtils::postString("action");
$positionId = uUtils::postInt('posid'); $positionId = uUtils::postInt("posid");
$comment = uUtils::postString('comment'); $comment = uUtils::postString("comment");
$config = uConfig::getInstance(); $config = uConfig::getInstance();
$lang = (new uLang($config))->getStrings(); $lang = (new uLang($config))->getStrings();
@ -41,26 +41,52 @@ if (!$position->isValid ||
uUtils::exitWithError($lang["servererror"]); uUtils::exitWithError($lang["servererror"]);
} }
$data = null;
switch ($action) { switch ($action) {
case 'update': case "update":
$position->comment = $comment; $position->comment = $comment;
if ($position->update() === false) { if ($position->update() === false) {
uUtils::exitWithError($lang["servererror"]); uUtils::exitWithError($lang["servererror"]);
} }
break; break;
case 'delete': case "delete":
if ($position->delete() === false) { if ($position->delete() === false) {
uUtils::exitWithError($lang["servererror"]); uUtils::exitWithError($lang["servererror"]);
} }
break; break;
case "imageadd":
try {
$fileMeta = uUtils::requireFile("image");
if ($position->setImage($fileMeta) === false) {
uUtils::exitWithError($lang["servererror"]);
}
$data = [ "image" => $position->image ];
} catch (ErrorException $ee) {
$message = $lang["servererror"];
$message .= ": {$ee->getMessage()}";
uUtils::exitWithError($message);
} catch (Exception $e) {
$message = $lang["iuploadfailure"];
$message .= ": {$e->getMessage()}";
uUtils::exitWithError($message);
}
break;
case "imagedel":
if ($position->removeImage() === false) {
uUtils::exitWithError($lang["servererror"]);
}
break;
default: default:
uUtils::exitWithError($lang["servererror"]); uUtils::exitWithError($lang["servererror"]);
break; break;
} }
uUtils::exitWithSuccess(); uUtils::exitWithSuccess($data);
?> ?>