Files

8 lines
20 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 331,
"slug": "editorial-2018-10-field-image-workflow",
"title": "Bitrix: как не потерять изображение между preview и сохранением формы",
"excerpt": "Preview показывает выбранный файл, но не доказывает, что Bitrix получил его и записал в PREVIEW_PICTURE. Разделяем нативный multipart-контракт и AJAX-режим FileInput, разбираем ветки замены и удаления и проверяем результат после обновления элемента.",
"contentHtml": "<p>Редактор показывает новую фотографию, пользователь нажимает «Сохранить», а после перезагрузки карточка снова открывается со старой. Иногда поле изображения становится пустым. В файловом хранилище при этом остаются лишние загрузки без связи с элементом. Оператор повторяет действие, а разработчик видит только удачный preview и ответ HTTP 200. Ошибка стоит времени пользователя, мусора в хранилище и риска опубликовать карточку не с тем изображением.</p>\n<p>Preview подтверждает только состояние интерфейса. Надёжное сохранение проходит три границы: браузер отправляет файл, сервер принимает и проверяет его, Bitrix обновляет поле элемента. Важно выбрать один контракт загрузки: нативная форма передаёт файл в <code>$_FILES</code>, а AJAX-контрол Bitrix сначала создаёт своё значение. Если смешать эти пути, экран может быть правильным, а <code>PREVIEW_PICTURE</code> — прежним.</p>\n<h2>У изображения несколько состояний</h2>\n<p>До отправки браузер владеет выбранным объектом <code>File</code>. Preview владеет картинкой на экране и текстом статуса. После обычной отправки файл появляется в <code>$_FILES</code>; после отдельной AJAX-загрузки контрол возвращает путь или хеш в своём формате. Только серверный обработчик решает, какое значение можно передать в <code>CIBlockElement::Update()</code>. Поле <code>PREVIEW_PICTURE</code> принадлежит элементу инфоблока, поэтому его нужно перечитать после обновления.</p>\n<p>В legacy-шаблоне рядом могут жить поле выбора, скрытый идентификатор, HTML-редактор и Ajax-перерисовка контейнера. У каждого значения должна быть одна роль. Скрытый <code>PHOTO_ID</code> не превращает локальный файл в сохранённый. Идентификатор отдельной загрузки нужно связать с пользователем и элементом; брать «последний файл» из базы нельзя, потому что параллельный запрос может изменить результат.</p>\n<table><thead><tr><th>Сигнал</th><th>Владелец</th><th>Что он доказывает</th></tr></thead><tbody><tr><td><code>CATALOG_PREVIEW</code> в <code>$_FILES</code></td><td>браузер и multipart POST</td><td>файл дошёл до PHP</td></tr><tr><td><code>DELETE_PICTURE=1</code></td><td>явное действие пользователя</td><td>запрошено удаление</td></tr><tr><td><code>.js-photo-state</code> и preview</td><td>DOM и jQuery</td><td>только обратная связь</td></tr><tr><td><code>PREVIEW_PICTURE</code></td><td>элемент инфоблока</td><td>сохранённая привязка файла</td></tr></tbody></table>\n<figure><img src=\"/assets/editorial/2018/legacy-photo-form-contract-2018.svg\" alt=\"Схема контракта формы: preview, multipart POST, ветка Bitrix и повторное чтение\" /><figcaption>Preview — сигнал браузера. Результат появляется только после проверки multipart-запроса, успешного обновления элемента и нового чтения карточки.</figcaption></figure>\n<h2>Нативная форма: самый короткий контракт</h2>\n<p>Для простого legacy-сценария достаточно обычного поля файла. У формы есть <code>enctype=\"multipart/form-data\"</code>, у поля — имя, которое сервер ищет в <code>$_FILES</code>. Этот пример намеренно не использует <code>FileInput</code>: так легче увидеть, какой запрос должен прийти обработчику.</p>\n<pre><code>&lt;form method=\"post\" enctype=\"multipart/form-data\" id=\"catalog-photo-form\"&gt;\n &lt;input type=\"file\" name=\"CATALOG_PREVIEW\" accept=\"image/*\"&gt;\n &lt;label&gt;\n &lt;input type=\"checkbox\" name=\"DELETE_PICTURE\" value=\"1\"&gt;\n удалить текущее изображение\n &lt;/label&gt;\n &lt;p class=\"js-photo-state\" aria-live=\"polite\"&gt;&lt;/p&gt;\n &lt;button type=\"submit\"&gt;Сохранить&lt;/button&gt;\n&lt;/form&gt;</code></pre>\n<p>Если форма отправляется Ajax-ом, соберите её через <code>FormData</code>. Метод <code>serialize()</code> не передаёт содержимое поля файла. Заголовок <code>Content-Type</code> вручную не задавайте: браузер добавляет boundary, по которому сервер разделяет части запроса. В Network проверяйте имя поля, размер части, код ответа и тело ответа. HTTP 200 означает ответ сервера, но не успешное обновление элемента.</p>\n<pre><code>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});</code></pre>\n<p>Для статуса выбора можно использовать делегированный обработчик jQuery. Он остаётся рабочим после замены дочернего HTML-контейнера. Namespace позволяет снять только обработчик этого сценария:</p>\n<pre><code>(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 &amp;&amp; this.files[0];\n $('.js-photo-state').text(file\n ? 'Выбран файл: ' + file.name + '. Сохранение ещё не выполнено.'\n : 'Новый файл не выбран.');\n });\n}(jQuery));</code></pre>\n<p>Текст намеренно говорит о выборе, а не о сохранении. Имя файла, data URL и размер полезны для экрана, но не могут заменять серверный ID. После ответа сервера статус нужно менять только по явному полю результата, а не по факту отправки формы.</p>\n<h2>Сервер выбирает одну ветку</h2>\n<p>Для редактирования изображения нужны три операции: оставить старое, заменить новым или удалить. Запрет на одновременные замену и удаление — это политика данного обработчика, а не обещание самого Bitrix. Она убирает зависимость от порядка полей в запросе. Пустой <code>input type=\"file\"</code> означает «нового файла нет», но не означает «удалить старый».</p>\n<pre><code>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 &amp;&amp; $error !== UPLOAD_ERR_OK) {\n throw new RuntimeException('Загрузка файла завершилась ошибкой: ' . $error);\n }\n\n $hasNewFile = $error === UPLOAD_ERR_OK;\n if ($hasNewFile &amp;&amp; (empty($upload['tmp_name']) || !is_uploaded_file($upload['tmp_name']))) {\n throw new RuntimeException('Файл не найден во временном хранилище');\n }\n\n if ($hasNewFile &amp;&amp; $delete) {\n throw new RuntimeException('Выберите замену или удаление');\n }\n if (!$hasNewFile &amp;&amp; !$delete) {\n return false; // оставить текущее значение\n }\n\n $fields = array(\n 'PREVIEW_PICTURE' =&gt; $hasNewFile\n ? $upload\n : array('del' =&gt; 'Y'),\n );\n $element = new CIBlockElement();\n\n if (!$element-&gt;Update((int) $elementId, $fields)) {\n throw new RuntimeException($element-&gt;LAST_ERROR);\n }\n\n return true;\n}</code></pre>\n<p>Код показывает минимальную развилку, а не готовую политику приёма файлов. Перед <code>Update()</code> сервер должен ограничить размер, проверить фактический тип изображения, права пользователя и принадлежность элемента. Значения <code>UPLOAD_ERR_*</code> нельзя превращать в «файла нет»: ошибка лимита или сбой временного хранилища должны стать ошибкой формы.</p>\n<p>Документация Bitrix описывает для <code>CIBlockElement::Update()</code> массив полей и сообщает, что при <code>false</code> текст причины находится в <code>LAST_ERROR</code>. Для уже существующего серверного файла нужен подтверждённый путь и файловый массив, например через <code>CFile::MakeFileArray()</code>. Для файлового свойства, а не поля <code>PREVIEW_PICTURE</code>, формат <code>PROPERTY_VALUES</code> проверяйте отдельно.</p>\n<h2>FileInput: другой способ передачи</h2>\n<p><code>\\Bitrix\\Main\\UI\\FileInput</code> удобен, когда нужен готовый контрол выбора и загрузки. Но параметр <code>upload =&gt; true</code> включает отдельную AJAX-загрузку. В документации у него есть <code>uploadType</code> со значениями <code>path</code> и <code>hash</code>; результат такого контрола не следует автоматически искать в <code>$_FILES['CATALOG_PREVIEW']</code>.</p>\n<pre><code>&lt;?php\nuse Bitrix\\Main\\UI\\FileInput;\n\necho FileInput::createInstance(array(\n 'id' =&gt; 'catalog_preview',\n 'name' =&gt; 'CATALOG_PREVIEW',\n 'upload' =&gt; true,\n 'uploadType' =&gt; 'hash',\n 'allowUpload' =&gt; FileInput::UPLOAD_IMAGES,\n 'maxCount' =&gt; 1,\n 'delete' =&gt; true,\n))-&gt;show($files);</code></pre>\n<p>Для этого варианта сначала определите реальный ответ контрол-эндпойнта и способ, которым проект вызывает <code>prepareFile()</code>. Затем сервер должен проверить подпись или идентификатор, владельца временного файла, элемент и срок жизни загрузки, после чего передать подготовленный файловый массив в обновление. Если нужна одна обычная отправка формы, не включайте AJAX-загрузку FileInput: используйте нативное поле и обрабатывайте <code>$_FILES</code> по первому контракту.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<table><thead><tr><th>Симптом</th><th>Вероятная причина</th><th>Проверка</th><th>Действие</th></tr></thead><tbody><tr><td>Preview новый, карточка старая</td><td>файл не попал в POST или не вызван <code>Update()</code></td><td>Network: multipart-часть, ответ обработчика и свежая выборка</td><td>исправить имя поля или ветку сохранения</td></tr><tr><td>Ответ 200, поле пустое</td><td>ошибка скрыта в теле ответа или неверен формат поля</td><td>проверить JSON, <code>LAST_ERROR</code> и ID элемента</td><td>вернуть ошибку формы и перечитать элемент</td></tr><tr><td>Ajax работает со второго раза</td><td><code>serialize()</code> не передаёт файл или обработчик дублируется</td><td>проверить Request Payload и число срабатываний <code>change</code></td><td>использовать <code>FormData</code> и namespace</td></tr><tr><td>Удаление срабатывает само</td><td>пустой preview ошибочно принят за команду удаления</td><td>сравнить явный флаг с данными формы</td><td>передавать отдельный флаг удаления</td></tr><tr><td>Появляются сиротские файлы</td><td>отдельная загрузка завершилась без обновления элемента</td><td>сопоставить временный ID с пользователем и элементом</td><td>ввести владельца и очистку незавершённых загрузок</td></tr></tbody></table>\n<h2>Проверка по порядку отказа</h2>\n<ol><li>Открываю карточку с известным текущим ID изображения и фиксирую его до изменения.</li><li>Отправляю форму без нового файла и без удаления. После свежего чтения ID должен остаться прежним.</li><li>Выбираю небольшой учебный JPEG и проверяю в Network имя поля, multipart-часть и размер файла.</li><li>Проверяю ответ обработчика, результат <code>Update()</code> и <code>LAST_ERROR</code>, если метод вернул <code>false</code>.</li><li>Открываю карточку новым HTTP-запросом и сравниваю URL изображения с ожидаемым файлом.</li><li>Отправляю явное удаление без нового файла и проверяю, что сработала только ветка удаления.</li><li>Отправляю новый файл вместе с удалением. Ожидаю понятную ошибку формы.</li><li>Перерисовываю контейнер Ajax-ом и меняю файл ещё раз. Событие должно обработаться один раз.</li><li>Повторяю сценарий с превышенным размером и повреждённым изображением. Обработчик должен вернуть ошибку до изменения элемента.</li></ol>\n<h2>Ограничения</h2>\n<p>Пример не задаёт универсальные размеры, MIME-типы, права и антивирусную проверку. Эти правила зависят от версии Bitrix, настроек PHP, роли пользователя и требований каталога. Проверка расширения в браузере не защищает сервер. Поле <code>PREVIEW_PICTURE</code> и файловое свойство используют разные контракты, поэтому их нельзя менять одним предположением.</p>\n<p>Если FileInput загружает файл отдельным запросом, путь через <code>$_FILES</code> неприменим без адаптации. Сначала определите ответ контролла и место хранения временного файла. При отмене формы не оставляйте такой идентификатор без владельца. Для небольшой синхронной формы не нужны очереди, но нужна понятная очистка незавершённых загрузок.</p>\n<h2>Критерий готовности</h2>\n<p>Сценарий готов, если форма делает один понятный запрос, сервер принимает только допустимый файл, <code>Update()</code> возвращает успех, а свежая страница показывает новое изображение. Отправка без нового файла оставляет старое значение. Явное удаление меняет поле только по флагу пользователя. Конфликт «новый файл плюс удаление» даёт ошибку. После Ajax-перерисовки нет двойного обработчика. Эти условия проверяются на тестовой записи и не являются заявлением о результате рабочей среды.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://dev.1c-bitrix.ru/api_help/iblock/classes/ciblockelement/update.php?print=Y\" target=\"_blank\" rel=\"noopener\">Официальная документация Bitrix: CIBlockElement::Update</a></li><li><a href=\"https://dev.1c-bitrix.ru/api_d7/bitrix/main/ui/fileinput/index.php\" target=\"_blank\" rel=\"noopener\">Официальная документация Bitrix: FileInput</a></li><li><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest_API/Using_FormData_Objects\" target=\"_blank\" rel=\"noopener\">MDN: FormData, multipart и граница Content-Type</a></li><li><a href=\"https://api.jquery.com/on/\" target=\"_blank\" rel=\"noopener\">Официальная документация jQuery: делегирование и namespace</a></li></ul>"
}