From b9894c02cd72758db3844fd69941e1f4be69f314 Mon Sep 17 00:00:00 2001 From: Yan Rudenko Date: Fri, 21 Nov 2025 13:43:05 +0100 Subject: [PATCH 1/4] FIX: Allow to submit form while required fields are hidden --- .../services/user-field-validations.js | 33 ++++++++++ plugin.rb | 62 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/assets/javascripts/discourse/services/user-field-validations.js b/assets/javascripts/discourse/services/user-field-validations.js index a9607ed..34fa1ea 100644 --- a/assets/javascripts/discourse/services/user-field-validations.js +++ b/assets/javascripts/discourse/services/user-field-validations.js @@ -9,8 +9,27 @@ export default class UserFieldValidations extends Service { @tracked totalCustomValidationFields = 0; currentCustomValidationFieldCount = 0; + constructor() { + super(...arguments); + + this._initializeOriginallyRequired(); + } + + _initializeOriginallyRequired() { + this.site?.user_fields?.forEach((field) => { + if (field.has_custom_validation && field.originally_required === undefined) { + field.originally_required = field.required; + } + }); + } + @action setValidation(userField, value) { + // Initialize originally_required for userField if not done yet + if (userField.originally_required === undefined) { + userField.originally_required = userField.required; + } + this._bumpTotalCustomValidationFields(); if ( @@ -59,12 +78,26 @@ export default class UserFieldValidations extends Service { .toLowerCase() .replace(/\s+/g, "-")}`; const userFieldElement = document.querySelector(`.${className}`); + + // Save original required value on first call + if (userField.originally_required === undefined) { + userField.originally_required = userField.required; + } + if (userFieldElement && !shouldShow) { // Clear and hide nested fields userFieldElement.style.display = "none"; this._clearUserField(userField); + + // Remove required for hidden field + userField.required = false; } else { userFieldElement.style.display = ""; + + //Restore original required for visible field + if (userField.originally_required !== undefined) { + userField.required = userField.originally_required; + } } }); } diff --git a/plugin.rb b/plugin.rb index a20dac7..9a4d6b5 100644 --- a/plugin.rb +++ b/plugin.rb @@ -19,6 +19,10 @@ module ::DiscourseAuthenticationValidations require_relative "lib/discourse_authentication_validations/engine" after_initialize do + class ::DiscourseAuthenticationValidations::Current < ActiveSupport::CurrentAttributes + attribute :user_fields_params + end + add_to_serializer(:user_field, :has_custom_validation) { object.has_custom_validation } add_to_serializer(:user_field, :show_values) { object.show_values } add_to_serializer(:user_field, :target_user_field_ids) { object.target_user_field_ids } @@ -33,4 +37,62 @@ module ::DiscourseAuthenticationValidations ) columns end + + # Add helper method for UserField - check if field should be hidden + class ::UserField + def should_be_hidden? + # Get user_fields_params from CurrentAttributes + user_fields_params = DiscourseAuthenticationValidations::Current.user_fields_params + return false unless user_fields_params.present? + + # Find all parent fields that have THIS field (self) in target_user_field_ids + parent_fields = UserField.where("? = ANY(target_user_field_ids)", self.id) + + # If no parent fields with custom validation - field is not hidden + return false if parent_fields.empty? + + parent_fields.any? do |parent_field| + # Skip if parent doesn't have custom validation + next unless parent_field.has_custom_validation + + # Get parent field value from user_fields_params + parent_value = user_fields_params[parent_field.id.to_s] + + # If parent field value is NOT in show_values - field should be hidden + !parent_field.show_values.include?(parent_value.to_s) + end + end + end + + # Minimally invasive UsersController patch via prepend + module UsersControllerHiddenFieldsPatch + def create + # Save user_fields to CurrentAttributes (automatically cleaned up after request) + if params[:user_fields].present? + DiscourseAuthenticationValidations::Current.user_fields_params = params[:user_fields] + Rails.logger.info("[discourse-authentication-validations] Saved user_fields for validation") + end + + # Call original method + super + end + end + + # Patch UserField#required? to check should_be_hidden + class ::UserField + alias_method :original_required?, :required? + + def required? + # If field should be hidden - ignore required + if should_be_hidden? + return false + end + + # Otherwise use original method + original_required? + end + end + + require_dependency 'users_controller' + ::UsersController.prepend(UsersControllerHiddenFieldsPatch) end From 9620cd59b15baea996a91c5d958aa7c4125b3f27 Mon Sep 17 00:00:00 2001 From: Yan Rudenko Date: Wed, 26 Nov 2025 09:59:38 +0100 Subject: [PATCH 2/4] FEATURE: Add repeatable conditional fields logic --- .../custom-user-fields.gjs | 190 +++++++++++++++++- .../services/user-field-validations.js | 166 +++++++++++++-- assets/stylesheets/common/admin/common.scss | 17 ++ config/locales/client.en.yml | 15 ++ ...3_add_conditional_fields_to_user_fields.rb | 7 + plugin.rb | 162 ++++++++++++++- 6 files changed, 525 insertions(+), 32 deletions(-) create mode 100644 db/migrate/20251124111823_add_conditional_fields_to_user_fields.rb diff --git a/assets/javascripts/discourse/connectors/after-admin-user-fields/custom-user-fields.gjs b/assets/javascripts/discourse/connectors/after-admin-user-fields/custom-user-fields.gjs index 2bf0745..604867f 100644 --- a/assets/javascripts/discourse/connectors/after-admin-user-fields/custom-user-fields.gjs +++ b/assets/javascripts/discourse/connectors/after-admin-user-fields/custom-user-fields.gjs @@ -1,30 +1,141 @@ import Component from "@glimmer/component"; import { tracked } from "@glimmer/tracking"; import { Input } from "@ember/component"; +import { action } from "@ember/object"; +import { scheduleOnce } from "@ember/runloop"; import { service } from "@ember/service"; import { withPluginApi } from "discourse/lib/plugin-api"; import { i18n } from "discourse-i18n"; import AdminFormRow from "admin/components/admin-form-row"; import ValueList from "admin/components/value-list"; import MultiSelect from "select-kit/components/multi-select"; +import DButton from "discourse/components/d-button"; export default class CustomUserFields extends Component { @service site; - @tracked userFieldsMinusCurrent = this.site.user_fields.filter( - (userField) => userField.id !== this.args.outletArgs.userField.id - ); + // Compute the list of other user fields dynamically — site.user_fields may + // be populated asynchronously, so a getter ensures the MultiSelect has the + // latest content on first render. + get userFieldsMinusCurrent() { + const currentId = this.args?.outletArgs?.userField?.id; + return (this.site.user_fields || []).filter((userField) => userField.id !== currentId); + } + @tracked rules = []; constructor() { super(...arguments); + + // Ensure the admin form includes our custom properties when saving user fields withPluginApi((api) => { [ "has_custom_validation", "show_values", "target_user_field_ids", "value_validation_regex", + "conditional_fields", ].forEach((property) => api.includeUserFieldPropertyOnSave(property)); }); + + // ensure we load rules after render when args are available (handles async loads) + scheduleOnce("afterRender", this, this._loadRules); + } + + _loadRules() { + try { + const raw = this.args?.outletArgs?.userField?.conditional_fields; + if (raw) { + // case: JSON string + if (Array.isArray(raw)) { + this.rules = raw; + // case: object returned from server + } else if (raw && typeof raw === "object") { + // prefer { rules: [...] } shape + if (Array.isArray(raw.rules)) { + this.rules = raw.rules; + } else { + // convert numeric-keyed objects to array: {0: {...},1: {...}} + const numericKeys = Object.keys(raw).filter((k) => String(Number(k)) === String(k)); + if (numericKeys.length > 0) { + this.rules = numericKeys + .sort((a, b) => Number(a) - Number(b)) + .map((k) => raw[k]); + } else { + this.rules = []; + } + } + } + } + } catch (e) { + this.rules = []; + } + + // Normalize target ids to numbers so MultiSelect matches the content's ids + if (this.rules && Array.isArray(this.rules)) { + this.rules = this.rules.map((r) => { + const targets = r && r.target_user_field_ids ? r.target_user_field_ids : []; + return { + ...r, + target_user_field_ids: Array.isArray(targets) ? targets.map((v) => Number(v)) : [], + }; + }); + } + + // Always ensure at least one editable rule is present so admin can fill it in + if (!this.rules || this.rules.length === 0) { + this.rules = [{ show_values: [], target_user_field_ids: [] }]; + } + } + + @action + addRule(field) { + this.rules = [...this.rules, { show_values: [], target_user_field_ids: [] }]; + if (field && typeof field.set === "function") { + // pass the actual object so the admin form can serialize it properly (JSONB) + field.set(this.rules); + } + } + + @action + removeRule(field, idx) { + const r = [...this.rules]; + r.splice(idx, 1); + this.rules = r; + if (field && typeof field.set === "function") { + field.set(this.rules); + } + } + + @action + updateShowValues(field, idx, newValues) { + const r = [...this.rules]; + r[idx] = { ...r[idx], show_values: newValues || [] }; + this.rules = r; + if (field && typeof field.set === "function") { + field.set(this.rules); + } + } + + // Return a closure that ValueList can call with (newValues). + @action + getShowValuesHandler(idx, field) { + return (newValues) => this.updateShowValues(field, idx, newValues); + } + + @action + updateTargets(field, idx, newTargets) { + const r = [...this.rules]; + r[idx] = { ...r[idx], target_user_field_ids: (newTargets || []).map((v) => Number(v)) }; + this.rules = r; + if (field && typeof field.set === "function") { + field.set(this.rules); + } + } + + // Return a closure that MultiSelect can call with (newTargets). + @action + getTargetsHandler(idx, field) { + return (newTargets) => this.updateTargets(field, idx, newTargets); } } diff --git a/assets/javascripts/discourse/services/user-field-validations.js b/assets/javascripts/discourse/services/user-field-validations.js index 34fa1ea..0fd1c48 100644 --- a/assets/javascripts/discourse/services/user-field-validations.js +++ b/assets/javascripts/discourse/services/user-field-validations.js @@ -45,30 +45,119 @@ export default class UserFieldValidations extends Service { @action hideNestedCustomValidations(userField, value) { - if (!this._shouldShow(userField, value)) { - const nestedUserFields = this.site.user_fields - .filter((field) => userField.target_user_field_ids.includes(field.id)) - .flatMap((nestedField) => - this.site.user_fields.filter((field) => - nestedField.target_user_field_ids.includes(field.id) - ) - ); + // Determine which direct target ids are currently hidden for this userField/value + let hiddenDirectTargets = []; - // Clear and hide nested fields - nestedUserFields.forEach((field) => this._clearUserField(field)); - this._updateTargets( - nestedUserFields.map((field) => field.id), - false - ); + const cfs = userField.conditional_fields; + if (Array.isArray(cfs) && cfs.length >= 1) { + // Build candidate set only from the conditional_fields rules themselves. + // Do NOT mix in legacy userField.target_user_field_ids when conditional_fields are present. + const candidateSet = new Set(); + cfs.forEach((rule) => { + (rule.target_user_field_ids || []).forEach((v) => candidateSet.add(Number(v))); + }); + + Array.from(candidateSet).forEach((id) => { + const should = this._shouldShowForTarget(userField, value, id); + if (!should) { + hiddenDirectTargets.push(id); + } + }); + } else { + // Legacy behavior: if parent is not shown, all direct targets are hidden + if (!this._shouldShow(userField, value)) { + hiddenDirectTargets = (userField.target_user_field_ids || []).map((v) => Number(v)); + } + } + + if (hiddenDirectTargets.length === 0) { + return; } + + // For each hidden direct target, find its nested targets and clear/hide them + const nestedUserFields = hiddenDirectTargets + .flatMap((tid) => { + const nestedField = this.site.user_fields.find((f) => f.id === tid); + if (!nestedField) { + return []; + } + return this.site.user_fields.filter((field) => + (nestedField.target_user_field_ids || []).map((v) => Number(v)).includes(field.id) + ); + }); + + // Clear and hide nested fields + nestedUserFields.forEach((field) => this._clearUserField(field)); + this._updateTargets(nestedUserFields.map((field) => field.id), false); } @action crossCheckValidations(userField, value) { - this._updateTargets( - userField.target_user_field_ids, - this._shouldShow(userField, value) - ); + const cfs = userField.conditional_fields; + + // If conditional_fields exists and has rules, prefer those rules. + if (Array.isArray(cfs) && cfs.length >= 1) { + // build candidate set of all target ids referenced by this field or its rules + // Build candidate set only from the conditional_fields rules themselves. + // Do NOT mix in legacy userField.target_user_field_ids when conditional_fields are present. + const candidateSet = new Set(); + cfs.forEach((rule) => { + (rule.target_user_field_ids || []).forEach((v) => candidateSet.add(Number(v))); + }); + + const toShow = []; + const toHide = []; + + const stringValue = value?.toString(); + const isNull = value === null; + + Array.from(candidateSet).forEach((id) => { + let matched = false; + + for (const rule of cfs) { + let sv = rule.show_values || rule.show_values === 0 ? rule.show_values : rule.show_value; + + // normalize show values to array of strings + let showArr = []; + if (Array.isArray(sv)) { + showArr = sv.map((v) => (v === null ? "null" : v?.toString())); + } else if (sv !== undefined && sv !== null) { + showArr = [(sv === null ? "null" : sv.toString())]; + } + + const ruleMatchesValue = isNull ? showArr.includes("null") : showArr.includes(stringValue); + if (!ruleMatchesValue) { + continue; + } + + const targetIds = (rule.target_user_field_ids || []).map((v) => Number(v)); + if (targetIds.includes(Number(id))) { + matched = true; + break; + } + } + + if (matched) { + toShow.push(id); + } else { + toHide.push(id); + } + }); + + // Update visibility for matched and unmatched targets + if (toShow.length) { + this._updateTargets(toShow, true); + } + if (toHide.length) { + this._updateTargets(toHide, false); + } + } else { + // Fallback to legacy behavior when no conditional_fields rules exist + this._updateTargets( + userField.target_user_field_ids, + this._shouldShow(userField, value) + ); + } } _updateTargets(userFieldIds, shouldShow) { @@ -111,6 +200,47 @@ export default class UserFieldValidations extends Service { return shouldShow; } + // Determine whether a specific target id should be shown for the given + // parent userField and value, taking conditional_fields into account if + // present. Falls back to legacy behavior when conditional_fields absent. + _shouldShowForTarget(userField, value, id) { + const cfs = userField.conditional_fields; + const stringValue = value?.toString(); + const isNull = value === null; + + if (Array.isArray(cfs) && cfs.length >= 1) { + for (const rule of cfs) { + const sv = rule.show_values || rule.show_value; + + let showArr = []; + if (Array.isArray(sv)) { + showArr = sv.map((v) => (v === null ? "null" : v?.toString())); + } else if (sv !== undefined && sv !== null) { + showArr = [(sv === null ? "null" : sv.toString())]; + } + + const ruleMatchesValue = isNull ? showArr.includes("null") : showArr.includes(stringValue); + if (!ruleMatchesValue) { + continue; + } + + const targetIds = (rule.target_user_field_ids || []).map((v) => Number(v)); + if (targetIds.includes(Number(id))) { + return true; + } + } + + return false; + } + + // Legacy fallback: show if parent would be shown at all and id is a declared target + if (this._shouldShow(userField, value)) { + return (userField.target_user_field_ids || []).map((v) => Number(v)).includes(Number(id)); + } + + return false; + } + _clearUserField(userField) { switch (userField.field_type) { case "confirm": diff --git a/assets/stylesheets/common/admin/common.scss b/assets/stylesheets/common/admin/common.scss index 42b0497..2bde3a5 100644 --- a/assets/stylesheets/common/admin/common.scss +++ b/assets/stylesheets/common/admin/common.scss @@ -2,3 +2,20 @@ .target-user-field-ids-input { margin: 0.4em 0; } + +#control-conditional_fields .form-kit__container-content.--large { + width: 100%; +} + +.conditional-rules .conditional-rule { + border: 1px solid #eee; + padding: 8px; + margin-bottom: 8px; +} + +.conditional-rules .conditional-rule .item-row { + display:flex; + gap:8px; + align-items:flex-start; + width: 100%; +} \ No newline at end of file diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index a8fc8e6..9642147 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -16,5 +16,20 @@ en: value_validation_regex: label: "Value Validation Regex" description: "The regular expression that the value must match (only applicable to text fields)" + conditional_fields: + label: "Conditional Fields" + description: "The User Field(s) that will be made visible based on conditions." + conditional_fields_show_values: + label: "Show Values" + description: "The value(s) required to have the target User Field be made visible. If multiple values are provided, only one needs to be present to show the target User Field." + conditional_fields_target_user_field_ids: + label: "Target User Fields" + description: "The User Field(s) that will be made visible" + add_rule_button: + label: "Add Rule" + remove_rule_button: + label: "Remove" + rules_preview: + label: "Rules Preview" value_validation_error_message: | Please enter a valid %{user_field_name} diff --git a/db/migrate/20251124111823_add_conditional_fields_to_user_fields.rb b/db/migrate/20251124111823_add_conditional_fields_to_user_fields.rb new file mode 100644 index 0000000..c23545c --- /dev/null +++ b/db/migrate/20251124111823_add_conditional_fields_to_user_fields.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddConditionalFieldsToUserFields < ActiveRecord::Migration[7.0] + def change + add_column :user_fields, :conditional_fields, :jsonb, null: true, default: [] + end +end diff --git a/plugin.rb b/plugin.rb index 9a4d6b5..ca73afa 100644 --- a/plugin.rb +++ b/plugin.rb @@ -27,6 +27,7 @@ class ::DiscourseAuthenticationValidations::Current < ActiveSupport::CurrentAttr add_to_serializer(:user_field, :show_values) { object.show_values } add_to_serializer(:user_field, :target_user_field_ids) { object.target_user_field_ids } add_to_serializer(:user_field, :value_validation_regex) { object.value_validation_regex } + add_to_serializer(:user_field, :conditional_fields) { object.conditional_fields } register_modifier(:admin_user_fields_columns) do |columns| columns.push( @@ -34,6 +35,7 @@ class ::DiscourseAuthenticationValidations::Current < ActiveSupport::CurrentAttr :show_values, :target_user_field_ids, :value_validation_regex, + :conditional_fields, ) columns end @@ -45,21 +47,77 @@ def should_be_hidden? user_fields_params = DiscourseAuthenticationValidations::Current.user_fields_params return false unless user_fields_params.present? - # Find all parent fields that have THIS field (self) in target_user_field_ids - parent_fields = UserField.where("? = ANY(target_user_field_ids)", self.id) + # First, try to find parent fields that reference THIS field (self) + # via `conditional_fields` (new format). We prefer this because it's the + # newer authorable rules format. Only if none are found do we fall back + # to the legacy `target_user_field_ids` array lookup. + parent_fields = UserField.all.select do |pf| + cf = pf.conditional_fields + next false unless cf.present? + + if cf.is_a?(Array) + cf.any? { |rule| Array(rule["target_user_field_ids"] || rule[:target_user_field_ids]).map(&:to_i).include?(self.id) } + elsif cf.is_a?(Hash) + cf.values.any? { |v| Array(v).map(&:to_i).include?(self.id) } + else + false + end + end + + # If no parents found via conditional_fields, fall back to legacy array search + if parent_fields.empty? + parent_fields = UserField.where("? = ANY(target_user_field_ids)", self.id) + end # If no parent fields with custom validation - field is not hidden return false if parent_fields.empty? - parent_fields.any? do |parent_field| - # Skip if parent doesn't have custom validation - next unless parent_field.has_custom_validation - - # Get parent field value from user_fields_params - parent_value = user_fields_params[parent_field.id.to_s] - - # If parent field value is NOT in show_values - field should be hidden - !parent_field.show_values.include?(parent_value.to_s) + # Determine whether we should operate in conditional_fields mode. + # If at least one parent defines non-empty conditional_fields, we use + # conditional_fields logic for ALL parents (do not mix with legacy). + use_conditional = parent_fields.any? { |pf| pf.respond_to?(:conditional_fields) && Array(pf.conditional_fields).any? } + + if use_conditional + # Only consider parents that have conditional_fields defined (non-empty) + parents_to_check = parent_fields.select { |pf| pf.respond_to?(:conditional_fields) && Array(pf.conditional_fields).any? } + + parents_to_check.any? do |parent_field| + parent_value = user_fields_params[parent_field.id.to_s] + mapping = parent_field.conditional_fields + + if mapping.is_a?(Array) + matched_rules = mapping.select do |rule| + sv = rule["show_values"] || rule[:show_values] || rule["show_value"] || rule[:show_value] + next false unless sv.present? + Array(sv).map(&:to_s).include?(parent_value.to_s) + end + + if matched_rules.any? + ids = matched_rules.flat_map { |r| Array(r["target_user_field_ids"] || r[:target_user_field_ids]) }.map(&:to_i) + next !(ids.include?(self.id)) + else + # If parent has conditional_fields but no rule matches this parent_value, + # treat as hidden (do not fall back to legacy mapping when operating in + # conditional_fields mode to avoid mixing behaviors). + next true + end + else + # mapping is a hash (legacy mapping stored in conditional_fields) + ids_for_value = mapping[parent_value.to_s] + if ids_for_value.present? + next !(ids_for_value.map(&:to_i).include?(self.id)) + else + # No mapping for this value -> treat as hidden under conditional mode + next true + end + end + end + else + # Legacy mode: check parent.show_values and target_user_field_ids as before + parent_fields.any? do |parent_field| + parent_value = user_fields_params[parent_field.id.to_s] + !parent_field.show_values.include?(parent_value.to_s) + end end end end @@ -95,4 +153,86 @@ def required? require_dependency 'users_controller' ::UsersController.prepend(UsersControllerHiddenFieldsPatch) + + # Normalize admin params for conditional_fields so numeric-keyed hashes + # like {"0"=>{...}, "1"=>{...}} become arrays before the controller + # assigns them to the model. This keeps JSONB as an array. + begin + # correct path for the admin controller in Discourse + require_dependency 'admin/user_fields_controller' + + module AdminUserFieldsParamsPatch + def create + normalize_conditional_fields_param + super + end + + def update + normalize_conditional_fields_param + super + end + + private + + def normalize_conditional_fields_param + begin + uf = params[:user_field] || params[:user_fields] + # TEMP DEBUG: log whether conditional_fields was included in incoming params + if uf && uf.key?(:conditional_fields) + begin + Rails.logger.info("[discourse-authentication-validations] Incoming conditional_fields param class=#{uf[:conditional_fields].class} present=true") + rescue + end + else + Rails.logger.info("[discourse-authentication-validations] Incoming conditional_fields param present=false") + end + + # If missing `conditional_fields`, and + # custom validation is enabled for this field, treat that as an + # explicit empty array (the admin removed all rules). + # This is needed because empty arrays don't submit params from form. + if uf && (!uf.key?(:conditional_fields) || uf[:conditional_fields].blank?) + has_cv = uf.key?(:has_custom_validation) ? uf[:has_custom_validation] : nil + if has_cv.present? + # Normalize presence values like 'on'/'true'/'1' to truthy + if has_cv == true || has_cv.to_s =~ /^(true|on|1)$/i + uf[:conditional_fields] = [] + end + end + end + + return unless uf && uf[:conditional_fields] + + cf = uf[:conditional_fields] + + # Convert ActionController::Parameters to plain Hash so checks below work + if cf.respond_to?(:to_unsafe_h) + begin + cf = cf.to_unsafe_h + rescue + # ignore conversion errors + end + end + + # If it's already an Array - nothing to do + return if cf.is_a?(Array) + + # If numeric-keyed Hash-like -> convert to ordered Array + if cf.is_a?(Hash) + numeric_keys = cf.keys.select { |k| k.to_s =~ /\A\d+\z/ } + if numeric_keys.any? + arr = numeric_keys.sort_by { |k| k.to_i }.map { |k| cf[k] } + uf[:conditional_fields] = arr + end + end + rescue + # swallow errors silently in normalization + end + end + end + + ::Admin::UserFieldsController.prepend(AdminUserFieldsParamsPatch) + rescue LoadError + # If admin controller isn't available in this runtime, skip silently. + end end From 3ab533d2cfbe8258638b8daf58285b941cd99826 Mon Sep 17 00:00:00 2001 From: Yan Rudenko Date: Wed, 26 Nov 2025 16:14:02 +0100 Subject: [PATCH 3/4] FIX: Fix issue with Fields slug to be as Core --- .../discourse/services/user-field-validations.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/assets/javascripts/discourse/services/user-field-validations.js b/assets/javascripts/discourse/services/user-field-validations.js index 0fd1c48..a273fa0 100644 --- a/assets/javascripts/discourse/services/user-field-validations.js +++ b/assets/javascripts/discourse/services/user-field-validations.js @@ -163,9 +163,11 @@ export default class UserFieldValidations extends Service { _updateTargets(userFieldIds, shouldShow) { userFieldIds.forEach((id) => { const userField = this.site.user_fields.find((field) => field.id === id); - const className = `user-field-${userField.name - .toLowerCase() - .replace(/\s+/g, "-")}`; + // Use the same slugification logic as Discourse core `UserFieldBase.customFieldClass`: + const className = `user-field-${String(userField.name) + .replace(/\s+/g, "-") + .replace(/[!\"#$%&'()\*\+,\.\/:;<=>\?@\[\\\]\^`\{\|\}~]/g, "") + .toLowerCase()}`; const userFieldElement = document.querySelector(`.${className}`); // Save original required value on first call From 8b1af7bf4039d563904e4cec8b365dce4313a605 Mon Sep 17 00:00:00 2001 From: Yan Rudenko Date: Thu, 27 Nov 2025 12:57:52 +0100 Subject: [PATCH 4/4] FIX: Add missing required class to required fields --- .../services/user-field-validations.js | 76 ++++++++++++++++--- 1 file changed, 67 insertions(+), 9 deletions(-) diff --git a/assets/javascripts/discourse/services/user-field-validations.js b/assets/javascripts/discourse/services/user-field-validations.js index a273fa0..c419c31 100644 --- a/assets/javascripts/discourse/services/user-field-validations.js +++ b/assets/javascripts/discourse/services/user-field-validations.js @@ -1,6 +1,6 @@ import { tracked } from "@glimmer/tracking"; import { action } from "@ember/object"; -import { next } from "@ember/runloop"; +import { next, scheduleOnce } from "@ember/runloop"; import Service, { service } from "@ember/service"; export default class UserFieldValidations extends Service { @@ -20,6 +20,9 @@ export default class UserFieldValidations extends Service { if (field.has_custom_validation && field.originally_required === undefined) { field.originally_required = field.required; } + + scheduleOnce("afterRender", this, () => { + this._syncRequiredClass(field)}); }); } @@ -163,11 +166,7 @@ export default class UserFieldValidations extends Service { _updateTargets(userFieldIds, shouldShow) { userFieldIds.forEach((id) => { const userField = this.site.user_fields.find((field) => field.id === id); - // Use the same slugification logic as Discourse core `UserFieldBase.customFieldClass`: - const className = `user-field-${String(userField.name) - .replace(/\s+/g, "-") - .replace(/[!\"#$%&'()\*\+,\.\/:;<=>\?@\[\\\]\^`\{\|\}~]/g, "") - .toLowerCase()}`; + const className = this._userFieldClassName(userField); const userFieldElement = document.querySelector(`.${className}`); // Save original required value on first call @@ -190,6 +189,14 @@ export default class UserFieldValidations extends Service { userField.required = userField.originally_required; } } + + // Sync visual 'required' class to DOM for this field. Pass the + // already-found `userFieldElement` to avoid querying the DOM twice. + try { + this._syncRequiredClass(userField, userFieldElement); + } catch (e) { + // ignore errors + } }); } @@ -246,17 +253,68 @@ export default class UserFieldValidations extends Service { _clearUserField(userField) { switch (userField.field_type) { case "confirm": - userField.element.checked = false; + if (userField.element) { + userField.element.checked = false; + } break; case "dropdown": - userField.element.selectedIndex = 0; + if (userField.element) { + userField.element.selectedIndex = 0; + } + break; + case "multiselect": { + // There is nothing we can do here + // There no element to foucs on + // The selected values hidden from DOM while select is closed break; + } default: - userField.element.value = ""; + if (userField.element) { + userField.element.value = ""; + } break; } } + _userFieldClassName(userField) { + const name = String((userField && userField.name) || ""); + return `user-field-${name + .replace(/\s+/g, "-") + .replace(/[!"#$%&'()\*\+,\.\/:;<=>\?@\[\\\]\^`\{\|\}~]/g, "") + .toLowerCase()}`; + } + + // Ensure the DOM reflects the `required` state for a given userField by + // finding its wrapper and toggling the `required` CSS class. + _syncRequiredClass(userField, el) { + if (!userField) { + return; + } + + let element = el; + if (!element) { + const className = this._userFieldClassName(userField); + element = document.querySelector && document.querySelector(`.${className}`); + } + + if (!element) { + return; + } + + try { + // Only operate if this element is the standard `.user-field` wrapper + if (element.classList && element.classList.contains("user-field")) { + if (userField.required) { + element.classList.add("required"); + } else { + element.classList.remove("required"); + } + } + } catch (e) { + // ignore DOM errors + } + } + _bumpTotalCustomValidationFields() { if ( this.totalCustomValidationFields !==