diff --git a/contacts.libraries.yml b/contacts.libraries.yml
index 8f444a1..207e2d4 100644
--- a/contacts.libraries.yml
+++ b/contacts.libraries.yml
@@ -74,3 +74,9 @@ contact:
css:
theme:
css/contact.css: {}
+
+# Styling for the dedup page
+contacts_dedup_form:
+ css:
+ theme:
+ css/contacts.dedup.css: {}
diff --git a/contacts.routing.yml b/contacts.routing.yml
index 7bac45f..0cddcd3 100644
--- a/contacts.routing.yml
+++ b/contacts.routing.yml
@@ -119,3 +119,12 @@ contacts.manage.off_canvas_add:
type: entity:contact_tab
requirements:
_permission: 'manage contacts dashboard'
+
+contacts.dedup:
+ path: '/admin/contacts/dedup'
+ defaults:
+ _form: '\Drupal\contacts\Form\DedupForm'
+ requirements:
+ _permission: 'manage contacts dashboard'
+ options:
+ theme: '' # Do not automatically apply the contacts theme (see DashboardNegotiator)
diff --git a/css/contacts.dedup.css b/css/contacts.dedup.css
new file mode 100644
index 0000000..2859148
--- /dev/null
+++ b/css/contacts.dedup.css
@@ -0,0 +1,11 @@
+.contact-dedupe-match {
+ color: green;
+}
+
+.contact-dedupe-conflict {
+ color: red;
+}
+
+.contact-dedupe-link {
+ float: right;
+}
diff --git a/src/Event/GetPropertiesToShowOnDedupScreenEvent.php b/src/Event/GetPropertiesToShowOnDedupScreenEvent.php
new file mode 100644
index 0000000..f18612f
--- /dev/null
+++ b/src/Event/GetPropertiesToShowOnDedupScreenEvent.php
@@ -0,0 +1,111 @@
+properties = [
+ NULL => [
+ '#weight' => -99,
+ 'mail' => [
+ 'render field' => TRUE,
+ 'compare' => TRUE,
+ ],
+ 'profile_crm_indiv:entity:crm_gender' => [
+ 'render field' => TRUE,
+ 'compare' => TRUE,
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Get a list of properties to show on the dedup verification page.
+ *
+ * @return array
+ * An array of properties to show in the comparison. Outer keys are group
+ * labels (NULL is no group). Keys are property strings (a chain of metadata
+ * wrapper properties separated by :). Values are arrays containing:
+ * - label: Optional string to override the normal label.
+ * - use label: Optionally attempt to use the EntityValueWrapper::label().
+ * - render field: If the property is a field, setting this to TRUE will
+ * render the property via the Field API.
+ * - compare: A boolean indicating whether to run a comparison or a callable
+ * for a custom comparison function (anonymous function recommended)
+ * - strip html: TRUE if HTML should be stripped from the basic comparison.
+ * Defaults to TRUE.
+ */
+ public function getPropertiesByWeight() {
+ // Sort everything by weight.
+ $weight = 0;
+ $info = &$this->properties;
+
+ foreach ($this->properties as &$properties) {
+ // Set a default weight on the group.
+ $properties += ['#weight' => $weight++ / 100];
+
+ // Set up our defaults on the properties.
+ foreach (Element::children($properties) as $property) {
+ $properties[$property] += [
+ '#weight' => $weight++ / 100,
+ 'use label' => FALSE,
+ 'render field' => FALSE,
+ 'compare' => TRUE,
+ 'strip html' => TRUE,
+ ];
+ }
+
+ // Sort the propreties.
+ uasort($properties, [SortArray::class, 'sortByWeightProperty']);
+
+ // Remove the weight.
+ foreach (Element::children($properties) as $property) {
+ unset($properties[$property]['#weight']);
+ }
+ }
+
+ // Sort the group.
+ uasort($info, [SortArray::class, 'sortByWeightProperty']);
+
+ // Remove group weights.
+ foreach ($info as &$properties) {
+ unset($properties['#weight']);
+ }
+
+ return $info;
+
+ }
+
+ /**
+ * Dispatches the event.
+ *
+ * @return static
+ */
+ public static function dispatch() {
+ $dispatcher = \Drupal::service('event_dispatcher');
+ $event = new static();
+ $dispatcher->dispatch(GetPropertiesToShowOnDedupScreenEvent::EVENT_NAME, $event);
+ return $event;
+ }
+
+}
diff --git a/src/Form/DedupForm.php b/src/Form/DedupForm.php
new file mode 100644
index 0000000..fb9aff1
--- /dev/null
+++ b/src/Form/DedupForm.php
@@ -0,0 +1,448 @@
+renderer = $renderer;
+ $this->messenger = $messenger;
+ $this->dateFormatter = $date_formatter;
+ $this->merger = $merger;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function create(ContainerInterface $container) {
+ return new static(
+ $container->get('renderer'),
+ $container->get('messenger'),
+ $container->get('date.formatter'),
+ $container->get('entity_merge.merger')
+ );
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFormId() {
+ return "contacts_dedup_form";
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function buildForm(array $form, FormStateInterface $form_state, $primary_contact_id = NULL, $secondary_contact_id = NULL) {
+ if (!$primary_contact_id || !$secondary_contact_id) {
+ $this->buildSelectionForm($form, $form_state);
+
+ // Contact IDs may have been supplied by the previous form submission.
+ $primary_contact_id = !empty($form_state->getValue('selection')['primary_party']) ? $form_state->getValue('selection')['primary_party'] : NULL;
+ $secondary_contact_id = !empty($form_state->getValue('selection')['secondary_party']) ? $form_state->getValue('selection')['secondary_party'] : NULL;
+ }
+
+ // Add our current selection (URL or form) for our merge submission.
+ $form['primary_party'] = [
+ '#type' => 'value',
+ '#value' => $primary_contact_id,
+ ];
+ $form['secondary_party'] = [
+ '#type' => 'value',
+ '#value' => $secondary_contact_id,
+ ];
+
+ // If we don't have both values, there is nothing more to do.
+ // Just render the selection form.
+ if (!$primary_contact_id || !$secondary_contact_id) {
+ return $form;
+ }
+
+ // If we're able to load our parties,
+ // show a verification table with details.
+ $this->buildMergeForm($form, $form_state, $primary_contact_id, $secondary_contact_id);
+
+ return $form;
+ }
+
+ /**
+ * Gets the values of the specified field form both primary and secondary.
+ *
+ * @param \Drupal\user\Entity\User $primary_contact
+ * Primary contact for merge.
+ * @param \Drupal\user\Entity\User $secondary_contact
+ * Secondary contact for merge.
+ * @param string $property
+ * Name of property to render.
+ * @param array $info
+ * Associative array with metadata about the property.
+ *
+ * @return array
+ * Array containing 2 values (from primary and secondary contact).
+ *
+ * @throws \Exception
+ */
+ private function getFieldValuesToRender(User $primary_contact, User $secondary_contact, $property, array &$info) {
+ // Get our final value.
+ $v1_value = $v2_value = NULL;
+
+ $parts = explode(':', $property);
+ $final_part = array_pop($parts);
+
+ $v1 = $primary_contact;
+ $v2 = $secondary_contact;
+
+ foreach ($parts as $part) {
+ $v1 = $v1 && isset($v1->{$part}) ? $v1->{$part} : NULL;
+ $v2 = $v2 && isset($v2->{$part}) ? $v2->{$part} : NULL;
+
+ if ($v1 == NULL && $v2 == NULL) {
+ return [];
+ }
+ }
+
+ $property_info = $v1->get($final_part)->getFieldDefinition();
+
+ // If not set, pull through the label.
+ if (!isset($info['label'])) {
+ $info['label'] = $property_info->getLabel();
+ }
+
+ $fieldConfig = FieldConfig::loadByName($v1->getEntityTypeId(), $v1->bundle(), $final_part);
+
+ if ($fieldConfig != NULL && $info['render field']) {
+ $displayOptions = $fieldConfig->getDisplayOptions('default');
+ $displayOptions['label'] = 'hidden';
+
+ if ($v1->{$final_part}->value !== NULL) {
+ $v1_value = $v1->{$final_part}->view($displayOptions);
+ $v1_value = $this->renderer->render($v1_value);
+ }
+ if ($v2->{$final_part}->value !== NULL) {
+ $v2_value = $v2->{$final_part}->view($displayOptions);
+ $v2_value = $this->renderer->render($v2_value);
+ }
+ }
+ else {
+ // TODO: label/value switch not implemented yet.
+ // @codingStandardsIgnoreStart
+ // $method = $info['use label'] ? 'label' : 'value';
+ // $v1_value = $v1->hasField($final_part) && isset($v1->{$final_part}) ? $v1->{$final_part}->{$method}() : NULL;
+ // $v2_value = $v2 && $v2->value() && isset($v2->{$final_part}) ? $v2->{$final_part}->{$method}() : NULL;
+ // @codingStandardsIgnoreEnd
+ $v1_value = $v1->hasField($final_part) && isset($v1->{$final_part}) ? $v1->{$final_part}->value : NULL;
+ $v2_value = $v2->hasField($final_part) && isset($v2->{$final_part}) ? $v2->{$final_part}->value : NULL;
+
+ if (!$info['use label']) {
+ // If we don't have a list,
+ // wrap it in an array so we can process everything the same.
+ $type = $property_info->getType();
+ if (substr($type, 0, 5) != 'list<') {
+ $v1_value = isset($v1_value) ? [$v1_value] : NULL;
+ $v2_value = isset($v2_value) ? [$v2_value] : NULL;
+ }
+ else {
+ $type = $this->extractListType($type);
+ }
+
+ // Now loop over and do any processing on the type.
+ foreach (['v1_value', 'v2_value'] as $value) {
+ if (is_array($$value)) {
+ foreach ($$value as &$item) {
+ switch ($type) {
+ case 'date':
+ $item = $this->dateFormatter->format($item);
+ break;
+ }
+ }
+ }
+ }
+
+ // And convert back into a string.
+ $v1_value = is_array($v1_value) ? implode(', ', $v1_value) : $v1_value;
+ $v2_value = is_array($v2_value) ? implode(', ', $v2_value) : $v2_value;
+ }
+ }
+
+ return [$v1_value, $v2_value];
+ }
+
+ /**
+ * Builds the merge selection form.
+ */
+ private function buildSelectionForm(array &$form, FormStateInterface $form_state) {
+
+ // Show the selection form.
+ $form['selection'] = [
+ '#type' => 'fieldset',
+ '#title' => t('Select contacts to merge'),
+ '#description' => t('Enter the IDs of the contacts you want to merge.'),
+ '#tree' => TRUE,
+ ];
+ $form['selection']['#collapsible'] =
+ $form['selection']['#collapsed'] = $form_state->isSubmitted();
+
+ $form['selection']['primary_party'] = [
+ '#type' => 'textfield',
+ '#title' => t('Primary Contact Id'),
+ '#description' => t('Data from the secondary contact will be merged into this contact.'),
+ '#required' => TRUE,
+ ];
+
+ $form['selection']['secondary_party'] = [
+ '#type' => 'textfield',
+ '#title' => t('Duplicate contact id'),
+ '#description' => t('Data from this contact will be merged into the primary contact.'),
+ '#required' => TRUE,
+ ];
+
+ $form['selection']['actions'] = ['#type' => 'actions'];
+ $form['selection']['actions']['verify'] = [
+ '#type' => 'submit',
+ '#name' => 'verify_merge',
+ '#value' => t('Verify merge'),
+ ];
+
+ }
+
+ /**
+ * Builds the merge summary.
+ *
+ * @throws \Exception
+ */
+ private function buildMergeForm(array &$form, FormStateInterface $form_state, $primary_contact_id, $secondary_contact_id) {
+ /* @var $primary_contact \Drupal\user\Entity\User */
+ $primary_contact = User::load($primary_contact_id);
+ /* @var $secondary_contact \Drupal\user\Entity\User */
+ $secondary_contact = User::load($secondary_contact_id);
+
+ if ($primary_contact && $secondary_contact) {
+ $form['#attached']['library'][] = 'contacts/contacts_dedup_form';
+
+ $form['merge'] = [
+ '#type' => 'container',
+ ];
+ if (isset($form['selection'])) {
+ $form['merge']['#states'] = [
+ 'visible' => [
+ ':input[name="selection[primary_party]"]' => ['value' => $primary_contact->id()],
+ ':input[name="selection[secondary_party]"]' => ['value' => $secondary_contact->id()],
+ ],
+ ];
+ }
+
+ $form['merge']['verify'] = [
+ '#theme' => 'table',
+ '#header' => [
+ ['header' => FALSE],
+ t('Primary
@label [@pid]', [
+ '@label' => $primary_contact->label(),
+ '@pid' => $primary_contact->id(),
+ ]),
+ t('Duplicate
@label [@pid]', [
+ '@label' => $secondary_contact->label(),
+ '@pid' => $secondary_contact->id(),
+ ]),
+ ],
+ '#rows' => [],
+ ];
+
+ $form['merge']['config'] = [];
+
+ // Capture a flag so that custom callbacks can prevent merges.
+ $prevent_merge = FALSE;
+
+ $property_groups = GetPropertiesToShowOnDedupScreenEvent::dispatch()
+ ->getPropertiesByWeight();
+
+ foreach ($property_groups as $group => $properties) {
+ if ($group) {
+ $group_header = [
+ [
+ 'colspan' => 3,
+ 'header' => TRUE,
+ 'data' => $group,
+ ],
+ ];
+ }
+
+ foreach ($properties as $property => $info) {
+ list($v1_value, $v2_value) = $this->getFieldValuesToRender($primary_contact, $secondary_contact, $property, $info);
+
+ // If we don't have either value we can skip out.
+ if ($v1_value === NULL && $v2_value === NULL) {
+ continue;
+ }
+
+ // Output the group header if we have one.
+ if (isset($group_header)) {
+ $form['merge']['verify']['#rows'][$group] = $group_header;
+ unset($group_header);
+ }
+
+ // Build our row.
+ $row = [];
+ $row['data'][0] = $info['label'];
+ $row['data'][1] = $v1_value;
+ $row['data'][2]['data'] = $v2_value;
+
+ // Run the comparison if requested.
+ if ($info['compare']) {
+ $match = NULL;
+
+ // If a callable, pass onto the custom comparison.
+ if (is_callable($info['compare'])) {
+ $prevent_this_merge = FALSE;
+ $match = $info['compare']($v1_value, $v2_value, $prevent_this_merge, $form['merge']['config']);
+ $prevent_merge = $prevent_merge || $prevent_this_merge;
+ }
+ // Otherwise perform a simple string comparison.
+ elseif ($v1_value !== NULL && $v2_value !== NULL) {
+ if ($info['strip html']) {
+ $v1_value = isset($v1_value) ? strip_tags($v1_value) : $v1_value;
+ $v2_value = isset($v2_value) ? strip_tags($v2_value) : $v2_value;
+ }
+ $match = $v1_value == $v2_value;
+ }
+ elseif ($v1_value === NULL && $v2_value) {
+ $match = TRUE;
+ }
+
+ if (isset($match)) {
+ $row['data'][2]['class'][] = 'contact-dedupe-' . ($match ? 'match' : 'conflict');
+ }
+ }
+
+ // Add our row to the table.
+ $form['merge']['verify']['#rows'][$property] = $row;
+ }
+ }
+
+ $form['merge']['warning'] = [
+ '#type' => 'html_tag',
+ '#tag' => 'div',
+ '#attributes' => ['class' => ['messages', 'warning']],
+ '#value' => t('The colour coding provides an indication of what is likely to happen during the merge process. Results may vary according to specific circumstances.'),
+ ];
+
+ if ($prevent_merge) {
+ $form['merge']['prevented'] = [
+ '#type' => 'html_tag',
+ '#tag' => 'div',
+ '#attributes' => ['class' => ['messages', 'error']],
+ '#value' => t('This merge has been prevented. Please check for any at the top of the page.'),
+ ];
+ }
+
+ if (!empty($form['merge']['config'])) {
+ $form['merge']['config']['#type'] = 'fieldset';
+ $form['merge']['config']['#title'] = t('Additional settings');
+ $form['merge']['config']['#tree'] = TRUE;
+ }
+
+ $form['merge']['actions'] = [
+ '#type' => 'actions',
+ '#weight' => 100,
+ '#access' => !$prevent_merge,
+ ];
+
+ $form['merge']['actions']['submit'] = [
+ '#type' => 'submit',
+ '#name' => 'run_merge',
+ '#value' => t('Run merge'),
+ ];
+
+ // TODO Batch process not yet implemented.
+ // @codingStandardsIgnoreStart
+ /*// If we want to process immediately, add our batch submission handler.
+ if (variable_get('opencrm_dedupe_batch', 1)) {
+ $form['merge']['actions']['submit']['#submit'][] = 'opencrm_dedupe_party_merge_form_batch';
+ }*/
+ // @codingStandardsIgnoreEnd
+ }
+
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validateForm(array &$form, FormStateInterface $form_state) {
+ // When running a merge, make sure the user hasn't entered new IDs.
+ if ($form_state->getTriggeringElement()["#name"] == 'run_merge') {
+ // If we have the selection values, make sure they haven't been changed.
+ $primary_id_entered = $form_state->getValue('selection')['primary_party'];
+ $secondary_id_entered = $form_state->getValue('selection')['secondary_party'];
+
+ $primary_id_verified = $form_state->getValue('primary_party');
+ $secondary_id_verified = $form_state->getValue('secondary_party');
+
+ if (!empty($primary_id_entered) && !empty($primary_id_verified)) {
+ if ($primary_id_entered != $primary_id_verified) {
+ $form_state->setErrorByName('selection][primary_party', t('Please verify your new selection.'));
+ }
+ }
+
+ if (!empty($secondary_id_entered) && !empty($secondary_id_verified)) {
+ if ($secondary_id_entered != $secondary_id_verified) {
+ $form_state->setErrorByName('selection][secondary_party', t('Please verify your new selection.'));
+ }
+ }
+ }
+
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ if ($form_state->getTriggeringElement()['#name'] == 'run_merge') {
+ $primary_contact_id = $form_state->getValue("primary_party");
+ $secondary_contact_id = $form_state->getValue("secondary_party");
+ \Drupal::messenger()->addStatus("Running merge for $primary_contact_id and $secondary_contact_id");
+
+
+ $primary = User::load($primary_contact_id);
+ $secondary = User::load($secondary_contact_id);
+
+ $this->merger->merge($primary,$secondary);
+
+ $form_state->setRebuild();
+ }
+ else {
+ $form_state->setRebuild();
+ }
+ }
+
+ /**
+ * Extracts the underlying type from a list type.
+ */
+ private function extractListType($type) {
+ if (strpos($type, 'list<') === 0 && $type[strlen($type) - 1] == '>') {
+ return substr($type, 5, -1);
+ }
+ return FALSE;
+ }
+
+}
diff --git a/src/Theme/DashboardNegotiator.php b/src/Theme/DashboardNegotiator.php
index 2294ade..6828a28 100644
--- a/src/Theme/DashboardNegotiator.php
+++ b/src/Theme/DashboardNegotiator.php
@@ -28,6 +28,14 @@ public function applies(RouteMatchInterface $route_match) {
*/
public function determineActiveTheme(RouteMatchInterface $route_match) {
// Here you return the actual theme name.
+ // If the route provides a custom theme name, use it.
+ // Otherwise default to the contacts theme.
+ $route = $route_match->getRouteObject();
+
+ if ($route->hasOption('theme')) {
+ return $route->getOption('theme');
+ }
+
return 'contacts_theme';
}