diff --git a/.gitignore b/.gitignore index 8e5fdcceb7..72c37ca3c3 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,4 @@ brakeman_results.html .env .env.test +*~ diff --git a/Gemfile b/Gemfile index 8255fe4513..b4d9353613 100644 --- a/Gemfile +++ b/Gemfile @@ -193,3 +193,7 @@ gem "avo-record_link_field" gem "pagy", "> 8" gem "csv" + +gem "avo-meta", github: "avo-hq/avo-meta", branch: "main" + +gem "store_model" diff --git a/Gemfile.lock b/Gemfile.lock index e166bf0e34..e81de9b4d4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -5,6 +5,15 @@ GIT acts-as-taggable-on (10.0.0) activerecord (>= 6.1, < 8) +GIT + remote: https://github.com/avo-hq/avo-meta.git + revision: eebe10c85a961a938493282a4330343abd70441c + branch: main + specs: + avo-meta (0.1.0) + avo (~> 3) + store_model (~> 4.1) + GIT remote: https://github.com/rails/rails.git revision: b88d9af34fbc1c84ce2769ba02584eab2c28ac6e @@ -180,7 +189,7 @@ GEM backport (1.2.0) base64 (0.2.0) bcrypt (3.1.20) - benchmark (0.3.0) + benchmark (0.4.0) benchmark-ips (2.14.0) better_html (2.1.1) actionview (>= 6.0) @@ -369,7 +378,7 @@ GEM activesupport (>= 3.0) nokogiri (>= 1.6) io-console (0.7.2) - irb (1.14.0) + irb (1.14.1) rdoc (>= 4.0.0) reline (>= 0.4.2) iso (0.4.0) @@ -392,7 +401,7 @@ GEM rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) logger (1.6.1) - loofah (2.22.0) + loofah (2.23.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -410,8 +419,8 @@ GEM mini_histogram (0.3.1) mini_magick (4.13.2) mini_mime (1.1.5) - mini_portile2 (2.8.7) - minitest (5.25.1) + mini_portile2 (2.8.8) + minitest (5.25.2) monetize (1.13.0) money (~> 6.12) money (6.19.0) @@ -438,7 +447,7 @@ GEM nokogiri (1.16.7-x86_64-linux) racc (~> 1.4) orm_adapter (0.5.0) - pagy (9.0.9) + pagy (9.3.1) parallel (1.26.3) parser (3.3.5.0) ast (~> 2.4.1) @@ -451,20 +460,19 @@ GEM prettier_print (1.2.1) prop_initializer (0.2.0) zeitwerk (>= 2.6.18) - psych (5.1.2) + psych (5.2.0) stringio public_suffix (6.0.1) puma (6.4.3) nio4r (~> 2.0) racc (1.8.1) - rack (3.1.7) + rack (3.1.8) rack-session (2.0.0) rack (>= 3.0.0) rack-test (2.1.0) rack (>= 1.3) - rackup (2.1.0) + rackup (2.2.1) rack (>= 3) - webrick (~> 1.8) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -489,7 +497,7 @@ GEM rb-inotify (0.11.1) ffi (~> 1.0) rbs (2.8.4) - rdoc (6.7.0) + rdoc (6.8.1) psych (>= 4.0.0) redis (5.3.0) redis-client (>= 0.22.0) @@ -501,7 +509,7 @@ GEM rainbow (>= 2.0, < 4.0) rexml (~> 3.1) regexp_parser (2.9.2) - reline (0.5.10) + reline (0.5.11) io-console (~> 0.5) responders (3.1.1) actionpack (>= 5.2) @@ -567,7 +575,7 @@ GEM simplecov (>= 0.22.0) tty-which (~> 0.5.0) virtus (~> 2.0) - securerandom (0.3.1) + securerandom (0.3.2) sexp_processor (4.17.2) simplecov (0.22.0) docile (~> 1.1) @@ -621,7 +629,9 @@ GEM standard-performance (1.4.0) lint_roller (~> 1.1) rubocop-performance (~> 1.21.0) - stringio (3.1.1) + store_model (4.1.0) + activerecord (>= 7.0) + stringio (3.1.2) syntax_tree (6.2.0) prettier_print (>= 1.2.0) terminal-table (3.0.2) @@ -630,9 +640,9 @@ GEM thor (1.3.2) thread_safe (0.3.6) tilt (2.4.0) - timeout (0.4.1) + timeout (0.4.2) tty-which (0.5.0) - turbo-rails (2.0.10) + turbo-rails (2.0.11) actionpack (>= 6.0.0) railties (>= 6.0.0) turbo_power (0.6.2) @@ -641,10 +651,10 @@ GEM concurrent-ruby (~> 1.0) unaccent (0.4.0) unicode-display_width (2.6.0) - uri (0.13.1) + uri (1.0.2) useragent (0.16.10) - view_component (3.17.0) - activesupport (>= 5.2.0, < 8.0) + view_component (3.20.0) + activesupport (>= 5.2.0, < 8.1) concurrent-ruby (~> 1.0) method_source (~> 1.0) virtus (2.0.0) @@ -662,14 +672,14 @@ GEM addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - webrick (1.8.2) + webrick (1.9.0) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) yard (0.9.37) - zeitwerk (2.6.18) + zeitwerk (2.7.1) PLATFORMS ruby @@ -687,6 +697,7 @@ DEPENDENCIES annotate appraisal avo! + avo-meta! avo-money_field avo-record_link_field aws-sdk-s3 @@ -751,6 +762,7 @@ DEPENDENCIES spring-commands-rspec sprockets-rails standard + store_model test-prof tzinfo-data web-console (>= 3.3.0) diff --git a/db/factories.rb b/db/factories.rb index a9541adbd6..15ebab2230 100644 --- a/db/factories.rb +++ b/db/factories.rb @@ -37,7 +37,7 @@ factory :project do name { Faker::App.name } - status { ['closed', :rejected, :failed, 'loading', :running, :waiting].sample } + status { ["closed", :rejected, :failed, "loading", :running, :waiting].sample } stage { ["Discovery", "Idea", "Done", "On hold", "Cancelled", "Drafting"].sample } budget { Faker::Number.decimal(l_digits: 4) } country { Faker::Address.country_code } @@ -148,4 +148,13 @@ name { Faker::Company.name } size { ["small", "medium", "large"].sample } end + + factory :meta_schema, class: "Avo::Meta::Schema" do + resource_name { "User" } + schema_entries { + [ + Avo::Meta::SchemaEntry.new(name: "shoe_size", as: "number", default: "9") + ] + } + end end diff --git a/lib/avo/mappings.rb b/lib/avo/mappings.rb new file mode 100644 index 0000000000..6b7e5f23ae --- /dev/null +++ b/lib/avo/mappings.rb @@ -0,0 +1,105 @@ +module Avo + module Mappings + ASSOCIATIONS_MAPPING ||= { + ActiveRecord::Reflection::BelongsToReflection => { + field: "belongs_to" + }, + ActiveRecord::Reflection::HasOneReflection => { + field: "has_one" + }, + ActiveRecord::Reflection::HasManyReflection => { + field: "has_many" + }, + ActiveRecord::Reflection::HasAndBelongsToManyReflection => { + field: "has_and_belongs_to_many" + } + }.freeze + + ATTACHMENTS_MAPPING ||= { + ActiveRecord::Reflection::HasOneReflection => { + field: "file" + }, + ActiveRecord::Reflection::HasManyReflection => { + field: "files" + } + }.freeze + + FIELDS_MAPPING ||= { + primary_key: { + field: "id" + }, + string: { + field: "text" + }, + text: { + field: "textarea" + }, + integer: { + field: "number" + }, + float: { + field: "number" + }, + decimal: { + field: "number" + }, + datetime: { + field: "date_time" + }, + timestamp: { + field: "date_time" + }, + time: { + field: "date_time" + }, + date: { + field: "date" + }, + binary: { + field: "number" + }, + boolean: { + field: "boolean" + }, + references: { + field: "belongs_to" + }, + json: { + field: "code" + } + }.freeze + + NAMES_MAPPING ||= { + id: { + field: "id" + }, + description: { + field: "textarea" + }, + gravatar: { + field: "gravatar" + }, + email: { + field: "text" + }, + password: { + field: "password" + }, + password_confirmation: { + field: "password" + }, + stage: { + field: "select" + }, + budget: { + field: "currency" + }, + money: { + field: "currency" + }, + country: { + field: "country" + } + }.freeze + end +end diff --git a/lib/generators/avo/resource_generator.rb b/lib/generators/avo/resource_generator.rb index d67559bbf5..160aa1970f 100644 --- a/lib/generators/avo/resource_generator.rb +++ b/lib/generators/avo/resource_generator.rb @@ -175,7 +175,7 @@ def fields_from_model_associations fields[name] = if association.is_a? ActiveRecord::Reflection::ThroughReflection field_from_through_association(association) else - associations_mapping[association.class] + ::Avo::Mappings::ASSOCIATIONS_MAPPING[association.class] end end end @@ -198,7 +198,7 @@ def field_from_through_association(association) def fields_from_model_attachements attachments.each do |name, attachment| - fields[remove_last_word_from name] = attachments_mapping[attachment.class] + fields[remove_last_word_from name] = ::Avo::Mappings::ATTACHMENTS_MAPPING[attachment.class] end end @@ -228,115 +228,6 @@ def fields_from_model_db_columns end end - def associations_mapping - { - ActiveRecord::Reflection::BelongsToReflection => { - field: "belongs_to" - }, - ActiveRecord::Reflection::HasOneReflection => { - field: "has_one" - }, - ActiveRecord::Reflection::HasManyReflection => { - field: "has_many" - }, - ActiveRecord::Reflection::HasAndBelongsToManyReflection => { - field: "has_and_belongs_to_many" - } - } - end - - def attachments_mapping - { - ActiveRecord::Reflection::HasOneReflection => { - field: "file" - }, - ActiveRecord::Reflection::HasManyReflection => { - field: "files" - } - } - end - - def names_mapping - { - id: { - field: "id" - }, - description: { - field: "textarea" - }, - gravatar: { - field: "gravatar" - }, - email: { - field: "text" - }, - password: { - field: "password" - }, - password_confirmation: { - field: "password" - }, - stage: { - field: "select" - }, - budget: { - field: "currency" - }, - money: { - field: "currency" - }, - country: { - field: "country" - } - } - end - - def fields_mapping - { - primary_key: { - field: "id" - }, - string: { - field: "text" - }, - text: { - field: "textarea" - }, - integer: { - field: "number" - }, - float: { - field: "number" - }, - decimal: { - field: "number" - }, - datetime: { - field: "date_time" - }, - timestamp: { - field: "date_time" - }, - time: { - field: "date_time" - }, - date: { - field: "date" - }, - binary: { - field: "number" - }, - boolean: { - field: "boolean" - }, - references: { - field: "belongs_to" - }, - json: { - field: "code" - } - } - end def generate_fields return generate_fields_from_args if invoked_by_model_generator? return unless can_connect_to_the_database? @@ -385,7 +276,7 @@ def generate_fields_from_args end def field(name, type) - names_mapping[name.to_sym] || fields_mapping[type&.to_sym] || {field: "text"} + ::Avo::Mappings::NAMES_MAPPING[name.to_sym] || ::Avo::Mappings::FIELDS_MAPPING[type&.to_sym] || {field: "text"} end end end diff --git a/spec/dummy/app/avo/resources/post.rb b/spec/dummy/app/avo/resources/post.rb index e99173b743..d5f3c4ab47 100644 --- a/spec/dummy/app/avo/resources/post.rb +++ b/spec/dummy/app/avo/resources/post.rb @@ -63,6 +63,8 @@ def fields # suggestions_max_items: 2, help: "The only allowed values here are `one`, `two`, and `three`" + meta_panel + field :cover_photo_attachment, as: :has_one field :comments, as: :has_many, use_resource: Avo::Resources::PhotoComment diff --git a/spec/dummy/app/avo/resources/user.rb b/spec/dummy/app/avo/resources/user.rb index cfb603cc69..91029e8c40 100644 --- a/spec/dummy/app/avo/resources/user.rb +++ b/spec/dummy/app/avo/resources/user.rb @@ -54,6 +54,8 @@ def fields user_information_panel + meta_panel + first_tabs_group second_tabs_group diff --git a/spec/dummy/app/models/post.rb b/spec/dummy/app/models/post.rb index 96b9b36a10..845f67a2a2 100644 --- a/spec/dummy/app/models/post.rb +++ b/spec/dummy/app/models/post.rb @@ -14,6 +14,8 @@ # slug :string # class Post < ApplicationRecord + include Avo::Metaable + if Gem::Version.new(Rails.version) >= Gem::Version.new("7.3.0") enum :status, [:draft, :published, :archived] else diff --git a/spec/dummy/app/models/user.rb b/spec/dummy/app/models/user.rb index f57b859372..014d177d03 100644 --- a/spec/dummy/app/models/user.rb +++ b/spec/dummy/app/models/user.rb @@ -22,7 +22,9 @@ class User < ApplicationRecord ACCOUNT_STRUCT = Struct.new(:id, :name) unless const_defined?(:ACCOUNT_STRUCT) + include Avo::Metaable extend FriendlyId + # Include default devise modules. Others available are: # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable devise :database_authenticatable, :registerable, diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb index 29dcb07d2b..eb47b5e97d 100644 --- a/spec/dummy/config/routes.rb +++ b/spec/dummy/config/routes.rb @@ -10,6 +10,7 @@ get "custom_tool", to: "avo/tools#custom_tool", as: :custom_tool end + mount Avo::Meta::Engine, at: "#{Avo.configuration.root_path}/avo_meta/" mount Avo::Engine, at: Avo.configuration.root_path # Uncomment to test constraints /123/en/admin # scope ":course", constraints: {course: /\w+(-\w+)*/} do diff --git a/spec/dummy/db/migrate/20241030100735_add_meta_to_users.rb b/spec/dummy/db/migrate/20241030100735_add_meta_to_users.rb new file mode 100644 index 0000000000..fa09167687 --- /dev/null +++ b/spec/dummy/db/migrate/20241030100735_add_meta_to_users.rb @@ -0,0 +1,5 @@ +class AddMetaToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :meta, :json + end +end diff --git a/spec/dummy/db/migrate/20241111104026_create_avo_meta_schemas.avo_meta.rb b/spec/dummy/db/migrate/20241111104026_create_avo_meta_schemas.avo_meta.rb new file mode 100644 index 0000000000..068b0f0e5d --- /dev/null +++ b/spec/dummy/db/migrate/20241111104026_create_avo_meta_schemas.avo_meta.rb @@ -0,0 +1,13 @@ +# This migration comes from avo_meta (originally 20241111092303) +class CreateAvoMetaSchemas < ActiveRecord::Migration[7.2] + def change + create_table :avo_meta_schemas do |t| + t.string :resource_name + t.json :schema_entries + + t.timestamps + end + + add_index :avo_meta_schemas, :resource_name, unique: true + end +end diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index 4dfeba42ca..3ed344535c 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/db/schema.rb @@ -12,7 +12,7 @@ ActiveRecord::Schema[8.0].define(version: 2024_11_14_165947) do # These are extensions that must be enabled in order to support this database - enable_extension "plpgsql" + enable_extension "pg_catalog.plpgsql" create_table "action_text_rich_texts", force: :cascade do |t| t.string "name", null: false @@ -52,6 +52,14 @@ t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true end + create_table "avo_meta_schemas", force: :cascade do |t| + t.string "resource_name" + t.json "schema_entries" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["resource_name"], name: "index_avo_meta_schemas_on_resource_name", unique: true + end + create_table "cities", force: :cascade do |t| t.string "name" t.integer "population" @@ -172,7 +180,7 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "price_cents", default: 0, null: false - t.string "price_currency", default: "'USD'::character varying", null: false + t.string "price_currency", default: "USD", null: false end create_table "projects", force: :cascade do |t| @@ -290,6 +298,7 @@ t.datetime "updated_at", null: false t.boolean "active", default: true t.string "slug" + t.json "meta" t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["slug"], name: "index_users_on_slug", unique: true diff --git a/spec/system/avo/meta/meta_schema_spec.rb b/spec/system/avo/meta/meta_schema_spec.rb new file mode 100644 index 0000000000..4f9f3615fa --- /dev/null +++ b/spec/system/avo/meta/meta_schema_spec.rb @@ -0,0 +1,112 @@ +require "rails_helper" + +RSpec.describe "MetaSchema", type: :system do + include ActiveJob::TestHelper + + let!(:user) { create :user } + let!(:post) { create :post } + let!(:schema) { create :meta_schema } + + it "allows to add new entries to the user meta schema" do + visit "/admin/resources/meta_schemas" + + click_on "User" + end + + it "allows to create completely new meta schemas" do + visit avo.resources_post_path(Post.first) + + expect(page).not_to have_text("GUEST AUTHOR NAME") + + visit avo.resources_meta_schemas_path + + click_on "Create new meta schema" + + select "Post", from: "avo/meta/schema[resource_name]" + + click_on "Add a new property" + + fill_in "Name", with: "guest_author_name" + select "Text", from: find('select[name*="[schema_entries_attributes]["][name*="][as]"]')[:id] + + save + + visit avo.resources_post_path(Post.first) + + expect(page).to have_text("GUEST AUTHOR NAME") + end + + it "allows to add new entries to the user meta schema, which can instantly be used" do + visit avo.edit_resources_meta_schema_path(schema) + + click_on "Add a new property" + + fill_in "Name", with: "nickname" + select "Text", from: find('select[name*="[schema_entries_attributes]["][name*="][as]"]')[:id] + + save + + expect(page).to have_text("Meta schema was successfully updated") + + # assert that the new attribute is actually present and editable + visit avo.resources_user_path(User.last) + + expect(page).to have_text("Meta") + expect(page).to have_text("NICKNAME") + + click_on "Edit" + + fill_in "user_meta_nickname", with: "Ace Ventura" + + save + + expect(page).to have_text("Ace Ventura") + end + + it "allows to add new entries to the user meta schema with default" do + visit avo.edit_resources_meta_schema_path(schema) + + click_on "Add a new property" + + fill_in "Name", with: "driving_license" + select "Text", from: find('select[name*="[schema_entries_attributes]["][name*="][as]"]')[:id] + fill_in "Default", with: "B" + + save + + expect(page).to have_text("Meta schema was successfully updated") + + # assert that the new attribute is actually present and the default is prefilled + visit avo.new_resources_user_path + + expect(page).to have_field("Driving license", with: "B") + end + + it "ensures that existing meta entries are not touched by backfilling defaults" do + perform_enqueued_jobs + + visit avo.edit_resources_user_path(User.first) + + fill_in "Shoe size", with: "10" + + save + + visit avo.edit_resources_meta_schema_path(schema) + + click_on "Add a new property" + + fill_in "Name", with: "driving_license" + select "Text", from: find('select[name*="[schema_entries_attributes]["][name*="][as]"]')[:id] + fill_in "Default", with: "B" + + save + + perform_enqueued_jobs + + # assert that backfilling worked but didn't touch the existing meta properties + visit avo.resources_user_path(User.first) + + expect(find("[data-field-id=\"shoe_size\"]")).to have_text "10" + expect(find("[data-field-id=\"driving_license\"]")).to have_text "B" + end +end