Здесь показаны различия между двумя версиями данной страницы.
Both sides previous revision Предыдущая версия Следущая версия | Предыдущая версия | ||
eaze:samples:добавление_фотогалереи_в_vt_к_объекту [2011/09/20 18:39] sergeyfast |
eaze:samples:добавление_фотогалереи_в_vt_к_объекту [2011/09/21 11:30] (текущий) sergeyfast |
||
---|---|---|---|
Строка 20: | Строка 20: | ||
Т.к. объектов будет не очень много, то все управление поместится в одну дополнительную вкладку. | Т.к. объектов будет не очень много, то все управление поместится в одну дополнительную вкладку. | ||
- | ===== Интерфейс ===== | + | ====== Интерфейс ====== |
{{ :eaze:samples:album.png? |}} | {{ :eaze:samples:album.png? |}} | ||
- | Вот что в итоге у нас должно получится (вид с отображением ошибок). | + | Вот что в итоге у нас должно получиться (вид с отображением ошибок). |
> У альбома есть название и описание (необязательное поле). | > У альбома есть название и описание (необязательное поле). | ||
> У фотографии есть маленькая и большая картинка, из необязательных полей - название. | > У фотографии есть маленькая и большая картинка, из необязательных полей - название. | ||
Строка 34: | Строка 34: | ||
Все первоначальные требования выполнены. Приступим к реализации задачи. | Все первоначальные требования выполнены. Приступим к реализации задачи. | ||
+ | |||
+ | ====== Реализация ====== | ||
+ | |||
+ | Для начала нам нужно создать объекты в MFD, после добавить в data.tmpl.php новый таб "Фотогалерея" и вынести его в другой шаблон photos.tmpl.php. | ||
+ | Для реализации пользовательского интерфейса мы будем использовать JQuery Templates, поэтому не забудем добавить его в vt/elements/header.tmpl.php. Далее в photos.tmpl.php создадим шаблоны для jquery templates, весь javascript вынесем в отдельный файл в <nowiki>js://</nowiki>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 добавим новый таб "Фотографии". | ||
+ | <code php> | ||
+ | <ul class="tabs-list"> | ||
+ | <li><a href="#page-0">{lang:vt.common.commonInfo}</a></li> | ||
+ | <li><a href="#page-1">Фотографии</a></li> | ||
+ | </ul> | ||
+ | </code> | ||
+ | |||
+ | И подключим его после ''<nowiki><div id="page-0" class="tab-page rows">...</div></nowiki>'' | ||
+ | <code> | ||
+ | {increal:tmpl://vt/entities/photos.tmpl.php} | ||
+ | </code> | ||
+ | |||
+ | На этом работа с файлом закончена. | ||
+ | |||
+ | ===== photos.tmpl.php ===== | ||
+ | Данный шаблон будет работать с двумя переменными из Response: ''$data'' и ''$albumErrors''. | ||
+ | |||
+ | В ''$data'' содержится подготовленный массив с альбомами и фотографиями, который мы будем отдавать в качестве источника данных для JQuery Templates. Можно было бы конечно сделать сразу ''json_encode( $object->albums )'', но так мы получим избыток данных. В подготовленной переменной такого нет. Во время создания шаблона в ''var data = ..'' лежал заранее известный массив, который потом стал основой для php-массива (с помощью заранее готового массива можно проще отлаживать шаблон). | ||
+ | |||
+ | В ''$albumErrors'' находится массив с ошибками валидации альбомов и фотографий. Его нельзя положить в обычный массив ''$errors'', т.к. у него своя сложная структура. | ||
+ | |||
+ | <code php> | ||
+ | <? /** @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> | ||
+ | </code> | ||
+ | |||
+ | Создадим шаблон ''albumTemplate'', который будет инициализироваться строчкой (entity-photos.js) | ||
+ | <code javascript>$("#albumTemplate").tmpl(data, { counter: albumCounter, photoCounter: photoCounter } ).appendTo(".albums");</code> | ||
+ | * ''data'' - наш источник данных. | ||
+ | * ''albumCounter'' - объект, с помощью которого мы будем держать текущий индекс альбома (''photoCounter'' - аналогично с фото). Данный объект передается в tmpl() в качестве параметра для рендеринга (доступен в $item в шаблоне). | ||
+ | <code javascript> | ||
+ | /** | ||
+ | * 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(); | ||
+ | </code> | ||
+ | |||
+ | Опишем шаблон для создания строчки с альбомом. | ||
+ | <code html> | ||
+ | <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> | ||
+ | </code> | ||
+ | |||
+ | * ''${ $item.counter.nextIndex() }'' - пробелы между {} нужны для того, чтобы шаблонизатор в Eaze не подумал, что это переменная ''{$item}''. | ||
+ | * ''</script><script><nowiki>''</nowiki></script>'' - [[http://youtrack.jetbrains.net/issue/IDEA-73686|workaround]] для IDE. | ||
+ | * ''${id}'' - получение свойства id из элемента массива data. | ||
+ | * ''<nowiki>{{each(i, photo) photos}}</nowiki>'' - цикл в шаблоне для отображения фотографий. ''photos'' - свойство из элемента массива data. | ||
+ | * ''<nowiki>{{tmpl( photo , { counter: $item.photoCounter, albumIndex: $item.counter.index }) "#photoTemplate"}}</nowiki>'' - вызов шаблона photoTemplate, передача дополнительных параметров для рендеринга. | ||
+ | * ''{$prefix}'' - стандартная переменная для data.tmpl.php (префикс объекта). | ||
+ | |||
+ | Шаблон для отображения конкретной фотографии | ||
+ | <code html> | ||
+ | <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> | ||
+ | </code> | ||
+ | * ''${num}'' - это дополнительное свойство (индекс массива), который используется для поиска элемента при отображении ошибок (''$item.counter.nextIndex()'' имеет сквозную нумерацию, а ''${num}'' начинается с 0 для каждого альбома). | ||
+ | |||
+ | На этом работа с photos.tmpl.php завершена. | ||
+ | |||
+ | ===== entity-photos.js ===== | ||
+ | //Данный файл не претендует на идеальность :)// | ||
+ | |||
+ | Начинается данный файл с описания счетчиков для альбомов и фотографий (см. код выше). | ||
+ | |||
+ | После этого опишем поведение кнопок добавления и удаления альбомов и фотографий | ||
+ | <code javascript> | ||
+ | /** | ||
+ | * 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(); | ||
+ | }); | ||
+ | }); | ||
+ | </code> | ||
+ | |||
+ | ''rebindAlbumControls()'' необходимо для переинициализации vfs и сортировок | ||
+ | <code javascript> | ||
+ | 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' | ||
+ | }); | ||
+ | } | ||
+ | </code> | ||
+ | |||
+ | При загрузки страницы запустим рендеринг | ||
+ | <code javascript> | ||
+ | $(function() { | ||
+ | $("#albumTemplate").tmpl(data, { counter: albumCounter, photoCounter: photoCounter } ).appendTo(".albums"); | ||
+ | rebindAlbumControls(); | ||
+ | displayAlbumErrors(); | ||
+ | }); | ||
+ | </code> | ||
+ | |||
+ | И отобразим ошибки | ||
+ | <code javascript> | ||
+ | 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>' ); | ||
+ | } | ||
+ | }); | ||
+ | }); | ||
+ | } | ||
+ | </code> | ||
+ | |||
+ | Со клиентской частью мы закончили. | ||
+ | Данного кода достаточно для управления альбомами без серверной части, только они не будут сохранятся и валидироваться, но в ''$object'' данные попадут, т.к. мы используем полные пути к свойствам объекта (например ''{$prefix}[albums][${ $item.albumIndex }][photos][${ $item.counter.index }][smallImageId]'' ) - GetFromRequest соберет объект так, как нужно. | ||
+ | |||
+ | Попробуем модифицировать SaveEntityAction таким образом, чтобы он сохранял альбомы в базу. | ||
+ | |||
+ | ===== SaveEntityAction.php ===== | ||
+ | Прежде всего мы должны помнить о том, что albums у объекта Entity - лист. ''EntityFactory::GetById( $id, array( BaseFactory::WithLists => true ) )'' выберет только альбомы, без фотографий. Фотографии будем выбрать вручную. | ||
+ | Создадим метод ''refillAlbumPhotos()'', который добавляет к альбомам фотографии. | ||
+ | <code php> | ||
+ | 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]; | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | } | ||
+ | </code> | ||
+ | * дополнительно в EntityPhotoFactory мы добавили поиск по entityId. | ||
+ | |||
+ | Далее сделаем так, чтобы фотографии грузились только тогда, когда мы открыли объект для редактирования. | ||
+ | <code php> | ||
+ | /** | ||
+ | * 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 ) ); | ||
+ | } | ||
+ | </code> | ||
+ | * EntityAlbumUtility::PrepareAlbumsData() - метод, который подготавливает переменную ''data'' для шаблона photos.tmpl.php. | ||
+ | |||
+ | Теперь, если у объекта есть альбомы и фотографии, то мы уже увидим их в шаблоне :). | ||
+ | |||
+ | Добавим валидацию альбомов и фотографий. | ||
+ | <code 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; | ||
+ | } | ||
+ | </code> | ||
+ | * fields => photos поможет нам подсветить таб "Фотографии", если на нем были ошибки (''<div data-row="**photos**"...'' в photos.tmpl.php) и не дать сохранить ошибочный объект в базу. | ||
+ | |||
+ | Теперь можно перейти к сохранению. Для этого сначала нужно заполнить $originalObject фотографиями (в оригинальном объекте они нужны для того, чтобы удалить те фотографии, которые мы удалили с формы). | ||
+ | <code php> | ||
+ | protected function beforeAction() { | ||
+ | if ( !empty( $this->originalObject ) ) { | ||
+ | $this->refillAlbumPhotos( $this->originalObject ); | ||
+ | } | ||
+ | } | ||
+ | </code> | ||
+ | |||
+ | add() и update() будут обернуты в транзакцию. | ||
+ | <code php> | ||
+ | /** | ||
+ | * 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; | ||
+ | } | ||
+ | </code> | ||
+ | |||
+ | Последний штрих. При сохранении фоток мы не получаем их идентификаторы, из-за этого неправильно работает кнопка "Применить" в режиме редактирование. Исправляется это путем переполучения фотографий только при успешном сохранении. | ||
+ | <code php> | ||
+ | protected function afterAction( $success ) { | ||
+ | if ( $this->redirect == 'view' && $success ) { | ||
+ | $this->refillAlbumPhotos( $this->currentObject ); | ||
+ | Response::setParameter( 'data', EntityAlbumUtility::PrepareAlbumsData( $this->currentObject ) ); | ||
+ | } | ||
+ | } | ||
+ | </code> | ||
+ | |||
+ | Не забудьте посмотреть код [[eaze:samples:добавление_фотогалереи_в_vt_к_объекту_EntityAlbumUtility.php|EntityAlbumUtility.php]]. | ||
+ | |||
+ | |||
+ | ====== Итог ====== | ||
+ | Поставленной цели мы добились. Можно было бы конечно сделать массовый загрузчик на flash и потом и мышкой раскидать фотографии по альбомам, но для этого нужно написать ещё больше javascript'а. | ||
+ | |||
+ | Осталось рассмотреть плюсы и минусы подхода. | ||
+ | |||
+ | ===== Плюсы ===== | ||
+ | * Не нужно дополнительно обрабатывать получение связанных объектов из формы на PHP (albums, photos). | ||
+ | * Не нужно дублировать шаблон отображения album и photo сначала в PHP, потом на JS. Всего используется один шаблон. | ||
+ | * Минимальное количество кода в SaveEntityAction (в основном - только получение листов второго и последующих уровней). | ||
+ | |||
+ | ===== Минусы ===== | ||
+ | * GetFromRequest на втором уровне получает каждый объект через GetById (соответственно сколько файлов - столько запросов при сохранении). //можно исправить, но сложновато// | ||
+ | * Если сохранить страницу при выключенном JS - то все фотографии удалятся (потому что не пришли с формы). //можно исправить через дополнительную переменную, которая выставляется через JS// | ||
+ | * При сохранении страницы для не измененных данных каждый раз выполняется UPDATE. //можно исправить путем добавления проверки на эквивалентность объектов// | ||
+ | |||
+ | //Ещё раз повторюсь, что данный пример не претендует на идеальность и универсальность. Он пытается рассказать, как работать в VT со сложными объектами. Пожелания и дополнения приветствуются. ;)// | ||
+ | |||
+ | |||
+ | |||
+ | |||
+ | |||
+ | |||
+ | |||
+ |