From 87803a6f36913701219a11b86409abe41577dabf Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Tue, 2 Jun 2026 14:44:50 +1000 Subject: [PATCH 1/3] [SD-1485] site section custom validation https://digital-vic.atlassian.net/browse/SD-1485 --- .../tide_site_restriction/js/site_fields.js | 129 +++++++++++++++++ .../TideSiteRestrictionFieldWidget.php | 133 ++++++++++++++++++ .../tide_site_restriction.libraries.yml | 8 ++ 3 files changed, 270 insertions(+) create mode 100644 modules/tide_site_restriction/js/site_fields.js create mode 100644 modules/tide_site_restriction/tide_site_restriction.libraries.yml diff --git a/modules/tide_site_restriction/js/site_fields.js b/modules/tide_site_restriction/js/site_fields.js new file mode 100644 index 000000000..53278212e --- /dev/null +++ b/modules/tide_site_restriction/js/site_fields.js @@ -0,0 +1,129 @@ +/** + * @file + * Couples the Primary Site and Site node fields. + * + */ + +(function (Drupal, drupalSettings, once) { + + 'use strict'; + + Drupal.behaviors.tideSiteRestrictionSiteFields = { + attach: function (context) { + var settings = drupalSettings.tideSiteRestriction; + if (!settings || !settings.fields || !settings.fields.primary || !settings.fields.site) { + return; + } + + var primaryName = settings.fields.primary; + var siteName = settings.fields.site; + + // Bind once per radio group; bail out if the Primary Site field is not on + // this page (e.g. the widget is rendered on a non-node form). + var primaryInputs = once('tide-site-restriction', 'input[name="' + primaryName + '"]', context); + if (!primaryInputs.length) { + return; + } + + // Guards re-entrancy when the script toggles checkboxes programmatically. + var suppress = false; + + function siteCheckboxes() { + return Array.prototype.slice.call( + document.querySelectorAll('input[name^="' + siteName + '["]') + ); + } + + function setSite(tid, checked) { + var cb = document.querySelector('input[name="' + siteName + '[' + tid + ']"]'); + if (cb && cb.checked !== checked) { + cb.checked = checked; + cb.dispatchEvent(new Event('change', { bubbles: true })); + } + } + + function getCheckedPrimary() { + var checked = document.querySelector('input[name="' + primaryName + '"]:checked'); + return checked ? checked.value : null; + } + + /** + * Rebuilds the Site selection for the given Primary Site. + */ + function applyForPrimary(primaryTid) { + primaryTid = String(primaryTid); + suppress = true; + // Clear every existing Site selection + siteCheckboxes().forEach(function (cb) { + if (cb.checked) { + cb.checked = false; + cb.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + suppress = false; + + // Auto-check the Primary Site itself, the editor may add a single child. + setSite(primaryTid, true); + } + + /** + * Keeps the Site selection at "Primary Site + at most one child". + */ + function enforceSiteSelection(event) { + if (suppress) { + return; + } + var primaryTid = getCheckedPrimary(); + if (!primaryTid) { + return; + } + var cb = event.target; + + // The Primary Site must always stay selected. + if (cb.value === String(primaryTid)) { + if (!cb.checked) { + cb.checked = true; + } + return; + } + + // Selecting a child replaces any other (non-primary) selection. + if (cb.checked) { + suppress = true; + siteCheckboxes().forEach(function (other) { + if (other !== cb && other.checked && other.value !== String(primaryTid)) { + other.checked = false; + other.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + suppress = false; + } + } + + primaryInputs.forEach(function (input) { + input.addEventListener('change', function (event) { + if (event.target.checked) { + applyForPrimary(event.target.value); + } + }); + }); + + siteCheckboxes().forEach(function (cb) { + cb.addEventListener('change', enforceSiteSelection); + }); + + // Initial state + if (settings.isNew && primaryInputs.length === 1) { + var current = getCheckedPrimary(); + if (!current) { + primaryInputs[0].checked = true; + current = primaryInputs[0].value; + } + if (current) { + applyForPrimary(current); + } + } + } + }; + +})(Drupal, drupalSettings, once); diff --git a/modules/tide_site_restriction/src/Plugin/Field/FieldWidget/TideSiteRestrictionFieldWidget.php b/modules/tide_site_restriction/src/Plugin/Field/FieldWidget/TideSiteRestrictionFieldWidget.php index d1e6a040a..62808685f 100644 --- a/modules/tide_site_restriction/src/Plugin/Field/FieldWidget/TideSiteRestrictionFieldWidget.php +++ b/modules/tide_site_restriction/src/Plugin/Field/FieldWidget/TideSiteRestrictionFieldWidget.php @@ -7,6 +7,7 @@ use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Field\FieldDefinitionInterface; +use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Field\Plugin\Field\FieldWidget\OptionsButtonsWidget; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; @@ -88,6 +89,138 @@ public static function create(ContainerInterface $container, array $configuratio ); } + /** + * {@inheritdoc} + */ + public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) { + $element = parent::formElement($items, $delta, $element, $form, $form_state); + + $field_name = $this->fieldDefinition->getName(); + $entity_type = $this->fieldDefinition->getTargetEntityTypeId(); + + // The JS coupling only makes sense for the node Primary Site / Site fields. + $is_primary = TideSiteFields::isSiteField($field_name, TideSiteFields::FIELD_PRIMARY_SITE); + $is_site = TideSiteFields::isSiteField($field_name, TideSiteFields::FIELD_SITE); + if ($entity_type !== 'node' || (!$is_primary && !$is_site)) { + return $element; + } + + // Users who can bypass the site restriction (e.g. administrator, + // site_admin) may freely select any sites: skip the JS coupling and the + // single-tree validation entirely. + if ($this->helper->canBypassRestriction($this->currentUser)) { + return $element; + } + + // Attach the behaviour and the data it needs. Both field instances push + // into the same drupalSettings bucket; the shared payload is idempotent and + // each instance only declares its own role. + $element['#attached']['library'][] = 'tide_site_restriction/site_fields'; + $element['#attached']['drupalSettings']['tideSiteRestriction'] = [ + 'fields' => [$is_primary ? 'primary' : 'site' => $field_name], + 'isNew' => $this->isNewEntityForm($form_state), + ]; + + // Backend safety net: enforce the single-tree invariant on the Site field. + if ($is_site) { + $element['#tide_primary_field'] = TideSiteFields::normaliseFieldName(TideSiteFields::FIELD_PRIMARY_SITE, $entity_type); + $element['#tide_site_field'] = $field_name; + $element['#element_validate'][] = [static::class, 'validateSiteTree']; + } + + return $element; + } + + /** + * Determines whether the widget is rendered on a new entity form. + * + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @return bool + * TRUE if the entity being edited is new. + */ + protected function isNewEntityForm(FormStateInterface $form_state) { + $form_object = $form_state->getFormObject(); + return $form_object instanceof EntityFormInterface && $form_object->getEntity()->isNew(); + } + + /** + * Element validate handler enforcing the Primary Site tree invariant. + * + * The Site field must contain the selected Primary Site and may only contain + * terms that belong to that Primary Site's tree (the Primary Site or one of + * its descendants). This mirrors the JS behaviour for php submits. + */ + public static function validateSiteTree(array &$element, FormStateInterface $form_state) { + $primary_field = $element['#tide_primary_field'] ?? NULL; + $site_field = $element['#tide_site_field'] ?? NULL; + if (!$primary_field || !$site_field) { + return; + } + + $primary_tid = static::extractTargetIds($form_state->getValue($primary_field)); + $primary_tid = reset($primary_tid); + // When no Primary Site is selected, the field's own required validation + // reports it; there is nothing to cross-check here. + if (empty($primary_tid)) { + return; + } + + $site_tids = static::extractTargetIds($form_state->getValue($site_field)); + + if (!in_array($primary_tid, $site_tids)) { + $form_state->setError($element, t('The Site field must include the selected Primary Site.')); + return; + } + + /** @var \Drupal\tide_site_restriction\Helper $helper */ + $helper = \Drupal::service('tide_site_restriction.helper'); + foreach ($site_tids as $site_tid) { + $trail = $helper->getSiteTrail($site_tid) ?: []; + if (!in_array($primary_tid, $trail)) { + $form_state->setError($element, t('Selected Site must belong to the selected Primary Site.')); + return; + } + } + + // Besides the Primary Site itself, at most one child (at any depth) may be + // selected. + $children = array_diff($site_tids, [$primary_tid]); + if (count($children) > 1) { + $form_state->setError($element, t('You may select at most one Site in addition to the Primary Site.')); + } + } + + /** + * Normalises a submitted reference field value into a list of target ids. + * + * @param mixed $value + * The raw form value (scalar, list of scalars or list of items). + * + * @return array + * The list of non-empty target ids as strings. + */ + protected static function extractTargetIds($value) { + $ids = []; + if (is_array($value)) { + foreach ($value as $item) { + if (is_array($item) && isset($item['target_id'])) { + $ids[] = $item['target_id']; + } + elseif (is_scalar($item)) { + $ids[] = $item; + } + } + } + elseif (is_scalar($value) && $value !== '') { + $ids[] = $value; + } + return array_values(array_filter(array_map('strval', $ids), function ($id) { + return $id !== '' && $id !== '0' && $id !== '_none'; + })); + } + /** * {@inheritdoc} */ diff --git a/modules/tide_site_restriction/tide_site_restriction.libraries.yml b/modules/tide_site_restriction/tide_site_restriction.libraries.yml new file mode 100644 index 000000000..ab2d5763c --- /dev/null +++ b/modules/tide_site_restriction/tide_site_restriction.libraries.yml @@ -0,0 +1,8 @@ +site_fields: + version: 1.x + js: + js/site_fields.js: {} + dependencies: + - core/drupal + - core/once + - core/drupalSettings From f5fd48451b52a587810e02c620ee6d450b31616c Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Tue, 2 Jun 2026 14:57:19 +1000 Subject: [PATCH 2/3] [SD-1485] Added toggle to enable/disable Primary Site and Site field coupling. --- .../TideSiteRestrictionFieldWidget.php | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/modules/tide_site_restriction/src/Plugin/Field/FieldWidget/TideSiteRestrictionFieldWidget.php b/modules/tide_site_restriction/src/Plugin/Field/FieldWidget/TideSiteRestrictionFieldWidget.php index 62808685f..1fe0637fe 100644 --- a/modules/tide_site_restriction/src/Plugin/Field/FieldWidget/TideSiteRestrictionFieldWidget.php +++ b/modules/tide_site_restriction/src/Plugin/Field/FieldWidget/TideSiteRestrictionFieldWidget.php @@ -89,6 +89,40 @@ public static function create(ContainerInterface $container, array $configuratio ); } + /** + * {@inheritdoc} + */ + public static function defaultSettings() { + return [ + 'enable_site_coupling' => TRUE, + ] + parent::defaultSettings(); + } + + /** + * {@inheritdoc} + */ + public function settingsForm(array $form, FormStateInterface $form_state) { + $element = parent::settingsForm($form, $form_state); + $element['enable_site_coupling'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Couple Primary Site and Site fields'), + '#description' => $this->t('When enabled, the Site field is bound to the selected Primary Site: it keeps the Primary Site checked and allows at most one additional child site, and changing the Primary Site rebuilds the selection. Disable to turn this behaviour off and let editors select sites manually. Only applies to the node Primary Site / Site fields and users who cannot bypass the site restriction.'), + '#default_value' => $this->getSetting('enable_site_coupling'), + ]; + return $element; + } + + /** + * {@inheritdoc} + */ + public function settingsSummary() { + $summary = parent::settingsSummary(); + $summary[] = $this->getSetting('enable_site_coupling') + ? $this->t('Primary Site / Site coupling: enabled') + : $this->t('Primary Site / Site coupling: disabled'); + return $summary; + } + /** * {@inheritdoc} */ @@ -105,6 +139,12 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen return $element; } + // The behaviour can be switched off per field on "Manage form display". + // Both fields must keep it enabled for the coupling to take effect. + if (!$this->getSetting('enable_site_coupling')) { + return $element; + } + // Users who can bypass the site restriction (e.g. administrator, // site_admin) may freely select any sites: skip the JS coupling and the // single-tree validation entirely. From d072a27fdf3334abdd4246ba4010df7f5cbe12da Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Wed, 10 Jun 2026 12:52:14 +1000 Subject: [PATCH 3/3] Update help text --- .../src/TideSiteRestrictionOperation.php | 19 +++++++++++++++++++ .../tide_site_restriction.install | 8 ++++++++ 2 files changed, 27 insertions(+) diff --git a/modules/tide_site_restriction/src/TideSiteRestrictionOperation.php b/modules/tide_site_restriction/src/TideSiteRestrictionOperation.php index f830dc342..625432621 100644 --- a/modules/tide_site_restriction/src/TideSiteRestrictionOperation.php +++ b/modules/tide_site_restriction/src/TideSiteRestrictionOperation.php @@ -145,4 +145,23 @@ public static function addNecessarySettings() { } } + /** + * Set field_node_site help text. + */ + public static function updateHelpText() { + $help_text = 'You must select only one site section.'; + $field_configs = \Drupal::entityTypeManager() + ->getStorage('field_config') + ->loadByProperties([ + 'entity_type' => 'node', + 'field_name' => 'field_node_site', + ]); + foreach ($field_configs as $field_config) { + if ($field_config->getDescription() !== $help_text) { + $field_config->setDescription($help_text); + $field_config->save(); + } + } + } + } diff --git a/modules/tide_site_restriction/tide_site_restriction.install b/modules/tide_site_restriction/tide_site_restriction.install index ad0a6e083..96d8c54ad 100644 --- a/modules/tide_site_restriction/tide_site_restriction.install +++ b/modules/tide_site_restriction/tide_site_restriction.install @@ -15,5 +15,13 @@ function tide_site_restriction_install() { TideSiteRestrictionOperation::addSubSitesFilter(); TideSiteRestrictionOperation::installWidgets(); TideSiteRestrictionOperation::addNecessarySettings(); + TideSiteRestrictionOperation::updateHelpText(); } + +/** + * Update the help text on all field_node_site instances. + */ +function tide_site_restriction_update_10001() { + TideSiteRestrictionOperation::updateHelpText(); +}