Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 107
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 66
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Collaboration
0.00% covered (danger)
0.00%
0 / 101
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 66
0.00% covered (danger)
0.00%
0 / 8
756
0.00% covered (danger)
0.00%
0 / 1
 create
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 defaultConfiguration
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 buildConfigurationForm
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 configurationSummary
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 build
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 alterRenderable
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 removeInactiveUsers
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 buildRenderable
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
90
1<?php
2
3declare(strict_types=1);
4
5namespace Drupal\display_builder\Plugin\display_builder\Island;
6
7use Drupal\Core\Datetime\DateFormatterInterface;
8use Drupal\Core\Entity\EntityFieldManagerInterface;
9use Drupal\Core\Form\FormStateInterface;
10use Drupal\Core\Htmx\Htmx;
11use Drupal\Core\Session\AccountInterface;
12use Drupal\Core\StringTranslation\TranslatableMarkup;
13use Drupal\Core\Url;
14use Drupal\display_builder\Attribute\Island;
15use Drupal\display_builder\InstanceInterface;
16use Drupal\display_builder\Island\IslandConfigurationFormInterface;
17use Drupal\display_builder\Island\IslandConfigurationFormTrait;
18use Drupal\display_builder\Island\IslandPluginBase;
19use Drupal\display_builder\Island\IslandReloadEventsTrait;
20use Drupal\display_builder\Island\IslandType;
21use Drupal\file\FileInterface;
22use Symfony\Component\DependencyInjection\ContainerInterface;
23
24/**
25 * Real-time collaboration island plugin implementation.
26 */
27#[Island(
28  id: 'collaboration',
29  label: new TranslatableMarkup('Real-time collaboration'),
30  description: new TranslatableMarkup('Allow concurrent editing with multiple users.'),
31  type: IslandType::Button,
32  default_region: 'end',
33)]
34class Collaboration extends IslandPluginBase implements IslandConfigurationFormInterface {
35
36  use IslandReloadEventsTrait;
37  use IslandConfigurationFormTrait;
38
39  /**
40   * Seconds in 15 minutes.
41   */
42  private const SECONDS_IN_15_MINUTES = 900;
43
44  /**
45   * The current user.
46   */
47  protected AccountInterface $currentUser;
48
49  /**
50   * The date formatter.
51   */
52  protected DateFormatterInterface $dateFormatter;
53
54  /**
55   * The entity field manager.
56   */
57  protected EntityFieldManagerInterface $entityFieldManager;
58
59  /**
60   * {@inheritdoc}
61   */
62  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
63    $instance = parent::create($container, $configuration, $plugin_id, $plugin_definition);
64    $instance->currentUser = $container->get('current_user');
65    $instance->dateFormatter = $container->get('date.formatter');
66    $instance->entityFieldManager = $container->get('entity_field.manager');
67
68    return $instance;
69  }
70
71  /**
72   * {@inheritdoc}
73   */
74  public function defaultConfiguration(): array {
75    return [
76      'image_field' => 'user_picture',
77      'image_style' => 'thumbnail',
78    ];
79  }
80
81  /**
82   * {@inheritdoc}
83   */
84  public function buildConfigurationForm(array $form, FormStateInterface $form_state): array {
85    $options = [
86      '' => $this->t('- None -'),
87    ];
88    $configuration = $this->getConfiguration();
89
90    $fields = $this->entityFieldManager->getFieldDefinitions('user', 'user');
91
92    foreach ($fields as $field_id => $field) {
93      if ($field->getType() === 'image') {
94        $options[$field_id] = $field->getLabel();
95      }
96    }
97    $form['image_field'] = [
98      '#title' => $this->t('Image field'),
99      '#type' => 'select',
100      '#default_value' => $configuration['image_field'],
101      '#options' => $options,
102    ];
103
104    $styles = $this->entityTypeManager->getStorage('image_style')->loadMultiple();
105    $options = [
106      '' => $this->t('- None -'),
107    ];
108
109    foreach ($styles as $name => $style) {
110      $options[$name] = $style->label();
111    }
112    $form['image_style'] = [
113      '#title' => $this->t('Image style'),
114      '#type' => 'select',
115      '#default_value' => $configuration['image_style'],
116      '#options' => $options,
117    ];
118
119    return $form;
120  }
121
122  /**
123   * {@inheritdoc}
124   */
125  public function configurationSummary(): array {
126    $conf = $this->getConfiguration();
127    $with_picture_text = $conf['image_style'] ? $this->t('With @style picture.', ['@style' => $conf['image_style']]) : $this->t('With picture.');
128
129    return [
130      $conf['image_field'] ? $with_picture_text : $this->t('Without picture.'),
131    ];
132  }
133
134  /**
135   * {@inheritdoc}
136   */
137  public function build(InstanceInterface $builder, array $data = [], array $options = []): array {
138    $this->builder = $builder;
139    $users = $builder->getUsers();
140    $current_user = $this->currentUser->id();
141    $users = $this->removeInactiveUsers($users);
142
143    // If no users (this situation must not happen), don't show anything.
144    if (\count($users) === 0) {
145      return [];
146    }
147
148    if (\array_key_exists($current_user, $users)) {
149      // If the only user is the current user, don't show anything.
150      if (\count($users) === 1) {
151        return [];
152      }
153      // Move current user at the beginning of the list.
154      $users = [$current_user => $users[$current_user]] + $users;
155
156      return $this->buildRenderable($users);
157    }
158
159    // If the only user is not the current user, add they at the start.
160    if (\count($users) === 1) {
161      $users = [$current_user => NULL] + $users;
162    }
163
164    return $this->buildRenderable($users);
165  }
166
167  /**
168   * {@inheritdoc}
169   */
170  public function alterRenderable(InstanceInterface $instance, array $build): array {
171    $build['#attributes'] = [
172      'sse-connect' => Url::fromRoute('display_builder.api_sse', ['display_builder_instance' => (string) $instance->id()])->toString(),
173    ];
174    (new Htmx())->ext('sse')->applyTo($build);
175    // We don't attach it from the ::build() because the island doesn't
176    // always render something in the toolbar.
177    $build['#attached']['library'][] = 'display_builder/htmx_sse';
178
179    return $build;
180  }
181
182  /**
183   * Remove inactive users.
184   *
185   * @param array $users
186   *   Each key is an User entity ID, each value is a timestamp.
187   *
188   * @return array
189   *   Each key is an User entity ID, each value is a timestamp.
190   */
191  protected function removeInactiveUsers(array $users): array {
192    foreach ($users as $user_id => $time) {
193      if (\time() - $time > self::SECONDS_IN_15_MINUTES) {
194        unset($users[$user_id]);
195      }
196    }
197
198    return $users;
199  }
200
201  /**
202   * Build renderable.
203   *
204   * @param array $users
205   *   Each key is an User entity ID, each value is a timestamp.
206   *
207   * @return array
208   *   A renderable array.
209   */
210  protected function buildRenderable(array $users): array {
211    $avatars = [];
212    $configuration = $this->getConfiguration();
213
214    foreach ($users as $user_id => $time) {
215      /** @var \Drupal\user\UserInterface $user */
216      $user = $this->entityTypeManager->getStorage('user')->load($user_id);
217
218      if (!$user) {
219        // For example, if the user was deleted.
220        continue;
221      }
222      $avatar = [
223        '#type' => 'component',
224        '#component' => 'display_builder:avatar',
225        '#props' => [
226          'name' => $user->getDisplayName(),
227        ],
228        '#attributes' => [
229          'style' => '--size: 38px',
230        ],
231      ];
232
233      if ($time) {
234        // We can't use DateFormatterInterface::formatTimeDiffSince() because
235        // the displayed value will become obsolete if the island is not updated
236        // for a while.
237        $time = $this->dateFormatter->format($time, 'custom', 'G:i');
238        $avatar['#props']['name'] .= ', ' . $this->t('at @time', ['@time' => $time]);
239      }
240
241      if (isset($configuration['image_field']) && $user->hasField($configuration['image_field'])) {
242        $image = $user->get($configuration['image_field'])->entity;
243
244        if ($image instanceof FileInterface) {
245          $image_style = $configuration['image_style'];
246          $style = $this->entityTypeManager->getStorage('image_style')->load($image_style);
247          $avatar['#props']['image'] = $style ? $style->buildUri($image->getFileUri()) : $image->getFileUri();
248        }
249      }
250      $avatars[] = $avatar;
251    }
252
253    if (\count($avatars) === 0) {
254      return [];
255    }
256
257    return [
258      '#type' => 'html_tag',
259      '#tag' => 'span',
260      '#attributes' => [
261        'class' => 'sl-avatar-group',
262      ],
263      'avatars' => $avatars,
264    ];
265  }
266
267}

