You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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;
}

问题原因

  1. Ajax回调仅替换剩余数量区域:当前代码只更新#field-photos-left元素,删除按钮所在的user_pictures_container未被重新渲染。第二次点击删除时,表单状态中的触发元素无法找到有效的回调函数绑定。
  2. 表单重建不完整:删除图片后,表单未重新生成所有删除按钮的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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.21 11:36:17