В системе существует объект Entity. Необходимо добавить к нему возможность управлять фотографиями, которые группируются в альбомы.
Исходя из этих требований попробуем спроектировать соответствующие таблицы.
По базе данных все достаточно просто, единственное, что мы добавили - это поле createdAt timestamp default now()
на всякий случай.
smallImageId
и bigImageId
- ссылки на стандартную таблицу vfsFiles
.
Т.к. объектов будет не очень много, то все управление поместится в одну дополнительную вкладку.
Вот что в итоге у нас должно получиться (вид с отображением ошибок).
У альбома есть название и описание (необязательное поле).
У фотографии есть маленькая и большая картинка, из необязательных полей - название.
В случае случайного удаления должна быть ручная возможность восстановления данных.
В базе необходимые поля присутствуют.
Должна быть возможность сортировки фотографий и альбомов.
Порядок элементов меняется путем перетаскивания строчек.
Поддержка начальной массовой загрузки фотографий.
Сначала все файлы загружаем в VFS, а потом выбираем в фотогалерею.
Все первоначальные требования выполнены. Приступим к реализации задачи.
Для начала нам нужно создать объекты в MFD, после добавить в data.tmpl.php новый таб «Фотогалерея» и вынести его в другой шаблон photos.tmpl.php. Для реализации пользовательского интерфейса мы будем использовать JQuery Templates, поэтому не забудем добавить его в vt/elements/header.tmpl.php. Далее в photos.tmpl.php создадим шаблоны для jquery templates, весь javascript вынесем в отдельный файл в js://vt/entity-photos.js и подключим его в photos.tmpl.php. В entity-photos.js будет располагаться основной код управления интерфейсом. После перейдем к серверной части и допишем SaveEntityAction.php.
entityPhotos
и entityAlbums
в MFD с флагом WithoutTemplate (CanPages ставить не нужно).После того, как мы добавили листы к объектам, у нас будет рекурсивно работать метод IFactory::GetFromRequest(). Это нам пригодится в получении объектов с формы.
В шаблоне data.tmpl.php добавим новый таб «Фотографии».
<ul class="tabs-list"> <li><a href="#page-0">{lang:vt.common.commonInfo}</a></li> <li><a href="#page-1">Фотографии</a></li> </ul>
И подключим его после <div id="page-0" class="tab-page rows">...</div>
{increal:tmpl://vt/entities/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' ); ?> <div id="page-1" class="tab-page rows albums"> <div data-row="photos" style="display: none;"></div> <div class="row"> <ul class="actions"> <li class="edit"><a href="#" id="add-album">Добавить альбом</a></li> </ul> </div> </div> <script type="text/javascript"> var data = <?= ObjectHelper::ToJSON( $data ); ?>; <? if ( !empty( $albumErrors ) ) { ?> var albumErrors = <?= ObjectHelper::ToJSON( $albumErrors )?> <? } ?> </script>
Создадим шаблон 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();
Опишем шаблон для создания строчки с альбомом.
<script id="albumTemplate" type="text/x-jquery-tmpl"> <div class="row album sort" id="album-${ $item.counter.nextIndex() }"> <input type="hidden" name="{$prefix}[albums][${ $item.counter.index }][entityAlbumId]" value="${id}" /> <br /> <table class="objects" style="width:auto;"> <tbody> <tr> <td class="handle">Альбом</td> <td class="left"><input type="text" name="{$prefix}[albums][${ $item.counter.index }][title]" value="${title}" /></td> <td colspan="2" class="left"><input type="text" name="{$prefix}[albums][${ $item.counter.index }][description]" value="${description}" size="60" style="width: auto;" /></td> <td width="10%"> <ul class="actions"> <li class="edit"><a href="#" class="add-photo" data-album-id="${ $item.counter.index }">Добавить</a></li> <li class="delete"><a href="#" class="delete-album" title="Удалить" data-album-id="${ $item.counter.index }">Удалить</a></li> </ul> </td> </tr> {{each(i, photo) photos}} {{tmpl( photo , { counter: $item.photoCounter, albumIndex: $item.counter.index }) "#photoTemplate"}} {{/each}} </tbody> </table> </div> </script><script>''</script>
${ $item.counter.nextIndex() }
- пробелы между {} нужны для того, чтобы шаблонизатор в Eaze не подумал, что это переменная {$item}
.</script><script>''</script>
- workaround для IDE.${id}
- получение свойства id из элемента массива data.{{each(i, photo) photos}}
- цикл в шаблоне для отображения фотографий. photos
- свойство из элемента массива data.{{tmpl( photo , { counter: $item.photoCounter, albumIndex: $item.counter.index }) "#photoTemplate"}}
- вызов шаблона photoTemplate, передача дополнительных параметров для рендеринга.{$prefix}
- стандартная переменная для data.tmpl.php (префикс объекта).Шаблон для отображения конкретной фотографии
<script id="photoTemplate" type="text/x-jquery-tmpl"> <tr class="sort"> <td><input type="hidden" name="{$prefix}[albums][${ $item.albumIndex }][photos][${ $item.counter.nextIndex() }][entityPhotoId]" value="${id}" /></td> <td><input type="text" name="{$prefix}[albums][${ $item.albumIndex }][photos][${ $item.counter.index }][title]" value="${title}" class="title-${ $item.albumIndex }-${num}" size="60" style="width: auto;" /></td> <td class="left"><input type="hidden" class="vfsFile" name="{$prefix}[albums][${ $item.albumIndex }][photos][${ $item.counter.index }][smallImageId]" rel="smallImageId-${ $item.albumIndex }-${num}" id="photo-small-${ $item.counter.index }" vfs:previewType="image" {{if smallImage}}value="${ smallImage.id}" vfs:src="{web:vfs://}${ smallImage.src}" vfs:name="${ smallImage.name}"{{/if}}/></td> <td class="left"><input type="hidden" class="vfsFile" name="{$prefix}[albums][${ $item.albumIndex }][photos][${ $item.counter.index }][bigImageId]" rel="bigImageId-${ $item.albumIndex }-${num}" id="photo-big-${ $item.counter.index }" vfs:previewType="image" {{if bigImage}}value="${ bigImage.id}" vfs:src="{web:vfs://}${ bigImage.src}" vfs:name="${ bigImage.name}"{{/if}}/></td> <td width="10%"> <ul class="actions"> <li class="delete"><a class="delete-photo" href="#" title="Удалить">Удалить</a></li> </ul> </td> </tr> </script><script>''</script>
${num}
- это дополнительное свойство (индекс массива), который используется для поиска элемента при отображении ошибок ($item.counter.nextIndex()
имеет сквозную нумерацию, а ${num}
начинается с 0 для каждого альбома).На этом работа с photos.tmpl.php завершена.
Данный файл не претендует на идеальность :)
Начинается данный файл с описания счетчиков для альбомов и фотографий (см. код выше).
После этого опишем поведение кнопок добавления и удаления альбомов и фотографий
/** * 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( ' <span style="color:red;">*</span>' ); }); }); } else { $("input[name='entity[albums][" + albumIndex + "]["+ albumField + "]']").parent().append( ' <span style="color:red;">*</span>' ); } }); }); }
Со клиентской частью мы закончили.
Данного кода достаточно для управления альбомами без серверной части, только они не будут сохранятся и валидироваться, но в $object
данные попадут, т.к. мы используем полные пути к свойствам объекта (например {$prefix}[albums][${ $item.albumIndex }][photos][${ $item.counter.index }][smallImageId]
) - GetFromRequest соберет объект так, как нужно.
Попробуем модифицировать SaveEntityAction таким образом, чтобы он сохранял альбомы в базу.
Прежде всего мы должны помнить о том, что 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]; } } } }
Далее сделаем так, чтобы фотографии грузились только тогда, когда мы открыли объект для редактирования.
/** * 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 ) ); }
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; }
<div data-row=«photos»…
в photos.tmpl.php) и не дать сохранить ошибочный объект в базу.Теперь можно перейти к сохранению. Для этого сначала нужно заполнить $originalObject фотографиями (в оригинальном объекте они нужны для того, чтобы удалить те фотографии, которые мы удалили с формы).
protected function beforeAction() { if ( !empty( $this->originalObject ) ) { $this->refillAlbumPhotos( $this->originalObject ); } }
add() и update() будут обернуты в транзакцию.
/** * 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 ) ); } }
Не забудьте посмотреть код EntityAlbumUtility.php.
Поставленной цели мы добились. Можно было бы конечно сделать массовый загрузчик на flash и потом и мышкой раскидать фотографии по альбомам, но для этого нужно написать ещё больше javascript'а.
Осталось рассмотреть плюсы и минусы подхода.
Ещё раз повторюсь, что данный пример не претендует на идеальность и универсальность. Он пытается рассказать, как работать в VT со сложными объектами. Пожелания и дополнения приветствуются. ;)