diff --git a/editorial/agent-rewrites/331.json b/editorial/agent-rewrites/331.json index 763bbbb..686613d 100644 --- a/editorial/agent-rewrites/331.json +++ b/editorial/agent-rewrites/331.json @@ -2,6 +2,6 @@ "index": 331, "slug": "editorial-2018-10-field-image-workflow", "title": "Bitrix: как не потерять изображение между preview и сохранением формы", - "excerpt": "Preview показывает выбранный файл, но не доказывает, что Bitrix получил его и записал в PREVIEW_PICTURE. Разбираем контракт legacy-формы, multipart-запрос, серверные ветки замены и удаления и проверки после обновления элемента.", - "contentHtml": "

Редактор показывает новую фотографию, пользователь нажимает «Сохранить», а после перезагрузки карточка снова открывается со старой. Иногда поле изображения становится пустым. В файловом хранилище при этом остаются лишние загрузки без связи с элементом. Оператор повторяет действие, а разработчик видит только успешный preview и ответ HTTP 200. Ошибка стоит времени пользователя, мусора в хранилище и риска опубликовать карточку не с тем изображением.

\n

Тезис простой: preview — состояние DOM, а не подтверждение данных. Для сохранения нужны три отдельные границы: браузер должен отправить файл как multipart-часть, сервер должен принять и проверить его, а Bitrix должен успешно обновить поле элемента. Если одна граница скрыта за jQuery или FileInput, интерфейс может выглядеть исправным при потерянном результате.

\n

Механизм: у одного изображения несколько состояний

\n

До отправки браузер владеет выбранным объектом File. Preview владеет только картинкой на экране и текстом статуса. После загрузки сервер регистрирует файл и получает ID. Только затем обработчик может передать файл или файловый массив в CIBlockElement::Update(). Поле PREVIEW_PICTURE принадлежит элементу инфоблока, поэтому его значение нужно проверить отдельным чтением после обновления.

\n

В legacy-шаблоне часто живут обычный input type=file, скрытый ID, HTML редактора и Ajax-перерисовка одного контейнера. У каждого поля должна быть одна роль. Скрытый PHOTO_ID не превращает выбранный в браузере файл в сохранённый. Если контрол загружает файл отдельным запросом, его временный ID нужно связать с конкретным пользователем и элементом. Нельзя брать «последний файл» из базы: параллельный запрос может изменить этот результат.

\n
Поле или сигналКто владеетЧто означает
CATALOG_PREVIEWбраузер и multipart POSTкандидат на новую картинку
DELETE_PICTURE=1явное действие пользователязапросить удаление текущей картинки
.js-photo-stateDOM и jQueryтолько текст статуса, не ID файла
PREVIEW_PICTUREэлемент инфоблокаподтверждённая ссылка на файл
\n
\"Контракт
Контракт формы: файл приходит в multipart POST, preview остаётся сигналом DOM, а результатом становится значение PREVIEW_PICTURE после успешного обновления.
\n

Конкретный пример формы и обработчика

\n

Ниже учебный пример для элемента инфоблока с одним изображением. Он показывает границы данных, но не заменяет политику доступа и проверки конкретного проекта. Форма должна явно указать multipart/form-data, а имя поля должно совпасть с ключом в $_FILES.

\n
<?php\nuse Bitrix\\Main\\UI\\FileInput;\n\n$currentId = (int) $arResult['PREVIEW_PICTURE'];\n?>\n<form method=\"post\" enctype=\"multipart/form-data\" id=\"catalog-photo-form\">\n  <?php\n  echo FileInput::createInstance(array(\n      'id' => 'catalog_preview',\n      'name' => 'CATALOG_PREVIEW',\n      'upload' => true,\n      'allowUpload' => FileInput::UPLOAD_IMAGES,\n      'maxCount' => 1,\n      'delete' => true,\n  ))->show($currentId);\n  ?>\n  <label><input type=\"checkbox\" name=\"DELETE_PICTURE\" value=\"1\"> удалить</label>\n  <p class=\"js-photo-state\" aria-live=\"polite\"></p>\n  <button type=\"submit\">Сохранить</button>\n</form>
\n

Если форма отправляется Ajax-ом, собирайте её через FormData. Не вызывайте serialize() для передачи файла. Не задавайте вручную заголовок Content-Type: multipart/form-data: браузер должен добавить boundary. После отправки смотрите в Network имя file-поля, размер части, код ответа и тело ответа. Статус 200 означает только, что сервер ответил, а не то, что элемент обновился.

