We’re running into some friction between the Name field’s conditional validation and Conditional Fields’ (CF) late-stage validation. While each module behaves correctly in isolation, CF currently re-applies required broadly, which reintroduces validation errors that Name has already cleared based on its own internal logic. Ideally, all validators would be idempotent — order shouldn’t change the outcome — but that’s not quite how things play out right now.
What “idempotent” should mean here
- Order shouldn’t matter: Whether Name, CF, or Require on Publish (ROP) validates first, the same inputs should pass or fail consistently.
- Composite-aware: A composite field like Name knows which components are actually required. External validators shouldn’t override that.
- Stay in your lane: CF and ROP should validate the root field, not each sub-input within a composite.
- Don’t undo cleared errors: If a composite has determined it’s not required and cleared its own errors, later validators shouldn’t bring them back.
Plan for this issue
- Ensure Name handles conditionally required components internally using its own gating logic.
- Add a late Name-level cleanup that removes any errors left behind on components when the condition makes them not required.
- No dependencies on CF or ROP: Name simply fixes its own validation state without assuming how others behave.
Follow-up
- Open a CF issue proposing that it respect composite fields: emit
state:requiredonly on the composite wrapper and avoid forcing HTML5requiredon inner elements unless that was already true server-side. - Open a follow-up Name issue to take advantage of CF’s new idempotent behavior once available. 🙂
This approach restores predictable, order-agnostic behavior across Name, CF, and ROP — each module doing its part without stepping on the others.
This isn’t about adding special-case support for any one module, but about moving toward idempotent validation that enables clean interoperability — without being blocked awaiting CF to adopts the proposed approach.
Changes to MR !45 with this in mind forthcoming.
We’re running into some friction between the Name field’s conditional validation and Conditional Fields’ (CF) late-stage validation. While each module behaves correctly in isolation, CF currently re-applies required broadly, which reintroduces validation errors that Name has already cleared based on its own internal logic. Ideally, all validators would be idempotent — order shouldn’t change the outcome — but that’s not quite how things play out right now.
What “idempotent” should mean here
- Order shouldn’t matter: Whether Name, CF, or Require on Publish (ROP) validates first, the same inputs should pass or fail consistently.
- Composite-aware: A composite field like Name knows which components are actually required. External validators shouldn’t override that.
- Stay in your lane: CF and ROP should validate the root field, not each sub-input within a composite.
- Don’t undo cleared errors: If a composite has determined it’s not required and cleared its own errors, later validators shouldn’t bring them back.
Plan for this issue
- Ensure Name handles conditionally required components internally using its own gating logic.
- Add a late Name-level cleanup that removes any errors left behind on components when the condition makes them not required.
- No dependencies on CF or ROP: Name simply fixes its own validation state without assuming how others behave.
Follow-up
- Open a CF issue proposing that it respect composite fields: emit
state:requiredonly on the composite wrapper and avoid forcing HTML5requiredon inner elements unless that was already true server-side. - Open a follow-up Name issue to take advantage of CF’s new idempotent behavior once available. 🙂
This approach restores predictable, order-agnostic behavior across Name, CF, and ROP — each module doing its part without stepping on the others.
This isn’t about adding special-case support for any one module, but about moving toward idempotent validation that enables clean interoperability — without being blocked awaiting CF to adopts the proposed approach.
Changes to MR !42 with this in mind forthcoming.
Seems shoving the hook_form_alters to the bottom via hook_implementation creates a race to the bottom. Looking at a more surgical solution.
I especially want your opinion on this as a maintainer of the Storage Entities → module.
Rather than defining another node type, there’s a strong case for treating Location as a simple entity—something that stores structured address and geospatial data that other entities can reference. Many Drupal CMS sites may need the editorial/SEO layer (title, moderation, path, menus), but that can live in a wrapper node type (e.g. “Location Page”) that simply references the underlying location entity.
Since recipes can’t define new content-entity types, a pragmatic way to stay “recipe-only” would be to depend on the Storage Entities module. That gives us a fieldable, revisionable, translatable, referenceable entity type without writing PHP. The recipe can then create a location storage bundle with Address + Geofield fields.
This approach keeps Locations lightweight, composable, and immediately useful to site builders.
I guess I am really curious if such entities could start picking up steam in the contrib space. Drupal is so much more than a CMS, it really is a structured data framework that could underly AI-driven SaaS and ERP systems. Is this the right approach for contrib modules wanting to fill that gap?
Not totally sure this is the appropriate forum for this kind of discussion, but I've really been thinking about this topic lately, came across your module, and wanted to get your opinion, as I've seen you are very active in the community. Thanks for any insight you might offer.
With ✨ Integrate Conditional Fields server-side evaluation for Paragraph subfields Active being closed due to reasons explained in #3544869-3: Expose server-side dependency evaluator as a public service → , no shim will be implemented and therefore will not need to be removed. Closing.
We initially thought Conditional Fields needed to expose a server-side “states evaluator” service for third-party states like require_on_publish to be able to properly handle their server-side validation. However, after finishing the work in
✨
Name element should scope required state to minimum components when #states['required'] is applied to the wrapper/element
Active
and
🐛
Respect Name field “show required markers” and minimum components for ROP visuals
Active
, we found the service is unnecessary.
What changed was our use of core’s Form States model end-to-end, instead of recreating any CF evaluator in ✨ Integrate Conditional Fields server-side evaluation for Paragraph subfields Active .
Two key details for other contrib maintainers who might need to implement custom #states handling:
Client-side behavior
Client-side behavior uses the standard event that Drupal.states emits.
e.triggeris only truthy for Form States–originated updates (guards against unrelated events).e.valueis a boolean that already reflects your state condition evaluated by core Drupal.states (including negation like!checked/!value).
(($, Drupal) => {
$(document).on('state:my_custom_state', (e) => {
if (!e.trigger) { return; }
// For the state:my_custom_state, 'e.target' is the conditional field
// wrapper whose #states includes my_custom_state.
// When e.value === true, the conditional expression defined in
// #states is true (the state condition is met)
// Example: for {'[name="field_gate[value]"]': {'!checked': true}},
// e.value is true when the checkbox is NOT checked.
// Something I learned from CF's states JS implementation:
// Labels you typically want to paint:
// - Avoid editor format labels (WYSIWYG format subfields)
// - Avoid radio group labels (paint individual radios instead if needed)
const labels = $(e.target).find(
':not(.form-item--editor-format, .form-type-radio) > label'
);
// Fieldset/vertical-tabs support: some wrappers use <legend> instead of <label>.
const legends = $(e.target).find('legend');
const legendSpan = $(e.target).find('legend span');
// Optional: vertical tabs indicator (bold <strong> inside the active tab link).
const tabs = $('.vertical-tabs');
let tab = '';
if (tabs.length) {
const detail = legends.closest('details');
const selector = `a[href='#${detail.attr('id')}']`;
tab = $(selector);
}
if (e.value) {
// State condition is TRUE: add the visual marker.
if (legends.length) {
legends.addClass('my-custom-class');
legendSpan.addClass('my-custom-class');
if (tabs.length) {
tab.find('strong').addClass('my-custom-class');
}
} else {
labels.addClass('my-custom-class');
}
} else {
// State condition is FALSE: remove the marker.
if (legends.length) {
legends.removeClass('my-custom-class');
legendSpan.removeClass('my-custom-class');
if (tabs.length) {
tab.find('strong').removeClass('my-custom-class');
}
} else {
labels.removeClass('my-custom-class');
}
}
});
})(jQuery, Drupal);
When and why to involve <legend>?
Wrappers rendered as <details>/<fieldset> don’t use a standard <label> for the group title; they render it in a <legend> (and in vertical tabs, also mirror state in the tab link). If your conditional field is a fieldset, you must paint the <legend> instead of (or in addition to) individual labels to match core’s required marker behavior.
Why not include editor-format and type-radio labels?
:not(.form-item--editor-format, .form-type-radio)>label
.form-item--editor-formatprevents painting the WYSIWYG “Text format” sublabel; core doesn’t treat it as a required field label..form-type-radioprevents painting the group label that wraps the radio set; radios usually want per-option handling or legend handling instead of the group label.
Server-side enforcement
Server-side enforcement does not need a CF service. The form submission already contains the triggering values. If you must enforce something on submit (e.g., validation on publish), evaluate the same #states condition against submitted values in your validator/submission handlers:
/**
* Evaluate a single #states condition against submitted values.
* Supports 'checked', '!checked', 'value', '!value'.
*/
function mymodule_states_condition_matches(array $condition, array $submitted): bool {
foreach ($condition as $selector => $ops) {
if (!preg_match('/\[name="([^"]+)"\]/', $selector, $matches)) {
return FALSE;
}
$name = $matches[1];
$parts = explode('[', str_replace(']', '', $name));
$actual = \Drupal\Component\Utility\NestedArray::getValue($submitted, $parts);
// Normalize common field shapes.
if (is_array($actual) && isset($actual[0]['value'])) {
$actual = $actual[0]['value'];
}
elseif (is_array($actual) && isset($actual['value'])) {
$actual = $actual['value'];
}
foreach ($ops as $op => $expected) {
if ($op === 'checked' && !in_array($actual, [1,'1',TRUE,'on'], TRUE)) { return FALSE; }
if ($op === '!checked' && in_array($actual, [1,'1',TRUE,'on'], TRUE)) { return FALSE; }
if ($op === 'value' && (string) $actual !== (string) $expected) { return FALSE; }
if ($op === '!value' && (string) $actual === (string) $expected) { return FALSE; }
}
}
return TRUE;
}
In our case (Require on Publish), we simply stored the field’s #states['require_on_publish'] array on the entity during build (so our validator could access it), and then evaluated it against the submitted values when validating on publish. No duplication of CF's evaluator, no duplication of Drupal.states logic — just reading the same operators core uses.
For Name, we avoid attaching #states to every sub-element. Instead:
- Keep the module’s semantic markers (e.g. Name's minimum components).
- Use server-side paint only when ungated, and JS toggling when gated — so visuals stay in sync with Drupal.states.
- If another widget re-asserts “required” late (e.g., via preprocess), attach a small
#after_builddemotion to remove red/HTML5 required after it runs, while preserving your semantic markers.
TL;DR for composite widgets where you custom handle required subfields (e.g. Name, Address)
For composite widgets where you want #states handling and validation, clearing the way for clean CF integration.
- Paint/toggle visuals in JS by listening to
state:your_state— treate.value === trueas “state condition is met.” - If you need server-side enforcement, stash the field’s
#states['your_state']array somewhere reachable (entity, form state, etc.), then evaluate it against submitted values in your validator using the simple operators core supports (checked,!checked,value,!value). - For composite widgets, avoid per-component
#states. Lean on structural markers and, if necessary, use#after_buildto reconcile late “required” markup.
Conclusion: Conditional Fields does not need to provide an evaluator service. Our feature works with core’s Form States as designed. Closing this issue as works as designed.
Not documented that I've seen, for anyone needing to register a state with CF so it appears in CF UIs (obviously this comes after your front-end/server-side logic is in place):
function mymodule_conditionalFieldsStates_alter(array &$states) {
$states['require_on_publish'] = t('Required on Publish');
}
In my work on 🐛 Respect Name field “show required markers” and minimum components for ROP visuals Active , I was able to get this working. No evaluator service from CF nor a shim was needed. With that, we'll be able to ✨ Add Conditional Fields integration for “Required on Publish” state Active . Closing.
Thanks for the fix in MR 27 — it resolves the warning.
However, with the current patch, the warning goes away because PHP no longer tries to access a missing key — but the <select> still doesn’t get #empty_value or #empty_option properties, since those are only set when the key is exactly '_none' .
To make it fully robust, the renderer could also handle empty options keyed as 0 or '', which are common in Drupal forms:
if ($matches = array_intersect_key($element['#options'], array_flip(['_none', 0, '']))) {
$empty_key = array_key_first($matches);
$element['#empty_value'] = '_none';
$element['#empty_option'] = $matches[$empty_key];
unset($element['#options'][$empty_key]);
}
That way, selects with numeric or empty-string keys get both the warning fix and correct empty-option behavior.
Yeah, 🐛 Undefined array key "_none" Active looks like it has a solution to this issue.
Oh, but that 2nd one's been merged. I'm seeing this on 8.x-1.x today. Resolved it with the above solution.
Crap. I am getting sloppy--not checking for existing tickets. 2nd time. My bad.
Marking these 2.x behaviors with names so we can reference them.
First-pass, in-sync, not-split
...yes, I am also seeing this behavior--complete-split-enabled modules are listed in the sync directory's core.extensions.yml rather than in the split storage. But then they went away!
On first export, after configuring a complete split, I noticed that and that there were a few configuration .yml files for those modules in the split storage directory.
This is what is shown in the screen recording above.
Post-uninstall, unlisted
Based on the help text, expecting this was incorrect, I attempted to get them corrected by uninstalling the modules and re-trying the config-export. But this removed the configuration .yml files from the split storage directory AND from the core.extensions.yml in the sync directory! Given it was now an empty directory except for the generated .htaccess, I deleted the directory and drush config:export again. The .htaccess file reappeared, still with no other files.
Should I grab a screen recording of this piece?
It does seem that the new behavior works as expected: config import correctly enables the modules listed in the active split. But, the help text (and docs) need to be updated, and I don't know if the now-missing single-export files that were listed in the split storage directory need to be there.
Again, to be clear, after disabling the complete-split-enabled modules, they were removed from the sync directory's core.extensions.yml file.
Placing this in needs review. Really need maintainer input on whether this is an issue that needs documentation and UI help-text updates, or if it is a bug that needs to be fixed.
We probably need a bit more testing to see if the First-pass, in-sync, not-split correctly enables/disables the modules listed in the active/inactive split
Since this change was needed, this issue could be captured via ✨ Name element should scope required state to minimum components when #states['required'] is applied to the wrapper/element Active .
Got the changes in to only apply when Name field present using the default widget, and passes code linters.
Also added #states target field condition checks.
In order to get the test coverage necessary for this change, I did adopt
🐛
Component subfield labels are duplicated when label display set to above
Active
and
📌
Failing Tests: Core 11.2+
Active
. The first could be replaced by this issue, the second can be reverted with git revert d1e3c0c1 if necessary to prevent merge conflicts.
Seems we came to similar conclusions. My bad for not checking the issue queue for this. :)
RTBC
Tests fail due to
🐛
Update tests for new Drupal 10.2 Add Field page
Active
. If that is merged, we will see the tests pass, and the test-only changes job correctly fail having added bug regression coverage.
Confirmed locally that 11.1.8 does indeed still have the radio select and continue button.
My bad, patch #3 included an untracked file I had locally. Here's a corrected patch.
Seems the phpunit job recently began failing due to Form field with id|name|label|value|placeholder "new_storage_type" not found. Unrelated to this issue and fix.
New test passes. Also correctly fails test-only changes.
In Needs work since it would make sense to only apply JS when a Name field is present.
Feedback appreciated.
@markdc, your update in #5 is different from how the help text currently describes the expected storage behavior:
Complete Split: Configuration listed here will be removed from the sync directory and saved in the split storage instead. Modules will be removed from core.extension when exporting (and added back when importing with the split enabled.). Config dependencies are updated and their changes are recorded in a config patch saved in in the split storage.
But, yes, I am also seeing this behavior--complete-split-enabled modules are listed in the sync directory's core.extension rather than in the split storage.
On first export, after configuring a complete split, I noticed that and that there were a few configuration .yml files for those modules in the split storage directory.
Based on the help text, expecting this was incorrect, I attempted to get them corrected by uninstalling the modules and re-trying the config-export. But this just removed the configuration .yml files from the split storage directory. Given it was now an empty directory except for the generated .htaccess, I deleted the directory and drush config:export again. The .htaccess file reappeared, still with no other files.
It does seem that the new behavior works as expected: config import correctly enables the modules listed in the active split. But, that the help text (and perhaps docs) need to be updated, and I don't know if the now-missing single-export files that were listed in the split storage directory need to be there.
Doesn't seem that MR !412 quite worked. This MR pipeline list still shows a merge train pipeline. Maybe I didn't implement the CI variables to capture this fork's branch reference correctly. Not sure.
Captured in 🐛 Fix missing validation errors on Paragraphs-nested Name fields Active .
jcandan → changed the visibility of the branch 3550843-fix-lone-name-validation-errors to hidden.
Patch #13 applied successfully to 4.0.0-alpha6 and resolved the error for me.
Seems you've done more work on 🐛 Errors after update to alpha6 Active since May 22nd. I propose we keep this marked duplicate to avoid confusion.
So far as I can tell, @group demo_b and @group demo_c should have had tests. I'm not quite sure what to make of this. The fix seems right, but with these 2 groups' tests not running, I am leaving as needs work.
I'm setting to Needs work. I just saw that 2 of the examples show "No tests executed." Might be on purpose. I am looking more closely.
@jonathan1055, Excellent! Well done. pipeline #2051348583 shows MR 408 adds Recipe support nicely.
You didn't put this to Needs Review. Are you expecting to do more work or change anything? Based on the above, I am putting in RTBC, but feel free to change if you'd planned additional changes.
I know it is strange, but recipes are not expected to be in separate directories (contrib/custom). If the desire here is to be able to easily adopt GenericRecipeTestBase , then the path really should be ../recipes/{$name} . Otherwise, we'd need to manipulate installer-paths.
@jonathan1055, yes, feel free to submit an MR. You'll probably need to do something like:
.composer-base:
variables:
DRUPAL_GITLAB_TEMPLATES_PATCH_URL: "https://git.drupalcode.org/project/gitlab_templates/-/merge_requests/408.patch"
GITLAB_TEMPLATES_PACKAGE: "drupal/gitlab_templates"
GITLAB_TEMPLATES_PATCH_DESC: "Apply MR408 to gitlab_templates"
before_script:
- |
composer config --no-plugins --no-interaction allow-plugins.cweagans/composer-patches true || true
composer require --no-interaction cweagans/composer-patches || true
composer config --no-interaction \
"extra.patches.${GITLAB_TEMPLATES_PACKAGE}.\"${GITLAB_TEMPLATES_PATCH_DESC}\"" \
"${DRUPAL_GITLAB_TEMPLATES_PATCH_URL}"
@fjgarlin, I meant to clean that up. Not really, it just adds hidden-variables, additional security scans, and changes workflow rules.
@jonathan1055, the default value, no. Docs indicate it will default to modules/custom. But, if you meant that it would get it from the PROJECT_TYPE, it seems this value comes from the *.info.yml, which Recipes don't have.
Since supplying the type would place it in recipes/custom, one might be inclined to think the easy solution would be:
variables:
PROJECT_TYPE: 'recipe'
...
return "{$absolute_path}/web/recipes/custom/{$dirname}";
But, if you only specify the PROJECT_TYPE: 'recipe' CI variable, a dev would also need to update the Composer installer-paths (perhaps via .composer-base, maybe). Because, otherwise, dependencies will be in ../recipes, and he would get a recipe does not exist.
Here's an demo of this:
https://gitlab.com/jcandan/demo_drupal_gitlab_templates_recipe/-/merge_r...
So, would you like to change scope for this ticket to update documentation with an explanation about using DRUPAL_PROJECTS_PATH: '../recipes'?
Until a solution is released, the following workaround seems to suffice:
# .gitlab-ci.yml
include:
- project: 'blue-hangar/project/gitlab_templates'
file:
- '/includes/include.bluehangarci.main.yml'
variables:
DRUPAL_PROJECTS_PATH: '../recipes'
/**
* @file recipes/myrecipe/tests/src/Functional/GenericTest.php
*/
declare(strict_types=1);
namespace Drupal\Tests\myrecipe\Functional;
use Drupal\Tests\system\Functional\Recipe\GenericRecipeTestBase;
/**
* @group myrecipe_recipe
*/
class GenericTest extends GenericRecipeTestBase {
protected function getRecipePath(): string {
// Assume this test in located in PROJECT_DIR/tests/src/Functional.
$absolute_path = dirname((new \ReflectionObject($this))->getFileName(), 4);
$dirname = basename($absolute_path);
return "{$absolute_path}/recipes/{$dirname}";
}
}
I can also confirm this fixes the error as seemingly introduced by 🐛 Controlled-by fields inside a Paragraph don't work Needs work .
My bad: tests/src/FunctionalJavascript/ConditionalFieldParagraphsTest.php. Since there was no .gitlab-ci.yml entry to get Paragraphs, I wrongly assumed that there were no tests. I didn't realize Paragraphs was a dev-dependency for this module.
With ✨ Add require on publish as a conditional field option Active merged, we need to determine if this is still a concern.
jcandan → created an issue.
Closing in lieu of prioritizing ✨ Support plugin integration Active .
jcandan → changed the visibility of the branch 3454164-support-compound-fields to hidden.
Needs to be re-worked with the recent merge from 🐛 content_moderation support Needs work . I'm giving it a go.
Added tests. Tests pass, but since the PHPUnit update bug they fail.
Needs review.