Drupal 9 Ajax回调仅生效一次的问题求助
Drupal 9.4 用户表单Ajax回调二次触发报错问题解决
问题描述
在Drupal 9.4站点的.module文件中通过hook_form_alter修改user_form,实现以下功能:
- 展示用户已上传的图片并附带删除按钮
- 显示剩余可上传图片数量
- 点击删除按钮需完成两项操作:删除图片字段的引用、更新剩余可上传数量
仅执行删除图片操作时功能正常,但添加剩余数量更新的Ajax响应后,首次删除操作正常,再次删除时触发报错:
Symfony\Component\HttpKernel\Exception\HttpException: The specified #ajax callback is empty or not callable
相关代码
表单修改代码
function main_form_alter(&$form, $form_state, $form_id) { if ($form_id === 'user_form') { /** @var \Drupal\user\Entity\User $user */ $user = $form_state->getFormObject()->getEntity(); $already_uploaded_files = getUserPictureFiles($user); $nbre_file = count($already_uploaded_files); $callback = 'remove_user_picture_callback'; if ($nbre_file < 10) { $form['field_photos'] = [ '#title' => t('Vos photos'), '#type' => 'dropzonejs', '#max_files' => (10 - $nbre_file), '#dropzone_description' => t('Déposez les fichiers ici pour les télécharger'), '#attributes' => [ 'id' => 'field-photos', ], ]; $form['photos_left'] = [ '#type' => 'html_tag', '#tag' => 'p', '#value' => t(10 - $nbre_file . " photo(s) restante(s)"), '#prefix' => '<div id="field-photos-left">', '#suffix' => '</div>', ]; } if ($nbre_file === 10) { $form['field_photos'] = [ '#title' => t('Vos photos'), '#type' => 'markup', '#markup' => t("Nombre maximum de photos atteint."), ]; } if ($already_uploaded_files) { $form['saved_uploaded_files'] = [ '#type' => 'value', '#value' => $already_uploaded_files, '#attributes' => ['id' => 'save-uploaded-files'], ]; $form['user_pictures_container'] = [ '#type' => 'container', '#attributes' => [ 'class' => ['user-photos-container'], 'id' => ['user-photos-container'], ], ]; foreach ($already_uploaded_files as $key => $file) { $form['user_pictures_container']['picture_container_' . $key] = [ '#type' => 'container', '#attributes' => [ 'class' => ['image-square-container'], 'id' => ['image-square-container-' . $file->id()], ], ]; $form['user_pictures_container']['picture_container_' . $key]['user_uploaded_picture_' . $key] = [ '#type' => 'html_tag', '#tag' => 'img', '#attributes' => [ 'src' => $file->createFileUrl(), ], ]; $form['user_pictures_container']['picture_container_' . $key]['remove_picture_' . $key] = [ '#type' => 'button', '#name' => 'remove-button-' . $file->id(), '#attributes' => [ 'class' => ['user-picture-delete-icon-field'], 'data-name' => 'remove-button-' . $file->id(), ], '#ajax' => [ 'callback' => $callback, 'wrapper' => 'field-photos-left', 'effect' => 'fade', 'event' => 'click', 'prevent' => 'submit', 'progress' => [ 'type' => 'throbber', 'message' => t('Suppression de la photo...'), ], ], ]; } } }
回调及辅助函数代码
function remove_user_picture_callback($form, FormStateInterface $form_state): AjaxResponse { // Get user Entity. $user = getUserEntity($form_state); // Get trigger fid. $fid = getFid($form_state); // Delete picture reference from user field_photos, deletePictureReference($user, $fid); return updateNbrePhotoUploaded($form, $form_state); } function updateNbrePhotoUploaded($form, FormStateInterface $form_state): AjaxResponse { $user = getUserEntity($form_state); $already_uploaded_files = getUserPictureFiles($user); $nbre_file = count($already_uploaded_files); $elem['photos_left'] = [ '#type' => 'html_tag', '#tag' => 'p', '#value' => t(10 - $nbre_file . " photo(s) restante(s)"), '#prefix' => '<div id="field-photos-left">', '#suffix' => '</div>', ]; $response = new AjaxResponse(); $response->addCommand(new ReplaceCommand('#field-photos-left', $elem)); return $response; } /** * @param \Drupal\Core\Form\FormStateInterface $form_state * * @return string */ function getFid(FormStateInterface $form_state): string { $element = $form_state->getTriggeringElement()['#name']; return substr($element, strpos($element, '-') + 8); } /** * @param \Drupal\Core\Form\FormStateInterface $form_state * * @return \Drupal\user\Entity\User */ function getUserEntity(FormStateInterface $form_state): User { $form_state_user = $form_state->getFormObject()->getEntity(); return User::load($form_state_user->id()); } /** * @param \Drupal\user\Entity\User $user * @param string $fid * * @return void * @throws \Drupal\Core\Entity\EntityStorageException */ function deletePictureReference(User $user, string $fid): void { $field_photos = $user->get('field_photos'); $field_photos_values = $field_photos->getValue(); // Get the index to remove $index_to_remove = array_search($fid, array_column($field_photos_values, 'target_id')); $field_photos->removeItem($index_to_remove); $user->save(); } /** * Return array of user picture Files entity. * * @param $user * * @return array */ function getUserPictureFiles($user): array { $user_pictures = $user->get('field_photos')->getValue(); $files = []; if ($user_pictures) { foreach ($user_pictures as $user_picture) { if (array_key_exists('target_id', $user_picture)) { /** @var File $file */ $file = File::load($user_picture['target_id']); $files[] = $file; } } } return $files; }
问题原因
- Ajax回调仅替换剩余数量区域:当前代码只更新
#field-photos-left元素,删除按钮所在的user_pictures_container未被重新渲染。第二次点击删除时,表单状态中的触发元素无法找到有效的回调函数绑定。 - 表单重建不完整:删除图片后,表单未重新生成所有删除按钮的Ajax配置,导致后续操作的回调失效。
解决方案
1. 调整表单容器结构
将剩余数量和图片容器放入同一个父容器,方便Ajax一次性更新所有相关元素:
function main_form_alter(&$form, $form_state, $form_id) { if ($form_id === 'user_form') { /** @var \Drupal\user\Entity\User $user */ $user = $form_state->getFormObject()->getEntity(); $already_uploaded_files = getUserPictureFiles($user); $nbre_file = count($already_uploaded_files); $callback = 'remove_user_picture_callback'; // 新增父容器,用于Ajax整体替换 $form['photos_management_container'] = [ '#type' => 'container', '#attributes' => ['id' => 'photos-management-container'], ]; if ($nbre_file < 10) { $form['field_photos'] = [ '#title' => t('Vos photos'), '#type' => 'dropzonejs', '#max_files' => (10 - $nbre_file), '#dropzone_description' => t('Déposez les fichiers ici pour les télécharger'), '#attributes' => [ 'id' => 'field-photos', ], ]; // 将剩余数量放入父容器 $form['photos_management_container']['photos_left'] = [ '#type' => 'html_tag', '#tag' => 'p', '#value' => t(10 - $nbre_file . " photo(s) restante(s)"), '#prefix' => '<div id="field-photos-left">', '#suffix' => '</div>', ]; } if ($nbre_file === 10) { $form['field_photos'] = [ '#title' => t('Vos photos'), '#type' => 'markup', '#markup' => t("Nombre maximum de photos atteint."), ]; } if ($already_uploaded_files) { $form['saved_uploaded_files'] = [ '#type' => 'value', '#value' => $already_uploaded_files, '#attributes' => ['id' => 'save-uploaded-files'], ]; // 将图片容器放入父容器 $form['photos_management_container']['user_pictures_container'] = [ '#type' => 'container', '#attributes' => [ 'class' => ['user-photos-container'], 'id' => ['user-photos-container'], ], ]; foreach ($already_uploaded_files as $key => $file) { $form['photos_management_container']['user_pictures_container']['picture_container_' . $key] = [ '#type' => 'container', '#attributes' => [ 'class' => ['image-square-container'], 'id' => ['image-square-container-' . $file->id()], ], ]; $form['photos_management_container']['user_pictures_container']['picture_container_' . $key]['user_uploaded_picture_' . $key] = [ '#type' => 'html_tag', '#tag' => 'img', '#attributes' => [ 'src' => $file->createFileUrl(), ], ]; $form['photos_management_container']['user_pictures_container']['picture_container_' . $key]['remove_picture_' . $key] = [ '#type' => 'button', '#name' => 'remove-button-' . $file->id(), '#attributes' => [ 'class' => ['user-picture-delete-icon-field'], 'data-fid' => $file->id(), // 新增data-fid属性,简化fid获取 ], '#ajax' => [ 'callback' => $callback, 'wrapper' => 'photos-management-container', // 修改wrapper为父容器 'effect' => 'fade', 'event' => 'click', 'prevent' => 'submit', 'progress' => [ 'type' => 'throbber', 'message' => t('Suppression de la photo...'), ], ], ]; } } } }
2. 修改回调函数,重新构建完整容器内容
function remove_user_picture_callback($form, FormStateInterface $form_state): AjaxResponse { // 获取用户实体 $user = getUserEntity($form_state); // 从data-fid属性获取文件ID,替代字符串截取 $fid = $form_state->getTriggeringElement()['#attributes']['data-fid']; // 删除图片引用 deletePictureReference($user, $fid); // 重新构建父容器内容 $already_uploaded_files = getUserPictureFiles($user); $nbre_file = count($already_uploaded_files); $new_container = []; // 添加剩余数量元素 if ($nbre_file < 10) { $new_container['photos_left'] = [ '#type' => 'html_tag', '#tag' => 'p', '#value' => t(10 - $nbre_file . " photo(s) restante(s)"), '#prefix' => '<div id="field-photos-left">', '#suffix' => '</div>', ]; } // 重新生成删除按钮列表 if ($already_uploaded_files) { $new_container['user_pictures_container'] = [ '#type' => 'container', '#attributes' => [ 'class' => ['user-photos-container'], 'id' => ['user-photos-container'], ], ]; foreach ($already_uploaded_files as $key => $file) { $new_container['user_pictures_container']['picture_container_' . $key] = [ '#type' => 'container', '#attributes' => [ 'class' => ['image-square-container'], 'id' => ['image-square-container-' . $file->id()], ], ]; $new_container['user_pictures_container']['picture_container_' . $key]['user_uploaded_picture_' . $key] = [ '#type' => 'html_tag', '#tag' => 'img', '#attributes' => [ 'src' => $file->createFileUrl(), ], ]; $new_container['user_pictures_container']['picture_container_' . $key]['remove_picture_' . $key] = [ '#type' => 'button', '#name' => 'remove-button-' . $file->id(), '#attributes' => [ 'class' => ['user-picture-delete-icon-field'], 'data-fid' => $file->id(), ], '#ajax' => [ 'callback' => 'remove_user_picture_callback', 'wrapper' => 'photos-management-container', 'effect' => 'fade', 'event' => 'click', 'prevent' => 'submit', 'progress' => [ 'type' => 'throbber', 'message' => t('Suppression de la photo...'), ], ], ]; } } // 生成Ajax响应,替换整个父容器 $response = new AjaxResponse(); $response->addCommand(new ReplaceCommand('#photos-management-container', $new_container)); // 可选:同步更新dropzone的最大可上传数量 if ($nbre_file < 9) { $response->addCommand(new InvokeCommand('#field-photos', 'dropzone', ['options', 'maxFiles', 10 - ($nbre_file - 1)])); } return $response; } // 优化getFid函数(可选,现在直接从data-fid获取) function getFid(FormStateInterface $form_state): string { return $form_state->getTriggeringElement()['#attributes']['data-fid']; }
3. 关键修改说明
- 将删除按钮和剩余数量放入同一父容器,确保Ajax回调时重新渲染所有相关元素,保留正确的回调绑定。
- 使用
data-fid属性替代字符串截取获取文件ID,避免硬编码偏移量导致的错误。 - 可选同步更新dropzone组件的最大上传数量,保证前端交互一致性。
内容的提问来源于stack exchange,提问作者Juju
相关产品推荐
相关产品推荐

