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
use PHPUnit\Framework\TestCase;
require_once(__DIR__ . "/../lib/UloggerDatabaseTestCase.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(4, $this->getConnection()->getRowCount('positions'), "Wrong row count");
$posArr = uPosition::getLastAllUsers();
$this->assertEquals(2, count($posArr), "Wrong row count");
$this->assertCount(2, $posArr, "Wrong row count");
foreach ($posArr as $position) {
/** @var uPosition $position */
switch ($position->id) {
@ -152,15 +151,15 @@ class PositionTest extends UloggerDatabaseTestCase {
$this->assertEquals(3, $this->getConnection()->getRowCount('positions'), "Wrong row count");
$posArr = uPosition::getAll();
$this->assertEquals(3, count($posArr), "Wrong row count");
$this->assertCount(3, $posArr, "Wrong row count");
$posArr = uPosition::getAll($userId);
$this->assertEquals(2, count($posArr), "Wrong row count");
$this->assertCount(2, $posArr, "Wrong row count");
$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);
$this->assertEquals(1, count($posArr), "Wrong row count");
$this->assertCount(1, $posArr, "Wrong row count");
$posArr = uPosition::getAll($userId3);
$this->assertEquals(0, count($posArr), "Wrong row count");
$this->assertCount(0, $posArr, "Wrong row count");
}
public function testDistanceTo() {
@ -169,7 +168,7 @@ class PositionTest extends UloggerDatabaseTestCase {
$pos1 = $this->addTestPosition($userId, $trackId, $this->testTimestamp, 0, 0);
$pos2 = $this->addTestPosition($userId, $trackId, $this->testTimestamp, 0, 1);
$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");
}
@ -179,7 +178,7 @@ class PositionTest extends UloggerDatabaseTestCase {
$pos1 = $this->addTestPosition($userId, $trackId, $this->testTimestamp);
$pos2 = $this->addTestPosition($userId, $trackId, $this->testTimestamp + 1);
$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");
}

View File

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

View File

@ -194,9 +194,7 @@ require_once(ROOT_DIR . "/helpers/upload.php");
$query = "DELETE FROM " . self::db()->table('positions') . " WHERE id = ?";
$stmt = self::db()->prepare($query);
$stmt->execute([ $this->id ]);
if ($this->hasImage()) {
uUpload::delete($this->image);
}
$this->removeImage();
$ret = true;
$this->id = NULL;
$this->isValid = false;
@ -373,15 +371,9 @@ require_once(ROOT_DIR . "/helpers/upload.php");
*/
public static function removeImages($userId, $trackId = NULL) {
if (($positions = self::getAllWithImage($userId, $trackId)) !== false) {
/** @var uUpload $position */
foreach ($positions as $position) {
try {
$query = "UPDATE " . self::db()->table('positions') . "
SET image = NULL WHERE id = ?";
$stmt = self::db()->prepare($query);
$stmt->execute([ $position->id ]);
// ignore unlink errors
uUpload::delete($position->image);
$position->removeImage();
} catch (PDOException $e) {
// TODO: handle exception
syslog(LOG_ERR, $e->getMessage());
@ -392,6 +384,38 @@ require_once(ROOT_DIR . "/helpers/upload.php");
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
*

View File

@ -210,7 +210,7 @@ export default class MapViewModel extends ViewModel {
if (pos.hasImage()) {
const image = node.querySelector('#pimage img');
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');
closeEl.onclick = () => modal.destroy();
modal.element.classList.add('image');

View File

@ -85,6 +85,17 @@ export default class uPosition {
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() {
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
* @param {Object} data
* @return {Promise<void, Error>}
* @return {Promise<void|Object, Error>}
*/
static update(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/>.
*/
import { lang as $ } from './initializer.js';
import { lang as $, config } from './initializer.js';
import ViewModel from './viewmodel.js';
import uAlert from './alert.js';
import uDialog from './dialog.js';
import uObserve from './observe.js';
import uUtils from './utils.js';
const hiddenClass = 'hidden';
/**
* @class PositionDialogModel
*/
@ -38,14 +40,22 @@ export default class PositionDialogModel extends ViewModel {
onPositionDelete: null,
onPositionUpdate: null,
onCancel: null,
comment: ''
comment: null,
image: null,
onImageDelete: null
});
this.state = state;
this.positionIndex = 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.onPositionUpdate = () => this.onPositionUpdate();
this.model.onCancel = () => this.onCancel();
this.model.onImageDelete = () => this.onImageDelete();
this.onChanged('image', (image) => {
if (image && image !== this.position.image) { this.readImage(); }
});
}
init() {
@ -53,6 +63,61 @@ export default class PositionDialogModel extends ViewModel {
this.dialog = new uDialog(html);
this.dialog.show();
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>
<form id="positionForm">
<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">
<button class="button-reject" data-bind="onCancel" type="button">${$._('cancel')}</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() {
this.model.comment.trim();
if (this.validate()) {
this.position.comment = this.model.comment;
this.position.save()
this.updateImage()
.then(() => this.position.save())
.then(() => {
uObserve.forceUpdate(this.state, 'currentTrack');
this.dialog.destroy()
@ -107,6 +195,7 @@ export default class PositionDialogModel extends ViewModel {
* @return {boolean} True if valid
*/
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 });
});
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', () => {
// given
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["editingposition"] = "You are editing position #%d of track %s"; // substitutes position index and track name
$lang["delposition"] = "Remove position";
$lang["delimage"] = "Remove image";
$lang["comment"] = "Comment";
$lang["image"] = "Image";
$lang["editposition"] = "Edit position";
$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";

View File

@ -25,9 +25,9 @@ require_once(ROOT_DIR . "/helpers/config.php");
$auth = new uAuth();
$action = uUtils::postString('action');
$positionId = uUtils::postInt('posid');
$comment = uUtils::postString('comment');
$action = uUtils::postString("action");
$positionId = uUtils::postInt("posid");
$comment = uUtils::postString("comment");
$config = uConfig::getInstance();
$lang = (new uLang($config))->getStrings();
@ -41,26 +41,52 @@ if (!$position->isValid ||
uUtils::exitWithError($lang["servererror"]);
}
$data = null;
switch ($action) {
case 'update':
case "update":
$position->comment = $comment;
if ($position->update() === false) {
uUtils::exitWithError($lang["servererror"]);
}
break;
case 'delete':
case "delete":
if ($position->delete() === false) {
uUtils::exitWithError($lang["servererror"]);
}
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:
uUtils::exitWithError($lang["servererror"]);
break;
}
uUtils::exitWithSuccess();
uUtils::exitWithSuccess($data);
?>