DarkRiDDeR11 мин

Bitrix API. Legacy-форма с редактором изображения: разбор типичной ошибки

BitrixPHPjQueryФормы

Редактор изображения показывает новую фотографию, но после отправки формы карточка снова открывается со старой. Цена ошибки заметна не сразу: менеджер повторяет загрузку, а в файловом хранилище появляются лишние записи без понятной связи с товаром.

В такой legacy-форме я не пытаюсь заставить jQuery «запомнить картинку». Нужно решить, что именно передаёт форма: новый бинарный файл, ID уже существующего файла или команду удалить старый. Preview — только экранный сигнал. Сервер должен получить одно из этих состояний и вернуть результат, который форма может показать без догадок.

Фиксирую контракт формы до правки плагина

Перед работой я выписываю имена полей и один реальный POST. Старые шаблоны часто держат одновременно обычный input type=file, скрытый PHOTO_ID и HTML редактора. Если после Ajax-перерисовки в документе остаются два поля с одинаковым name, браузер отправит оба, а обработчик выберет не то значение. Поэтому в контракте у каждого поля одна роль.

Поле или сигналЗначениеВладелецЧто означает на сервере
CATALOG_PREVIEWбинарный файл из multipart POSTбраузер до отправкикандидат на новую картинку
KEEP_PICTURE0 или 1форма и сценарий редактированиясохраняем существующую картинку, если нового файла нет
DELETE_PICTURE0 или 1явное действие пользователязапрашиваем удаление, не выводим его из пустого DOM
.js-photo-stateтекст статусаjQueryне попадает в модель данных и не является ID файла
Контракт legacy-формы: выбранный файл живёт в multipart POST, preview и статус принадлежат DOM, а сервер возвращает зарегистрированный ID и результат обновления карточки.
У preview нет права менять карточку; его задача — показать пользователю, какой файл выбран до подтверждения сервера.

Не прячу имена полей внутри редактора

Сначала оставляю контрол и обработчик читаемыми. В примере ниже FileInput рисует интерфейс для одного изображения, а вокруг него есть контейнер для статуса. Если конкретная версия Bitrix возвращает файл через свой формат, я проверяю его отдельным тестовым POST и передаю дальше как файловый массив. Не подменяю это знание строкой из скрытого поля.

<?php
use Bitrix\\Main\\UI\\FileInput;

$currentId = (int) $arResult['PREVIEW_PICTURE'];
?>
<form id="catalog-photo-form" method="post" enctype="multipart/form-data">
  <div class="js-photo-editor">
    <?php
    echo FileInput::createInstance(array(
        'id' => 'catalog_preview',
        'name' => 'CATALOG_PREVIEW',
        'upload' => true,
        'allowUpload' => FileInput::UPLOAD_IMAGES,
        'maxCount' => 1,
        'maxSize' => 5 * 1024 * 1024,
        'delete' => true,
    ))->show($currentId);
    ?>
  </div>
  <label><input type="checkbox" name="KEEP_PICTURE" value="1" checked> оставить текущую картинку</label>
  <label><input type="checkbox" name="DELETE_PICTURE" value="1"> удалить картинку</label>
  <p class="js-photo-state" aria-live="polite"></p>
  <button type="submit">Сохранить</button>
</form>

Контрол FileInput доступен в D7-ядре и умеет формировать HTML и JavaScript. Но значение name — часть договора с PHP, а не косметика. После первой подстановки шаблона я смотрю исходный HTML и реальный Request Payload: имя, число file-полей, код ответа и наличие enctype=multipart/form-data. Если форма отправляется Ajax-ом, проверяю, что код строит FormData, а не сериализует только текстовые inputs.

jQuery показывает состояние, но не делает файл сохранённым

Устаревший шаблон может переотрисовать блок формы через .html(). Прямой обработчик на старом input после этого исчезнет, а повторная инициализация способна добавить второй обработчик. Я привязываю событие к стабильному контейнеру и использую namespace: перед повторной инициализацией снимаю именно свой обработчик. В браузере это даёт один статус выбора и не меняет серверную модель.

(function ($) {
  function bindPhotoForm(root) {
    var $root = $(root);

    $root.off('change.photoWorkflow', 'input[type=file][name=CATALOG_PREVIEW]')
      .on('change.photoWorkflow', 'input[type=file][name=CATALOG_PREVIEW]', function () {
        var file = this.files && this.files[0];
        var message = file
          ? 'Выбран файл: ' + file.name + '. Сохранение ещё не выполнено.'
          : 'Новый файл не выбран.';

        $root.find('.js-photo-state').text(message);
        $root.find('input[name=KEEP_PICTURE]').prop('checked', !file);
        $root.find('input[name=DELETE_PICTURE]').prop('checked', false);
      });
  }

  $(function () {
    bindPhotoForm(document);
  });
}(jQuery));

