Инструменты пользователя

Инструменты сайта


eaze:samples:добавление_фотогалереи_в_vt_к_объекту

Это старая версия документа.


Добавление фотогалереи в VT у объекта

Задача

В системе существует объект Entity. Необходимо добавить к нему возможность управлять фотографиями, которые группируются в альбомы.

  • У альбома есть название и описание (необязательное поле).
  • У фотографии есть маленькая и большая картинка, из необязательных полей - название.
  • Должна быть возможность сортировки фотографий и альбомов.
  • Количество фотографий у одного объекта будет не больше 30.
  • Поддержка начальной массовой загрузки фотографий.
  • В случае случайного удаления должна быть ручная возможность восстановления данных.

Исходя из этих требований попробуем спроектировать соответствующие таблицы.

База данных

По базе данных все достаточно просто, единственное, что мы добавили - это поле 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.

MFD

  1. Добавим таблицы entityPhotos и entityAlbums в MFD с флагом WithoutTemplate (CanPages ставить не нужно).
  2. У объекта Entity пропишем лист albums (EntityAlbum).
  3. У объекте EntityAlbum добавим лист photos (EntityPhoto).
  4. Сохраним объекты.

После того, как мы добавили листы к объектам, у нас будет рекурсивно работать метод IFactory::GetFromRequest(). Это нам пригодится в получении объектов с формы.

data.tmpl.php

В шаблоне 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}

На этом работа с файлом закончена.

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 завершена.

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(  ' <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 таким образом, чтобы он сохранял альбомы в базу.

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 поможет нам подсветить таб «Фотографии», если на нем были ошибки (<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'а.

Осталось рассмотреть плюсы и минусы подхода.

Плюсы

  • Не нужно дополнительно обрабатывать получение связанных объектов из формы на PHP (albums, photos).
  • Не нужно дублировать шаблон отображения album и photo сначала в PHP, потом на JS. Всего используется один шаблон.
  • Минимальное количество кода в SaveEntityAction (в основном - только получение листов второго и последующих уровней).

Минусы

  • GetFromRequest на втором уровне получает каждый объект через GetById (соответственно сколько файлов - столько запросов при сохранении). можно исправить, но сложновато
  • Если сохранить страницу при выключенном JS - то все фотографии удалятся (потому что не пришли с формы). можно исправить через дополнительную переменную, которая выставляется через JS
  • При сохранении страницы для не измененных данных каждый раз выполняется UPDATE. можно исправить путем добавления проверки на эквивалентность объектов
eaze/samples/добавление_фотогалереи_в_vt_к_объекту.1316589985.txt.gz · Последние изменения: 2011/09/21 11:26 — sergeyfast