Branches

Below are the source code lines that represent each code branch as identified by Xdebug. Please note a branch is not necessarily coterminous with a line, a line may contain multiple branches and therefore show up more than once. Please also be aware that some branches may be implicit rather than explicit, e.g. an if statement always has an else as part of its logical flow even if you didn't write one.

Collaboration->alterRenderable
170  public function alterRenderable(InstanceInterface $instance, array $build): array {
171    $build['#attributes'] = [
172      'sse-connect' => Url::fromRoute('display_builder.api_sse', ['display_builder_instance' => (string) $instance->id()])->toString(),
173    ];
174    (new Htmx())->ext('sse')->applyTo($build);
175    // We don't attach it from the ::build() because the island doesn't
176    // always render something in the toolbar.
177    $build['#attached']['library'][] = 'display_builder/htmx_sse';
178
179    return $build;
180  }
Collaboration->build
137  public function build(InstanceInterface $builder, array $data = [], array $options = []): array {
138    $this->builder = $builder;
139    $users = $builder->getUsers();
140    $current_user = $this->currentUser->id();
141    $users = $this->removeInactiveUsers($users);
142
143    // If no users (this situation must not happen), don't show anything.
144    if (\count($users) === 0) {
145      return [];
148    if (\array_key_exists($current_user, $users)) {
150      if (\count($users) === 1) {
151        return [];
154      $users = [$current_user => $users[$current_user]] + $users;
155
156      return $this->buildRenderable($users);
160    if (\count($users) === 1) {
161      $users = [$current_user => NULL] + $users;
162    }
163
164    return $this->buildRenderable($users);
164    return $this->buildRenderable($users);
165  }
Collaboration->buildConfigurationForm
84  public function buildConfigurationForm(array $form, FormStateInterface $form_state): array {
85    $options = [
86      '' => $this->t('- None -'),
87    ];
88    $configuration = $this->getConfiguration();
89
90    $fields = $this->entityFieldManager->getFieldDefinitions('user', 'user');
91
92    foreach ($fields as $field_id => $field) {
92    foreach ($fields as $field_id => $field) {
92    foreach ($fields as $field_id => $field) {
93      if ($field->getType() === 'image') {
92    foreach ($fields as $field_id => $field) {
93      if ($field->getType() === 'image') {
94        $options[$field_id] = $field->getLabel();
92    foreach ($fields as $field_id => $field) {
92    foreach ($fields as $field_id => $field) {
93      if ($field->getType() === 'image') {
94        $options[$field_id] = $field->getLabel();
95      }
96    }
97    $form['image_field'] = [
98      '#title' => $this->t('Image field'),
99      '#type' => 'select',
100      '#default_value' => $configuration['image_field'],
101      '#options' => $options,
102    ];
103
104    $styles = $this->entityTypeManager->getStorage('image_style')->loadMultiple();
105    $options = [
106      '' => $this->t('- None -'),
107    ];
108
109    foreach ($styles as $name => $style) {
109    foreach ($styles as $name => $style) {
109    foreach ($styles as $name => $style) {
109    foreach ($styles as $name => $style) {
110      $options[$name] = $style->label();
111    }
112    $form['image_style'] = [
113      '#title' => $this->t('Image style'),
114      '#type' => 'select',
115      '#default_value' => $configuration['image_style'],
116      '#options' => $options,
117    ];
118
119    return $form;
120  }
Collaboration->buildRenderable
210  protected function buildRenderable(array $users): array {
211    $avatars = [];
212    $configuration = $this->getConfiguration();
213
214    foreach ($users as $user_id => $time) {
214    foreach ($users as $user_id => $time) {
214    foreach ($users as $user_id => $time) {
215      /** @var \Drupal\user\UserInterface $user */
216      $user = $this->entityTypeManager->getStorage('user')->load($user_id);
217
218      if (!$user) {
220        continue;
223        '#type' => 'component',
224        '#component' => 'display_builder:avatar',
225        '#props' => [
226          'name' => $user->getDisplayName(),
227        ],
228        '#attributes' => [
229          'style' => '--size: 38px',
230        ],
231      ];
232
233      if ($time) {
237        $time = $this->dateFormatter->format($time, 'custom', 'G:i');
238        $avatar['#props']['name'] .= ', ' . $this->t('at @time', ['@time' => $time]);
239      }
240
241      if (isset($configuration['image_field']) && $user->hasField($configuration['image_field'])) {
241      if (isset($configuration['image_field']) && $user->hasField($configuration['image_field'])) {
241      if (isset($configuration['image_field']) && $user->hasField($configuration['image_field'])) {
241      if (isset($configuration['image_field']) && $user->hasField($configuration['image_field'])) {
242        $image = $user->get($configuration['image_field'])->entity;
243
244        if ($image instanceof FileInterface) {
245          $image_style = $configuration['image_style'];
246          $style = $this->entityTypeManager->getStorage('image_style')->load($image_style);
247          $avatar['#props']['image'] = $style ? $style->buildUri($image->getFileUri()) : $image->getFileUri();
247          $avatar['#props']['image'] = $style ? $style->buildUri($image->getFileUri()) : $image->getFileUri();
247          $avatar['#props']['image'] = $style ? $style->buildUri($image->getFileUri()) : $image->getFileUri();
247          $avatar['#props']['image'] = $style ? $style->buildUri($image->getFileUri()) : $image->getFileUri();
248        }
249      }
250      $avatars[] = $avatar;
214    foreach ($users as $user_id => $time) {
215      /** @var \Drupal\user\UserInterface $user */
216      $user = $this->entityTypeManager->getStorage('user')->load($user_id);
217
218      if (!$user) {
219        // For example, if the user was deleted.
220        continue;
221      }
222      $avatar = [
223        '#type' => 'component',
224        '#component' => 'display_builder:avatar',
225        '#props' => [
226          'name' => $user->getDisplayName(),
227        ],
228        '#attributes' => [
229          'style' => '--size: 38px',
230        ],
231      ];
232
233      if ($time) {
234        // We can't use DateFormatterInterface::formatTimeDiffSince() because
235        // the displayed value will become obsolete if the island is not updated
236        // for a while.
237        $time = $this->dateFormatter->format($time, 'custom', 'G:i');
238        $avatar['#props']['name'] .= ', ' . $this->t('at @time', ['@time' => $time]);
239      }
240
241      if (isset($configuration['image_field']) && $user->hasField($configuration['image_field'])) {
242        $image = $user->get($configuration['image_field'])->entity;
243
244        if ($image instanceof FileInterface) {
245          $image_style = $configuration['image_style'];
246          $style = $this->entityTypeManager->getStorage('image_style')->load($image_style);
247          $avatar['#props']['image'] = $style ? $style->buildUri($image->getFileUri()) : $image->getFileUri();
248        }
249      }
250      $avatars[] = $avatar;
214    foreach ($users as $user_id => $time) {
215      /** @var \Drupal\user\UserInterface $user */
216      $user = $this->entityTypeManager->getStorage('user')->load($user_id);
217
218      if (!$user) {
219        // For example, if the user was deleted.
220        continue;
221      }
222      $avatar = [
223        '#type' => 'component',
224        '#component' => 'display_builder:avatar',
225        '#props' => [
226          'name' => $user->getDisplayName(),
227        ],
228        '#attributes' => [
229          'style' => '--size: 38px',
230        ],
231      ];
232
233      if ($time) {
234        // We can't use DateFormatterInterface::formatTimeDiffSince() because
235        // the displayed value will become obsolete if the island is not updated
236        // for a while.
237        $time = $this->dateFormatter->format($time, 'custom', 'G:i');
238        $avatar['#props']['name'] .= ', ' . $this->t('at @time', ['@time' => $time]);
239      }
240
241      if (isset($configuration['image_field']) && $user->hasField($configuration['image_field'])) {
242        $image = $user->get($configuration['image_field'])->entity;
243
244        if ($image instanceof FileInterface) {
245          $image_style = $configuration['image_style'];
246          $style = $this->entityTypeManager->getStorage('image_style')->load($image_style);
247          $avatar['#props']['image'] = $style ? $style->buildUri($image->getFileUri()) : $image->getFileUri();
248        }
249      }
250      $avatars[] = $avatar;
251    }
252
253    if (\count($avatars) === 0) {
254      return [];
258      '#type' => 'html_tag',
259      '#tag' => 'span',
260      '#attributes' => [
261        'class' => 'sl-avatar-group',
262      ],
263      'avatars' => $avatars,
264    ];
265  }
Collaboration->configurationSummary
126    $conf = $this->getConfiguration();
127    $with_picture_text = $conf['image_style'] ? $this->t('With @style picture.', ['@style' => $conf['image_style']]) : $this->t('With picture.');
127    $with_picture_text = $conf['image_style'] ? $this->t('With @style picture.', ['@style' => $conf['image_style']]) : $this->t('With picture.');
127    $with_picture_text = $conf['image_style'] ? $this->t('With @style picture.', ['@style' => $conf['image_style']]) : $this->t('With picture.');
127    $with_picture_text = $conf['image_style'] ? $this->t('With @style picture.', ['@style' => $conf['image_style']]) : $this->t('With picture.');
128
129    return [
130      $conf['image_field'] ? $with_picture_text : $this->t('Without picture.'),
130      $conf['image_field'] ? $with_picture_text : $this->t('Without picture.'),
130      $conf['image_field'] ? $with_picture_text : $this->t('Without picture.'),
130      $conf['image_field'] ? $with_picture_text : $this->t('Without picture.'),
131    ];
132  }
Collaboration->create
62  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
63    $instance = parent::create($container, $configuration, $plugin_id, $plugin_definition);
64    $instance->currentUser = $container->get('current_user');
65    $instance->dateFormatter = $container->get('date.formatter');
66    $instance->entityFieldManager = $container->get('entity_field.manager');
67
68    return $instance;
69  }
Collaboration->defaultConfiguration
76      'image_field' => 'user_picture',
77      'image_style' => 'thumbnail',
78    ];
79  }
Collaboration->removeInactiveUsers
191  protected function removeInactiveUsers(array $users): array {
192    foreach ($users as $user_id => $time) {
192    foreach ($users as $user_id => $time) {
192    foreach ($users as $user_id => $time) {
193      if (\time() - $time > self::SECONDS_IN_15_MINUTES) {
192    foreach ($users as $user_id => $time) {
193      if (\time() - $time > self::SECONDS_IN_15_MINUTES) {
194        unset($users[$user_id]);
192    foreach ($users as $user_id => $time) {
192    foreach ($users as $user_id => $time) {
193      if (\time() - $time > self::SECONDS_IN_15_MINUTES) {
194        unset($users[$user_id]);
195      }
196    }
197
198    return $users;
199  }