decidim-toggle 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. checksums.yaml +7 -0
  2. data/.erb-lint.yml +2134 -0
  3. data/.github/workflows/website.yml +57 -0
  4. data/.gitignore +13 -0
  5. data/.gitlab-ci.yml +165 -0
  6. data/.node-version +1 -0
  7. data/.rspec +1 -0
  8. data/.rubocop.yml +2 -0
  9. data/.ruby-version +1 -0
  10. data/.simplecov +18 -0
  11. data/CONTRIBUTING.md +17 -0
  12. data/Gemfile +33 -0
  13. data/Gemfile.lock +843 -0
  14. data/LICENSE.md +661 -0
  15. data/README.md +90 -0
  16. data/Rakefile +38 -0
  17. data/app/commands/decidim/toggle/update_authorizations_command.rb +31 -0
  18. data/app/commands/decidim/toggle/update_emails_command.rb +30 -0
  19. data/app/commands/decidim/toggle/update_file_upload_settings_command.rb +31 -0
  20. data/app/commands/decidim/toggle/update_locale_command.rb +47 -0
  21. data/app/commands/decidim/toggle/update_module_config_command.rb +31 -0
  22. data/app/commands/decidim/toggle/update_name_command.rb +31 -0
  23. data/app/commands/decidim/toggle/update_omniauth_command.rb +30 -0
  24. data/app/commands/decidim/toggle/update_security_command.rb +31 -0
  25. data/app/controllers/decidim_toggle/system/settings_tab_controller.rb +64 -0
  26. data/app/forms/decidim/toggle/update_authorizations_form.rb +54 -0
  27. data/app/forms/decidim/toggle/update_emails_form.rb +28 -0
  28. data/app/forms/decidim/toggle/update_file_upload_settings_form.rb +26 -0
  29. data/app/forms/decidim/toggle/update_locale_form.rb +116 -0
  30. data/app/forms/decidim/toggle/update_name_form.rb +63 -0
  31. data/app/forms/decidim/toggle/update_omniauth_form.rb +37 -0
  32. data/app/forms/decidim/toggle/update_security_form.rb +65 -0
  33. data/app/helpers/decidim/toggle/javascript_config_helper.rb +11 -0
  34. data/app/helpers/decidim/toggle/system_settings_tab_helper.rb +59 -0
  35. data/app/models/decidim/toggle/organization_module_config.rb +15 -0
  36. data/app/overrides/add_toggle_javascript_admin.rb +11 -0
  37. data/app/overrides/add_toggle_javascript_public.rb +11 -0
  38. data/app/packs/entrypoints/decidim_toggle.js +3 -0
  39. data/app/packs/src/decidim/toggle/organization_settings_tabs.js +114 -0
  40. data/app/packs/stylesheets/decidim/toggle/organization_settings.scss +160 -0
  41. data/app/views/decidim/system/organizations/edit.html.erb +20 -0
  42. data/app/views/decidim_toggle/system/organizations/_default_form_tab.html.erb +5 -0
  43. data/app/views/decidim_toggle/system/organizations/_encryption_not_configured_callout.html.erb +6 -0
  44. data/app/views/decidim_toggle/system/organizations/_settings_tab_active_tab_field.html.erb +1 -0
  45. data/app/views/decidim_toggle/system/organizations/_settings_tab_submit.html.erb +4 -0
  46. data/app/views/decidim_toggle/system/organizations/_settings_tabs.html.erb +31 -0
  47. data/app/views/decidim_toggle/system/organizations/tabs/_authorizations_tab.html.erb +9 -0
  48. data/app/views/decidim_toggle/system/organizations/tabs/_emails_tab.html.erb +5 -0
  49. data/app/views/decidim_toggle/system/organizations/tabs/_file_upload_tab.html.erb +5 -0
  50. data/app/views/decidim_toggle/system/organizations/tabs/_language_tab.html.erb +35 -0
  51. data/app/views/decidim_toggle/system/organizations/tabs/_omniauth_tab.html.erb +5 -0
  52. data/app/views/decidim_toggle/system/organizations/tabs/_security_tab.html.erb +20 -0
  53. data/app/views/layouts/decidim/toggle/_javascript_config.html.erb +3 -0
  54. data/bin/check +10 -0
  55. data/bin/i18n-tasks +27 -0
  56. data/bin/postversion +14 -0
  57. data/config/assets.rb +8 -0
  58. data/config/locales/decidim_toggle_en.yml +58 -0
  59. data/crowdin.yml +15 -0
  60. data/db/migrate/20260321120000_create_decidim_toggle_organization_module_configs.rb +20 -0
  61. data/decidim-toggle.gemspec +35 -0
  62. data/docker-compose.yml +41 -0
  63. data/lib/decidim/toggle/engine.rb +62 -0
  64. data/lib/decidim/toggle/expose_attributes_to_js.rb +26 -0
  65. data/lib/decidim/toggle/expose_attributes_to_js_validator.rb +32 -0
  66. data/lib/decidim/toggle/gem_registry.rb +15 -0
  67. data/lib/decidim/toggle/informative_callouts.rb +76 -0
  68. data/lib/decidim/toggle/javascript_config.rb +87 -0
  69. data/lib/decidim/toggle/module_config.rb +64 -0
  70. data/lib/decidim/toggle/module_config_form.rb +41 -0
  71. data/lib/decidim/toggle/module_config_i18n.rb +44 -0
  72. data/lib/decidim/toggle/module_configuration_presenter.rb +55 -0
  73. data/lib/decidim/toggle/organization_settings_tabs.rb +69 -0
  74. data/lib/decidim/toggle/settings_form_builder.rb +200 -0
  75. data/lib/decidim/toggle/settings_tab_item.rb +37 -0
  76. data/lib/decidim/toggle/settings_tab_registry.rb +109 -0
  77. data/lib/decidim/toggle/settings_tabs.rb +56 -0
  78. data/lib/decidim/toggle/tab_form.rb +20 -0
  79. data/lib/decidim/toggle/version.rb +14 -0
  80. data/lib/decidim/toggle.rb +36 -0
  81. data/lib/tasks/decidim/toggle/toggle_upgrade.rake +13 -0
  82. data/lib/tasks/decidim/toggle/toggle_webpacker.rake +60 -0
  83. data/log/.gitignore +2 -0
  84. data/package.json +18 -0
  85. data/prettier.config.js +15 -0
  86. data/spec/commands/decidim/toggle/update_authorizations_command_spec.rb +41 -0
  87. data/spec/commands/decidim/toggle/update_emails_command_spec.rb +84 -0
  88. data/spec/commands/decidim/toggle/update_file_upload_settings_command_spec.rb +28 -0
  89. data/spec/commands/decidim/toggle/update_locale_command_spec.rb +53 -0
  90. data/spec/commands/decidim/toggle/update_module_config_command_spec.rb +38 -0
  91. data/spec/commands/decidim/toggle/update_name_command_spec.rb +49 -0
  92. data/spec/commands/decidim/toggle/update_omniauth_command_spec.rb +80 -0
  93. data/spec/commands/decidim/toggle/update_security_command_spec.rb +25 -0
  94. data/spec/decidim/toggle/settings_tab_item_spec.rb +34 -0
  95. data/spec/decidim/toggle/settings_tab_registry_spec.rb +66 -0
  96. data/spec/decidim/toggle/settings_tabs_spec.rb +60 -0
  97. data/spec/forms/concerns/decidim/toggle/informative_callouts_spec.rb +48 -0
  98. data/spec/forms/decidim/toggle/update_authorizations_form_spec.rb +40 -0
  99. data/spec/forms/decidim/toggle/update_emails_form_spec.rb +35 -0
  100. data/spec/forms/decidim/toggle/update_file_upload_settings_form_spec.rb +20 -0
  101. data/spec/forms/decidim/toggle/update_locale_form_spec.rb +64 -0
  102. data/spec/forms/decidim/toggle/update_name_form_spec.rb +57 -0
  103. data/spec/forms/decidim/toggle/update_omniauth_form_spec.rb +56 -0
  104. data/spec/forms/decidim/toggle/update_security_form_spec.rb +32 -0
  105. data/spec/helpers/decidim/toggle/system_settings_tab_helper_spec.rb +99 -0
  106. data/spec/lib/decidim/toggle/expose_attributes_to_js_spec.rb +31 -0
  107. data/spec/lib/decidim/toggle/expose_attributes_to_js_validator_spec.rb +30 -0
  108. data/spec/lib/decidim/toggle/gem_registry_spec.rb +30 -0
  109. data/spec/lib/decidim/toggle/javascript_config_spec.rb +169 -0
  110. data/spec/lib/decidim/toggle/module_config_form_spec.rb +45 -0
  111. data/spec/lib/decidim/toggle/module_config_spec.rb +74 -0
  112. data/spec/lib/decidim/toggle/module_configuration_presenter_spec.rb +53 -0
  113. data/spec/lib/decidim/toggle/settings_form_builder_spec.rb +115 -0
  114. data/spec/requests/decidim_toggle/system/settings_tab_spec.rb +144 -0
  115. data/spec/spec_helper.rb +12 -0
  116. data/spec/support/decidim_factories.rb +3 -0
  117. data/spec/support/devise.rb +5 -0
  118. data/spec/system/decidim_toggle/javascript_config_spec.rb +56 -0
  119. data/website/.gitignore +4 -0
  120. data/website/docs/developer/_category_.json +8 -0
  121. data/website/docs/developer/code-of-conduct.md +99 -0
  122. data/website/docs/developer/contribute.md +50 -0
  123. data/website/docs/developer/deface-usage.md +37 -0
  124. data/website/docs/developer/documentation.md +58 -0
  125. data/website/docs/developer/error-handling.md +51 -0
  126. data/website/docs/developer/tab-registry.md +51 -0
  127. data/website/docs/developer/view-customization.md +49 -0
  128. data/website/docs/index.md +80 -0
  129. data/website/docs/integrate/_category_.json +8 -0
  130. data/website/docs/integrate/attributes.md +121 -0
  131. data/website/docs/integrate/customize-views.md +62 -0
  132. data/website/docs/integrate/index.md +38 -0
  133. data/website/docs/integrate/informative_callout.md +80 -0
  134. data/website/docs/integrate/javascript.md +84 -0
  135. data/website/docs/integrate/labels.md +91 -0
  136. data/website/docs/integrate/quickstart.md +77 -0
  137. data/website/docusaurus.config.ts +100 -0
  138. data/website/package.json +31 -0
  139. data/website/sidebars.ts +7 -0
  140. data/website/src/css/custom.css +37 -0
  141. data/website/static/img/logo.svg +1 -0
  142. data/website/static/img/schema_overview.png +0 -0
  143. data/website/static/img/screenshot_informative_callouts.png +0 -0
  144. data/website/static/img/screenshot_saved_flash.png +0 -0
  145. data/website/static/img/screenshots_locale_tab.png +0 -0
  146. data/website/static/img/screenshots_name_tab.png +0 -0
  147. data/website/static/img/screenshots_security_tab.png +0 -0
  148. data/website/tsconfig.json +6 -0
  149. data/website/yarn.lock +8336 -0
  150. data/yarn.lock +13 -0
  151. metadata +249 -0
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "decidim/toggle/module_configuration_presenter"
4
+
5
+ module Decidim
6
+ module Toggle
7
+ class << self
8
+ def normalize_module_name(name)
9
+ name.to_s
10
+ end
11
+
12
+ def config_for(organization, module_name, registry_name: :organization_settings)
13
+ key = normalize_module_name(module_name)
14
+ raw = raw_config_hash(organization, key)
15
+ entry = SettingsTabRegistry.find(registry_name)&.form_tab_for_module(key)
16
+
17
+ if entry && entry[:form]
18
+ form = build_form_from_hash(entry[:form], organization, raw)
19
+ ModuleConfigurationPresenter.new(form).to_config_hash.with_indifferent_access
20
+ else
21
+ raw.with_indifferent_access
22
+ end
23
+ end
24
+
25
+ def save_config!(organization, module_name, attributes, merge: true)
26
+ key = normalize_module_name(module_name)
27
+ record = OrganizationModuleConfig.find_or_initialize_by(
28
+ decidim_organization_id: organization.id,
29
+ module_name: key
30
+ )
31
+ incoming = stringify_keys(attributes)
32
+ record.config = merge ? (record.config || {}).stringify_keys.merge(incoming) : incoming
33
+ record.save!
34
+ record
35
+ end
36
+
37
+ private
38
+
39
+ def build_form_from_hash(form_class, organization, raw_hash)
40
+ form_class.from_params(organization: raw_hash).with_context(current_organization: organization)
41
+ end
42
+
43
+ def raw_config_hash(organization, module_name)
44
+ OrganizationModuleConfig.find_by(
45
+ decidim_organization_id: organization.id,
46
+ module_name:
47
+ )&.config || {}
48
+ end
49
+
50
+ def stringify_keys(hash)
51
+ hash.to_h.stringify_keys.transform_values do |v|
52
+ case v
53
+ when Hash
54
+ stringify_keys(v)
55
+ when Array
56
+ v.map { |e| e.is_a?(Hash) ? stringify_keys(e) : e }
57
+ else
58
+ v
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Toggle
5
+ # Include in a {Decidim::Form} used with +add_tab ..., module_name:+ so +from_model(organization)+
6
+ # loads JSON from {OrganizationModuleConfig} and +UpdateModuleConfigCommand+ can persist it.
7
+ # Field labels resolve from +decidim_toggle.system.<module_config_name>+ (see integrate/labels.md).
8
+ #
9
+ # class MyModule::AdminConfigForm < Decidim::Form
10
+ # include Decidim::Toggle::ModuleConfigForm
11
+ #
12
+ # self.module_config_name = "decidim_geo"
13
+ # mimic :organization
14
+ # attribute :enabled, :boolean
15
+ # end
16
+ module ModuleConfigForm
17
+ extend ActiveSupport::Concern
18
+
19
+ class_methods do
20
+ def human_attribute_name(attr, options = {})
21
+ ModuleConfigI18n.translate_label(module_config_name, attr) || super
22
+ end
23
+
24
+ def from_model(organization)
25
+ raise NotImplementedError, "#{name} must set self.module_config_name = \"...\"" if module_config_name.blank?
26
+
27
+ raw = OrganizationModuleConfig.find_by(
28
+ decidim_organization_id: organization.id,
29
+ module_name: module_config_name
30
+ )&.config || {}
31
+
32
+ from_params(organization: raw).with_context(current_organization: organization)
33
+ end
34
+ end
35
+
36
+ included do
37
+ class_attribute :module_config_name, instance_accessor: false
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Toggle
5
+ # I18n scope helpers for {ModuleConfigForm} tab fields.
6
+ module ModuleConfigI18n
7
+ module_function
8
+
9
+ def scope_for(module_name)
10
+ return if module_name.blank?
11
+
12
+ "decidim_toggle.system.#{module_name}"
13
+ end
14
+
15
+ def label_key(module_name, attribute)
16
+ base = scope_for(module_name)
17
+ return unless base
18
+
19
+ "#{base}.#{attribute}"
20
+ end
21
+
22
+ def helptext_key(module_name, attribute)
23
+ base = scope_for(module_name)
24
+ return unless base
25
+
26
+ "#{base}.helptext.#{attribute}"
27
+ end
28
+
29
+ def translate_label(module_name, attribute)
30
+ key = label_key(module_name, attribute)
31
+ return unless key && I18n.exists?(key)
32
+
33
+ I18n.t(key)
34
+ end
35
+
36
+ def translate_helptext(module_name, attribute)
37
+ key = helptext_key(module_name, attribute)
38
+ return unless key && I18n.exists?(key)
39
+
40
+ I18n.t(key)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Toggle
5
+ # Read model around a {Decidim::Form} built from stored JSON: normalizes +nil+ for
6
+ # array/hash/boolean attributes so runtime code can use +enabled?+, empty collections, etc.
7
+ class ModuleConfigurationPresenter
8
+ def initialize(form)
9
+ @form = form
10
+ define_accessors!
11
+ end
12
+
13
+ attr_reader :form
14
+
15
+ def to_config_hash
16
+ @form.class.attribute_names.each_with_object({}) do |attr, hash|
17
+ next if attr == "id"
18
+
19
+ type = @form.class.attribute_types[attr]
20
+ hash[attr] = normalize(@form.public_send(attr), attr, type)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def define_accessors!
27
+ @form.class.attribute_names.each do |attr|
28
+ next if attr == "id"
29
+
30
+ type = @form.class.attribute_types[attr]
31
+
32
+ define_singleton_method(attr) do
33
+ normalize(@form.public_send(attr), attr, type)
34
+ end
35
+
36
+ next unless type.is_a?(ActiveModel::Type::Boolean)
37
+
38
+ define_singleton_method("#{attr}?") do
39
+ !!normalize(@form.public_send(attr), attr, type)
40
+ end
41
+ end
42
+ end
43
+
44
+ def normalize(value, _attr_name, type)
45
+ return value unless value.nil?
46
+
47
+ return [] if type.try(:type) == :array
48
+ return {} if type.try(:type) == :hash
49
+ return false if type.is_a?(ActiveModel::Type::Boolean)
50
+
51
+ value
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Toggle
5
+ # Registers default system organization settings tabs.
6
+ #
7
+ # Extension contract: the authorizations tab uses the stable identifier
8
+ # +:authorizations+ (vanilla Decidim: string array of verification workflow names).
9
+ # Another engine may register an additional +Decidim::Toggle.settings_tabs+
10
+ # block **after** +decidim_toggle.organization_settings_tabs+ and call
11
+ # +remove_tab(:authorizations)+ then +add_tab(:authorizations, ...)+ with the
12
+ # same identifier to replace the tab. The last +register_form_tab+ for that id
13
+ # wins; see {Decidim::Toggle::SettingsTabRegistry#register_form_tab}.
14
+ class OrganizationSettingsTabs
15
+ def self.register!
16
+ scope = "decidim_toggle.system.organizations.settings_tabs"
17
+ Decidim::Toggle.settings_tabs :organization_settings do |tabs|
18
+ tabs.add_tab :name,
19
+ t("name", scope:),
20
+ form: Decidim::Toggle::UpdateNameForm,
21
+ command: Decidim::Toggle::UpdateNameCommand,
22
+ position: 1, open: true
23
+
24
+ tabs.add_tab :omniauth,
25
+ t("omniauth", scope:),
26
+ form: Decidim::Toggle::UpdateOmniauthForm,
27
+ command: Decidim::Toggle::UpdateOmniauthCommand,
28
+ position: 2,
29
+ form_layout_partial: "decidim_toggle/system/organizations/tabs/omniauth_tab"
30
+
31
+ tabs.add_tab :emails,
32
+ t("emails", scope:),
33
+ form: Decidim::Toggle::UpdateEmailsForm,
34
+ command: Decidim::Toggle::UpdateEmailsCommand,
35
+ position: 3,
36
+ form_layout_partial: "decidim_toggle/system/organizations/tabs/emails_tab"
37
+
38
+ tabs.add_tab :language,
39
+ t("language", scope:),
40
+ form: Decidim::Toggle::UpdateLocaleForm,
41
+ command: Decidim::Toggle::UpdateLocaleCommand,
42
+ position: 4,
43
+ form_layout_partial: "decidim_toggle/system/organizations/tabs/language_tab"
44
+
45
+ tabs.add_tab :authorizations,
46
+ t("authorizations", scope:),
47
+ form: Decidim::Toggle::UpdateAuthorizationsForm,
48
+ command: Decidim::Toggle::UpdateAuthorizationsCommand,
49
+ position: 5,
50
+ form_layout_partial: "decidim_toggle/system/organizations/tabs/authorizations_tab"
51
+
52
+ tabs.add_tab :security,
53
+ t("security", scope:),
54
+ form: Decidim::Toggle::UpdateSecurityForm,
55
+ command: Decidim::Toggle::UpdateSecurityCommand,
56
+ form_layout_partial: "decidim_toggle/system/organizations/tabs/security_tab",
57
+ position: 6
58
+
59
+ tabs.add_tab :other,
60
+ t("file_upload", scope:),
61
+ form: Decidim::Toggle::UpdateFileUploadSettingsForm,
62
+ command: Decidim::Toggle::UpdateFileUploadSettingsCommand,
63
+ position: 7,
64
+ form_layout_partial: "decidim_toggle/system/organizations/tabs/file_upload_tab"
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Toggle
5
+ # Form builder for add_tab form tabs. Renders all form attributes via #all_fields.
6
+ # Form classes can define class method collection_for_<attribute> to return [[value, label], ...] for collection inputs.
7
+ # Loaded from the engine after +require "decidim/core"+ so +Decidim::FormBuilder+ autoload works.
8
+ class SettingsFormBuilder < Decidim::FormBuilder
9
+ CALLOUT_CLASS_BY_TYPE = {
10
+ info: "info",
11
+ warning: "warning",
12
+ danger: "alert"
13
+ }.freeze
14
+
15
+ def informative_callouts
16
+ return "".html_safe unless object.class.include?(InformativeCallouts)
17
+
18
+ entries = object.visible_informative_callouts
19
+ return "".html_safe if entries.blank?
20
+
21
+ callouts = entries.map do |entry|
22
+ callout = @template.cell(
23
+ "decidim/announcement",
24
+ entry.message_for(object),
25
+ callout_class: CALLOUT_CLASS_BY_TYPE.fetch(entry.type)
26
+ )
27
+ @template.content_tag(:div, callout, class: InformativeCallouts::WRAPPER_CLASS)
28
+ end
29
+ safe_join(callouts)
30
+ end
31
+
32
+ def all_fields
33
+ fields = attribute_names.map do |name|
34
+ @template.content_tag(:div, input_field(name), class: field_wrapper_classes(name))
35
+ end
36
+ safe_join(fields)
37
+ end
38
+
39
+ # Renders a subset of attributes (e.g. machine translation fields after a custom locale table).
40
+ def fields_for_names(*names)
41
+ names = names.flatten.map(&:to_s)
42
+ fields = names.filter_map do |name|
43
+ next unless object.class.respond_to?(:attribute_types) && object.class.attribute_types.has_key?(name)
44
+
45
+ @template.content_tag(:div, input_field(name.to_sym), class: field_wrapper_classes(name))
46
+ end
47
+ safe_join(fields)
48
+ end
49
+
50
+ private
51
+
52
+ def input_field(name)
53
+ name = name.to_sym
54
+ input_html =
55
+ if translatable_hash_attribute?(name)
56
+ translated_input_field(name)
57
+ else
58
+ build_input_field(name)
59
+ end
60
+
61
+ helptext = helptext_for_attribute(name)
62
+ return input_html if helptext.blank?
63
+
64
+ # Render the help text under the field label (the upstream `Decidim::FormBuilder`
65
+ # typically renders the label inside the field HTML returned above).
66
+ @template.safe_join(
67
+ [
68
+ input_html,
69
+ @template.content_tag(:p, helptext, class: "field-helptext")
70
+ ]
71
+ )
72
+ end
73
+
74
+ def build_input_field(name)
75
+ type = attribute_type(name)
76
+
77
+ if (collection = collection_for(name))
78
+ if type == :array
79
+ collection_check_boxes(name, collection, :first, :last) do |b|
80
+ @template.content_tag(:div, b.check_box(checked: Array(object.public_send(name)).include?(b.value)) + b.label { b.text })
81
+ end
82
+ else
83
+ collection_radio_buttons(name, collection, :first, :last) do |b|
84
+ @template.content_tag(:div, b.radio_button + b.label { b.text })
85
+ end
86
+ end
87
+ else
88
+ options = field_html_options_for(name)
89
+ case type
90
+ when :string
91
+ name.to_s == "secondary_hosts" ? text_area(name, options) : text_field(name, options)
92
+ when :integer then number_field(name, options)
93
+ when :boolean then check_box(name, options)
94
+ else text_field(name, options)
95
+ end
96
+ end
97
+ end
98
+
99
+ def field_html_options_for(name)
100
+ attribute_disabled?(name) ? { disabled: true } : {}
101
+ end
102
+
103
+ def field_wrapper_classes(name)
104
+ attribute_disabled?(name) ? "field is-disabled" : "field"
105
+ end
106
+
107
+ def attribute_disabled?(name)
108
+ name = name.to_sym
109
+ method = :"disabled_for_#{name}?"
110
+ return object.public_send(method) if object.respond_to?(method)
111
+
112
+ return object.attribute_disabled?(name) if object.respond_to?(:attribute_disabled?)
113
+
114
+ false
115
+ end
116
+
117
+ def collection_for(attribute)
118
+ method = :"collection_for_#{attribute}"
119
+ object.class.respond_to?(method) ? object.class.public_send(method) : nil
120
+ end
121
+
122
+ def attribute_names
123
+ return [] unless object.class.respond_to?(:attribute_types)
124
+
125
+ object.class.attribute_types.keys.reject do |k|
126
+ k.to_s == "id" || translatable_locale_field?(k.to_s)
127
+ end
128
+ end
129
+
130
+ def attribute_type(name)
131
+ return :string unless object.class.respond_to?(:attribute_types)
132
+
133
+ type_obj = object.class.attribute_types[name.to_s]
134
+ type_obj.respond_to?(:type) ? type_obj.type : :string
135
+ end
136
+
137
+ # translatable_attribute adds a hash (:name) plus one String per locale (:name_en, …).
138
+ # Decidim forms use #translated (tabs or locale select); listing locale keys duplicates the UI.
139
+ def translatable_hash_attribute?(base)
140
+ return false unless object.class.include?(Decidim::TranslatableAttributes)
141
+
142
+ type = object.class.attribute_types[base.to_s]
143
+ type.respond_to?(:type) && type.type == :hash
144
+ end
145
+
146
+ def translatable_locale_field?(key)
147
+ return false unless object.class.include?(Decidim::TranslatableAttributes)
148
+
149
+ Decidim.available_locales.any? do |locale|
150
+ suffix = locale.to_s.tr("-", "__")
151
+ next unless key.end_with?("_#{suffix}")
152
+
153
+ base = key.delete_suffix("_#{suffix}")
154
+ next if base.blank?
155
+
156
+ translatable_hash_attribute?(base)
157
+ end
158
+ end
159
+
160
+ def translated_input_field(name)
161
+ locale = Decidim.available_locales.first
162
+ locale_key = "#{name}_#{locale.to_s.tr("-", "__")}"
163
+ t = attribute_type(locale_key)
164
+ case t
165
+ when :"decidim/attributes/rich_text"
166
+ translated(:editor, name)
167
+ else
168
+ translated(:text_field, name)
169
+ end
170
+ end
171
+
172
+ def helptext_for_attribute(name)
173
+ attribute_name = name.to_s
174
+
175
+ if object.class.respond_to?(:module_config_name)
176
+ helptext = ModuleConfigI18n.translate_helptext(object.class.module_config_name, attribute_name)
177
+ return helptext if helptext.present?
178
+ end
179
+
180
+ # When a form `mimic`s another ActiveModel (e.g. organization), `model_name`
181
+ # is expected to match i18n keys used across Decidim.
182
+ candidate_model_keys = []
183
+ candidate_model_keys << object.class.model_name.i18n_key if object.class.respond_to?(:model_name) && object.class.model_name.respond_to?(:i18n_key)
184
+ candidate_model_keys << object_name if respond_to?(:object_name)
185
+
186
+ candidate_model_keys.compact!
187
+ candidate_model_keys.uniq!
188
+
189
+ candidate_model_keys.each do |model_key|
190
+ # Preferred location avoids making `activemodel.attributes.<model>.<attr>` a Hash (which can break
191
+ # attribute name i18n lookups for the same key).
192
+ helptext = I18n.t("activemodel.attributes.#{model_key}.helptext.#{attribute_name}", default: nil)
193
+ return helptext if helptext.present?
194
+ end
195
+
196
+ nil
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Toggle
5
+ class SettingsTabItem
6
+ def initialize(identifier, label, options = {})
7
+ @identifier = identifier
8
+ @label = label
9
+ @form_layout_partial = options[:form_layout_partial]
10
+ @form_class = options[:form_class]
11
+ @command_class = options[:command_class]
12
+ @position = options[:position] || Float::INFINITY
13
+ @if = options[:if]
14
+ @open = options.fetch(:open, false)
15
+ @module_name = options[:module_name]
16
+ end
17
+
18
+ attr_reader :identifier, :label, :form_layout_partial, :form_class, :command_class, :module_name
19
+
20
+ attr_accessor :position
21
+
22
+ def form_tab?
23
+ form_class.present? && command_class.present?
24
+ end
25
+
26
+ def open?
27
+ @open
28
+ end
29
+
30
+ def visible?
31
+ return true if @if.nil? || @if
32
+
33
+ false
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Toggle
5
+ class DuplicateTabRegistrationError < StandardError; end
6
+
7
+ class SettingsTabRegistry
8
+ class << self
9
+ def register(name, &block)
10
+ registry = find(name) || create(name)
11
+ registry.configurations << block
12
+ registry
13
+ end
14
+
15
+ def find(name)
16
+ all[name.to_sym]
17
+ end
18
+
19
+ def create(name)
20
+ all[name.to_sym] = new(name)
21
+ end
22
+
23
+ def destroy(name)
24
+ all[name.to_sym] = nil
25
+ end
26
+
27
+ private
28
+
29
+ def all
30
+ @all ||= {}
31
+ end
32
+ end
33
+
34
+ attr_reader :configurations, :form_tabs, :module_configs, :registry_name
35
+
36
+ def initialize(name)
37
+ @registry_name = name.to_sym
38
+ @configurations = []
39
+ @form_tabs = {}
40
+ @module_configs = {}
41
+ @tab_to_module_name = {}
42
+ @configurations_applied = false
43
+ end
44
+
45
+ def mark_configurations_applied!
46
+ @configurations_applied = true
47
+ end
48
+
49
+ # Runs registered +settings_tabs+ blocks so +register_form_tab+ / +module_configs+ are populated
50
+ # (e.g. for {Decidim::Toggle.config_for} before any HTTP request builds tabs).
51
+ def ensure_configurations_applied!
52
+ return if @configurations_applied
53
+
54
+ ctx = Object.new
55
+ ctx.define_singleton_method(:t) { |key, **kw| I18n.t(key, **kw) }
56
+ SettingsTabs.new(@registry_name).build_for(ctx)
57
+ end
58
+
59
+ # Last registration for a given +identifier+ wins (used when an extension replaces a tab).
60
+ # When +module_name+ is set, last registration for that module name wins (same as tab id).
61
+ def register_form_tab(identifier, form_class, command_class, module_name: nil)
62
+ tid = identifier.to_sym
63
+ if (previous = @tab_to_module_name[tid])
64
+ @module_configs.delete(previous)
65
+ end
66
+
67
+ if duplicate_tab_registration?(tid, form_class, command_class)
68
+ raise DuplicateTabRegistrationError,
69
+ "Tab :#{tid} is already registered with form #{@form_tabs[tid][:form]} " \
70
+ "and command #{@form_tabs[tid][:command]}; attempted #{form_class} / #{command_class}"
71
+ end
72
+
73
+ @form_tabs[tid] = { form: form_class, command: command_class }
74
+ if module_name.blank?
75
+ @tab_to_module_name.delete(tid)
76
+ return
77
+ end
78
+
79
+ key = module_name.to_s
80
+ @module_configs[key] = {
81
+ form: form_class,
82
+ command: command_class,
83
+ tab_identifier: tid
84
+ }
85
+ @tab_to_module_name[tid] = key
86
+ end
87
+
88
+ def form_tab(identifier)
89
+ form_tabs[identifier.to_sym]
90
+ end
91
+
92
+ # @return [Hash, nil] +:form+, +:command+, +:tab_identifier+ when an +add_tab+ registered this +module_name+
93
+ def form_tab_for_module(module_name)
94
+ ensure_configurations_applied!
95
+ module_configs[module_name.to_s]
96
+ end
97
+
98
+ private
99
+
100
+ def duplicate_tab_registration?(tid, form_class, command_class)
101
+ return false unless Rails.env.development? || Rails.env.test?
102
+ return false unless @form_tabs[tid]
103
+
104
+ existing = @form_tabs[tid]
105
+ existing[:form] != form_class || existing[:command] != command_class
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Toggle
5
+ #
6
+ # Builds a list of settings tabs from registered configurations (same pattern as Decidim::Menu).
7
+ #
8
+ class SettingsTabs
9
+ def initialize(name)
10
+ @name = name
11
+ @items = []
12
+ @removed_items = []
13
+ end
14
+
15
+ def build_for(context, **options)
16
+ raise "Settings tabs #{@name} is not registered" if registry.blank?
17
+
18
+ registry.configurations.each do |configuration|
19
+ context.instance_exec(self, **options, &configuration)
20
+ end
21
+ registry.mark_configurations_applied!
22
+ end
23
+
24
+ # Tab with form + command: common layout renders form.all_fields and submits to generic controller.
25
+ # @param identifier [Symbol] Tab id
26
+ # @param label [String] Tab button label
27
+ # @param form [Class] Decidim::Form subclass (must respond to .from_model(organization))
28
+ # @param command [Class] Decidim::Command that receives (organization, form)
29
+ # @param options [Hash] :position, :if, :open,
30
+ # :form_layout_partial (optional full tab layout partial)
31
+ def add_tab(identifier, label, form:, **options)
32
+ command = options.fetch(:command)
33
+ options = { position: (1 + @items.length) }.merge(options)
34
+
35
+ module_name = options[:module_name]
36
+ registry.register_form_tab(identifier, form, command, module_name:)
37
+ @items << SettingsTabItem.new(identifier, label, options.merge(form_class: form, command_class: command))
38
+ end
39
+
40
+ def remove_tab(identifier)
41
+ @removed_items << identifier
42
+ end
43
+
44
+ def items
45
+ @items.reject! { |item| @removed_items.include?(item.identifier) }
46
+ @items.select(&:visible?).sort_by(&:position)
47
+ end
48
+
49
+ private
50
+
51
+ def registry
52
+ @registry ||= SettingsTabRegistry.find(@name)
53
+ end
54
+ end
55
+ end
56
+ end