====== Добавление фотогалереи в 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 вынесем в отдельный файл в js://vt/entity-photos.js и подключим его в photos.tmpl.php. В entity-photos.js будет располагаться основной код управления интерфейсом. После перейдем к серверной части и допишем SaveEntityAction.php. ===== MFD ===== - Добавим таблицы ''entityPhotos'' и ''entityAlbums'' в MFD с флагом WithoutTemplate (CanPages ставить не нужно). - У объекта Entity пропишем лист albums (EntityAlbum). - У объекте EntityAlbum добавим лист photos (EntityPhoto). - Сохраним объекты. После того, как мы добавили листы к объектам, у нас будет рекурсивно работать метод IFactory::GetFromRequest(). Это нам пригодится в получении объектов с формы. ===== data.tmpl.php ===== В шаблоне data.tmpl.php добавим новый таб "Фотографии". И подключим его после ''
...
'' {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'', т.к. у него своя сложная структура.
Создадим шаблон ''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. * ''{{each(i, photo) photos}}'' - цикл в шаблоне для отображения фотографий. ''photos'' - свойство из элемента массива data. * ''{{tmpl( photo , { counter: $item.photoCounter, albumIndex: $item.counter.index }) "#photoTemplate"}}'' - вызов шаблона photoTemplate, передача дополнительных параметров для рендеринга. * ''{$prefix}'' - стандартная переменная для data.tmpl.php (префикс объекта). Шаблон для отображения конкретной фотографии * ''${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 поможет нам подсветить таб "Фотографии", если на нем были ошибки (''
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 ) ); } } Не забудьте посмотреть код [[eaze:samples:добавление_фотогалереи_в_vt_к_объекту_EntityAlbumUtility.php|EntityAlbumUtility.php]]. ====== Итог ====== Поставленной цели мы добились. Можно было бы конечно сделать массовый загрузчик на flash и потом и мышкой раскидать фотографии по альбомам, но для этого нужно написать ещё больше javascript'а. Осталось рассмотреть плюсы и минусы подхода. ===== Плюсы ===== * Не нужно дополнительно обрабатывать получение связанных объектов из формы на PHP (albums, photos). * Не нужно дублировать шаблон отображения album и photo сначала в PHP, потом на JS. Всего используется один шаблон. * Минимальное количество кода в SaveEntityAction (в основном - только получение листов второго и последующих уровней). ===== Минусы ===== * GetFromRequest на втором уровне получает каждый объект через GetById (соответственно сколько файлов - столько запросов при сохранении). //можно исправить, но сложновато// * Если сохранить страницу при выключенном JS - то все фотографии удалятся (потому что не пришли с формы). //можно исправить через дополнительную переменную, которая выставляется через JS// * При сохранении страницы для не измененных данных каждый раз выполняется UPDATE. //можно исправить путем добавления проверки на эквивалентность объектов// //Ещё раз повторюсь, что данный пример не претендует на идеальность и универсальность. Он пытается рассказать, как работать в VT со сложными объектами. Пожелания и дополнения приветствуются. ;)//