\n
var form = document.getElementById('catalog-photo-form');\n\nform.addEventListener('submit', function (event) {\n  event.preventDefault();\n  var data = new FormData(form);\n\n  fetch('/admin/catalog/photo.php', {\n    method: 'POST',\n    body: data\n  });\n});
\n

jQuery нужен для поведения интерфейса, но не для хранения результата. При Ajax-перерисовке обработчик поля может исчезнуть. Делегирование от стабильного контейнера сохраняет событие, а namespace позволяет снять только свой обработчик:

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

Сообщение намеренно говорит «сохранение ещё не выполнено». Это удерживает границу между экранным событием и серверным результатом. Не записывайте имя файла, data URL или размер в поле, которое сервер трактует как ID.

\n

Сервер выбирает одну ветку

\n

Для редактирования изображения нужны три нормальные операции: оставить старое, заменить новым или удалить. Новый файл и удаление в одном запросе — конфликт, который лучше отклонить. Пустой input type=file не означает удаление: пользователь мог не менять картинку, а DOM мог перерисоваться.

\n
function updatePreviewPicture($elementId, array $post, array $files)\n{\n    $upload = $files['CATALOG_PREVIEW'] ?? array();\n    $hasNewFile = ($upload['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK;\n    $delete = ($post['DELETE_PICTURE'] ?? '') === '1';\n\n    if ($hasNewFile && $delete) {\n        throw new RuntimeException('Выберите замену или удаление');\n    }\n    if (!$hasNewFile && !$delete) {\n        return; // оставить текущее значение\n    }\n\n    $fields = array(\n        'PREVIEW_PICTURE' => $hasNewFile\n            ? $upload\n            : array('del' => 'Y'),\n    );\n    $element = new CIBlockElement();\n\n    if (!$element->Update((int) $elementId, $fields)) {\n        throw new RuntimeException($element->LAST_ERROR);\n    }\n}
\n

Для старых версий Bitrix формат входного файла и политика удаления могут отличаться. Если файл уже зарегистрирован отдельно, используйте подтверждённый ID и соберите файловый массив через CFile::MakeFileArray(). После Update() проверяйте не только возврат метода, но и LAST_ERROR. Для файлового свойства, а не поля PREVIEW_PICTURE, формат PROPERTY_VALUES нужно сверить отдельно.

\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
Preview новый, карточка стараяфайл не попал в POST или обработчик не вызвал UpdateNetwork: multipart-часть и серверный лог ID элементаисправить enctype, имя поля или ветку сохранения
Ответ 200, поле пустоеошибка скрыта за ответом или неверный формат поляпроверить JSON/HTML ответа, Update() и LAST_ERRORвозвращать ошибку формы и перечитать элемент
Ajax работает только со второго разаserialize() не передаёт файл или повторно создан обработчикпроверить Request Payload и число срабатываний changeиспользовать FormData и namespace-делегирование
Удаление срабатывает самоудаление выведено из пустого previewсравнить явный флаг удаления с состоянием DOMпередавать отдельный флаг и отклонять конфликт
В хранилище много сиротских файловотдельная загрузка завершилась без сохранения элементасопоставить ID временного файла с запросом и элементомввести владельца черновика и регламент очистки
\n

Проверяю путь в порядке отказа

\n
  1. Открываю карточку с известным текущим ID изображения и фиксирую его до изменения.
  2. Отправляю форму без нового файла и без удаления. После свежего чтения элемента ID должен остаться прежним.
  3. Выбираю небольшой учебный JPEG и проверяю в Network имя поля, multipart-часть и размер файла.
  4. Проверяю ответ обработчика, результат Update() и текст LAST_ERROR, если метод вернул false.
  5. Открываю карточку новым HTTP-запросом и сравниваю URL изображения с ожидаемым файлом.
  6. Отправляю явное удаление без нового файла и проверяю, что сработала именно ветка удаления.
  7. Отправляю новый файл вместе с удалением. Ожидаю понятную ошибку формы, а не выбор ветки по порядку полей.
  8. Перерисовываю контейнер Ajax-ом и меняю файл ещё раз. Событие должно обработаться один раз.
\n

Ограничения

\n

Пример не задаёт универсальные MIME-типы, размеры, права и антивирусную проверку. Эти правила зависят от версии Bitrix, настроек PHP, роли пользователя и требований каталога. Проверка расширения в браузере не защищает сервер. На сервере нужно проверять размер, фактический тип, ошибки загрузки, доступ к элементу и допустимость операции для пользователя.

\n

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

\n

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

\n

Интеграция готова, если форма делает один понятный запрос, новый файл проходит серверные проверки, Update() возвращает успех, а свежая страница показывает новое изображение. Сохранение без нового файла оставляет старое значение. Явное удаление меняет поле только по флагу пользователя. Конфликт «новый файл плюс удаление» даёт ошибку. В браузере после Ajax-перерисовки нет двойного обработчика. Эти условия проверяются на тестовой записи; они не являются заявлением о результате production.

\n

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

\n" + "excerpt": "Preview показывает выбранный файл, но не доказывает, что Bitrix получил его и записал в PREVIEW_PICTURE. Разделяем нативный multipart-контракт и AJAX-режим FileInput, разбираем ветки замены и удаления и проверяем результат после обновления элемента.", + "contentHtml": "

Редактор показывает новую фотографию, пользователь нажимает «Сохранить», а после перезагрузки карточка снова открывается со старой. Иногда поле изображения становится пустым. В файловом хранилище при этом остаются лишние загрузки без связи с элементом. Оператор повторяет действие, а разработчик видит только удачный preview и ответ HTTP 200. Ошибка стоит времени пользователя, мусора в хранилище и риска опубликовать карточку не с тем изображением.

\n

Preview подтверждает только состояние интерфейса. Надёжное сохранение проходит три границы: браузер отправляет файл, сервер принимает и проверяет его, Bitrix обновляет поле элемента. Важно выбрать один контракт загрузки: нативная форма передаёт файл в $_FILES, а AJAX-контрол Bitrix сначала создаёт своё значение. Если смешать эти пути, экран может быть правильным, а PREVIEW_PICTURE — прежним.

\n

У изображения несколько состояний

\n

До отправки браузер владеет выбранным объектом File. Preview владеет картинкой на экране и текстом статуса. После обычной отправки файл появляется в $_FILES; после отдельной AJAX-загрузки контрол возвращает путь или хеш в своём формате. Только серверный обработчик решает, какое значение можно передать в CIBlockElement::Update(). Поле PREVIEW_PICTURE принадлежит элементу инфоблока, поэтому его нужно перечитать после обновления.

\n

В legacy-шаблоне рядом могут жить поле выбора, скрытый идентификатор, HTML-редактор и Ajax-перерисовка контейнера. У каждого значения должна быть одна роль. Скрытый PHOTO_ID не превращает локальный файл в сохранённый. Идентификатор отдельной загрузки нужно связать с пользователем и элементом; брать «последний файл» из базы нельзя, потому что параллельный запрос может изменить результат.

\n
СигналВладелецЧто он доказывает
CATALOG_PREVIEW в $_FILESбраузер и multipart POSTфайл дошёл до PHP
DELETE_PICTURE=1явное действие пользователязапрошено удаление
.js-photo-state и previewDOM и jQueryтолько обратная связь
PREVIEW_PICTUREэлемент инфоблокасохранённая привязка файла
\n
\"Схема
Preview — сигнал браузера. Результат появляется только после проверки multipart-запроса, успешного обновления элемента и нового чтения карточки.
\n

Нативная форма: самый короткий контракт

\n

Для простого legacy-сценария достаточно обычного поля файла. У формы есть enctype=\"multipart/form-data\", у поля — имя, которое сервер ищет в $_FILES. Этот пример намеренно не использует FileInput: так легче увидеть, какой запрос должен прийти обработчику.

\n
<form method=\"post\" enctype=\"multipart/form-data\" id=\"catalog-photo-form\">\n  <input type=\"file\" name=\"CATALOG_PREVIEW\" accept=\"image/*\">\n  <label>\n    <input type=\"checkbox\" name=\"DELETE_PICTURE\" value=\"1\">\n    удалить текущее изображение\n  </label>\n  <p class=\"js-photo-state\" aria-live=\"polite\"></p>\n  <button type=\"submit\">Сохранить</button>\n</form>
\n

Если форма отправляется Ajax-ом, соберите её через FormData. Метод serialize() не передаёт содержимое поля файла. Заголовок Content-Type вручную не задавайте: браузер добавляет boundary, по которому сервер разделяет части запроса. В Network проверяйте имя поля, размер части, код ответа и тело ответа. HTTP 200 означает ответ сервера, но не успешное обновление элемента.

\n
var form = document.getElementById('catalog-photo-form');\n\nform.addEventListener('submit', function (event) {\n  event.preventDefault();\n\n  var data = new FormData(form);\n  fetch('/admin/catalog/photo.php', {\n    method: 'POST',\n    body: data\n  })\n    .then(function (response) {\n      if (!response.ok) {\n        throw new Error('HTTP ' + response.status);\n      }\n      return response.json();\n    })\n    .then(function (result) {\n      if (!result.ok) {\n        throw new Error(result.error || 'Изображение не сохранено');\n      }\n    });\n});
\n

Для статуса выбора можно использовать делегированный обработчик jQuery. Он остаётся рабочим после замены дочернего HTML-контейнера. Namespace позволяет снять только обработчик этого сценария:

\n
(function ($) {\n  $(document)\n    .off('change.photoWorkflow', 'input[name=CATALOG_PREVIEW]')\n    .on('change.photoWorkflow', 'input[name=CATALOG_PREVIEW]', function () {\n      var file = this.files && this.files[0];\n      $('.js-photo-state').text(file\n        ? 'Выбран файл: ' + file.name + '. Сохранение ещё не выполнено.'\n        : 'Новый файл не выбран.');\n    });\n}(jQuery));
\n

Текст намеренно говорит о выборе, а не о сохранении. Имя файла, data URL и размер полезны для экрана, но не могут заменять серверный ID. После ответа сервера статус нужно менять только по явному полю результата, а не по факту отправки формы.

\n

Сервер выбирает одну ветку

\n

Для редактирования изображения нужны три операции: оставить старое, заменить новым или удалить. Запрет на одновременные замену и удаление — это политика данного обработчика, а не обещание самого Bitrix. Она убирает зависимость от порядка полей в запросе. Пустой input type=\"file\" означает «нового файла нет», но не означает «удалить старый».

\n
function updatePreviewPicture($elementId, array $post, array $files)\n{\n    $upload = $files['CATALOG_PREVIEW'] ?? array();\n    $error = $upload['error'] ?? UPLOAD_ERR_NO_FILE;\n    $delete = ($post['DELETE_PICTURE'] ?? '') === '1';\n\n    if ($error !== UPLOAD_ERR_NO_FILE && $error !== UPLOAD_ERR_OK) {\n        throw new RuntimeException('Загрузка файла завершилась ошибкой: ' . $error);\n    }\n\n    $hasNewFile = $error === UPLOAD_ERR_OK;\n    if ($hasNewFile && (empty($upload['tmp_name']) || !is_uploaded_file($upload['tmp_name']))) {\n        throw new RuntimeException('Файл не найден во временном хранилище');\n    }\n\n    if ($hasNewFile && $delete) {\n        throw new RuntimeException('Выберите замену или удаление');\n    }\n    if (!$hasNewFile && !$delete) {\n        return false; // оставить текущее значение\n    }\n\n    $fields = array(\n        'PREVIEW_PICTURE' => $hasNewFile\n            ? $upload\n            : array('del' => 'Y'),\n    );\n    $element = new CIBlockElement();\n\n    if (!$element->Update((int) $elementId, $fields)) {\n        throw new RuntimeException($element->LAST_ERROR);\n    }\n\n    return true;\n}
\n

Код показывает минимальную развилку, а не готовую политику приёма файлов. Перед Update() сервер должен ограничить размер, проверить фактический тип изображения, права пользователя и принадлежность элемента. Значения UPLOAD_ERR_* нельзя превращать в «файла нет»: ошибка лимита или сбой временного хранилища должны стать ошибкой формы.

\n

Документация Bitrix описывает для CIBlockElement::Update() массив полей и сообщает, что при false текст причины находится в LAST_ERROR. Для уже существующего серверного файла нужен подтверждённый путь и файловый массив, например через CFile::MakeFileArray(). Для файлового свойства, а не поля PREVIEW_PICTURE, формат PROPERTY_VALUES проверяйте отдельно.

\n

FileInput: другой способ передачи

\n

\\Bitrix\\Main\\UI\\FileInput удобен, когда нужен готовый контрол выбора и загрузки. Но параметр upload => true включает отдельную AJAX-загрузку. В документации у него есть uploadType со значениями path и hash; результат такого контрола не следует автоматически искать в $_FILES['CATALOG_PREVIEW'].

\n
<?php\nuse Bitrix\\Main\\UI\\FileInput;\n\necho FileInput::createInstance(array(\n    'id' => 'catalog_preview',\n    'name' => 'CATALOG_PREVIEW',\n    'upload' => true,\n    'uploadType' => 'hash',\n    'allowUpload' => FileInput::UPLOAD_IMAGES,\n    'maxCount' => 1,\n    'delete' => true,\n))->show($files);
\n

Для этого варианта сначала определите реальный ответ контрол-эндпойнта и способ, которым проект вызывает prepareFile(). Затем сервер должен проверить подпись или идентификатор, владельца временного файла, элемент и срок жизни загрузки, после чего передать подготовленный файловый массив в обновление. Если нужна одна обычная отправка формы, не включайте AJAX-загрузку FileInput: используйте нативное поле и обрабатывайте $_FILES по первому контракту.

\n

Симптом → причина → проверка → действие

\n
СимптомВероятная причинаПроверкаДействие
Preview новый, карточка стараяфайл не попал в POST или не вызван Update()Network: multipart-часть, ответ обработчика и свежая выборкаисправить имя поля или ветку сохранения
Ответ 200, поле пустоеошибка скрыта в теле ответа или неверен формат поляпроверить JSON, LAST_ERROR и ID элементавернуть ошибку формы и перечитать элемент
Ajax работает со второго разаserialize() не передаёт файл или обработчик дублируетсяпроверить Request Payload и число срабатываний changeиспользовать FormData и namespace
Удаление срабатывает самопустой preview ошибочно принят за команду удалениясравнить явный флаг с данными формыпередавать отдельный флаг удаления
Появляются сиротские файлыотдельная загрузка завершилась без обновления элементасопоставить временный ID с пользователем и элементомввести владельца и очистку незавершённых загрузок
\n

Проверка по порядку отказа

\n
  1. Открываю карточку с известным текущим ID изображения и фиксирую его до изменения.
  2. Отправляю форму без нового файла и без удаления. После свежего чтения ID должен остаться прежним.
  3. Выбираю небольшой учебный JPEG и проверяю в Network имя поля, multipart-часть и размер файла.
  4. Проверяю ответ обработчика, результат Update() и LAST_ERROR, если метод вернул false.
  5. Открываю карточку новым HTTP-запросом и сравниваю URL изображения с ожидаемым файлом.
  6. Отправляю явное удаление без нового файла и проверяю, что сработала только ветка удаления.
  7. Отправляю новый файл вместе с удалением. Ожидаю понятную ошибку формы.
  8. Перерисовываю контейнер Ajax-ом и меняю файл ещё раз. Событие должно обработаться один раз.
  9. Повторяю сценарий с превышенным размером и повреждённым изображением. Обработчик должен вернуть ошибку до изменения элемента.
\n

Ограничения

\n

Пример не задаёт универсальные размеры, MIME-типы, права и антивирусную проверку. Эти правила зависят от версии Bitrix, настроек PHP, роли пользователя и требований каталога. Проверка расширения в браузере не защищает сервер. Поле PREVIEW_PICTURE и файловое свойство используют разные контракты, поэтому их нельзя менять одним предположением.

\n

Если FileInput загружает файл отдельным запросом, путь через $_FILES неприменим без адаптации. Сначала определите ответ контролла и место хранения временного файла. При отмене формы не оставляйте такой идентификатор без владельца. Для небольшой синхронной формы не нужны очереди, но нужна понятная очистка незавершённых загрузок.

\n

Критерий готовности

\n

Сценарий готов, если форма делает один понятный запрос, сервер принимает только допустимый файл, Update() возвращает успех, а свежая страница показывает новое изображение. Отправка без нового файла оставляет старое значение. Явное удаление меняет поле только по флагу пользователя. Конфликт «новый файл плюс удаление» даёт ошибку. После Ajax-перерисовки нет двойного обработчика. Эти условия проверяются на тестовой записи и не являются заявлением о результате рабочей среды.

\n

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

\n" }