From 6311ddc26a3668dc32cee06596bb8d2c53da8ccb Mon Sep 17 00:00:00 2001 From: Md Nadim Hossain Date: Mon, 4 May 2026 08:27:36 +1000 Subject: [PATCH 1/4] [sd-1485] site section custom validation. --- .../tide_site_restriction.settings.yml | 1 + .../Constraint/SiteHierarchyConstraint.php | 23 +++++++ .../SiteHierarchyConstraintValidator.php | 60 +++++++++++++++++++ .../tide_site_restriction.install | 9 +++ .../tide_site_restriction.module | 14 +++++ 5 files changed, 107 insertions(+) create mode 100644 modules/tide_site_restriction/config/install/tide_site_restriction.settings.yml create mode 100644 modules/tide_site_restriction/src/Plugin/Validation/Constraint/SiteHierarchyConstraint.php create mode 100644 modules/tide_site_restriction/src/Plugin/Validation/Constraint/SiteHierarchyConstraintValidator.php diff --git a/modules/tide_site_restriction/config/install/tide_site_restriction.settings.yml b/modules/tide_site_restriction/config/install/tide_site_restriction.settings.yml new file mode 100644 index 00000000..960846fb --- /dev/null +++ b/modules/tide_site_restriction/config/install/tide_site_restriction.settings.yml @@ -0,0 +1 @@ +limit_site_hierarchy: TRUE \ No newline at end of file diff --git a/modules/tide_site_restriction/src/Plugin/Validation/Constraint/SiteHierarchyConstraint.php b/modules/tide_site_restriction/src/Plugin/Validation/Constraint/SiteHierarchyConstraint.php new file mode 100644 index 00000000..d0cad9aa --- /dev/null +++ b/modules/tide_site_restriction/src/Plugin/Validation/Constraint/SiteHierarchyConstraint.php @@ -0,0 +1,23 @@ +get('limit_site_hierarchy'); + + if ($is_enabled === FALSE) { + return; + } + + $current_user = \Drupal::currentUser(); + if (in_array('administrator', $current_user->getRoles())) { + return; + } + + if (!isset($items) || count($items) === 0) { + return; + } + + $root_count = 0; + $child_count = 0; + $term_storage = \Drupal::entityTypeManager()->getStorage('taxonomy_term'); + + foreach ($items as $item) { + if ($term_id = $item->target_id) { + $parents = $term_storage->loadParents($term_id); + empty($parents) ? $root_count++ : $child_count++; + } + } + + if ($root_count > 1 || $child_count > 1) { + $this->context->addViolation($constraint->message); + } + + } + +} diff --git a/modules/tide_site_restriction/tide_site_restriction.install b/modules/tide_site_restriction/tide_site_restriction.install index ad0a6e08..4cadb9f4 100644 --- a/modules/tide_site_restriction/tide_site_restriction.install +++ b/modules/tide_site_restriction/tide_site_restriction.install @@ -17,3 +17,12 @@ function tide_site_restriction_install() { TideSiteRestrictionOperation::addNecessarySettings(); } + +/** + * Creates the initial configuration for site hierarchy restriction. + */ +function tide_site_restriction_update_10001() { + \Drupal::configFactory()->getEditable('tide_site_restriction.settings') + ->set('limit_site_hierarchy', TRUE) + ->save(); +} diff --git a/modules/tide_site_restriction/tide_site_restriction.module b/modules/tide_site_restriction/tide_site_restriction.module index 320fea51..89c9987a 100644 --- a/modules/tide_site_restriction/tide_site_restriction.module +++ b/modules/tide_site_restriction/tide_site_restriction.module @@ -8,6 +8,7 @@ use Drupal\Core\Access\AccessResult; use Drupal\Core\Cache\Cache; use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Form\FormStateInterface; @@ -478,3 +479,16 @@ function tide_site_restriction_field_widget_single_element_form_alter(&$element, } } } + +/** + * Implements hook_entity_bundle_field_info_alter(). + * + * Adds the SiteHierarchy validation constraint to the 'field_node_site' field. + * This ensures the hierarchy restrictions (max one parent, max one child) + * are enforced at the entity level for all node bundles. + */ +function tide_site_restriction_entity_bundle_field_info_alter(&$fields, EntityTypeInterface $entity_type, $bundle) { + if ($entity_type->id() === 'node' && isset($fields['field_node_site'])) { + $fields['field_node_site']->addConstraint('SiteHierarchy', []); + } +} From bae3973d02724d8879572604ad3bcdc7f948ee2c Mon Sep 17 00:00:00 2001 From: Md Nadim Hossain Date: Sat, 9 May 2026 00:35:21 +1000 Subject: [PATCH 2/4] [SD-1485] Added custom site section validation. --- .../Constraint/SiteHierarchyConstraint.php | 23 ---- .../SiteHierarchyConstraintValidator.php | 60 ---------- .../tide_site_restriction.module | 113 ++++++++++++++++-- 3 files changed, 105 insertions(+), 91 deletions(-) delete mode 100644 modules/tide_site_restriction/src/Plugin/Validation/Constraint/SiteHierarchyConstraint.php delete mode 100644 modules/tide_site_restriction/src/Plugin/Validation/Constraint/SiteHierarchyConstraintValidator.php diff --git a/modules/tide_site_restriction/src/Plugin/Validation/Constraint/SiteHierarchyConstraint.php b/modules/tide_site_restriction/src/Plugin/Validation/Constraint/SiteHierarchyConstraint.php deleted file mode 100644 index d0cad9aa..00000000 --- a/modules/tide_site_restriction/src/Plugin/Validation/Constraint/SiteHierarchyConstraint.php +++ /dev/null @@ -1,23 +0,0 @@ -get('limit_site_hierarchy'); - - if ($is_enabled === FALSE) { - return; - } - - $current_user = \Drupal::currentUser(); - if (in_array('administrator', $current_user->getRoles())) { - return; - } - - if (!isset($items) || count($items) === 0) { - return; - } - - $root_count = 0; - $child_count = 0; - $term_storage = \Drupal::entityTypeManager()->getStorage('taxonomy_term'); - - foreach ($items as $item) { - if ($term_id = $item->target_id) { - $parents = $term_storage->loadParents($term_id); - empty($parents) ? $root_count++ : $child_count++; - } - } - - if ($root_count > 1 || $child_count > 1) { - $this->context->addViolation($constraint->message); - } - - } - -} diff --git a/modules/tide_site_restriction/tide_site_restriction.module b/modules/tide_site_restriction/tide_site_restriction.module index 89c9987a..0f3a0c16 100644 --- a/modules/tide_site_restriction/tide_site_restriction.module +++ b/modules/tide_site_restriction/tide_site_restriction.module @@ -8,7 +8,6 @@ use Drupal\Core\Access\AccessResult; use Drupal\Core\Cache\Cache; use Drupal\Core\Entity\EntityInterface; -use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Form\FormStateInterface; @@ -481,14 +480,112 @@ function tide_site_restriction_field_widget_single_element_form_alter(&$element, } /** - * Implements hook_entity_bundle_field_info_alter(). + * Implements hook_form_BASE_FORM_ID_alter() for node_form. * - * Adds the SiteHierarchy validation constraint to the 'field_node_site' field. - * This ensures the hierarchy restrictions (max one parent, max one child) - * are enforced at the entity level for all node bundles. + * Adds custom validation to enforce site hierarchy rules and handles + * auto-selection of site fields if the user only has access to a single site. + * Logic is bypassed for 'administrator' and 'site_admin' roles. + * + * @param array $form + * Nested array of form elements that comprise the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * @param string $form_id + * String representing the name of the form itself. + */ +function tide_site_restriction_form_node_form_alter(&$form, FormStateInterface $form_state, $form_id) { + $config = \Drupal::config('tide_site_restriction.settings'); + $is_enabled = $config->get('limit_site_hierarchy'); + + if ($is_enabled === FALSE) { + return; + } + $user = \Drupal::currentUser(); + $roles = $user->getRoles(); + + $is_admin = array_intersect(['administrator', 'site_admin'], $roles); + if (!empty($is_admin)) { + return; + } + + /** @var \Drupal\node\NodeInterface $node */ + $node = $form_state->getFormObject()->getEntity(); + $form['#validate'][] = '_tide_site_restriction_node_form_validate'; + + $options = $form['field_node_primary_site']['widget']['#options'] ?? []; + if (count($options) === 1 && $node->isNew()) { + $target_site_id = key($options); + + // Set the value for Primary Site. + $form['field_node_primary_site']['widget']['#default_value'] = $target_site_id; + + // Set the value for the Site field. + if (isset($form['field_node_site'])) { + $form['field_node_site']['widget']['#default_value'] = [$target_site_id]; + } + } +} + +/** + * Custom validation handler for node forms to enforce site selection rules. + * + * This validator ensures that: + * 1. A maximum of one Root (Level 1) and one Child (Section) is selected. + * 2. If a Root site is selected in 'field_node_site', it must match + * 'field_node_primary_site'. + * 3. Any selected Child sites in 'field_node_site' must be direct or indirect + * descendants of the 'field_node_primary_site'. + * + * @param array $form + * The form structure. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form, used to retrieve values and set errors. */ -function tide_site_restriction_entity_bundle_field_info_alter(&$fields, EntityTypeInterface $entity_type, $bundle) { - if ($entity_type->id() === 'node' && isset($fields['field_node_site'])) { - $fields['field_node_site']->addConstraint('SiteHierarchy', []); +function _tide_site_restriction_node_form_validate($form, FormStateInterface $form_state) { + $primary_site = $form_state->getValue('field_node_primary_site'); + $site_list = $form_state->getValue('field_node_site'); + + $primary_site_id = !empty($primary_site[0]['target_id']) ? $primary_site[0]['target_id'] : NULL; + + if (!$site_list || !$primary_site_id) { + return; + } + + $term_storage = \Drupal::entityTypeManager()->getStorage('taxonomy_term'); + $selected_root_id = NULL; + $root_count = 0; + $child_count = 0; + $invalid_children = []; + + foreach ($site_list as $item) { + if ($tid = $item['target_id']) { + $parents = $term_storage->loadParents($tid); + if (empty($parents)) { + $root_count++; + $selected_root_id = $tid; + } + else { + $child_count++; + // Check if this child belongs to the selected Primary Site. + if (!isset($parents[$primary_site_id])) { + $invalid_children[] = $tid; + } + } + } + } + + // Hierarchy limit check. + if ($root_count > 1 || $child_count > 1) { + $form_state->setErrorByName('field_node_site', t('You can select a maximum of one primary site and one site section.')); + } + + // Ensures the "Root" selected in site matches "Primary Site" field. + if ($selected_root_id && $primary_site_id != $selected_root_id) { + $form_state->setErrorByName('field_node_site', t('The selected site must match the site selected in Primary Site field.')); + } + + // Ensures the "Child" is actually a child of the "Primary Site". + if (!empty($invalid_children)) { + $form_state->setErrorByName('field_node_site', t('The selected site section does not belong to the selected Primary Site.')); } } From f3a336a24e766a4848d2065db193a3d01b5c2255 Mon Sep 17 00:00:00 2001 From: Md Nadim Hossain Date: Sat, 9 May 2026 00:43:29 +1000 Subject: [PATCH 3/4] lint fix. --- .../tide_site_restriction/tide_site_restriction.module | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/modules/tide_site_restriction/tide_site_restriction.module b/modules/tide_site_restriction/tide_site_restriction.module index 0f3a0c16..c978e4c7 100644 --- a/modules/tide_site_restriction/tide_site_restriction.module +++ b/modules/tide_site_restriction/tide_site_restriction.module @@ -482,16 +482,9 @@ function tide_site_restriction_field_widget_single_element_form_alter(&$element, /** * Implements hook_form_BASE_FORM_ID_alter() for node_form. * - * Adds custom validation to enforce site hierarchy rules and handles + * Adds custom validation to enforce site hierarchy rules and handles * auto-selection of site fields if the user only has access to a single site. * Logic is bypassed for 'administrator' and 'site_admin' roles. - * - * @param array $form - * Nested array of form elements that comprise the form. - * @param \Drupal\Core\Form\FormStateInterface $form_state - * The current state of the form. - * @param string $form_id - * String representing the name of the form itself. */ function tide_site_restriction_form_node_form_alter(&$form, FormStateInterface $form_state, $form_id) { $config = \Drupal::config('tide_site_restriction.settings'); From bf43e5d0e99802bb921dfad52f8443e9987bdf5d Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Fri, 15 May 2026 16:42:26 +1000 Subject: [PATCH 4/4] Refined site section validation --- .../tide_site_restriction.module | 81 ++++++++++++++----- 1 file changed, 60 insertions(+), 21 deletions(-) diff --git a/modules/tide_site_restriction/tide_site_restriction.module b/modules/tide_site_restriction/tide_site_restriction.module index c978e4c7..89f68bcd 100644 --- a/modules/tide_site_restriction/tide_site_restriction.module +++ b/modules/tide_site_restriction/tide_site_restriction.module @@ -482,9 +482,16 @@ function tide_site_restriction_field_widget_single_element_form_alter(&$element, /** * Implements hook_form_BASE_FORM_ID_alter() for node_form. * - * Adds custom validation to enforce site hierarchy rules and handles + * Adds custom validation to enforce site hierarchy rules and handles * auto-selection of site fields if the user only has access to a single site. * Logic is bypassed for 'administrator' and 'site_admin' roles. + * + * @param array $form + * Nested array of form elements that comprise the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * @param string $form_id + * String representing the name of the form itself. */ function tide_site_restriction_form_node_form_alter(&$form, FormStateInterface $form_state, $form_id) { $config = \Drupal::config('tide_site_restriction.settings'); @@ -505,8 +512,18 @@ function tide_site_restriction_form_node_form_alter(&$form, FormStateInterface $ $node = $form_state->getFormObject()->getEntity(); $form['#validate'][] = '_tide_site_restriction_node_form_validate'; + // Help text under the Site field heading (AC SD-1485). + if (isset($form['field_node_site']['widget'])) { + $form['field_node_site']['widget']['#description'] = t('You must select only one site section.'); + } + + // Skip auto-select on quick_node_clone forms — $node->isNew() is TRUE for + // clones too, but the form already carries field values copied from the + // source node. Forcing defaults here would wipe the cloned Site sections. + $is_clone_form = strpos($form_id, '_quick_node_clone_form') !== FALSE; + $options = $form['field_node_primary_site']['widget']['#options'] ?? []; - if (count($options) === 1 && $node->isNew()) { + if (count($options) === 1 && $node->isNew() && !$is_clone_form) { $target_site_id = key($options); // Set the value for Primary Site. @@ -522,8 +539,10 @@ function tide_site_restriction_form_node_form_alter(&$form, FormStateInterface $ /** * Custom validation handler for node forms to enforce site selection rules. * - * This validator ensures that: - * 1. A maximum of one Root (Level 1) and one Child (Section) is selected. + * This validator ensures that (per AC SD-1485): + * 1. At most one site may be selected in 'field_node_site' beyond the + * Primary Site itself — i.e. the user can pick the Primary plus one + * additional section, nothing more. * 2. If a Root site is selected in 'field_node_site', it must match * 'field_node_primary_site'. * 3. Any selected Child sites in 'field_node_site' must be direct or indirect @@ -547,29 +566,49 @@ function _tide_site_restriction_node_form_validate($form, FormStateInterface $fo $term_storage = \Drupal::entityTypeManager()->getStorage('taxonomy_term'); $selected_root_id = NULL; $root_count = 0; - $child_count = 0; + $other_count = 0; $invalid_children = []; foreach ($site_list as $item) { - if ($tid = $item['target_id']) { - $parents = $term_storage->loadParents($tid); - if (empty($parents)) { - $root_count++; - $selected_root_id = $tid; - } - else { - $child_count++; - // Check if this child belongs to the selected Primary Site. - if (!isset($parents[$primary_site_id])) { - $invalid_children[] = $tid; - } - } + if (empty($item['target_id'])) { + continue; + } + $tid = $item['target_id']; + + // AC SD-1485: count selections that are not the Primary Site. At most + // one such "other" selection is allowed. + if ($tid != $primary_site_id) { + $other_count++; + } + + // loadAllParents() returns the term itself plus every ancestor, so a + // grandchild resolves to its full chain up to the root — needed to + // validate L1 + L3 selections where loadParents() only sees the L2 + // immediate parent. + $ancestors = $term_storage->loadAllParents($tid); + unset($ancestors[$tid]); + if (empty($ancestors)) { + $root_count++; + $selected_root_id = $tid; + } + elseif (!isset($ancestors[$primary_site_id])) { + // Primary Site is not in this term's ancestor chain. + $invalid_children[] = $tid; } } - // Hierarchy limit check. - if ($root_count > 1 || $child_count > 1) { - $form_state->setErrorByName('field_node_site', t('You can select a maximum of one primary site and one site section.')); + // AC SD-1485: max one site besides the Primary Site. Return early so the + // user sees a single, focused error instead of stacked messages when many + // sites are selected. + if ($other_count > 1) { + $form_state->setErrorByName('field_node_site', t('only one site can be selected, please select only one site')); + return; + } + + // Multiple top-level sites selected (e.g. two L1 roots). + if ($root_count > 1) { + $form_state->setErrorByName('field_node_site', t('only one site can be selected, please select only one site')); + return; } // Ensures the "Root" selected in site matches "Primary Site" field.