Метод .on() с селектором делегирует событие от потомка к уже существующему контейнеру. Это подходит для полей, которые появятся после перерисовки. Обработчик выше намеренно не кладёт имя или data URL в PHOTO_ID: такой ID существует только после серверного шага. Если нужны размеры картинки для preview, их можно показать рядом, но финальную проверку и привязку оставляю обработчику.

На сервере выбираю ровно один путь

Для формы редактирования полезно свести три пользовательских действия к трём веткам. Новый файл важнее флага «оставить»; явное удаление нельзя смешивать с новым файлом в одном запросе. При конфликте возвращаю ошибку формы, а не выбираю вариант по порядку полей. Это проще объяснить оператору и проще проверить через один POST.

function updatePreviewPicture($elementId, array $post, array $files)
{
    $hasNewFile = isset($files['CATALOG_PREVIEW'])
        && ($files['CATALOG_PREVIEW']['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK;
    $deleteRequested = ($post['DELETE_PICTURE'] ?? '') === '1';

    if ($hasNewFile && $deleteRequested) {
        throw new RuntimeException('Choose a new picture or deletion, not both');
    }

    if (!$hasNewFile && !$deleteRequested) {
        return; // карточка сохраняет текущую картинку
    }

    $fields = array();
    if ($hasNewFile) {
        $fields['PREVIEW_PICTURE'] = $files['CATALOG_PREVIEW'];
    } else {
        $fields['PREVIEW_PICTURE'] = array('del' => 'Y');
    }

    $element = new CIBlockElement();
    if (!$element->Update((int) $elementId, $fields)) {
        throw new RuntimeException($element->LAST_ERROR);
    }
}

Это минимальный сценарий именно для поля изображения элемента. Если приложение сначала создаёт отдельную запись в b_file, я храню возвращённый ID в серверной сессии или черновике и при финальном сохранении снова проверяю права на элемент. Для повторного использования существующего файла документация Bitrix предлагает CFile::MakeFileArray(), принимающий и ID файла. Важно не смешивать эту ветку с «пользователь выбрал файл, но ещё не отправил форму».

Проверяю не только счастливый путь

  1. Открываю существующую карточку, фиксирую текущий ID изображения и отправляю форму без нового файла: ID должен остаться прежним.
  2. Выбираю небольшой тестовый файл, проверяю один POST с multipart-частью и убеждаюсь, что серверный ответ содержит успешный результат обновления.
  3. Перезагружаю страницу отдельным запросом; preview и URL картинки должны соответствовать новому значению элемента.
  4. Нажимаю «удалить» без нового файла и проверяю, что обработчик получает явный флаг, а не выводит удаление из пустого preview.
  5. Имитирую Ajax-перерисовку контейнера и ещё раз меняю файл: статус должен смениться один раз, без двойного обработчика.
  6. Отправляю новый файл вместе с удалением и ожидаю понятную ошибку формы, а не неявный выбор одной ветки.

Где legacy-интеграция чаще всего обманывает

Первый обман — вызывать serialize() для формы с файлом. Метод собирает текстовые поля и не переносит бинарное содержимое; для Ajax нужен FormData и правильные параметры запроса. Второй — считать, что серверный 200 подтвердил картинку: обработчик мог вернуть HTML ошибки или не обновить элемент. Третий — брать «последний созданный файл» из базы. В форме одновременно работают люди, поэтому связь должна идти из конкретного запроса и конкретного элемента.

Ещё один риск связан с повторной инициализацией. Namespace в change.photoWorkflow даёт узкую очистку только нашего события; нельзя заменять его общим off('change'), потому что так легко сломать чужой плагин на той же форме. В 2018 году это особенно важно для старых шаблонов, где порядок подключения JavaScript не документирован.

Ограничения и следующий шаг

Пример не определяет политику доступа, допустимые расширения и обработку больших изображений. Их надо связать с ролью пользователя, настройками PHP и правилами каталога на конкретной установке. Если FileInput уже загружает файл отдельным действием, не копируйте ветку с $_FILES: сначала посмотрите, какое значение возвращает контрол и кто владеет временным ID.

Критерий готовности простой: после новой загрузки форма делает один понятный запрос, Update() проходит, а свежая страница показывает новое изображение. После сохранения без файла старая картинка остаётся. Этого достаточно, чтобы следующая правка формы не превратила DOM-preview в ложное подтверждение данных.

Проверяемые источники

  • 1С-Битрикс: FileInput — описание контрола, его методов createInstance, prepareFile и show, а также параметров загрузки
  • 1С-Битрикс: CIBlockElement::Update — Update принимает массив полей, возвращает true или false, а текст ошибки остаётся в LAST_ERROR
  • 1С-Битрикс: CFile::MakeFileArray — формирует файловый массив, в том числе по ID существующего файла
  • jQuery API: .on() — делегированный обработчик работает на потомках существующего контейнера и может быть привязан с namespace
  • jQuery API: .ready() — обработчик запускается, когда DOM готов к безопасному изменению
  • PHP Manual: $_FILES — структура данных, переданных HTTP POST с файлом