blog name

Drupal 10 - Sortable config form with Add / Remove item

Description

Over the years of working with Drupal, there have been occasions where a configuration page with an "Add Another" functionality was required.

Of course, I could create a configuration entity, but for simpler configurations that need to be edited on a single page, it doesn't make much sense.

In this specific case, it was necessary to create a configuration page for social media. The site is multilingual, and for certain languages, the social media links differ, so the configuration needs to be translatable. Additionally, it is required that the admin can rearrange the already added social media links.

Image
Drupal config page

We will focus only on a few important steps and the full example you can find in the GitHub repo.

First, we need to define a draggable table.

$form['table-row'] = [
  '#type' => 'table',
  '#header' => [
    $this->t(''),
    $this->t('Icon'),
    $this->t('Label'),
    $this->t('Url'),
    $this->t('Operations'),
    $this->t('Weight'),
  ],
  '#tabledrag' => [
    [
      'action' => 'order',
      'relationship' => 'sibling',
      'group' => 'table-sort-weight',
    ],
  ],
  '#prefix' => '<div id="wrapper">',
  '#suffix' => '</div>',
];

Then we have to calculate the number of rows and add table rows:

// Calculate the number of rows.
// If there is no "num_of_rows" state value, count number of config items,
// and it there is no config items, set the number of rows to 1,
// so there is at least one row.
if (!$num_of_rows = $form_state->get('num_of_rows')) {
$num_of_rows = count($data['items']) ?: 1;
$form_state->set('num_of_rows', $num_of_rows);
}
// Build the table rows and columns.
for ($i = 0; $i < $num_of_rows; $i++) {
$form['table-row'][$i]['#attributes']['class'][] = 'draggable';
// Sort the table row according to its weight.
$form['table-row'][$i]['#weight'] = $i;
// This is needed to avoid writing additional css to make all inline.
$form['table-row'][$i]['id'] = [
  '#markup' => ''
];
$form['table-row'][$i]['icon'] = [
  '#type' => 'textfield',
  '#default_value' => $data['items'][$i]['icon'] ?? '',
];
// ... othere elements
// Remove row button.
$form['table-row'][$i]['op'] = [
  '#type' => 'submit',
  '#name' => $i . '-row',
  '#value' => $this->t('Remove'),
  '#disabled' => $num_of_rows <= 1,
  '#submit' => ['::removeSubmit'],
  '#ajax' => [
    'callback' => '::addRemoveCallback',
    'wrapper' => 'wrapper',
  ],
];
// Weight element.
$form['table-row'][$i]['weight'] = [
  '#type' => 'weight',
  '#title_display' => 'invisible',
  '#default_value' => $i,
  // Classify the weight element for #tabledrag.
  '#attributes' => [
    'class' => [
      'table-sort-weight'
    ]
  ],
];

Then we need to add the "Add Another" element:

$form['table-row'][$i]['op'] = [
  '#type' => 'submit',
  '#name' => $i . '-row',
  '#value' => $this->t('Remove'),
  '#disabled' => $num_of_rows <= 1,
  '#submit' => ['::removeSubmit'],
  '#ajax' => [
    'callback' => '::addRemoveCallback',
    'wrapper' => 'wrapper',
  ],
];

The only thing addRemoveCallback needs to do is to return table rows.

/**
 * Callback for add another button.
 */
public function addRemoveCallback(array &$form, FormStateInterface $form_state) {
  return $form['table-row'];
}

Then submit handlers for add another and remove one are pretty simple:

/**
 * Submit handler for the "Add another" button.
 *
 * Increments the counter and causes a rebuild.
 */
public function addAnotherSubmit(array &$form, FormStateInterface $form_state) {
  // Increase number of rows count.
  $num_of_rows = $form_state->get('num_of_rows');
  $form_state->set('num_of_rows', $num_of_rows + 1);
  // Rebuild form.
  $form_state->setRebuild();
}
/**
 * Submit handler for the "Remove" button.
 */
public function removeSubmit(array &$form, FormStateInterface $form_state) {
  // Get the row id of the remove input.
  $row_id = $form_state->getTriggeringElement()['#parents'][1];
  // Remove the row from the user input and reindex table-row data.
  $input = $form_state->getUserInput();
  unset($input['table-row'][$row_id]);
  $input['table-row'] = array_values($input['table-row']);
  // Update user input and data object.
  $form_state->setUserInput($input);
  $form_state->set('data', $input['table-row']);
  // Decrease number of rows count.
  $num_of_rows = $form_state->get('num_of_rows');
  $form_state->set('num_of_rows', $num_of_rows - 1);
  // Rebuild form state.
  $form_state->setRebuild();
}

In the form submit method we need to filter submitted data and save them to the config file:

/**
 * {@inheritdoc}
 */
public function submitForm(array &$form, FormStateInterface $form_state) {
  parent::submitForm($form, $form_state);
  $data = $form_state->getValue('table-row');
  // Filter data.
  $networks = [];
  foreach ($data as $item) {
    if ($item['icon'] && $item['icon'] && $item['url']) {
      $networks[] = [
        'icon' => $item['icon'],
        'label' => $item['label'],
        'url' => $item['url'],
      ];
    }
  }
  // Save config.
  $this->config('add_another_example.social_networks')
    ->setData([
      'items' => $networks,
    ])
    ->save();
}

Full example: GitHub