====== Добавление фотогалереи в VT у объекта ======
====== Задача ======
В системе существует объект Entity. Необходимо добавить к нему возможность управлять фотографиями, которые группируются в альбомы.
* У альбома есть название и описание (необязательное поле).
* У фотографии есть маленькая и большая картинка, из необязательных полей - название.
* Должна быть возможность сортировки фотографий и альбомов.
* Количество фотографий у одного объекта будет не больше 30.
* Поддержка начальной массовой загрузки фотографий.
* В случае случайного удаления должна быть ручная возможность восстановления данных.
Исходя из этих требований попробуем спроектировать соответствующие таблицы.
====== База данных ======
{{ :eaze:samples:albums-db.png }}
По базе данных все достаточно просто, единственное, что мы добавили - это поле ''createdAt timestamp default now()'' на всякий случай.
''smallImageId'' и ''bigImageId'' - ссылки на стандартную таблицу ''vfsFiles''.
Т.к. объектов будет не очень много, то все управление поместится в одну дополнительную вкладку.
====== Интерфейс ======
{{ :eaze:samples:album.png? |}}
Вот что в итоге у нас должно получиться (вид с отображением ошибок).
> У альбома есть название и описание (необязательное поле).
> У фотографии есть маленькая и большая картинка, из необязательных полей - название.
> В случае случайного удаления должна быть ручная возможность восстановления данных.
В базе необходимые поля присутствуют.
> Должна быть возможность сортировки фотографий и альбомов.
Порядок элементов меняется путем перетаскивания строчек.
> Поддержка начальной массовой загрузки фотографий.
Сначала все файлы загружаем в VFS, а потом выбираем в фотогалерею.
Все первоначальные требования выполнены. Приступим к реализации задачи.
====== Реализация ======
Для начала нам нужно создать объекты в MFD, после добавить в data.tmpl.php новый таб "Фотогалерея" и вынести его в другой шаблон photos.tmpl.php.
Для реализации пользовательского интерфейса мы будем использовать JQuery Templates, поэтому не забудем добавить его в vt/elements/header.tmpl.php. Далее в photos.tmpl.php создадим шаблоны для jquery templates, весь javascript вынесем в отдельный файл в
И подключим его после ''
{increal:tmpl://vt/entities/photos.tmpl.php}
На этом работа с файлом закончена.
===== photos.tmpl.php =====
Данный шаблон будет работать с двумя переменными из Response: ''$data'' и ''$albumErrors''.
В ''$data'' содержится подготовленный массив с альбомами и фотографиями, который мы будем отдавать в качестве источника данных для JQuery Templates. Можно было бы конечно сделать сразу ''json_encode( $object->albums )'', но так мы получим избыток данных. В подготовленной переменной такого нет. Во время создания шаблона в ''var data = ..'' лежал заранее известный массив, который потом стал основой для php-массива (с помощью заранее готового массива можно проще отлаживать шаблон).
В ''$albumErrors'' находится массив с ошибками валидации альбомов и фотографий. Его нельзя положить в обычный массив ''$errors'', т.к. у него своя сложная структура.
/** @var array $data */ ?>
/** @var array $albumErrors */ ?>
JsHelper::PushFile( 'js://vt/entity-photos.js' ); ?>
Создадим шаблон ''albumTemplate'', который будет инициализироваться строчкой (entity-photos.js)
$("#albumTemplate").tmpl(data, { counter: albumCounter, photoCounter: photoCounter } ).appendTo(".albums");
* ''data'' - наш источник данных.
* ''albumCounter'' - объект, с помощью которого мы будем держать текущий индекс альбома (''photoCounter'' - аналогично с фото). Данный объект передается в tmpl() в качестве параметра для рендеринга (доступен в $item в шаблоне).
/**
* Counters for album index
*/
var Counter = function() {
this.index = -1;
}
Counter.prototype.nextIndex = function() {
this.index ++;
return this.index;
}
var albumCounter = new Counter();
var photoCounter = new Counter();
Опишем шаблон для создания строчки с альбомом.
* ''${ $item.counter.nextIndex() }'' - пробелы между {} нужны для того, чтобы шаблонизатор в Eaze не подумал, что это переменная ''{$item}''.
* '''' - [[http://youtrack.jetbrains.net/issue/IDEA-73686|workaround]] для IDE.
* ''${id}'' - получение свойства id из элемента массива data.
* ''
* ''${num}'' - это дополнительное свойство (индекс массива), который используется для поиска элемента при отображении ошибок (''$item.counter.nextIndex()'' имеет сквозную нумерацию, а ''${num}'' начинается с 0 для каждого альбома).
На этом работа с photos.tmpl.php завершена.
===== entity-photos.js =====
//Данный файл не претендует на идеальность :)//
Начинается данный файл с описания счетчиков для альбомов и фотографий (см. код выше).
После этого опишем поведение кнопок добавления и удаления альбомов и фотографий
/**
* Album Controls
*/
$(function() {
$("#add-album").live('click', function(e) {
$("#albumTemplate").tmpl(null, { counter: albumCounter } ).appendTo(".albums");
rebindAlbumControls();
e.preventDefault();
});
$(".add-photo").live('click', function(e) {
var id = $(this).data("albumId");
$("#photoTemplate").tmpl( null, { counter: photoCounter, albumIndex: id } ).appendTo("#album-" + id + " .objects");
vfsSelector.Init();
e.preventDefault();
});
$(".delete-photo").live('click', function(e) {
$(this).parent().parent().parent().parent().remove();
e.preventDefault();
});
$(".delete-album").live('click', function(e) {
var id = $(this).data("albumId");
$("#album-" + id).remove();
e.preventDefault();
});
});
''rebindAlbumControls()'' необходимо для переинициализации vfs и сортировок
function rebindAlbumControls() {
vfsSelector.Init();
$('.objects').sortable({
'items': 'tr.sort'
, 'forceHelperSize': true
, 'forcePlaceholderSize': true
, 'handle': 'td:first-child'
});
$('.albums').sortable({
'items': '.album'
, 'forceHelperSize': true
, 'forcePlaceholderSize': true
, 'handle': '.handle'
});
}
При загрузки страницы запустим рендеринг
$(function() {
$("#albumTemplate").tmpl(data, { counter: albumCounter, photoCounter: photoCounter } ).appendTo(".albums");
rebindAlbumControls();
displayAlbumErrors();
});
И отобразим ошибки
function displayAlbumErrors() {
if ( typeof( albumErrors ) == 'undefined' ) {
return;
}
$.each( albumErrors, function( albumIndex, album ) {
$.each( album, function( albumField, albumData ) {
if ( albumField == 'photos' ) {
$.each( albumData, function(photoIndex, photoField ){
$.each( photoField, function( fieldName, fieldError ) {
$( '[rel=' + fieldName + '-' + albumIndex + '-' + photoIndex + ']' ).parent().append( ' *' );
});
});
} else {
$("input[name='entity[albums][" + albumIndex + "]["+ albumField + "]']").parent().append( ' *' );
}
});
});
}
Со клиентской частью мы закончили.
Данного кода достаточно для управления альбомами без серверной части, только они не будут сохранятся и валидироваться, но в ''$object'' данные попадут, т.к. мы используем полные пути к свойствам объекта (например ''{$prefix}[albums][${ $item.albumIndex }][photos][${ $item.counter.index }][smallImageId]'' ) - GetFromRequest соберет объект так, как нужно.
Попробуем модифицировать SaveEntityAction таким образом, чтобы он сохранял альбомы в базу.
===== SaveEntityAction.php =====
Прежде всего мы должны помнить о том, что albums у объекта Entity - лист. ''EntityFactory::GetById( $id, array( BaseFactory::WithLists => true ) )'' выберет только альбомы, без фотографий. Фотографии будем выбрать вручную.
Создадим метод ''refillAlbumPhotos()'', который добавляет к альбомам фотографии.
protected function refillAlbumPhotos( $object ) {
$photos = EntityPhotoFactory::Get( array( 'entityId' => $object->entityId ) );
$photos = ArrayHelper::Collapse( $photos, 'entityAlbumId' );
if ( !empty( $photos ) ){
foreach( $object->albums as $album ) {
if ( isset( $photos[$album->entityAlbumId] ) ) {
$album->photos = $photos[$album->entityAlbumId];
}
}
}
}
* дополнительно в EntityPhotoFactory мы добавили поиск по entityId.
Далее сделаем так, чтобы фотографии грузились только тогда, когда мы открыли объект для редактирования.
/**
* Set Json Albums Data to Template
* @return void
*/
protected function beforeSave() {
if ( $this->action != self::UpdateAction && !empty( $this->currentObject->entityId ) ) {
$this->refillAlbumPhotos( $this->currentObject );
}
Response::setParameter( 'data', EntityAlbumUtility::PrepareAlbumsData( $this->currentObject ) );
}
* EntityAlbumUtility::PrepareAlbumsData() - метод, который подготавливает переменную ''data'' для шаблона photos.tmpl.php.
Теперь, если у объекта есть альбомы и фотографии, то мы уже увидим их в шаблоне :).
Добавим валидацию альбомов и фотографий.
protected function validate( $object ) {
$errors = parent::$factory->Validate( $object );
$albumErrors = EntityAlbumUtility::ValidateAlbums( $object->albums );
if ( !empty( $albumErrors ) ) {
$errors['fields']['photos']['format'] = 'format';
Response::setParameter( 'albumErrors', $albumErrors );
}
return $errors;
}
* fields => photos поможет нам подсветить таб "Фотографии", если на нем были ошибки (''
/**
* Add Object
*
* @param Entity $object
* @return bool
*/
protected function add( $object ) {
ConnectionFactory::BeginTransaction();
$result = parent::$factory->Add( $object, array( BaseFactory::WithReturningKeys => true ) );
$result = $result && EntityAlbumUtility::SaveAlbums( $object, $this->originalObject );
ConnectionFactory::CommitTransaction( $result );
return $result;
}
/**
* Update Object
*
* @param Entity $object
* @return bool
*/
protected function update( $object ) {
ConnectionFactory::BeginTransaction();
$result = parent::$factory->Update( $object );
$result = $result && EntityAlbumUtility::SaveAlbums( $object, $this->originalObject );
ConnectionFactory::CommitTransaction( $result );
return $result;
}
Последний штрих. При сохранении фоток мы не получаем их идентификаторы, из-за этого неправильно работает кнопка "Применить" в режиме редактирование. Исправляется это путем переполучения фотографий только при успешном сохранении.
protected function afterAction( $success ) {
if ( $this->redirect == 'view' && $success ) {
$this->refillAlbumPhotos( $this->currentObject );
Response::setParameter( 'data', EntityAlbumUtility::PrepareAlbumsData( $this->currentObject ) );
}
}
Не забудьте посмотреть код [[eaze:samples:добавление_фотогалереи_в_vt_к_объекту_EntityAlbumUtility.php|EntityAlbumUtility.php]].
====== Итог ======
Поставленной цели мы добились. Можно было бы конечно сделать массовый загрузчик на flash и потом и мышкой раскидать фотографии по альбомам, но для этого нужно написать ещё больше javascript'а.
Осталось рассмотреть плюсы и минусы подхода.
===== Плюсы =====
* Не нужно дополнительно обрабатывать получение связанных объектов из формы на PHP (albums, photos).
* Не нужно дублировать шаблон отображения album и photo сначала в PHP, потом на JS. Всего используется один шаблон.
* Минимальное количество кода в SaveEntityAction (в основном - только получение листов второго и последующих уровней).
===== Минусы =====
* GetFromRequest на втором уровне получает каждый объект через GetById (соответственно сколько файлов - столько запросов при сохранении). //можно исправить, но сложновато//
* Если сохранить страницу при выключенном JS - то все фотографии удалятся (потому что не пришли с формы). //можно исправить через дополнительную переменную, которая выставляется через JS//
* При сохранении страницы для не измененных данных каждый раз выполняется UPDATE. //можно исправить путем добавления проверки на эквивалентность объектов//
//Ещё раз повторюсь, что данный пример не претендует на идеальность и универсальность. Он пытается рассказать, как работать в VT со сложными объектами. Пожелания и дополнения приветствуются. ;)//