|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +# TODO: Refactor this concern to be more readable and maintainable |
| 4 | +# rubocop:disable Metrics/ModuleLength |
| 5 | +module Avo |
| 6 | + module Concerns |
| 7 | + # This concern facilitates field discovery for models in Avo, |
| 8 | + # mapping database columns and associations to Avo fields. |
| 9 | + # It supports: |
| 10 | + # - Automatic detection of fields based on column names, types, and associations. |
| 11 | + # - Customization via `only`, `except`, and global configuration overrides. |
| 12 | + # - Handling of special associations like rich text, attachments, and tags. |
| 13 | + module HasFieldDiscovery |
| 14 | + extend ActiveSupport::Concern |
| 15 | + |
| 16 | + COLUMN_NAMES_TO_IGNORE = %i[ |
| 17 | + encrypted_password reset_password_token reset_password_sent_at remember_created_at password_digest |
| 18 | + ].freeze |
| 19 | + |
| 20 | + class_methods do |
| 21 | + def column_names_mapping |
| 22 | + @column_names_mapping ||= Avo::Mappings::NAMES_MAPPING.dup |
| 23 | + .merge(Avo.configuration.column_names_mapping || {}) |
| 24 | + end |
| 25 | + |
| 26 | + def column_types_mapping |
| 27 | + @column_types_mapping ||= Avo::Mappings::FIELDS_MAPPING.dup |
| 28 | + .merge(Avo.configuration.column_types_mapping || {}) |
| 29 | + end |
| 30 | + end |
| 31 | + |
| 32 | + # Returns database columns for the model, excluding ignored columns |
| 33 | + def model_db_columns |
| 34 | + @model_db_columns ||= safe_model_class.columns_hash.symbolize_keys.except(*COLUMN_NAMES_TO_IGNORE) |
| 35 | + end |
| 36 | + |
| 37 | + # Discovers and configures database columns as fields |
| 38 | + def discover_columns(only: nil, except: nil, **field_options) |
| 39 | + setup_discovery_options(only, except, field_options) |
| 40 | + return unless safe_model_class.respond_to?(:columns_hash) |
| 41 | + |
| 42 | + discoverable_columns.each do |column_name, column| |
| 43 | + process_column(column_name, column) |
| 44 | + end |
| 45 | + |
| 46 | + discover_tags |
| 47 | + discover_rich_texts |
| 48 | + end |
| 49 | + |
| 50 | + # Discovers and configures associations as fields |
| 51 | + def discover_associations(only: nil, except: nil, **field_options) |
| 52 | + setup_discovery_options(only, except, field_options) |
| 53 | + return unless safe_model_class.respond_to?(:reflections) |
| 54 | + |
| 55 | + discover_attachments |
| 56 | + discover_basic_associations |
| 57 | + end |
| 58 | + |
| 59 | + private |
| 60 | + |
| 61 | + def setup_discovery_options(only, except, field_options) |
| 62 | + @only = only |
| 63 | + @except = except |
| 64 | + @field_options = field_options |
| 65 | + end |
| 66 | + |
| 67 | + def discoverable_columns |
| 68 | + model_db_columns.reject do |column_name, _| |
| 69 | + skip_column?(column_name) |
| 70 | + end |
| 71 | + end |
| 72 | + |
| 73 | + def skip_column?(column_name) |
| 74 | + !column_in_scope?(column_name) || |
| 75 | + reflections.key?(column_name) || |
| 76 | + rich_text_column?(column_name) |
| 77 | + end |
| 78 | + |
| 79 | + def rich_text_column?(column_name) |
| 80 | + rich_texts.key?(:"rich_text_#{column_name}") |
| 81 | + end |
| 82 | + |
| 83 | + def process_column(column_name, column) |
| 84 | + field_config = determine_field_config(column_name, column) |
| 85 | + return unless field_config |
| 86 | + |
| 87 | + create_field(column_name, field_config) |
| 88 | + end |
| 89 | + |
| 90 | + def create_field(column_name, field_config) |
| 91 | + field_options = {as: field_config.dup.delete(:field).to_sym}.merge(field_config) |
| 92 | + field(column_name, **field_options.symbolize_keys, **@field_options.symbolize_keys) |
| 93 | + end |
| 94 | + |
| 95 | + def create_attachment_field(association_name, reflection) |
| 96 | + field_name = association_name&.to_s&.delete_suffix("_attachment")&.to_sym || association_name |
| 97 | + field_type = determine_attachment_field_type(reflection) |
| 98 | + field(field_name, as: field_type, **@field_options) |
| 99 | + end |
| 100 | + |
| 101 | + def determine_attachment_field_type(reflection) |
| 102 | + ( |
| 103 | + reflection.is_a?(ActiveRecord::Reflection::HasOneReflection) || |
| 104 | + reflection.is_a?(ActiveStorage::Reflection::HasOneAttachedReflection) |
| 105 | + ) ? :file : :files |
| 106 | + end |
| 107 | + |
| 108 | + def create_association_field(association_name, reflection) |
| 109 | + options = base_association_options(reflection) |
| 110 | + options.merge!(polymorphic_options(reflection)) if reflection.options[:polymorphic] |
| 111 | + |
| 112 | + field(association_name, **options, **@field_options) |
| 113 | + end |
| 114 | + |
| 115 | + def base_association_options(reflection) |
| 116 | + { |
| 117 | + as: reflection.macro, |
| 118 | + searchable: true, |
| 119 | + sortable: true |
| 120 | + } |
| 121 | + end |
| 122 | + |
| 123 | + # Fetches the model class, falling back to the items_holder parent record in certain instances |
| 124 | + # (e.g. in the context of the sidebar) |
| 125 | + def safe_model_class |
| 126 | + respond_to?(:model_class) ? model_class : @items_holder.parent.model_class |
| 127 | + rescue ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished |
| 128 | + nil |
| 129 | + end |
| 130 | + |
| 131 | + def model_enums |
| 132 | + @model_enums ||= if safe_model_class.respond_to?(:defined_enums) |
| 133 | + safe_model_class.defined_enums.transform_values do |enum| |
| 134 | + { |
| 135 | + field: :select, |
| 136 | + enum: |
| 137 | + } |
| 138 | + end |
| 139 | + else |
| 140 | + {} |
| 141 | + end.with_indifferent_access |
| 142 | + end |
| 143 | + |
| 144 | + # Determines if a column is included in the discovery scope. |
| 145 | + # A column is in scope if it's included in `only` and not in `except`. |
| 146 | + def column_in_scope?(column_name) |
| 147 | + (!@only || @only.include?(column_name)) && (!@except || !@except.include?(column_name)) |
| 148 | + end |
| 149 | + |
| 150 | + def determine_field_config(attribute, column) |
| 151 | + model_enums[attribute.to_s] || |
| 152 | + self.class.column_names_mapping[attribute] || |
| 153 | + self.class.column_types_mapping[column.type] |
| 154 | + end |
| 155 | + |
| 156 | + def discover_by_type(associations, as_type) |
| 157 | + associations.each_key do |association_name| |
| 158 | + next unless column_in_scope?(association_name) |
| 159 | + |
| 160 | + field association_name, as: as_type, **@field_options.merge(name: yield(association_name)) |
| 161 | + end |
| 162 | + end |
| 163 | + |
| 164 | + def discover_rich_texts |
| 165 | + rich_texts.each_key do |association_name| |
| 166 | + next unless column_in_scope?(association_name) |
| 167 | + |
| 168 | + field_name = association_name&.to_s&.delete_prefix("rich_text_")&.to_sym || association_name |
| 169 | + field field_name, as: :trix, **@field_options |
| 170 | + end |
| 171 | + end |
| 172 | + |
| 173 | + def discover_tags |
| 174 | + tags.each_key do |association_name| |
| 175 | + next unless column_in_scope?(association_name) |
| 176 | + |
| 177 | + field( |
| 178 | + tag_field_name(association_name), as: :tags, |
| 179 | + acts_as_taggable_on: tag_field_name(association_name), |
| 180 | + **@field_options |
| 181 | + ) |
| 182 | + end |
| 183 | + end |
| 184 | + |
| 185 | + def tag_field_name(association_name) |
| 186 | + association_name&.to_s&.delete_suffix("_taggings")&.pluralize&.to_sym || association_name |
| 187 | + end |
| 188 | + |
| 189 | + def discover_attachments |
| 190 | + attachment_associations.each do |association_name, reflection| |
| 191 | + next unless column_in_scope?(association_name) |
| 192 | + |
| 193 | + create_attachment_field(association_name, reflection) |
| 194 | + end |
| 195 | + end |
| 196 | + |
| 197 | + def discover_basic_associations |
| 198 | + associations.each do |association_name, reflection| |
| 199 | + next unless column_in_scope?(association_name) |
| 200 | + |
| 201 | + create_association_field(association_name, reflection) |
| 202 | + end |
| 203 | + end |
| 204 | + |
| 205 | + def polymorphic_options(reflection) |
| 206 | + {polymorphic_as: reflection.name, types: detect_polymorphic_types(reflection)} |
| 207 | + end |
| 208 | + |
| 209 | + def detect_polymorphic_types(reflection) |
| 210 | + ApplicationRecord.descendants.select { |klass| klass.reflections[reflection.plural_name] } |
| 211 | + end |
| 212 | + |
| 213 | + def reflections |
| 214 | + @reflections ||= safe_model_class.reflections.symbolize_keys.reject do |name, _| |
| 215 | + ignore_reflection?(name.to_s) |
| 216 | + end |
| 217 | + end |
| 218 | + |
| 219 | + def attachment_associations |
| 220 | + @attachment_associations ||= reflections.select { |_, r| r.options[:class_name] == "ActiveStorage::Attachment" } |
| 221 | + end |
| 222 | + |
| 223 | + def rich_texts |
| 224 | + @rich_texts ||= reflections.select { |_, r| r.options[:class_name] == "ActionText::RichText" } |
| 225 | + end |
| 226 | + |
| 227 | + def tags |
| 228 | + @tags ||= reflections.select { |_, r| r.options[:as] == :taggable } |
| 229 | + end |
| 230 | + |
| 231 | + def associations |
| 232 | + @associations ||= reflections.reject do |key| |
| 233 | + attachment_associations.key?(key) || tags.key?(key) || rich_texts.key?(key) |
| 234 | + end |
| 235 | + end |
| 236 | + |
| 237 | + def ignore_reflection?(name) |
| 238 | + %w[blob blobs tags].include?(name.split("_").pop) || name.to_sym == :taggings |
| 239 | + end |
| 240 | + end |
| 241 | + end |
| 242 | +end |
| 243 | +# rubocop:enable Metrics/ModuleLength |
0 commit comments