diff --git a/.rubocop.yml b/.rubocop.yml index e095b678..a158936b 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,13 +1,35 @@ # This yaml describes our current checks. # Any checks marked "# TODO: RWC" are "re-enable when corrected.RWC" are "re-enable when corrected. +# We will add the following back in later, but they cause +# a completely outsized amount of cop failures for the number of files: +# - db/seedfiles +# - lib/tasks +AllCops: + TargetRubyVersion: 2.5 + Exclude: + - "./bin/**/*" + - "./project_gems/effective_datatables-2.6.14/effective_datatables-2.6.14.gemspec" + - "./node_modules/**/*" + - "./db/seedfiles/**/*" + - "./lib/tasks/**/*" + - "./script/**/*" + - "./components/benefit_markets/spec/dummy/**/*" + - "./components/benefit_markets/db/seedfiles/**/*" + - "./components/benefit_sponsors/spec/dummy/**/*" + - "./components/benefit_sponsors/db/seedfiles/**/*" + - "./components/notifier/spec/dummy/**/*" + - "./components/old_sponsored_benefits/**/*" + - "./components/sponsored_benefits/spec/dummy/**/*" + - "./components/transport_profiles/spec/dummy/**/*" + - "./components/transport_profiles/spec/dummy/**/*" + - "./eyes/enroll.eye.rb" + +# TODO: RWC Layout/CommentIndentation: - Enabled: true - -Layout/EmptyLines: Enabled: false -Layout/EmptyLineAfterGuardClause: +Layout/EmptyLines: Enabled: false Layout/EmptyLinesAroundBlockBody: @@ -19,6 +41,9 @@ Layout/LeadingCommentSpace: Layout/ExtraSpacing: Enabled: false +Layout/EmptyLineAfterGuardClause: + Enabled: false + Layout/EmptyLinesAroundClassBody: Enabled: false @@ -44,7 +69,10 @@ Layout/TrailingEmptyLines: Layout/IndentationWidth: Enabled: true -Layout/Tab: +Layout/LineLength: + Max: 250 + +Layout/IndentationStyle: Enabled: true Layout/TrailingWhitespace: @@ -56,24 +84,21 @@ Metrics/AbcSize: Metrics/ClassLength: Max: 300 +Metrics/ModuleLength: + Max: 300 + Metrics/CyclomaticComplexity: - Max: 8 + Max: 11 Metrics/BlockLength: Enabled: false -Metrics/LineLength: - Max: 250 - Metrics/MethodLength: Max: 50 Metrics/PerceivedComplexity: Max: 15 -Naming/AccessorMethodName: - Enabled: false - Naming/PredicateName: Enabled: false @@ -88,32 +113,32 @@ Style/BlockComments: # We will want to turn this back on or customize it more fully Style/Documentation: - Enabled: false + Enabled: true Style/EachWithObject: Enabled: false -Style/EmptyLiteral: - Enabled: false - -Style/EmptyMethod: - Enabled: false - Style/ExpandPathArguments: Enabled: false Style/HashSyntax: Enabled: false +Style/HashEachMethods: + Enabled: true + +Style/HashTransformKeys: + Enabled: true + +Style/HashTransformValues: + Enabled: true + Style/NumericPredicate: Enabled: false Style/RedundantSelf: Enabled: false -Style/SafeNavigation: - Enabled: false - Style/StringLiterals: Enabled: false @@ -130,4 +155,58 @@ Bundler/OrderedGems: Enabled: false Gemspec/OrderedDependencies: - Enabled: false + Enabled: + false + +Layout/SpaceBeforeBrackets: # (new in 1.7) + Enabled: true +Lint/AmbiguousAssignment: # (new in 1.7) + Enabled: true +Lint/DeprecatedConstants: # (new in 1.8) + Enabled: true +Lint/DuplicateBranch: # (new in 1.3) + Enabled: true +Lint/DuplicateRegexpCharacterClassElement: # (new in 1.1) + Enabled: true +Lint/EmptyBlock: # (new in 1.1) + Enabled: true +Lint/EmptyClass: # (new in 1.3) + Enabled: true +Lint/LambdaWithoutLiteralBlock: # (new in 1.8) + Enabled: true +Lint/NoReturnInBeginEndBlocks: # (new in 1.2) + Enabled: true +Lint/RedundantDirGlobSort: # (new in 1.8) + Enabled: true +Lint/ToEnumArguments: # (new in 1.1) + Enabled: true +Lint/UnexpectedBlockArity: # (new in 1.5) + Enabled: true +Lint/UnmodifiedReduceAccumulator: # (new in 1.1) + Enabled: true +Style/ArgumentsForwarding: # (new in 1.1) + Enabled: true +Style/CollectionCompact: # (new in 1.2) + Enabled: true +Style/DocumentDynamicEvalDefinition: # (new in 1.1) + Enabled: true +Style/EndlessMethod: # (new in 1.8) + Enabled: true +Style/HashExcept: # (new in 1.7) + Enabled: true +Style/NegatedIfElseCondition: # (new in 1.2) + Enabled: true +Style/NilLambda: # (new in 1.3) + Enabled: true +Style/RedundantArgument: # (new in 1.4) + Enabled: true +Style/SwapValues: # (new in 1.1) + Enabled: true + +require: + - ./cops/lint/empty_rescue_clause.rb +# Deprecated +# Style/TrailingBlankLines: +# Enabled: false +# AllCops: +# RunRailsCops: true diff --git a/Gemfile b/Gemfile index 3b10a930..08b5d5ea 100644 --- a/Gemfile +++ b/Gemfile @@ -5,3 +5,8 @@ source "https://rubygems.org" # Specify your gem's dependencies in resource_registry.gemspec gemspec +group :development, :test do + gem "pry", platform: :mri + gem "pry-byebug", platform: :mri +end + diff --git a/Gemfile.lock b/Gemfile.lock index 79623493..8cc2018c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,8 +1,7 @@ PATH remote: . specs: - resource_registry (0.7.0) - bootsnap (~> 1.0) + resource_registry (0.9.0) deep_merge (>= 1.0.0) dry-configurable (= 0.9) dry-container (~> 0.7) @@ -14,127 +13,127 @@ PATH i18n (>= 0.7.0) loofah (>= 2.3.1) mime-types - nokogiri (>= 1.9.1) + nokogiri (>= 1.11.1) ox (~> 2.0) - rack (>= 1.6.13) + rack (>= 2.2.3) + rgl GEM remote: https://rubygems.org/ specs: - actionpack (5.2.4.2) - actionview (= 5.2.4.2) - activesupport (= 5.2.4.2) + actionpack (5.2.4.4) + actionview (= 5.2.4.4) + activesupport (= 5.2.4.4) rack (~> 2.0, >= 2.0.8) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.2) - actionview (5.2.4.2) - activesupport (= 5.2.4.2) + actionview (5.2.4.4) + activesupport (= 5.2.4.4) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.0.3) - activemodel (5.2.4.2) - activesupport (= 5.2.4.2) - activesupport (5.2.4.2) + activemodel (5.2.4.4) + activesupport (= 5.2.4.4) + activesupport (5.2.4.4) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 0.7, < 2) minitest (~> 5.1) tzinfo (~> 1.1) - ast (2.4.0) - bootsnap (1.4.6) + ast (2.4.2) + bootsnap (1.7.0) msgpack (~> 1.0) - bson (4.8.2) + bson (4.11.1) builder (3.2.4) byebug (11.1.3) - coderay (1.1.2) - concurrent-ruby (1.1.6) + coderay (1.1.3) + concurrent-ruby (1.1.8) crass (1.0.6) - database_cleaner (1.8.4) + database_cleaner (1.99.0) deep_merge (1.2.1) - diff-lcs (1.3) - docile (1.3.2) + diff-lcs (1.4.4) + docile (1.3.5) dry-configurable (0.9.0) concurrent-ruby (~> 1.0) dry-core (~> 0.4, >= 0.4.7) dry-container (0.7.2) concurrent-ruby (~> 1.0) dry-configurable (~> 0.1, >= 0.1.3) - dry-core (0.4.9) + dry-core (0.5.0) concurrent-ruby (~> 1.0) dry-equalizer (0.3.0) dry-inflector (0.2.0) - dry-initializer (3.0.3) - dry-logic (1.0.6) + dry-initializer (3.0.4) + dry-logic (1.1.0) concurrent-ruby (~> 1.0) - dry-core (~> 0.2) - dry-equalizer (~> 0.2) + dry-core (~> 0.5, >= 0.5) dry-matcher (0.8.3) dry-core (>= 0.4.8) dry-monads (1.3.5) concurrent-ruby (~> 1.0) dry-core (~> 0.4, >= 0.4.4) dry-equalizer - dry-schema (1.5.0) + dry-schema (1.6.0) concurrent-ruby (~> 1.0) dry-configurable (~> 0.8, >= 0.8.3) - dry-core (~> 0.4) - dry-equalizer (~> 0.2) + dry-core (~> 0.5, >= 0.5) dry-initializer (~> 3.0) dry-logic (~> 1.0) - dry-types (~> 1.4) - dry-struct (1.3.0) - dry-core (~> 0.4, >= 0.4.4) - dry-equalizer (~> 0.3) - dry-types (~> 1.3) + dry-types (~> 1.5) + dry-struct (1.4.0) + dry-core (~> 0.5, >= 0.5) + dry-types (~> 1.5) ice_nine (~> 0.11) - dry-types (1.4.0) + dry-types (1.5.0) concurrent-ruby (~> 1.0) dry-container (~> 0.3) - dry-core (~> 0.4, >= 0.4.4) - dry-equalizer (~> 0.3) + dry-core (~> 0.5, >= 0.5) dry-inflector (~> 0.1, >= 0.1.2) dry-logic (~> 1.0, >= 1.0.2) - dry-validation (1.5.0) + dry-validation (1.6.0) concurrent-ruby (~> 1.0) dry-container (~> 0.7, >= 0.7.1) dry-core (~> 0.4) dry-equalizer (~> 0.2) dry-initializer (~> 3.0) - dry-schema (~> 1.5) - erubi (1.9.0) - i18n (1.8.2) + dry-schema (~> 1.5, >= 1.5.2) + erubi (1.10.0) + generator (0.0.1) + i18n (1.8.7) concurrent-ruby (~> 1.0) ice_nine (0.11.2) - jaro_winkler (1.5.4) - loofah (2.5.0) + lazy_priority_queue (0.1.1) + loofah (2.9.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) - method_source (0.9.2) + method_source (1.0.0) mime-types (3.3.1) mime-types-data (~> 3.2015) - mime-types-data (3.2020.0425) - mini_portile2 (2.4.0) - minitest (5.14.0) - mongo (2.12.1) + mime-types-data (3.2020.1104) + mini_portile2 (2.5.0) + minitest (5.14.3) + mongo (2.14.0) bson (>= 4.8.2, < 5.0.0) - mongoid (6.4.4) + mongoid (6.4.8) activemodel (>= 5.1, < 6.0.0) mongo (>= 2.5.1, < 3.0.0) - msgpack (1.3.3) - nokogiri (1.10.9) - mini_portile2 (~> 2.4.0) - ox (2.13.2) - parallel (1.19.1) - parser (2.7.1.1) - ast (~> 2.4.0) - pry (0.12.2) - coderay (~> 1.1.0) - method_source (~> 0.9.0) - pry-byebug (3.8.0) + msgpack (1.4.2) + nokogiri (1.11.1) + mini_portile2 (~> 2.5.0) + racc (~> 1.4) + ox (2.14.1) + parallel (1.20.1) + parser (3.0.0.0) + ast (~> 2.4.1) + pry (0.13.1) + coderay (~> 1.1) + method_source (~> 1.0) + pry-byebug (3.9.0) byebug (~> 11.0) - pry (~> 0.10) - rack (2.2.2) + pry (~> 0.13.0) + racc (1.5.2) + rack (2.2.3) rack-test (1.1.0) rack (>= 1.0, < 3) rails-dom-testing (2.0.3) @@ -142,21 +141,26 @@ GEM nokogiri (>= 1.6) rails-html-sanitizer (1.3.0) loofah (~> 2.3) - railties (5.2.4.2) - actionpack (= 5.2.4.2) - activesupport (= 5.2.4.2) + railties (5.2.4.4) + actionpack (= 5.2.4.4) + activesupport (= 5.2.4.4) method_source rake (>= 0.8.7) thor (>= 0.19.0, < 2.0) rainbow (3.0.0) rake (12.3.3) + regexp_parser (2.0.3) + rexml (3.2.4) + rgl (0.5.7) + lazy_priority_queue (~> 0.1.0) + stream (~> 0.5.3) rspec (3.9.0) rspec-core (~> 3.9.0) rspec-expectations (~> 3.9.0) rspec-mocks (~> 3.9.0) - rspec-core (3.9.1) - rspec-support (~> 3.9.1) - rspec-expectations (3.9.1) + rspec-core (3.9.3) + rspec-support (~> 3.9.3) + rspec-expectations (3.9.4) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.9.0) rspec-mocks (3.9.1) @@ -170,42 +174,52 @@ GEM rspec-expectations (~> 3.9.0) rspec-mocks (~> 3.9.0) rspec-support (~> 3.9.0) - rspec-support (3.9.2) - rubocop (0.74.0) - jaro_winkler (~> 1.5.1) + rspec-support (3.9.4) + rubocop (1.9.1) parallel (~> 1.10) - parser (>= 2.6) + parser (>= 3.0.0.0) rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml + rubocop-ast (>= 1.2.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 1.4.0, < 1.7) - ruby-progressbar (1.10.1) - simplecov (0.18.5) + unicode-display_width (>= 1.4.0, < 3.0) + rubocop-ast (1.4.1) + parser (>= 2.7.1.5) + ruby-progressbar (1.11.0) + simplecov (0.21.2) docile (~> 1.1) simplecov-html (~> 0.11) - simplecov-html (0.12.2) - thor (1.0.1) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.12.3) + simplecov_json_formatter (0.1.2) + stream (0.5.3) + generator + thor (1.1.0) thread_safe (0.3.6) - timecop (0.9.1) - tzinfo (1.2.7) + timecop (0.9.2) + tzinfo (1.2.9) thread_safe (~> 0.1) - unicode-display_width (1.6.1) - yard (0.9.24) + unicode-display_width (2.0.0) + yard (0.9.26) PLATFORMS ruby DEPENDENCIES - actionview (>= 5.2.4.2) - activesupport (~> 5.2.4) + actionview (>= 5.2.4.3) + activesupport (~> 5.2.4.3) + bootsnap (~> 1.0) bundler (~> 2.0) database_cleaner (~> 1.7) mongoid (~> 6.0) + pry pry-byebug rake (~> 12.0) resource_registry! rspec (~> 3.9) rspec-rails (~> 3.9) - rubocop (~> 0.74.0) + rubocop (~> 1.9.0) simplecov timecop (~> 0.9) yard (~> 0.9) diff --git a/README.md b/README.md index b38c06f5..87e253ce 100644 --- a/README.md +++ b/README.md @@ -3,68 +3,70 @@ # @title README.md # @author Dan Thomas --> - # ResourceRegistry - [![Build Status](https://travis-ci.com/ideacrew/resource_registry.svg?branch=master)](https://travis-ci.com/ideacrew/resource_registry) - ResourceRegistry is a library for system configuration, feature flipping and eventing. It offers an approach to custom configuration for a single codebase, supporting use cases such as: +# ResourceRegistry - * Customer-level preference profiles - * Multitenancy - * Access control based on privilidges and subscriptions +[![Build Status](https://travis-ci.com/ideacrew/resource_registry.svg?branch=master)](https://travis-ci.com/ideacrew/resource_registry) - ResourceRegistry is intended to address 'logic sprawl' that can occur with minimally- or un-structured key/value system settings schemes. It offers an - alternative to code obfuscation issues that often pops up when using Rails Concerns. +ResourceRegistry is a library for system configuration, feature flipping and eventing. It offers an approach to custom configuration for a single codebase, supporting use cases such as: + +- Customer-level preference profiles +- Multitenancy +- Access control based on privilidges and subscriptions + +ResourceRegistry is intended to address 'logic sprawl' that can occur with minimally- or un-structured key/value system settings schemes. It offers an +alternative to code obfuscation issues that often pops up when using Rails Concerns. ## Gem Features - * Group associated system code and configuration settings as a Feature - * Define a namespace taxonomy to associate and nest Features and dependencies - * Enable/disable individual Features - * Store metadata values for a Feature that support auto generation of a configuration UI - * Access Features and their attribute values using a thread-safe key/value store - * Use YAML files to seed Features and namespaces +- Group associated system code and configuration settings as a Feature +- Define a namespace taxonomy to associate and nest Features and dependencies +- Enable/disable individual Features +- Store metadata values for a Feature that support auto generation of a configuration UI +- Access Features and their attribute values using a thread-safe key/value store +- Use YAML files to seed Features and namespaces ## Compatibility - * Ruby 2.6 - * Rails 5.2.4 +- Ruby 2.6 +- Rails 5.2.4 ### Installing on Rails - Add this line to your project's Gemfile: +Add this line to your project's Gemfile: gem 'resource_registry' - And then execute: +And then execute: $ bundle - Or install it yourself as: +Or install it yourself as: $ gem install resource_registry - In your project build the directory tree to house configuration files: +In your project build the directory tree to house configuration files: $ mkdir -p ./system/boot && mkdir -p ./system/config - Then, create Resource Registry's initializer file: +Then, create Resource Registry's initializer file: $ touch ./config/initializers/resource_registry.rb ## Feature - ResourceRegistry uses a Feature to group related system functions and settings. Featurse are composed of the following high level attributes: +ResourceRegistry uses a Feature to group related system functions and settings. Featurse are composed of the following high level attributes: - * key [Symbol] 'key' of the Feature's key/value pair. This is the Feature's identifier and must be unique - * item [Any] 'value' of the Feature's key/value pair. May be a static value, proc, class instance and may include an options hash - * namespace [Array] an ordered list that supports optional taxonomy for relationships between Features - * is_enabled [Boolean] indicator whether the Feature is accessible in the current configuration - * settings [Array] a list of key/item pairs associated with the Feature - * meta [Hash] a set of attributes to store configuration values and drive their presentation in User Interface +- key [Symbol] 'key' of the Feature's key/value pair. This is the Feature's identifier and must be unique +- item [Any] 'value' of the Feature's key/value pair. May be a static value, proc, class instance and may include an options hash +- namespace [Array] an ordered list that supports optional taxonomy for relationships between Features +- is_enabled [Boolean] indicator whether the Feature is accessible in the current configuration +- settings [Array] a list of key/item pairs associated with the Feature +- meta [Hash] a set of attributes to store configuration values and drive their presentation in User Interface - Here is an example Feature definition in YAML format. Note the settings ```effective_period``` value is an expression: +Here is an example Feature definition in YAML format. Note the settings `effective_period` value is an expression: -``` ruby +```ruby - namespace: - :enroll_app - :aca_shop_market @@ -90,11 +92,11 @@ item: :initial_sponsor_jan_default ``` - ### Registering Features +### Registering Features - Features are most useful when they're loaded into a registry for runtime access. For example: +Features are most useful when they're loaded into a registry for runtime access. For example: -``` ruby +```ruby require 'resource_registry' # Initialize registry @@ -113,6 +115,7 @@ my_registry.resolve_feature('stringify') {:my_symbol} # => "my_symbol" ``` ### Detailed Example + ```ruby my_registry = ResourceRegistry::Registry.new @@ -126,17 +129,17 @@ end # Specify the code to invoke when the registry resolves the Feature key greeter_instance = Greeter.new -# Assign the Feature to a Taxonomy namespace +# Assign the Feature to a Taxonomy namespace ns = [:operations, :ai] # Associate a Setting key/value pair with the Feature scope_setting = {key: :scope, item: "online"} - + # Define a Feature with a namespace and settings -greeter = ResourceRegistry::Feature.new(key: :greeter, - item: greeter_instance, - namespace: ns, +greeter = ResourceRegistry::Feature.new(key: :greeter, + item: greeter_instance, + namespace: ns, settings: [scope_setting]) # Add Feature to the Registry @@ -150,71 +153,73 @@ my_registry[:greeter] {"Dolly"} # => "Hello Dolly" ### Namepace - Use the optional Feature#namespace attribute to organize Features. Namespaces support enable you to define a structure to group Features into a logical structure or taxonomy that can help with code clarity. For example: +Use the optional Feature#namespace attribute to organize Features. Namespaces support enable you to define a structure to group Features into a logical structure or taxonomy that can help with code clarity. For example: -``` ruby - my_species = ResourceRegistry::Feature.new( key: :species, +```ruby + my_species = ResourceRegistry::Feature.new( key: :species, item: :Operations::Species::Create.new, is_enabled: true, namespace: [:kingdom, :phylum, :class, :order, :family, :genus]) my_registry.register_feature(my_species) ``` -Namespaced Features respect their anscesters with regard to code access. For instance ```Feature#enabled?``` will check not only the referenced Feature, but traverse all ancestors in its namespace. If any of the referenced Feature's anscestors is disabled, then the referenced Feature is considered disabled -- regardless of whether ```is_enabled``` is set to ```true``` or ```false```. +Namespaced Features respect their anscesters with regard to code access. For instance `Feature#enabled?` will check not only the referenced Feature, but traverse all ancestors in its namespace. If any of the referenced Feature's anscestors is disabled, then the referenced Feature is considered disabled -- regardless of whether `is_enabled` is set to `true` or `false`. -For instance, extending the ```species``` Feature example above: +For instance, extending the `species` Feature example above: -``` ruby - my_phylum = ResourceRegistry::Feature.new(key: :phylum, +```ruby + my_phylum = ResourceRegistry::Feature.new(key: :phylum, item: :Operations::Phylum::Create.new, is_enabled: false, namespace: [:kingdom]) my_registry.register_feature(my_phylum) ``` -Here the ```my_registry[:my_phylum].is_enabled? == false```. As it's a namespace ancestor to the ```my_registry[:species]```, ```my_registry[:species].is_enabled? == false``` also. +Here the `my_registry[:my_phylum].is_enabled? == false`. As it's a namespace ancestor to the `my_registry[:species]`, `my_registry[:species].is_enabled? == false` also. -Namespaces serve another purpose: enabling auto-generation of Admin UI configuration settings. This is a future function that uses Namespace in combination with Meta attributes to build the UI forms. +Namespaces serve another purpose: enabling auto-generation of Admin UI configuration settings. This is a future function that uses Namespace in combination with Meta attributes to build the UI forms. ## Rails Integration - A registry is configured and loaded when your application starts. +A registry is configured and loaded when your application starts. ## Configuration - The initializer and configuration files manage the setup and loading process. +The initializer and configuration files manage the setup and loading process. + +Configuration files are located in your project's `system/config` directory. All Yaml files in and below this directory are autoloaded during the boot process. Configuration settings may be organized into directories and files in any manner. Values will properly load into the container hierarchy provided the file begins with a reference to an identifiable parent key. - Configuration files are located in your project's ```system/config``` directory. All Yaml files in and below this directory are autoloaded during the boot process. Configuration settings may be organized into directories and files in any manner. Values will properly load into the container hierarchy provided the file begins with a reference to an identifiable parent key. +An example of a simple configuration file: - An example of a simple configuration file: - ```ruby +```ruby # ./system/config/enterprise.yml - ``` +``` - ## Defining Configuration Settings +## Defining Configuration Settings - ### UI-ready configuration settings +### UI-ready configuration settings ## Credits - Based on [dry-system](https://dry-rb.org/gems/dry-system/) and [dry-validation](https://dry-rb.org/gems/dry-validation/1.0/) ``` + +Based on [dry-system](https://dry-rb.org/gems/dry-system/) and [dry-validation](https://dry-rb.org/gems/dry-validation/1.0/) ``` ## Future Features - * Taxonomy: support namespace structures and validations - * Subscription - * Bootable infrastructure components +- Taxonomy: support namespace structures and validations +- Subscription +- Bootable infrastructure components ## Development - After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. +After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. - To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). +To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). ## Contributing - Bug reports and pull requests are welcome on GitHub at https://github.com/ideacrew/resource_registry. +Bug reports and pull requests are welcome on GitHub at https://github.com/ideacrew/resource_registry. ## License - The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). +The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). diff --git a/cops/lint/empty_rescue_clause.rb b/cops/lint/empty_rescue_clause.rb new file mode 100644 index 00000000..1a805bbf --- /dev/null +++ b/cops/lint/empty_rescue_clause.rb @@ -0,0 +1,19 @@ +module RuboCop + module Cop + module Lint + class EmptyRescueClause < Cop + include RescueNode + + def_node_matcher :rescue_with_empty_body?, <<-PATTERN + (resbody _ _ {nil? (nil) (array) (hash)}) + PATTERN + + def on_resbody(node) + rescue_with_empty_body?(node) do |_error| + add_offense(node, location: node.source_range, message: 'Avoid empty `rescue` bodies.') + end + end + end + end + end +end diff --git a/lib/resource_registry.rb b/lib/resource_registry.rb index 14763b88..c62d8a16 100644 --- a/lib/resource_registry.rb +++ b/lib/resource_registry.rb @@ -7,7 +7,6 @@ require 'dry/monads/do' require 'dry-struct' - require 'resource_registry/version' require 'resource_registry/error' @@ -17,13 +16,23 @@ require 'resource_registry/validation/application_contract' require 'resource_registry/railtie' if defined? Rails +require 'resource_registry/navigation' +require 'resource_registry/rgl' require 'resource_registry/meta' require 'resource_registry/setting' +require 'resource_registry/namespace_path' require 'resource_registry/feature' +require 'resource_registry/namespace' require 'resource_registry/feature_dsl' require 'resource_registry/configuration' require 'resource_registry/registry' module ResourceRegistry + def self.logger + @logger ||= defined?(Rails) ? Rails.logger : Logger.new($stdout) + end + def self.logger=(logger) + @logger = logger + end end diff --git a/lib/resource_registry/feature.rb b/lib/resource_registry/feature.rb index d8f906a6..72d846fa 100644 --- a/lib/resource_registry/feature.rb +++ b/lib/resource_registry/feature.rb @@ -3,6 +3,7 @@ require_relative 'validation/feature_contract' require_relative 'operations/features/create' +require_relative 'operations/features/renew' require_relative 'operations/features/configure' require_relative 'operations/features/disable' require_relative 'operations/features/enable' @@ -24,7 +25,7 @@ class Feature < Dry::Struct # @!attribute [r] namespace (optional) # The registry namespace where this item is stored # @return [Symbol] - attribute :namespace, Types::Array.of(Types::RequiredSymbol).default([].freeze).meta(omittable: false) + attribute :namespace_path, ::ResourceRegistry::NamespacePath.meta(omittable: false) # @!attribute [r] is_enabled (required) # Availability state of this Feature in the application: either enabled or disabled @@ -53,5 +54,6 @@ class Feature < Dry::Struct # @return [Array] attribute :settings, Types::Array.of(ResourceRegistry::Setting).default([].freeze).meta(omittable: true) + end end diff --git a/lib/resource_registry/feature_dsl.rb b/lib/resource_registry/feature_dsl.rb index a3ea719b..ef2a0b27 100644 --- a/lib/resource_registry/feature_dsl.rb +++ b/lib/resource_registry/feature_dsl.rb @@ -25,7 +25,11 @@ def key # @return [String] the namespace under which the feature is stored, in dot notation def namespace - @feature.namespace.map(&:to_s).join('.') + @feature.namespace_path.path.map(&:to_s).join('.') + end + + def assigned_namespace + @feature.namespace.path.map(&:to_s).join('.') end # @!method enabled? diff --git a/lib/resource_registry/helpers/date_controls.rb b/lib/resource_registry/helpers/date_controls.rb new file mode 100644 index 00000000..ef291f24 --- /dev/null +++ b/lib/resource_registry/helpers/date_controls.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# Helper methods to render setting date fields +module DateControls + + def input_date_control(setting, form) + id = setting[:key].to_s + + date_value = value_for(setting, form) + date_value = date_value.to_date if date_value.is_a?(Time) + date_value = date_value.to_s(:db) if date_value.is_a?(Date) + + meta = setting[:meta] + input_value = date_value || setting.item || meta&.default + is_required = meta[:is_required] == false ? meta[:is_required] : true + + tag.input(nil, type: "date", value: input_value, id: id, name: input_name_for(setting, form), placeholder: "mm/dd/yyyy", class: "form-control", required: is_required) + end + + def input_date_range_control(setting, form) + meta = setting[:meta] + date_bounds = setting.item.split('..').collect do |date_str| + if date_str.match?(/\d{4}-\d{2}-\d{2}/) + date_str + else + date = Date.strptime(date_str, "%m/%d/%Y") + date.to_s(:db) + end + end + + is_required = meta[:is_required] == false ? meta[:is_required] : true + from_input_name = form&.object_name.to_s + "[settings][#{setting.key}][begin]" + to_input_name = form&.object_name.to_s + "[settings][#{setting.key}][end]" + + tag.input(nil, type: "date", value: date_bounds[0], id: from_input_name, name: from_input_name, placeholder: "mm/dd/yyyy", class: "form-control", required: is_required) + + tag.div(class: 'input-group-addon') { 'to' } + + tag.input(nil, type: "date", value: date_bounds[1], id: to_input_name, name: to_input_name, placeholder: "mm/dd/yyyy", class: "form-control", required: is_required) + end +end \ No newline at end of file diff --git a/lib/resource_registry/helpers/form_group_controls.rb b/lib/resource_registry/helpers/form_group_controls.rb new file mode 100644 index 00000000..07370074 --- /dev/null +++ b/lib/resource_registry/helpers/form_group_controls.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require_relative 'input_controls' +require_relative 'date_controls' +# Helper methods to render interface from features/settings +module FormGroupControls + include ::InputControls + include ::DateControls + + def build_option_field(option, form, attrs = {}) + type = option.meta.content_type&.to_sym + input_control = case type + when :swatch + input_swatch_control(option, form) + when :base_64 + input_file_control(option, form) + when :radio_select + input_radio_control(option, form) + when :checkbox_select + input_checkbox_control(option, form) + when :select + select_control(option, form) + when :number + input_number_control(option, form) + when :email + input_email_control(option, form) + when :date + input_date_control(option, form) + when :date_range + input_date_range_control(option, form) + when :currency + input_currency_control(option, form) + when :feature_enabled + feature_enabled_control(option, form) + when :slider_switch + slider_switch_control(option, form) + else + input_text_control(option, form, attrs) + end + # else :text_field + # input_text_control(option, form) + # else + # # :dan_check_box + # # find dan_check_box_control helper + # # else + # # custom_helper #catch_all for custom types + # end + + if [:radio_select, :checkbox_select].include?(type) + custom_form_group(option, input_control) + else + return input_control if [:feature_enabled, :slider_switch].include?(type) + form_group(option, input_control, attrs) + end + end + + # Build a general-purpose form group wrapper around the supplied input control + def form_group(setting, control, options = {}) + id = setting[:key].to_s + # label = setting[:title] || id.titleize + label = setting.meta.label || id.titleize + help_id = "#{id}HelpBlock" + # help_text = setting[:description] + # aria_label = setting[:aria_label] || "Radio button for following text input" + help_text = setting.meta.description + aria_label = "Radio button for following text input" #setting[:aria_label] || "Radio button for following text input" + + tag.div(class: "form-group") do + if options[:horizontal] + tag.div(class: 'row') do + tag.div(class: 'col col-sm-12 col-md-4') do + tag.label(for: id, value: label, aria: { label: aria_label }) do + label + end + end + + tag.div(class: 'col col-sm-12 col-md-1') do + tag.i(class: 'fas fa-info-circle', rel: 'tooltip', title: setting.meta.description) + end + + tag.div(class: 'col col-sm-12 col-md-7') do + input_group { control } # + tag.small(help_text, id: help_id, class: ['form-text', 'text-muted']) + end + end + else + tag.label(for: id, value: label, aria: { label: aria_label }) do + label + end + + input_group { control } + tag.small(help_text, id: help_id, class: ['form-text', 'text-muted']) + end + end + end + + def custom_form_group(setting, control) + id = setting[:key].to_s + # label = setting[:title] || id.titleize + label = setting.meta.label || id.titleize + help_id = "#{id}HelpBlock" + help_text = setting.meta.description + aria_label = "#{setting.meta.content_type.to_s.humanize} button for following text input" #setting[:aria_label] || "Radio button for following text input" + + tag.div(class: "form-group") do + tag.label(for: id, value: label, aria: { label: aria_label }) do + label + end + + control + tag.small(help_text, id: help_id, class: ['form-text', 'text-muted']) + end + end + + def build_attribute_field(form, attribute) + setting = { + key: attribute, + default: form.object.send(attribute), + type: :string, + attribute: true + } + + input_control = input_text_control(setting, form) + form_group(setting, input_control) + end +end \ No newline at end of file diff --git a/lib/resource_registry/helpers/input_controls.rb b/lib/resource_registry/helpers/input_controls.rb new file mode 100644 index 00000000..fb89e830 --- /dev/null +++ b/lib/resource_registry/helpers/input_controls.rb @@ -0,0 +1,284 @@ +# frozen_string_literal: true + +# Helper methods to render setting input fields +module InputControls + def input_filter_control(_form, feature, data) + filter_setting = feature.settings.detect{|s| s.key == :filter_params} + filter_params = setting_value(filter_setting) + + option_list = tag.option(filter_setting.meta.label) + data[:calender_years].collect do |choice| + option_list += tag.option(choice, selected: (choice == data[:catalog_year]), value: choice) + end + + tag.div(class: 'row mb-4') do + tag.div(class: 'col-4') do + content = filter_params['criteria'].reduce([]) do |strs, (attr, value)| + strs << tag.input(type: 'hidden', id: "filter_#{attr}", name: "filter[#{attr}]", value: value) + end.join + content += tag.input(type: 'hidden', id: :target_feature, name: :target_feature, value: feature.key.to_s) + content += tag.select(option_list, id: filter_params['name'], class: "form-control feature-filter", name: "filter[#{filter_params['name']}]") + content.html_safe + end + end + end + + def feature_enabled_control(option, _form) + tag.div(class: "form-group") do + content = option.key.to_s.titleize + content += tag.label(class: 'switch') do + tag.input(type: 'hidden', value: option.key, name: 'feature_key') + + tag.input(type: "checkbox", checked: option.is_enabled) + + tag.span(class: "slider") + end + + content += tag.div(class: "spinner-border d-none text-success", role: "status") do + tag.span(class: "sr-only") do + "Loading..." + end + end + + content.html_safe + end + end + + def slider_switch_control(option, _form) + tag.div(class: "form-group") do + content = option.key.to_s.titleize + content += tag.label(class: 'switch') do + tag.input(type: 'hidden', value: option.key, name: 'feature_key') + + tag.input(type: "checkbox", checked: option.item) + + tag.span(class: "slider") + end + + content.html_safe + end + end + + def select_control(setting, form) + id = setting.key.to_s + selected_option = "Choose..." + meta = setting.meta + + # aria_describedby = id + + value = parse_input_value(setting, form) + option_list = tag.option(selected_option, selected: (value.blank? ? true : false)) + + choices = meta.enum + choices = meta.enum.constantize if choices.is_a?(String) + + choices.each do |choice| + choice = choice.is_a?(Hash) ? choice.first : [choice.to_s, choice.to_s.humanize] + option_list += tag.option(choice[1], selected: (choice[0] == value.to_s), value: choice[0]) + end + + tag.select(option_list, id: id, class: "form-control", name: input_name_for(setting, form)) + end + + def select_dropdown(input_id, list, show_default, selected = nil) + name = (input_id.to_s.scan(/supported_languages/).present? ? input_id : "admin[#{input_id}]") + + return unless list.is_a? Array + content_tag(:select, class: "form-control", id: input_id, name: name, required: true) do + concat(content_tag(:option, "Select", value: "")) unless show_default + list.each{|item| select_options_for(item, selected) } + end + end + + def select_options_for(item, selected) + if item.is_a? Array + is_selected = false + is_selected = true if selected.present? && selected == item[1] + concat(content_tag(:option, item[0], value: item[1], selected: is_selected)) + elsif item.is_a? Hash + concat(content_tag(:option, item.first[1], value: item.first[0])) + elsif input_id == 'state' + concat(content_tag(:option, item.to_s.titleize, value: item)) + elsif show_default + concat(content_tag(:option, item, value: item)) + else + concat(content_tag(:option, item.to_s.humanize, value: item)) + end + end + + def input_import_control(setting, _form) + id = setting[:key].to_s + aria_describedby = id + label = setting[:title] || id.titleize + + tag.div(class: "input-group-prepend") do + tag.span('Upload', class: "input-group-text", id: id) + end + + tag.div(class: "custom-file") do + tag.input(nil, type: "file", id: id, name: "#{id}[value]", class: "custom-file-input", aria: { describedby: aria_describedby }) + + tag.label('Choose File', for: id, value: label, class: "custom-file-label") + end + end + + def input_radio_control(setting, form) + meta = setting.meta + input_value = parse_input_value(setting, form) + aria_label = "Radio button for following text input" #setting[:aria_label] || "Radio button for following text input" + + if setting.is_a?(ResourceRegistry::Setting) + element_name = input_name_for(setting, form) + else + element_name = "#{form&.object_name}[is_enabled]" + input_value = form.object&.is_enabled + input_value ||= setting.is_enabled + end + + meta.enum.collect do |choice| + choice = send(choice) if choice.is_a?(String) + input_group do + tag.div(tag.div(tag.input(nil, type: "radio", name: element_name, value: choice.first[0], checked: input_value.to_s == choice.first[0].to_s, required: true), class: "input-group-text"), class: "input-group-prepend") + + tag.input(nil, type: "text", placeholder: choice.first[1], class: "form-control", aria: {label: aria_label }) + end + end.join.html_safe + end + + def input_checkbox_control(setting, form) + meta = setting.meta + input_value = parse_input_value(setting, form) + aria_label = 'Checkbox button for following text input' + meta.enum.collect do |choice| + choice = send(choice) if choice.is_a?(String) + input_group do + tag.div(tag.div(tag.input(nil, type: 'checkbox', name: "#{input_name_for(setting, form)}[]", value: choice.first[0], checked: input_value.include?(choice.first[0].to_s), required: false), class: 'input-group-text'), + class: 'input-group-prepend') + + tag.input(nil, type: 'text', placeholder: choice.first[1], class: 'form-control', aria: {label: aria_label }) + end + end.join.html_safe + end + + def parse_input_value(setting, form) + value_for(setting, form) || setting.item || setting.meta.default + end + + def input_file_control(setting, form) + meta = setting.meta + id = setting.key.to_s + aria_describedby = id + label = meta.label + input_value = setting.item || meta.default + + preview = if input_value.present? + tag.img(class: 'w-100', src: "data:#{meta.type};base64,#{input_value}") + else + tag.span('No logo') + end + + control_inputs = + tag.div(class: "input-group-prepend") do + tag.span('Upload', class: "input-group-text", id: id) + end + + tag.div(class: "custom-file") do + tag.input(nil, type: "file", id: id, name: form&.object_name.to_s + "[#{setting.key}]", class: "custom-file-input", aria: { describedby: aria_describedby }) + + tag.label('Choose File', for: id, value: label, class: "custom-file-label") + end + + tag.div(class: "col-2") do + preview + end + + tag.div(class: 'input-group') do + control_inputs + end + end + + def input_text_control(setting, form, options = {}) + id = setting[:key].to_s + meta = setting[:meta] + input_value = value_for(setting, form, options) || setting.item || meta&.default + + is_required = meta[:is_required] == false ? meta[:is_required] : true + placeholder = "Enter #{meta[:label]}".gsub('*','') if meta[:description].blank? + + tag.input(nil, type: "text", value: input_value, id: id, name: input_name_for(setting, form), placeholder: placeholder, class: "form-control", required: is_required) + end + + def input_number_control(setting, form) + id = setting[:key].to_s + meta = setting[:meta] + input_value = value_for(setting, form) || meta.value || meta.default + placeholder = "Enter #{meta[:label]}".gsub('*','') if meta[:description].blank? + + tag.input(nil, type: "number", step: "any", value: input_value, id: id, name: input_name_for(setting, form), placeholder: placeholder, class: "form-control", required: true, oninput: "check(this)") + end + + def input_email_control(setting, form) + id = setting[:key].to_s + meta = setting[:meta] + input_value = meta.value || meta.default + + tag.input(nil, type: "email", step: "any", value: input_value, id: id, name: input_name_for(setting, form),class: "form-control", required: true, oninput: "check(this)") + end + + def input_color_control(setting) + id = setting[:key].to_s + input_value = setting[:value] || setting[:default] + + tag.input(nil, type: "color", value: input_value, id: id) + end + + def input_swatch_control(setting, form) + id = setting[:key].to_s + meta = setting[:meta] + color = meta.value || meta.default + + tag.input(nil, type: "text", value: color, id: id, name: "#{form&.object_name}[value]",class: "js-color-swatch form-control") + + tag.div(tag.button(type: "button", id: id, class: "btn", value: "", style: "background-color: #{color}"), class: "input-group-append") + end + + def input_currency_control(setting, form) + id = setting[:key].to_s + meta = setting[:meta] + input_value = meta.value || meta.default + aria_map = { label: "Amount (to the nearest dollar)"} + + tag.div(tag.span('$', class: "input-group-text"), class: "input-group-prepend") + + tag.input(nil, type: "text", value: input_value, id: id, name: input_name_for(setting, form), class: "form-control", aria: { map: aria_map }) + + tag.div(tag.span('.00', class: "input-group-text"), class: "input-group-append") + end + + def value_for(setting, form, options = {}) + if options[:record].present? + item = setting_value(setting) + options[:record].send(item['attribute']) + else + value = if form.object.class.to_s.match(/^ResourceRegistry.*/).present? + form.object.settings.detect{|s| s.key == setting.key}&.item + else + form.object.send(setting.key) + end + + value = value.to_s if value.is_a?(FalseClass) + value + end + end + + def input_name_for(setting, form) + if form.object.class.to_s.match(/^ResourceRegistry.*/).present? + if form.index.present? + form&.object_name.to_s + "[#{form.index}][#{setting.key}]" + else + form&.object_name.to_s + "[settings][#{setting.key}]" + end + else + form&.object_name.to_s + "[#{setting.key}]" + end + end + + def setting_value(setting) + if setting.is_a?(ResourceRegistry::Setting) && setting.item.is_a?(String) + JSON.parse(setting.item) + else + setting&.item + end + end + + # Wrap any input group in
tag + def input_group + tag.div(yield, class: "input-group") + end +end \ No newline at end of file diff --git a/lib/resource_registry/helpers/view_controls.rb b/lib/resource_registry/helpers/view_controls.rb index 3ad6737d..31f0c466 100644 --- a/lib/resource_registry/helpers/view_controls.rb +++ b/lib/resource_registry/helpers/view_controls.rb @@ -1,404 +1,234 @@ # frozen_string_literal: true +require_relative 'form_group_controls' +# Helper methods to render interface from features/settings module RegistryViewControls - def render_feature(feature, form = nil) - feature = feature.feature if feature.is_a?(ResourceRegistry::FeatureDSL) - tag.div(class: 'card') do - tag.div(class: 'card-header') do - tag.h4(feature.setting(:label)&.item || feature.key.to_s.titleize) - end + - tag.div(class: 'card-body row') do - tag.div(class: 'col-6') do - content = if ['legend'].include?(feature.meta.content_type.to_s) - form.hidden_field(:is_enabled) - else - build_option_field(feature, form) - end - - (content + feature.settings.collect do |setting| - build_option_field(setting, form) if setting.meta - end.compact.join('')).html_safe - end - end - end - end - - def build_option_field(option, form) - type = option.meta.content_type&.to_sym - - input_control = case type - when :swatch - input_swatch_control(option, form) - when :base_64 - input_file_control(option, form) - when :radio_select - input_radio_control(option, form) - when :checkbox_select - input_checkbox_control(option, form) - when :select - select_control(option, form) - when :number - input_number_control(option, form) - when :email - input_email_control(option, form) - when :date - input_date_control(option, form) - when :currency - input_currency_control(option, form) - else - input_text_control(option, form) - end - - if [:radio_select, :checkbox_select].include?(type) - custom_form_group(option, input_control) - else - form_group(option, input_control) - end - end + include ::FormGroupControls - def select_control(setting, form) - id = setting[:key].to_s - selected_option = "Choose..." - meta = setting[:meta] + def render_settings(feature, form, registry, options) + return render_model_settings(feature, form, registry, options) if feature.meta.content_type == :model_attributes - # aria_describedby = id + feature = feature.feature if feature.is_a?(ResourceRegistry::FeatureDSL) + content = form.hidden_field(:is_enabled) - value = value_for(setting, form) || setting.item || meta&.default - option_list = tag.option(selected_option, selected: (value.blank? ? true : false)) - meta.enum.each do |choice| - option_list += tag.option(choice.first[1], selected: (choice.first[0].to_s == value.to_s), value: choice.first[0]) + if ['legend'].include?(feature.meta.content_type.to_s) + content += form.hidden_field(:namespace, value: feature.namespace_path.path.map(&:to_s).join('.')) + elsif feature.meta.content_type == :feature_enabled + content += build_option_field(feature, form, options) end - tag.select(option_list, id: id, class: "form-control", name: input_name_for(setting, form)) + content += feature.settings.collect{|setting| build_option_field(setting, form, options).html_safe if setting.meta}.compact.join.html_safe + content.html_safe end - def select_dropdown(input_id, list, show_default = false, selected = nil) - name = (input_id.to_s.scan(/supported_languages/).present? ? input_id : 'admin[' + input_id.to_s + ']') + def render_model_settings(feature, form, registry, options) + query_setting = feature.settings.detect{|setting| setting.key == :model_query_params} + query_params = setting_value(query_setting) + result = @filter_result + result ||= registry[feature.key]{ query_params || {}}.success + filter_setting = feature.settings.detect{|s| s.key == :filter_params} - return unless list.is_a? Array - content_tag(:select, class: "form-control", id: input_id, name: name, required: true) do - concat(content_tag(:option, "Select", value: "")) unless show_default - list.each do |item| - if item.is_a? Array - is_selected = false - is_selected = true if selected.present? && selected == item[1] - concat(content_tag(:option, item[0], value: item[1], selected: is_selected)) - elsif item.is_a? Hash - concat(content_tag(:option, item.first[1], value: item.first[0])) - elsif input_id == 'state' - concat(content_tag(:option, item.to_s.titleize, value: item)) - elsif show_default - concat(content_tag(:option, item, value: item)) - else - concat(content_tag(:option, item.to_s.humanize, value: item)) - end + content = '' + content = input_filter_control(form, feature, result).html_safe if filter_setting + content += form.hidden_field(:is_enabled) + content += form.hidden_field(:namespace, value: feature.namespace_path.path.map(&:to_s).join('.')) + + if result[:record] + feature.settings.each do |setting| + next if setting.meta.blank? || setting.key == :filter_params + content += build_option_field(setting, form, options.merge(record: result[:record])) end end - end - def input_import_control(setting, _form) - id = setting[:key].to_s - aria_describedby = id - label = setting[:title] || id.titleize - - tag.div(class: "input-group-prepend") do - tag.span('Upload', class: "input-group-text", id: id) - end + - tag.div(class: "custom-file") do - tag.input(nil, type: "file", id: id, name: id + "[value]", class: "custom-file-input", aria: { describedby: aria_describedby }) + - tag.label('Choose File', for: id, value: label, class: "custom-file-label") - end + content.html_safe end - def input_radio_control(setting, form) - meta = setting.meta - input_value = value_for(setting, form) || setting.item || meta&.default - aria_label = "Radio button for following text input" #setting[:aria_label] || "Radio button for following text input" - - if setting.is_a?(ResourceRegistry::Setting) - element_name = input_name_for(setting, form) - else - element_name = form&.object_name.to_s + "[is_enabled]" - input_value = form.object&.is_enabled - input_value = setting.is_enabled if input_value.blank? + def namespace_panel(namespace, feature_registry, options = {}) + tag.div(class: 'card') do + tag.div(class: 'card-body') do + if namespace.features.any?{|f| f.meta.content_type == :model_attributes} + namespace.features.collect{|feature| construct_feature_form(feature, feature_registry, options)}.join(tag.hr(class: 'mt-2 mb-3')).html_safe + else + construct_namespace_form(namespace, feature_registry, options) + end + end.html_safe end - - meta.enum.collect do |choice| - choice = send(choice) if choice.is_a?(String) - input_group do - tag.div(tag.div(tag.input(nil, type: "radio", name: element_name, value: choice.first[0], checked: input_value.to_s == choice.first[0].to_s, required: true), class: "input-group-text"), class: "input-group-prepend") + - tag.input(nil, type: "text", placeholder: choice.first[1], class: "form-control", aria: {label: aria_label }) - end - end.join('').html_safe - end - - def input_checkbox_control(setting, form) - meta = setting.meta - input_value = value_for(setting, form) || setting.item || meta&.default - aria_label = 'Checkbox button for following text input' - meta.enum.collect do |choice| - choice = send(choice) if choice.is_a?(String) - input_group do - tag.div(tag.div(tag.input(nil, type: 'checkbox', name: "#{input_name_for(setting, form)}[]", value: choice.first[0], checked: input_value.include?(choice.first[0].to_s), required: false), class: 'input-group-text'), class: 'input-group-prepend') + - tag.input(nil, type: 'text', placeholder: choice.first[1], class: 'form-control', aria: {label: aria_label }) - end - end.join('').html_safe end - def input_file_control(setting, form) - meta = setting.meta - id = setting.key.to_s - aria_describedby = id - label = meta.label - input_value = setting.item || meta.default + def construct_namespace_form(namespace, _registry, options) + form_for(namespace, as: 'namespace', url: update_namespace_exchanges_configurations_path, method: :post, remote: true, authenticity_token: true) do |form| + namespace_content = form.hidden_field(:path, value: namespace.path.map(&:to_s).join('.')) - preview = if input_value.present? - tag.img(class: 'w-100', src: "data:#{meta.type};base64,#{input_value}") - else - tag.span('No logo') - end - - control_inputs = - tag.div(class: "input-group-prepend") do - tag.span('Upload', class: "input-group-text", id: id) - end + - tag.div(class: "custom-file") do - tag.input(nil, type: "file", id: id, name: form&.object_name.to_s + "[#{setting.key}]", class: "custom-file-input", aria: { describedby: aria_describedby }) + - tag.label('Choose File', for: id, value: label, class: "custom-file-label") + namespace.features.each_with_index do |feature, index| + namespace_content += form.fields_for :features, feature, {index: index} do |feature_form| + tag.div(id: feature.key.to_s, role: 'tabpanel', 'aria-labelledby': "list-#{feature.key}-list", class: 'mt-2') do + feature_form.hidden_field(:key) + + render_settings(feature, feature_form, feature_registry, options) + end + end end - control = - tag.div(class: "col-2") do - preview - end + - tag.div(class: 'input-group') do - control_inputs + namespace_content += tag.div(class: 'row mt-3') do + tag.div(class: 'col-4') do + form.submit('Save', class: 'btn btn-primary') + end + + tag.div(class: 'col-6') do + tag.div(class: 'flash-message', id: "#{namespace.path.map(&:to_s).join('-')}-alert") + end end - control + namespace_content.html_safe + end end - # Wrap any input group in
tag - def input_group - tag.div(yield, class: "input-group") - end + def feature_panel(feature_key, registry, options = {}) + @filter_result = options[:filter_result] + @horizontal = true if options[:horizontal] - def value_for(setting, form) - value = if form.object.class.to_s.match(/^ResourceRegistry.*/).present? - form.object.settings.where(key: setting.key).first&.item - else - form.object.send(setting.key) + tag.div(class: 'card') do + tag.div(class: 'card-body') do + feature = get_feature(feature_key, registry) + if feature.present? + features = [feature] + if feature.item == 'features_display' + feature_group_display(feature, registry) + else + if feature.item == 'feature_collection' + list_panel_setting = feature.settings.detect{|setting| setting.meta&.content_type.to_s == 'feature_list_panel'} + features = setting_value(list_panel_setting) end - - value = value.to_s if value.is_a?(FalseClass) - value - end - - def input_name_for(setting, form) - if form.object.class.to_s.match(/^ResourceRegistry.*/).present? - form&.object_name.to_s + "[settings][#{setting.key}]" - else - form&.object_name.to_s + "[#{setting.key}]" + features.collect{|f| construct_feature_form(f, registry, options)}.join(tag.hr(class: 'mt-2 mb-3')).html_safe + end + end + end end end - def input_text_control(setting, form) - id = setting[:key].to_s - - meta = setting[:meta] - input_value = value_for(setting, form) || setting.item || meta&.default - # aria_describedby = id - is_required = meta&.is_required == false ? meta.is_required : true - placeholder = "Enter #{meta[:label]}".gsub('*','') if meta[:description].blank? - # if meta[:attribute] - # tag.input(nil, type: "text", value: input_value, id: id, name: form&.object_name.to_s + "[#{id}]",class: "form-control", required: true) - # else - tag.input(nil, type: "text", value: input_value, id: id, name: input_name_for(setting, form), placeholder: placeholder, class: "form-control", required: is_required) - # end - end - - def input_date_control(setting, form) - id = setting[:key].to_s - - date_value = value_for(setting, form) - date_value = date_value.to_date if date_value.is_a?(Time) - date_value = date_value.to_s(:db) if date_value.is_a?(Date) - - meta = setting[:meta] - input_value = date_value || setting.item || meta&.default - # aria_describedby = id - - is_required = meta&.is_required == false ? meta.is_required : true - - tag.input(nil, type: "date", value: input_value, id: id, name: input_name_for(setting, form), placeholder: "mm/dd/yyyy", class: "form-control", required: is_required) - end - - def input_number_control(setting, form) - id = setting[:key].to_s - meta = setting[:meta] - input_value = value_for(setting, form) || meta.value || meta.default - # input_value = setting[:value] || setting[:default] - # aria_describedby = id - placeholder = "Enter #{meta[:label]}".gsub('*','') if meta[:description].blank? - - # if setting[:attribute] - tag.input(nil, type: "number", step: "any", value: input_value, id: id, name: input_name_for(setting, form), placeholder: placeholder, class: "form-control", required: true, oninput: "check(this)") - # else - # tag.input(nil, type: "number", step:"any", value: input_value, id: id, name: form&.object_name.to_s + "[value]",class: "form-control", required: true, oninput: "check(this)") - # end - end - - def input_email_control(setting, form) - id = setting[:key].to_s - meta = setting[:meta] - input_value = meta.value || meta.default - # input_value = setting[:value] || setting[:default] - # aria_describedby = id + def construct_feature_form(feature, registry, options) + renew_action = options[:action_params][:action].to_s == 'renew' if options[:action_params] - # if setting[:attribute] - tag.input(nil, type: "email", step: "any", value: input_value, id: id, name: input_name_for(setting, form),class: "form-control", required: true, oninput: "check(this)") - # else - # tag.input(nil, type: "email", step:"any", value: input_value, id: id, name: form&.object_name.to_s + "[value]",class: "form-control", required: true, oninput: "check(this)") - # end - end - - def input_color_control(setting) - id = setting[:key].to_s - input_value = setting[:value] || setting[:default] - - tag.input(nil, type: "color", value: input_value, id: id) - end - - def input_swatch_control(setting, form) - # id = setting[:key].to_s - # color = setting[:value] || setting[:default] - id = setting[:key].to_s - meta = setting[:meta] - color = meta.value || meta.default - - tag.input(nil, type: "text", value: color, id: id, name: form&.object_name.to_s + "[value]",class: "js-color-swatch form-control") + - tag.div(tag.button(type: "button", id: id, class: "btn", value: "", style: "background-color: #{color}"), class: "input-group-append") - end - - def input_currency_control(setting, form) - id = setting[:key].to_s - meta = setting[:meta] - input_value = meta.value || meta.default - - # id = setting[:key].to_s - # input_value = setting[:value] || setting[:default] - aria_map = { label: "Amount (to the nearest dollar)"} - - tag.div(tag.span('$', class: "input-group-text"), class: "input-group-prepend") + - tag.input(nil, type: "text", value: input_value, id: id, name: input_name_for(setting, form), class: "form-control", aria: { map: aria_map }) + - tag.div(tag.span('.00', class: "input-group-text"), class: "input-group-append") - end - - def build_attribute_field(form, attribute) - setting = { - key: attribute, - default: form.object.send(attribute), - type: :string, - attribute: true - } - - input_control = input_text_control(setting, form) - form_group(setting, input_control) - end - - - ## FORM GROUPS - - # Build a general-purpose form group wrapper around the supplied input control - def form_group(setting, control) - id = setting[:key].to_s - # label = setting[:title] || id.titleize - label = setting.meta.label || id.titleize - help_id = id + 'HelpBlock' - # help_text = setting[:description] - # aria_label = setting[:aria_label] || "Radio button for following text input" - help_text = setting.meta.description - aria_label = "Radio button for following text input" #setting[:aria_label] || "Radio button for following text input" + submit_path = if renew_action + renew_feature_exchanges_configuration_path(feature.key) + else + update_feature_exchanges_configuration_path(feature.key) + end - tag.div(class: "form-group") do - tag.label(for: id, value: label, aria: { label: aria_label }) do - label - end + - input_group { control } + tag.small(help_text, id: help_id, class: ['form-text', 'text-muted']) + tag.div(id: feature.key.to_s, 'aria-labelledby': "list-#{feature.key}-list", class: 'card border-0') do + tag.div(class: 'card-body') do + tag.div(class: 'card-title h6 font-weight-bold mb-4') do + feature.meta&.label || feature.key.to_s.titleize + end + + form_for(feature, as: 'feature', url: submit_path, method: :post, remote: true, authenticity_token: true) do |form| + form.hidden_field(:key) + + (renew_action ? hidden_field_tag('feature[target_feature]', options[:action_params][:key]) : '') + + render_settings(feature, form, registry, options) + + tag.div(class: 'row mt-3') do + tag.div(class: 'col-4') do + form.submit('Save', class: 'btn btn-primary') + end + + tag.div(class: 'col-6') do + tag.div(class: 'flash-message', id: "#{feature.key}-alert") + end + end + end + end end end - def custom_form_group(setting, control) - id = setting[:key].to_s - # label = setting[:title] || id.titleize - label = setting.meta.label || id.titleize - help_id = id + 'HelpBlock' - help_text = setting.meta.description - aria_label = "#{setting.meta.content_type.to_s.humanize} button for following text input" #setting[:aria_label] || "Radio button for following text input" - - tag.div(class: "form-group") do - tag.label(for: id, value: label, aria: { label: aria_label }) do - label - end + - control + tag.small(help_text, id: help_id, class: ['form-text', 'text-muted']) + def feature_group_display(feature, registry) + tag.div(id: feature.key.to_s, role: 'tabpanel', 'aria-labelledby': "list-#{feature.key}-list") do + feature.settings.collect do |setting| + if setting.meta&.content_type.to_s == 'feature_group' + features = setting_value(setting) + feature_group_control(features, registry).html_safe + end + end.compact.join.html_safe end end - def list_group_menu(nested_namespaces = nil, features = nil, options = {}) - content = '' - tag.div({class: "list-group", id: "list-tab", role: "tablist"}.merge(options)) do - - if features - features.each do |feature| - feature_rec = ResourceRegistry::ActiveRecord::Feature.where(key: feature).first + def feature_group_control(features, _registry) + features = features.select{|feature| feature.meta.present? && feature.meta.content_type.to_s != 'feature_action' } - content += tag.a(href: "##{feature}", class: "list-group-item list-group-item-action border-0", 'data-toggle': 'list', role: 'tab', id: "list-#{feature}-list", 'aria-controls': feature.to_s) do - feature_rec&.setting(:label)&.item || feature.to_s.titleize - end.html_safe - end + features.collect do |feature| + tag.div(class: 'mt-3') do + render_feature_action(feature) + render_feature_list(feature) end + end.join + end - if nested_namespaces - nested_namespaces.each do |namespace, children| - - content += tag.a(href: "##{namespace}-group", class: "list-group-item list-group-item-action border-0", 'data-toggle': 'collapse', role: 'tab', id: "list-#{namespace}-list", 'aria-controls': namespace.to_s) do - "+ #{namespace.to_s.titleize}" - end - - content += tag.span('data-toggle': 'list') do - list_group_menu(children[:namespaces], children[:features], {class: "list-group collapse ml-4", id: "#{namespace}-group"}) + def render_feature_action(feature) + settings_with_meta = feature.settings.select{|s| s.meta.present?} + tag.div(class: 'row') do + tag.div(class: 'col-md-6') do + tag.h4 do + feature.meta&.label || feature.key.to_s.titleize + end + end + + tag.div(class: 'col-md-6') do + action_setting = settings_with_meta.detect{|setting| setting.meta.content_type.to_s == 'feature_action'} + if action_setting + form_with(model: feature, url: action_setting.item, method: :get, remote: true, local: false) do |f| + hidden_field_tag('feature[action]', 'renew') + + hidden_field_tag('feature[key]', feature.key) + + f.submit(action_setting.key.to_s.titleize, class: 'btn btn-link') + end.html_safe end end - end - - content.html_safe end end - def list_tab_panels(features, _feature_registry, _options = {}) - tag.div(class: "tab-content", id: "nav-tabContent") do - content = '' - - features.each do |feature_key| - feature = ResourceRegistry::ActiveRecord::Feature.where(key: feature_key).first - next if feature.blank? - content += tag.div(class: 'tab-pane fade', id: feature_key.to_s, role: 'tabpanel', 'aria-labelledby': "list-#{feature_key}-list") do - form_for(feature, as: 'feature', url: configuration_path(feature), method: :patch, remote: true, authenticity_token: true) do |form| - form.hidden_field(:key) + - render_feature(feature, form) + - tag.div(class: 'row mt-3') do - tag.div(class: 'col-4') do - form.submit(class: 'btn btn-primary') - end + - tag.div(class: 'col-6') do - tag.div(class: 'flash-message', id: feature_key.to_s + '-alert') - end + def render_feature_list(feature) + settings_with_meta = feature.settings.select{|s| s.meta.present?} + settings_with_meta.collect do |setting| + next if setting.meta.content_type.to_s == 'feature_action' + section_name = setting.meta&.label || setting.key.to_s.titleize + tag.div(class: 'mt-3') do + tag.div(class: 'row') do + tag.div(class: 'col-md-4') do + tag.strong do + section_name + end + end + + tag.div(class: 'col-md-4') do + tag.a(href: "/exchanges/configurations/#{feature.key}/edit", data: {remote: true}) do + tag.span do + "View #{section_name}" + end end - end + end + + tag.div(class: 'col-md-6') do + tag.ul(class: 'list-group list-group-flush ml-2') do + feature_list = setting_value(setting) + feature_list.collect{|f| tag.li(class: 'list-group-item'){ f.key.to_s.titleize }}.join.html_safe + end + end end end + end.compact.join.html_safe + end - content.html_safe + def get_feature(feature_key, registry) + defined?(Rails) ? find_feature(feature_key) : registry[feature_key].feature + end + + def find_feature(feature_key) + feature_class = ResourceRegistry::Stores.feature_model + return unless feature_class + feature_class.where(key: feature_key).first + end + + def setting_value(setting) + value = if setting.is_a?(ResourceRegistry::Setting) + JSON.parse(setting.item) + else + setting.item + end + + if value.is_a?(Hash) && value['operation'] + elements = value['operation'].split(/\./) + elements[0].constantize.send(elements[1]).call(value['params'].symbolize_keys).success + else + value end end -end +end \ No newline at end of file diff --git a/lib/resource_registry/meta.rb b/lib/resource_registry/meta.rb index 297bcf60..dd6da2ae 100644 --- a/lib/resource_registry/meta.rb +++ b/lib/resource_registry/meta.rb @@ -37,7 +37,7 @@ class Meta < Dry::Struct # @!attribute [r] enum # List of vaalid domain values when configuration values are constrained to an enumerated set # @return [Array] - attribute :enum, Types::Array.of(Types::Any).optional.meta(omittable: true) + attribute :enum, Types::Any.optional.meta(omittable: true) # @!attribute [r] is_required # Internal indicator whether the configuration setting value must be set in the UI diff --git a/lib/resource_registry/models/mongoid/feature.rb b/lib/resource_registry/models/mongoid/feature.rb index 31923b79..f320ea79 100644 --- a/lib/resource_registry/models/mongoid/feature.rb +++ b/lib/resource_registry/models/mongoid/feature.rb @@ -3,17 +3,30 @@ module ResourceRegistry module Mongoid class Feature - include Mongoid::Document - include Mongoid::Timestamps + include ::Mongoid::Document + include ::Mongoid::Timestamps field :key, type: Symbol - field :namespace, type: Array field :is_enabled, type: Boolean field :item, type: String - embeds_one :meta, class_name: 'ResourceRegistry::Mongoid::Meta' - embeds_many :settings, class_name: 'ResourceRegistry::Mongoid::Setting' + embeds_one :meta, as: :metable, class_name: '::ResourceRegistry::Mongoid::Meta', cascade_callbacks: true + embeds_one :namespace_path, class_name: '::ResourceRegistry::Mongoid::NamespacePath', cascade_callbacks: true + embeds_many :settings, class_name: '::ResourceRegistry::Mongoid::Setting', cascade_callbacks: true + def setting(key) + settings.detect{|setting| setting.key.to_s == key.to_s} + end + + def item + JSON.parse(super) if super.present? + rescue JSON::ParserError + super + end + + def item=(value) + write_attribute(:item, value.to_json) + end end end -end +end \ No newline at end of file diff --git a/lib/resource_registry/models/mongoid/meta.rb b/lib/resource_registry/models/mongoid/meta.rb index 2a760cf5..5d92b16d 100644 --- a/lib/resource_registry/models/mongoid/meta.rb +++ b/lib/resource_registry/models/mongoid/meta.rb @@ -3,18 +3,37 @@ module ResourceRegistry module Mongoid class Meta - include Mongoid::Document - include Mongoid::Timestamps + include ::Mongoid::Document + include ::Mongoid::Timestamps field :label, type: String - field :type, type: Symbol + field :content_type, type: Symbol field :default, type: String field :value, type: String field :description, type: String - field :enum, type: Array + field :enum, type: String field :is_required, type: Boolean field :is_visible, type: Boolean + embedded_in :metable, polymorphic: true + + def value_hash + JSON.parse(value) + end + + def value=(val) + super(val.to_json) + end + + def enum=(val) + super(val.to_json) + end + + def enum + JSON.parse(super) if super.present? + rescue JSON::ParserError + super + end end end -end +end \ No newline at end of file diff --git a/lib/resource_registry/models/mongoid/namespace_path.rb b/lib/resource_registry/models/mongoid/namespace_path.rb new file mode 100644 index 00000000..04253dae --- /dev/null +++ b/lib/resource_registry/models/mongoid/namespace_path.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module ResourceRegistry + module Mongoid + class NamespacePath + include ::Mongoid::Document + include ::Mongoid::Timestamps + + field :path, type: Array + + embeds_one :meta, as: :metable, class_name: '::ResourceRegistry::Mongoid::Meta', cascade_callbacks: true + embedded_in :feature, class_name: '::ResourceRegistry::Mongoid::Feature' + end + end +end diff --git a/lib/resource_registry/models/mongoid/setting.rb b/lib/resource_registry/models/mongoid/setting.rb index 871fffb8..2ee43a6a 100644 --- a/lib/resource_registry/models/mongoid/setting.rb +++ b/lib/resource_registry/models/mongoid/setting.rb @@ -2,15 +2,25 @@ module ResourceRegistry module Mongoid class Setting - include Mongoid::Document - include Mongoid::Timestamps + include ::Mongoid::Document + include ::Mongoid::Timestamps field :key, type: Symbol field :options, type: Array field :item, type: String - embeds_one :meta, class_name: 'ResourceRegistry::Mongoid::Meta' + embeds_one :meta, as: :metable, class_name: '::ResourceRegistry::Mongoid::Meta', cascade_callbacks: true + embedded_in :feature, class_name: '::ResourceRegistry::Mongoid::Feature' + def item + JSON.parse(super) if super.present? + rescue JSON::ParserError + super + end + + def item=(value) + write_attribute(:item, value.to_json) + end end end end diff --git a/lib/resource_registry/namespace.rb b/lib/resource_registry/namespace.rb new file mode 100644 index 00000000..869000ef --- /dev/null +++ b/lib/resource_registry/namespace.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require_relative 'validation/namespace_contract' +require_relative 'operations/namespaces/build' +require_relative 'operations/namespaces/create' +require_relative 'operations/namespaces/form' +require_relative 'operations/namespaces/update_features' +require_relative 'operations/namespaces/list_features' +require_relative 'operations/graphs/create' + +module ResourceRegistry + # Define a namespace container for organizing features + # + # @example Define the namespace + # Namespace.new(key: 'ue46632342', path: [:app, :greeter]) + class Namespace < Dry::Struct + + # @!attribute [r] key (required) + # Identifier for this Namespace. 10 character long MD5 digest key + # @return [Symbol] + attribute :key, Types::Symbol.meta(omittable: false) + + # @!attribute [r] path (required) + # The registry namespace where this item is stored + # @return [Array] + attribute :path, Types::Array.of(Types::RequiredSymbol).default([].freeze).meta(omittable: false) + + # @!attribute [r] meta (optional) + # Configuration settings and attributes that support presenting and updating their values in the User Interface + # @return [ResourceRegistry::Meta] + attribute :meta, ResourceRegistry::Meta.default({}.freeze).meta(omittable: true) + + # @!attribute [r] feature_keys (optional) + # Key references to the features under this namespace + # @return [Array] + attribute :feature_keys, Types::Array.of(Types::RequiredSymbol).default([].freeze).meta(omittable: true) + + # @!attribute [r] features (optional) + # @deprecated Use {feature_keys} instead + # List of full feature definitions under this namespace + # @return [Array] + attribute :features, Types::Array.of(::ResourceRegistry::Feature).meta(omittable: true) + + # @!attribute [r] namespaces (optional) + # Namespaces that are nested under this namespace + # @return [Array] + attribute :namespaces, Types::Array.of(::ResourceRegistry::Namespace).meta(omittable: true) + + + def persisted? + false + end + end +end \ No newline at end of file diff --git a/lib/resource_registry/namespace_path.rb b/lib/resource_registry/namespace_path.rb new file mode 100644 index 00000000..bf4c31a9 --- /dev/null +++ b/lib/resource_registry/namespace_path.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require_relative 'validation/namespace_path_contract' + +module ResourceRegistry + # Define a Feature together with its settings, code hook for dependency injection, and configuration UI attributes + # + # @example Define the feature + # Feature.new(key: :greeter, item: proc { |name| "Hello #{name}!" }) + # Feature.new(key: :logger, item: Logger.new(STDERR), settings: [{default: :warn}]) + class NamespacePath < Dry::Struct + # @!attribute [r] item (required) + # The reference or code to be evaluated when feature is resolved + # @return [Any] + attribute :path, Types::Array.of(Types::RequiredSymbol).optional.meta(omittable: false) + + # @!attribute [r] meta (optional) + # Configuration settings and attributes that support presenting and updating their values in the User Interface + # @return [ResourceRegistry::Meta] + attribute :meta, ::ResourceRegistry::Meta.optional.meta(omittable: true) + end +end \ No newline at end of file diff --git a/lib/resource_registry/navigation.rb b/lib/resource_registry/navigation.rb new file mode 100644 index 00000000..bec8ec24 --- /dev/null +++ b/lib/resource_registry/navigation.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require 'action_view' +module ResourceRegistry + # This would render navigation menu from registerd namespaces and features + class Navigation + include ActionView::Helpers::TagHelper + include ActionView::Context + + TAG_OPTION_DEFAULTS = { + ul: { + options: {class: 'nav flex-column flex-nowrap overflow-auto'} + }, + nested_ul: { + options: {class: 'flex-column nav pl-4'} + }, + li: { + options: {class: 'nav-item'} + }, + a: { + namespace_link: { + options: {class: 'nav-link collapsed text-truncate', 'data-toggle': 'collapse'} + }, + feature_link: { + options: {class: 'nav-link', 'data-remote': true} + } + } + }.freeze + + AUTHORIZATION_DEFAULTS = { + authorization_defaults: {} + }.freeze + + NAMESPACE_OPTION_DEFAULTS = { + include_all_disabled_features: true, + include_no_features_defined: true, + active_item: [:aca_shop, :benefit_market_catalogs, :catalog_2019], # TODO + starting_namespaces: [] # start vertices for graph + }.freeze + + OPTION_DEFAULTS = { + tag_options: TAG_OPTION_DEFAULTS, + authorization_options: AUTHORIZATION_DEFAULTS, + namespace_options: NAMESPACE_OPTION_DEFAULTS + }.freeze + + attr_reader :options, :namespaces + + def initialize(registry, options = {}) + @registry = registry + @graph = registry[:feature_graph] + @options = OPTION_DEFAULTS.deep_merge(options) + + build_namespaces + end + + def render_html + namespaces.collect{|namespace| to_ul(vertex: namespace)}.join.html_safe + end + + private + + def root_vertices + @graph.vertices_for(options[:namespace_options]) + end + + def build_namespaces + @namespaces = root_vertices.collect{|vertex| to_namespace(vertex)} + end + + def to_namespace(vertex) + namespace_dict = [vertex].inject({}) do |dict, record| + dict.merge!(record.to_h) + dict[:features] = dict[:feature_keys].collect{|key| @registry[key].feature.to_h.except(:settings)} + dict[:namespaces] = @graph.adjacent_vertices(record).collect{|adjacent_vertex| to_namespace(adjacent_vertex)} + attrs_to_skip = [:feature_keys] + attrs_to_skip << :meta if dict[:meta].empty? + dict.except(*attrs_to_skip) + end + result = ResourceRegistry::Validation::NamespaceContract.new.call(namespace_dict) + raise "Unable to construct graph due to #{result.errors.to_h}" unless result.success? + result.to_h + end + + def to_ul(vertex:, nested: false) + dict = to_namespace(vertex) if vertex.is_a?(ResourceRegistry::Namespace) + + tag.ul(options[:tag_options][(nested ? :nested_ul : :ul)][:options]) do + to_li(dict || vertex) + end + end + + def to_li(element) + tag.li(options[:tag_options][:li][:options]) do + if element[:namespaces] || element[:features] + namespace_nav_link(element) + content_to_expand(element) + else + feature_nav_link(element) + end + end + end + + def content_to_expand(element) + tag.div(class: 'collapse', id: "nav_#{element[:key]}", 'aria-expanded': 'false') do + nav_features = element[:features].select{|feature| feature[:meta][:content_type] == :nav} + (nav_features + element[:namespaces]).reduce('') do |list, child_ele| + list + to_ul(vertex: child_ele, nested: true) + end.html_safe + end + end + + def namespace_nav_link(element) + tag.a(options[:tag_options][:a][:namespace_link][:options].merge(id: 'namespace-link', href: "#nav_#{element[:key]}", data: {target: "#nav_#{element[:key]}", feature: element[:features][0]&.[](:key)})) do + tag.span do + link_title(element) + # element[:namespaces] ? element[:path].last.to_s.titleize : element[:meta][:label] + end + end + end + + def feature_nav_link(element) + feature_url = element[:item] if element[:item].to_s.match?(%r{^/.*$}) + feature_url ||= "/exchanges/configurations/#{element[:key]}/edit" + tag.a(options[:tag_options][:a][:feature_link][:options].merge(href: feature_url)) do + tag.span do + link_title(element) + # element[:namespaces] ? element[:path].last.to_s.titleize : element[:meta][:label] + end + end + end + + def link_title(element) + if element[:meta] + element[:meta][:label] + else + element[:path].last.to_s.titleize + end + end + end +end diff --git a/lib/resource_registry/operations/configurations/create.rb b/lib/resource_registry/operations/configurations/create.rb index 63fc1dd6..865c1095 100644 --- a/lib/resource_registry/operations/configurations/create.rb +++ b/lib/resource_registry/operations/configurations/create.rb @@ -3,7 +3,6 @@ module ResourceRegistry module Operations module Configurations - # Create a Configuration class Create send(:include, Dry::Monads[:result, :do]) diff --git a/lib/resource_registry/operations/features/authorize.rb b/lib/resource_registry/operations/features/authorize.rb index b21a1366..91178a21 100644 --- a/lib/resource_registry/operations/features/authorize.rb +++ b/lib/resource_registry/operations/features/authorize.rb @@ -20,8 +20,7 @@ def call(_account, _domain) private - def verify(params) - end + def verify(params); end end end diff --git a/lib/resource_registry/operations/features/configure.rb b/lib/resource_registry/operations/features/configure.rb index 8600bb4e..a57ec9db 100644 --- a/lib/resource_registry/operations/features/configure.rb +++ b/lib/resource_registry/operations/features/configure.rb @@ -6,11 +6,10 @@ module Features class Configure send(:include, Dry::Monads[:result, :do]) - def call(params) + def call(_params) yield configuration end - private end end diff --git a/lib/resource_registry/operations/features/create.rb b/lib/resource_registry/operations/features/create.rb index bc94a503..02baeafe 100644 --- a/lib/resource_registry/operations/features/create.rb +++ b/lib/resource_registry/operations/features/create.rb @@ -8,30 +8,33 @@ class Create send(:include, Dry::Monads[:result, :do]) def call(params) - feature_values = yield validate(params) - feature = yield create(feature_values) + params = yield construct(params) + values = yield validate(params) + feature = yield create(values) Success(feature) end private - def validate(params) - result = ResourceRegistry::Validation::FeatureContract.new.call(params) - - if result.success? - Success(result.to_h) - else - Failure(result) + def construct(params) + if params[:namespace_path].blank? + params['namespace_path'] = params['namespace'].is_a?(Hash) ? params.delete('namespace') : {path: params.delete('namespace')} end + + Success(params) + end + + def validate(params) + ResourceRegistry::Validation::FeatureContract.new.call(params) end - def create(feature_values) - feature = ResourceRegistry::Feature.new(feature_values) + def create(values) + feature = ResourceRegistry::Feature.new(values.to_h) Success(feature) end end end end -end +end \ No newline at end of file diff --git a/lib/resource_registry/operations/features/disable.rb b/lib/resource_registry/operations/features/disable.rb index 00eeb2c2..766b3b83 100644 --- a/lib/resource_registry/operations/features/disable.rb +++ b/lib/resource_registry/operations/features/disable.rb @@ -13,8 +13,7 @@ class Disable def call(name:, options: {}) feature(name).disable(args) end - end end end -end +end \ No newline at end of file diff --git a/lib/resource_registry/operations/features/enable.rb b/lib/resource_registry/operations/features/enable.rb index 2968e9c6..e1726b55 100644 --- a/lib/resource_registry/operations/features/enable.rb +++ b/lib/resource_registry/operations/features/enable.rb @@ -13,8 +13,7 @@ class Enable def call(name:, options: {}) feature(name).enable(args) end - end end end -end +end \ No newline at end of file diff --git a/lib/resource_registry/operations/features/renew.rb b/lib/resource_registry/operations/features/renew.rb new file mode 100644 index 00000000..f30ae6ec --- /dev/null +++ b/lib/resource_registry/operations/features/renew.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +module ResourceRegistry + module Operations + module Features + # Renew the given feature along with all associated features + class Renew + send(:include, Dry::Monads[:result, :do, :try]) + + attr_reader :original_year, :new_calender_year + + def call(params:, registry:) + options = yield extract_options(params, registry) + params = yield construct_params(options) + features = yield create(params) + yield persist(features, registry) + + Success(features) + end + + private + + def extract_options(params, registry) + features = [] + features << registry[params[:target_feature]].feature + + related_features = features[0].settings.collect do |setting| + get_features(setting.item) if setting.meta && setting.meta.content_type == :feature_list_panel + end.compact.flatten + + features += related_features.collect{|feature| registry[feature.key].feature} + + options = { + features: features, + calender_year: params[:settings][:calender_year] + } + + Success(options) + end + + def construct_params(options) + @original_year = options[:features][0].key.to_s.scan(/\d{4}/)[0] + @new_calender_year = options[:calender_year] + features_params = options[:features].collect{|feature| serialize_hash(feature.to_h)} + + Success(features_params) + end + + def create(feature_hashes) + Try do + feature_hashes.collect do |feature_hash| + result = ResourceRegistry::Operations::Features::Create.new.call(feature_hash) + return result if result.failure? + result.value! + end + end.to_result + end + + def persist(features, registry) + features.each do |feature| + ResourceRegistry::Stores.persist(feature, registry) if defined? Rails + registry.register_feature(feature) + end + + Success(registry) + end + + def get_features(item) + return item unless item.is_a?(Hash) && item['operation'].present? + elements = item['operation'].split(/\./) + elements[0].constantize.send(elements[1]).call(item['params'].symbolize_keys).success + end + + def serialize_hash(attributes) + attributes.reduce({}) do |values, (key, value)| + values[key] = case value + when Hash + serialize_hash(value) + when Array + value.collect do |element| + element.is_a?(Hash) ? serialize_hash(element) : serialize_text(element) + end + else + serialize_text(value) + end + + values + end + end + + def serialize_text(value) + return value if value.blank? + + if value.is_a?(Symbol) + value.to_s.gsub(original_year, new_calender_year).to_sym + elsif value.is_a?(Range) && value.min.is_a?(Date) + Range.new(new_date(value.begin), new_date(value.end)) + elsif value.is_a?(Date) + value.next_year + elsif value.is_a?(String) || value.to_s.match(/^\d+$/) + value.to_s.gsub(original_year, new_calender_year) + else + value + end + end + + def new_date(ref_date) + Date.new(new_calender_year.to_i, ref_date.month, ref_date.day) + end + end + end + end +end \ No newline at end of file diff --git a/lib/resource_registry/operations/features/update.rb b/lib/resource_registry/operations/features/update.rb index 328b8be9..706da552 100644 --- a/lib/resource_registry/operations/features/update.rb +++ b/lib/resource_registry/operations/features/update.rb @@ -8,69 +8,74 @@ class Update send(:include, Dry::Monads[:result, :do]) def call(params) - registry = params[:registry] + feature_params = yield build_params(params[:feature].to_h, params[:registry]) + feature_values = yield validate(feature_params, params[:registry]) + entity = yield create_entity(feature_values) + yield save_record(entity, params[:registry], params[:filter]) if defined? Rails + yield update_registry(entity, params[:registry]) - feature_params = yield build_params(params[:feature].to_h) - feature_entity = yield create_entity(feature_params) - feature = yield update_model(feature_entity) - yield update_registry(feature_entity, registry) - - Success(feature) + Success(entity) end private - def build_params(params) + def build_params(params, registry) + return feature_toggle_params(params, registry) if params[:toggle_feature] + feature_params = params.deep_symbolize_keys - feature_params[:settings] = feature_params[:settings].collect do |setting_hash| + feature_params[:namespace_path] = {path: params.delete(:namespace)&.split('.')} if params[:namespace].present? + feature_params[:settings] = feature_params[:settings]&.collect do |setting_hash| if setting_hash.is_a?(Hash) {key: setting_hash.keys[0], item: setting_hash.values[0]} else {key: setting_hash[0], item: setting_hash[1]} end end + feature_params[:settings] ||= [] Success(feature_params) end - def create_entity(params) - ResourceRegistry::Operations::Features::Create.new.call(params) - end - - def update_model(feature_entity) - if defined?(Rails) + def feature_toggle_params(params, registry) + feature_params = ResourceRegistry::Stores.find(params[:key])&.success&.attributes&.deep_symbolize_keys + feature_params ||= registry[params[:key]].feature.to_h + feature_params[:is_enabled] = params[:is_enabled] - feature = ResourceRegistry::ActiveRecord::Feature.where(key: feature_entity.key).first + Success(feature_params) + end - feature_entity.settings.each do |setting_entity| - feature.update(is_enabled: feature_entity.is_enabled) - setting = feature.settings.where(key: setting_entity.key).first - setting.update(item: setting_entity.item) - end + def validate(feature_params, registry) + date_range_settings = registry[feature_params[:key]].settings.select{|s| s.meta && s.meta.content_type == :date_range} - Success(feature) - else - Success(feature_entity) - end - end + if date_range_settings.present? + feature_params[:settings].each do |setting_hash| + next unless (date_range_setting = date_range_settings.detect{|s| s.key == setting_hash[:key].to_sym}) + expected_year = date_range_setting.item.min.year - def update_registry(new_feature, registry) - registered_feature_hash = registry[new_feature.key].feature.to_h - registered_feature_hash[:is_enabled] = new_feature.is_enabled + date_begin = Date.strptime(setting_hash[:item][:begin], "%Y-%m-%d") + date_end = Date.strptime(setting_hash[:item][:end], "%Y-%m-%d") - new_feature.settings.each do |setting| - registered_feature_hash[:settings].each do |setting_hash| - setting_hash[:item] = setting.item if setting.key == setting_hash[:key] + return Failure("#{setting_hash[:key].to_s.humanize} should be with in calender year.") unless date_begin.year == expected_year && date_end.year == expected_year + return Failure("#{setting_hash[:key].to_s.humanize} invalid date range selected.") unless date_end > date_begin end end - updated_feature = ResourceRegistry::Operations::Features::Create.new.call(registered_feature_hash).value! + Success(feature_params) + end - registry.swap_feature(updated_feature) + def create_entity(feature_values) + ResourceRegistry::Operations::Features::Create.new.call(feature_values) + end + + def save_record(entity, registry, filter_params = nil) + ResourceRegistry::Stores.persist(entity, registry, {filter: filter_params}) || Success(entity) + end - Success(updated_feature) + def update_registry(entity, registry) + return Success(entity) if registry[entity.key].meta&.content_type == :model_attributes + ResourceRegistry::Stores::Container::Update.new.call(entity, registry) end end end end -end +end \ No newline at end of file diff --git a/lib/resource_registry/operations/graphs/create.rb b/lib/resource_registry/operations/graphs/create.rb new file mode 100644 index 00000000..6b2120ef --- /dev/null +++ b/lib/resource_registry/operations/graphs/create.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'dry/monads' +require 'rgl/adjacency' +require 'rgl/implicit' +require 'digest/md5' + +module ResourceRegistry + module Operations + module Graphs + # Creates Directed Acyclic Graph with the given namespaces + class Create + send(:include, Dry::Monads[:result, :do]) + + def call(namespaces, registry) + graph = yield create(namespaces, registry) + result = yield validate(graph) + + Success(result) + end + + private + + def create(namespaces, registry) + graph = registry['feature_graph'] if registry.key?('feature_graph') + graph ||= ::RGL::DirectedAdjacencyGraph.new + @vertex_dir ||= {} + namespaces.each do |namespace| + namespace_vertices = namespace_to_vertices(namespace) + namespace_vertices.each_index do |index| + if namespace_vertices[index + 1].present? + graph.add_edge(namespace_vertices[index], namespace_vertices[index + 1]) + else + graph.add_vertex(namespace_vertices[index]) + end + end + end + + Success(graph) + end + + def validate(graph) + if graph.directed? && graph.cycles.empty? + Success(graph) + else + errors = [] + errors << 'Graph is not a directed graph' unless graph.directed? + errors << "Graph has cycles: #{print_cycles(graph)}" if graph.cycles.present? + + Failure(errors) + end + end + + def print_cycles(graph) + graph.cycles.collect do |cycle| + cycle.inject([]) {|vertices, vertex| vertices << {namespace: vertex.path, features: vertex.feature_keys}} + end + end + + # TODO: Output graph when its successful dotted graph in console/log + def namespace_to_vertices(namespace) + paths = namespace[:path].dup + vertex_path = [] + while true + current = paths.shift + vertex_path.push(current) + (vertices ||= []) << (paths.empty? ? find_or_create_vertex(vertex_path, namespace) : find_or_create_vertex(vertex_path)) + break if paths.empty? + end + vertices + end + + def find_or_create_vertex(vertex_path, namespace_hash = {}) + vertex_key = digest_key_for(vertex_path) + return @vertex_dir[vertex_key] if @vertex_dir[vertex_key] + @vertex_dir[vertex_key] = ResourceRegistry::Namespace.new(namespace_hash.merge(key: vertex_key.to_sym, path: vertex_path)) + end + + def digest_key_for(vertex_path) + Digest::MD5.hexdigest(vertex_path.map(&:to_s).join('_')).slice(0..9) + end + end + end + end +end diff --git a/lib/resource_registry/operations/namespaces/build.rb b/lib/resource_registry/operations/namespaces/build.rb new file mode 100644 index 00000000..00a7a849 --- /dev/null +++ b/lib/resource_registry/operations/namespaces/build.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'dry/monads' + +module ResourceRegistry + module Operations + module Namespaces + # This would construct namespace hash from feature when feature meta, namespace content_type validations are successful + class Build + send(:include, Dry::Monads[:result, :do]) + + def call(feature, content_types = []) + feature = yield validate(feature, content_types) + values = yield build(feature) + + Success(values) + end + + private + + def validate(feature, content_types) + errors = [] + errors << "feature meta can't be empty" if feature.meta.to_h.empty? + errors << "feature namespace path can't be empty" if feature.namespace_path.to_h.empty? + errors << "namesapce content type should be #{content_types}" if content_types.present? && content_types.exclude?(feature.namespace_path.meta&.content_type&.to_s) + + if errors.empty? + Success(feature) + else + Failure(errors) + end + end + + def build(feature) + namespace_path = feature.namespace_path + + Success({ + key: namespace_path.path.map(&:to_s).join('_'), + path: namespace_path.path, + feature_keys: [feature.key], + meta: namespace_path.meta.to_h + }) + end + end + end + end +end diff --git a/lib/resource_registry/operations/namespaces/create.rb b/lib/resource_registry/operations/namespaces/create.rb new file mode 100644 index 00000000..0c066d6f --- /dev/null +++ b/lib/resource_registry/operations/namespaces/create.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module ResourceRegistry + module Operations + module Namespaces + # Create a Namespace + class Create + send(:include, Dry::Monads[:result, :do]) + + def call(params) + values = yield validate(params) + namespace = yield create(values) + + Success(namespace) + end + + private + + def validate(params) + ResourceRegistry::Validation::NamespaceContract.new.call(params) + end + + def create(values) + namespace = ResourceRegistry::Namespace.new(values.to_h) + + Success(namespace) + end + end + end + end +end diff --git a/lib/resource_registry/operations/namespaces/form.rb b/lib/resource_registry/operations/namespaces/form.rb new file mode 100644 index 00000000..c5e56cf0 --- /dev/null +++ b/lib/resource_registry/operations/namespaces/form.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module ResourceRegistry + module Operations + module Namespaces + # Create a Namespace + class Form + send(:include, Dry::Monads[:result, :do, :try]) + + def call(namespace:, registry:) + features = yield find_features(namespace, registry) + params = yield construct_params(namespace, features) + values = yield validate(params) + namespace = yield create(values) + + Success(namespace) + end + + private + + def find_features(namespace, registry) + feature_keys = registry[:feature_graph].vertices.detect{|v| v.path == namespace}.feature_keys + features = feature_keys.collect{|feature_key| find_feature(feature_key, registry)} + features_for_display = features.reject{|feature| feature[:meta][:content_type] == :nav } + + Success(features_for_display) + end + + def construct_params(namespace, features) + params = { + key: namespace.last, + path: namespace, + feature_keys: features.collect{|feature| feature[:key]}, + features: features + } + + Success(params) + end + + def validate(params) + ResourceRegistry::Validation::NamespaceContract.new.call(params) + end + + def create(values) + namespace = ResourceRegistry::Namespace.new(values.to_h) + + Success(namespace) + end + + def find_feature(feature_key, registry) + feature = ResourceRegistry::Stores.find(feature_key) if defined? Rails + feature&.success&.attributes || registry[feature_key].feature + end + end + end + end +end diff --git a/lib/resource_registry/operations/namespaces/list_features.rb b/lib/resource_registry/operations/namespaces/list_features.rb new file mode 100644 index 00000000..76ab9be3 --- /dev/null +++ b/lib/resource_registry/operations/namespaces/list_features.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module ResourceRegistry + module Operations + module Namespaces + # List features under a given namespace. Features are also sorted in the order requested in the params. + class ListFeatures + send(:include, Dry::Monads[:result, :do, :try]) + + def call(params) + values = yield validate(params) + feature_keys = yield list_features(values) + features = yield find_features(feature_keys, values) + + Success(features) + end + + private + + def validate(params) + return Failure("Namespace parameter missing.") unless params[:namespace] + + registry = params[:registry] + registry = registry.constantize if registry.is_a?(String) + return Failure("Unable to find namespace #{params[:namespace]} under #{params[:registry]}.") unless registry.namespaces.include?(params[:namespace]) + + Success({ + namespace: params[:namespace], + registry: registry, + order: params[:order] + }) + end + + def list_features(values) + feature_keys = values[:registry].features_by_namespace(values[:namespace]) + + Success(feature_keys) + end + + def find_features(feature_keys, values) + feature_model = ResourceRegistry::Stores.feature_model + features = feature_keys.collect{|key| feature_model.present? ? feature_model.where(key: key).first : values[:registry][key].feature }.compact + + if values[:order] + features_for_sort = features.select{|f| f.settings.any?{|s| s.key == values[:order].to_sym && s.item}} + features = features_for_sort.sort_by{|f| sort_by_value(f, values[:order])}.reverse + (features - features_for_sort) + end + + Success(features) + end + + def sort_by_value(feature, setting_key) + setting = feature.settings.detect{|s| s.key == setting_key.to_sym} + setting.item.to_i + end + end + end + end +end \ No newline at end of file diff --git a/lib/resource_registry/operations/namespaces/update_features.rb b/lib/resource_registry/operations/namespaces/update_features.rb new file mode 100644 index 00000000..25b23c1b --- /dev/null +++ b/lib/resource_registry/operations/namespaces/update_features.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module ResourceRegistry + module Operations + module Namespaces + # Update features with incoming parameters + class UpdateFeatures + send(:include, Dry::Monads[:result, :do]) + + def call(params) + feature_params = yield extract(params[:namespace]) + registry = yield update(feature_params, params[:registry]) + + Success(registry) + end + + private + + def extract(params) + features = params[:features].values + + Success(features) + end + + def update(feature_params, registry) + feature_params.each do |params| + ResourceRegistry::Operations::Features::Update.new.call(feature: params, registry: registry) + end + + Success(registry) + end + end + end + end +end diff --git a/lib/resource_registry/operations/registries/create.rb b/lib/resource_registry/operations/registries/create.rb index d728d7d4..ed2083b1 100644 --- a/lib/resource_registry/operations/registries/create.rb +++ b/lib/resource_registry/operations/registries/create.rb @@ -5,16 +5,16 @@ module Operations module Registries # Create a Feature class Create - send(:include, Dry::Monads[:result, :do]) + send(:include, Dry::Monads[:result, :do, :try]) def call(path:, registry:) file_io = yield read(path) params = yield deserialize(file_io) feature_hashes = yield serialize(params) features = yield create(feature_hashes) - container = yield register(features, registry) + yield persist(features, registry) - Success(container) + Success(features) end private @@ -50,35 +50,18 @@ def serialize(params) end def create(feature_hashes) - features = feature_hashes.collect do |feature_hash| - feature = ResourceRegistry::Operations::Features::Create.new.call(feature_hash) - - if feature.success? - feature.value! - else - raise "Failed to create feature with #{feature.failure.errors.inspect}" + Try do + feature_hashes.collect do |feature_hash| + result = ResourceRegistry::Operations::Features::Create.new.call(feature_hash) + return result if result.failure? + result.value! end - end - - Success(features) - rescue Exception => e - raise "Error occurred while creating features using #{feature_hashes}. " \ - "Error: #{e.message}" + end.to_result end - def register(features, registry) + def persist(features, registry) features.each do |feature| - if defined?(Rails) && registry.db_connection&.table_exists?(:resource_registry_features) - feature_record = ResourceRegistry::ActiveRecord::Feature.where(key: feature.key).first - - if feature_record.blank? - ResourceRegistry::ActiveRecord::Feature.new(feature.to_h).save - else - result = ResourceRegistry::Operations::Features::Create.new.call(feature_record.to_h) - feature = result.success if result.success? - end - end - + ResourceRegistry::Stores.persist(feature, registry) if defined? Rails registry.register_feature(feature) end diff --git a/lib/resource_registry/operations/registries/load.rb b/lib/resource_registry/operations/registries/load.rb index 006bc0a3..279e2792 100644 --- a/lib/resource_registry/operations/registries/load.rb +++ b/lib/resource_registry/operations/registries/load.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + require 'dry/monads' module ResourceRegistry @@ -6,33 +7,51 @@ module Operations module Registries # Create a Feature class Load - send(:include, Dry::Monads[:result, :do]) + send(:include, Dry::Monads[:result, :do, :try]) def call(registry:) - paths = yield list_paths(load_path_for(registry)) - result = yield load(paths, registry) + paths = yield list_paths(registry) + features = yield load_features(paths, registry) + namespaces = yield serialize_namespaces(features) + registry = yield register_graph(namespaces, registry) - Success(result) + Success(registry) end private - def list_paths(load_path) + def list_paths(registry) + load_path = registry.resolve('configuration.load_path') paths = ResourceRegistry::Stores::File::ListPath.new.call(load_path) Success(paths) end - def load(paths, registry) - paths.value!.each do |path| - ResourceRegistry::Operations::Registries::Create.new.call(path: path, registry: registry) - end + def load_features(paths, registry) + Try do + paths = paths.value! + paths.reduce([]) do |features, path| + result = ResourceRegistry::Operations::Registries::Create.new.call(path: path, registry: registry) + features << result.success if result.success? + features + end.flatten + end.to_result + end - Success(registry) + def serialize_namespaces(features) + ResourceRegistry::Serializers::Namespaces::Serialize.new.call(features: features, namespace_types: %w[feature_list nav]) end - def load_path_for(registry) - registry.resolve('configuration.load_path') + def register_graph(namespaces, registry) + graph = ResourceRegistry::Operations::Graphs::Create.new.call(namespaces, registry) + + if graph.success? + registry.register_graph(graph.value!) + else + ResourceRegistry.logger.error(graph.failure) + end + + Success(registry) end end end diff --git a/lib/resource_registry/railtie.rb b/lib/resource_registry/railtie.rb index 9ed7e4e7..04e2025d 100644 --- a/lib/resource_registry/railtie.rb +++ b/lib/resource_registry/railtie.rb @@ -9,11 +9,12 @@ def gem_available?(gem_name, version = nil) installer = Gem::DependencyInstaller.new if gem_available?('mongoid') - # installer.install 'mongoid' + installer.install 'mongoid' - # require 'resource_registry/models/mongoid/feature' - # require 'resource_registry/models/mongoid/setting' - # require 'resource_registry/models/mongoid/meta' + require 'resource_registry/models/mongoid/namespace_path' + require 'resource_registry/models/mongoid/feature' + require 'resource_registry/models/mongoid/setting' + require 'resource_registry/models/mongoid/meta' else installer.install 'activerecord' diff --git a/lib/resource_registry/registry.rb b/lib/resource_registry/registry.rb index 9faba7f2..41b9ddb6 100644 --- a/lib/resource_registry/registry.rb +++ b/lib/resource_registry/registry.rb @@ -31,7 +31,12 @@ def configure(&block) ResourceRegistry::Operations::Registries::Configure.new.call(self, config.to_h) end + def navigation(options = {}) + ::ResourceRegistry::Navigation.new(self, options) + end + def swap_feature(feature) + feature = dsl_for(feature) self._container.delete("feature_index.#{feature.key}") self._container.delete(namespaced(feature.key, feature.namespace)) register_feature(feature) @@ -46,7 +51,7 @@ def swap_feature(feature) def register_feature(feature) raise ArgumentError, "#{feature} must be a ResourceRegistry::Feature or ResourceRegistry::FeatureDSL" if !feature.is_a?(ResourceRegistry::Feature) && !feature.is_a?(ResourceRegistry::FeatureDSL) - feature = dsl_for(feature) + feature = dsl_for(feature) unless feature.is_a?(ResourceRegistry::FeatureDSL) raise ResourceRegistry::Error::DuplicateFeatureError, "feature already registered #{feature.key.inspect}" if feature?(feature.key) @features_stale = true @@ -57,6 +62,11 @@ def register_feature(feature) self end + def register_graph(graph) + self._container.delete('feature_graph') if key?('feature_graph') + register('feature_graph', graph) + end + # Look up a feature stored in the registry # @param key [Symbol] unique identifier for the subject feature # @raise [ResourceRegistry::Error::FeatureNotFoundError] if a feature with this key isn't found in the registry @@ -104,8 +114,7 @@ def features end def namespaces - return @namespaces if defined? @namespaces - @namespaces = features.collect{|feature_key| self[feature_key].feature.namespace}.uniq + @namespaces = features.collect{|feature_key| self[feature_key].namespace}.uniq end def namespace_features_hash @@ -167,9 +176,7 @@ def feature_enabled?(key) return false unless feature.enabled? namespaces = feature.namespace.split('.') - namespaces.detect(-> {true}) do |ancestor_key| - feature?(ancestor_key) ? resolve_feature(ancestor_key.to_sym).disabled? : false - end + namespaces.all? {|ancestor_key| feature?(ancestor_key) ? resolve_feature(ancestor_key.to_sym).enabled? : true } end private diff --git a/lib/resource_registry/rgl.rb b/lib/resource_registry/rgl.rb new file mode 100644 index 00000000..25f0d296 --- /dev/null +++ b/lib/resource_registry/rgl.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module RGL + # Extend RGL library with useful methods + class DirectedAdjacencyGraph + + def forest + self + end + + def trees + vertices - edges.map{|edge| edge[1]}.uniq + end + + def trees_with_features + edges_with_features = self.edges.select{|edge| edge.to_a.any?{|ele| ele.feature_keys.present?}} + edges_with_features.collect(&:source).uniq - edges_with_features.collect(&:target).uniq + end + + def vertices_for(options) + return options[:starting_namespaces] if options[:starting_namespaces].present? + return trees_with_features unless options[:include_no_features_defined] + trees + end + end +end \ No newline at end of file diff --git a/lib/resource_registry/serializers.rb b/lib/resource_registry/serializers.rb index 46ef8ad3..1f34785a 100644 --- a/lib/resource_registry/serializers.rb +++ b/lib/resource_registry/serializers.rb @@ -3,6 +3,7 @@ require_relative 'serializers/yaml/deserialize' require_relative 'serializers/yaml/serialize' require_relative 'serializers/features/serialize' +require_relative 'serializers/namespaces/serialize' module ResourceRegistry module Serializers diff --git a/lib/resource_registry/serializers/features/serialize.rb b/lib/resource_registry/serializers/features/serialize.rb index e1f5f3de..09826dfd 100644 --- a/lib/resource_registry/serializers/features/serialize.rb +++ b/lib/resource_registry/serializers/features/serialize.rb @@ -18,8 +18,10 @@ def call(params) private def transform(params) - features = params['registry'].reduce([]) do |features_list, namespace| + return Success([]) if params.empty? || params['registry'].blank? + features = params['registry'].reduce([]) do |features_list, namespace| + next features_list unless namespace['features'] path = namespace['namespace'] if namespace.key?('namespace') namespace_features = namespace['features'].reduce([]) do |ns_features_list, feature_hash| diff --git a/lib/resource_registry/serializers/namespaces/serialize.rb b/lib/resource_registry/serializers/namespaces/serialize.rb new file mode 100644 index 00000000..d38f188c --- /dev/null +++ b/lib/resource_registry/serializers/namespaces/serialize.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module ResourceRegistry + module Serializers + module Namespaces + # Transform feature collection into Namespace hash + class Serialize + send(:include, Dry::Monads[:result, :do, :try]) + + # @param [Hash] params Hash to be transformed into YAML String + # @return [String] parsed values wrapped in Dry::Monads::Result object + def call(features:, namespace_types:) + namespaces = yield build(features, namespace_types) + namespace_dict = yield merge(namespaces) + + Success(namespace_dict.values) + end + + private + + def build(features, namespace_types) + Try do + features.collect do |feature| + namespace = ResourceRegistry::Operations::Namespaces::Build.new.call(feature, namespace_types) + namespace.success if namespace.success? + end.compact + end.to_result + end + + def merge(namespaces) + Try do + namespaces.reduce({}) do |data, ns| + if data[ns[:key]] + data[ns[:key]][:feature_keys] += ns[:feature_keys] + else + data[ns[:key]] = ns + end + data + end + end.to_result + end + end + end + end +end \ No newline at end of file diff --git a/lib/resource_registry/serializers/yaml/serialize.rb b/lib/resource_registry/serializers/yaml/serialize.rb index e6e5506b..da4441d1 100644 --- a/lib/resource_registry/serializers/yaml/serialize.rb +++ b/lib/resource_registry/serializers/yaml/serialize.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + require 'yaml' module ResourceRegistry diff --git a/lib/resource_registry/stores.rb b/lib/resource_registry/stores.rb index 4ba571e9..52fd9350 100644 --- a/lib/resource_registry/stores.rb +++ b/lib/resource_registry/stores.rb @@ -2,3 +2,44 @@ require_relative 'stores/file/read' require_relative 'stores/file/list_path' +require_relative 'stores/container/update' +require_relative 'stores/mongoid/find' +require_relative 'stores/mongoid/persist' +require_relative 'stores/active_record/find' +require_relative 'stores/active_record/update' + +module ResourceRegistry + # This module will provide interface for DB stores + module Stores + class << self + + def persist(entity, registry, params = {}) + return unless store_namespace + + "#{store_namespace}::Persist".constantize.new.call(entity, registry, params) + end + + def find(feature_key) + return unless store_namespace + + "#{store_namespace}::Find".constantize.new.call(feature_key) + end + + def store_namespace + "ResourceRegistry::Stores::#{orm}".constantize if orm + end + + def feature_model + "ResourceRegistry::#{orm}::Feature".constantize if orm + end + + def orm + if defined? ResourceRegistry::Mongoid + 'Mongoid' + elsif defined? ResourceRegistry::ActiveRecord + 'ActiveRecord' + end + end + end + end +end \ No newline at end of file diff --git a/lib/resource_registry/stores/active_record/find.rb b/lib/resource_registry/stores/active_record/find.rb new file mode 100644 index 00000000..e7f4a3c5 --- /dev/null +++ b/lib/resource_registry/stores/active_record/find.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module ResourceRegistry + module Stores + module ActiveRecord + # Instantiate a new Dry::Container object + class Find + send(:include, Dry::Monads[:result, :do]) + + # @param [String] key unique feature key + # @return [Dry::Container] A non-finalized Dry::Container with associated configuration values wrapped in Dry::Monads::Result + def call(key) + feature = yield find(key) + + Success(feature) + end + + private + + def find(key) + feature = ResourceRegistry::ActiveRecord::Feature.where(key: key).first + + Success(feature) + end + end + end + end +end + diff --git a/lib/resource_registry/stores/active_record/persist.rb b/lib/resource_registry/stores/active_record/persist.rb new file mode 100644 index 00000000..6422b4ef --- /dev/null +++ b/lib/resource_registry/stores/active_record/persist.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module ResourceRegistry + module Stores + module ActiveRecord + # Instantiate a new Dry::Container object + class Persist + send(:include, Dry::Monads[:result, :do]) + + # @param [Dry::Struct] feature_entity feature entity object + # @return [ResourceRegistry::Mongoid::Feature] persisted feature record wrapped in Dry::Monads::Result + def call(feature_entity) + feature = yield persist(feature_entity) + + Success(feature) + end + + private + + def persist(_feature_entity) + # if registry.db_connection&.table_exists?(:resource_registry_features) + feature = ResourceRegistry::ActiveRecord::Feature.where(key: feature.key).first + feature ||= ResourceRegistry::ActiveRecord::Feature.new(feature.to_h).save + # else + # result = ResourceRegistry::Operations::Features::Create.new.call(feature_record.to_h) + # feature = result.success if result.success? # TODO: Verify Failure Scenario + # end + # end + + Success(feature) + end + end + end + end +end diff --git a/lib/resource_registry/stores/active_record/update.rb b/lib/resource_registry/stores/active_record/update.rb new file mode 100644 index 00000000..b4a9fbdc --- /dev/null +++ b/lib/resource_registry/stores/active_record/update.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module ResourceRegistry + module Stores + module ActiveRecord + # Instantiate a new Dry::Container object + class Update + send(:include, Dry::Monads[:result, :do]) + + # @param [Dry::Struct] feature_entity feature entity object + # @return [ResourceRegistry::Mongoid::Feature] persisted feature record wrapped in Dry::Monads::Result + def call(feature_entity) + feature = yield update(feature_entity) + + Success(feature) + end + + private + + def update(feature_entity) + feature = ResourceRegistry::ActiveRecord::Feature.where(key: feature_entity.key).first + feature.is_enabled = feature_entity.is_enabled + + feature_entity.settings.each do |setting_entity| + if (setting = feature.settings.detect{|s| s.key == setting_entity.key}) + setting.item = setting_entity.item + else + feature.settings.build(setting_entity.to_h) + end + end + feature.save + + Success(feature) + end + end + end + end +end diff --git a/lib/resource_registry/stores/container/find.rb b/lib/resource_registry/stores/container/find.rb index 3f1b374f..54b9f89e 100644 --- a/lib/resource_registry/stores/container/find.rb +++ b/lib/resource_registry/stores/container/find.rb @@ -6,8 +6,7 @@ module Container class Find send(:include, Dry::Monads[:result, :do]) - def call(params = {}) - end + def call(params = {}); end end end diff --git a/lib/resource_registry/stores/container/read.rb b/lib/resource_registry/stores/container/read.rb index b766437f..bf2e7ebe 100644 --- a/lib/resource_registry/stores/container/read.rb +++ b/lib/resource_registry/stores/container/read.rb @@ -6,8 +6,7 @@ module Container class Read send(:include, Dry::Monads[:result, :do]) - def call(params = {}) - end + def call(params = {}); end end end diff --git a/lib/resource_registry/stores/container/update.rb b/lib/resource_registry/stores/container/update.rb new file mode 100644 index 00000000..3e53ead7 --- /dev/null +++ b/lib/resource_registry/stores/container/update.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module ResourceRegistry + module Stores + module Container + # Instantiate a new Dry::Container object + class Update + send(:include, Dry::Monads[:result, :do]) + + # @param [ResourceRegistry::Entities::Registry] container the container instance to which the constant will be assigned + # @param [String] constant_name the name to assign to the container and its associated dependency injector + # @return [Dry::Container] A non-finalized Dry::Container with associated configuration values wrapped in Dry::Monads::Result + def call(new_feature, container) + updated_feature = yield update(new_feature, container) + + Success(updated_feature) + end + + private + + def update(new_feature, container) + registered_feature_hash = container[new_feature.key].feature.to_h + registered_feature_hash[:is_enabled] = new_feature.is_enabled + + registered_feature_hash[:settings] = registered_feature_hash[:settings].collect do |setting_hash| + new_setting = new_feature.settings.detect{|setting| setting.key == setting_hash[:key]} + setting_hash[:item] = new_setting.item if new_setting + setting_hash + end + + updated_feature = ResourceRegistry::Operations::Features::Create.new.call(registered_feature_hash).value! + container.swap_feature(updated_feature) + + Success(updated_feature) + end + end + end + end +end diff --git a/lib/resource_registry/stores/container/write.rb b/lib/resource_registry/stores/container/write.rb index e1ebf69c..2cbd2a12 100644 --- a/lib/resource_registry/stores/container/write.rb +++ b/lib/resource_registry/stores/container/write.rb @@ -17,11 +17,9 @@ def call(container, keys) private - def find_or_create_namespace(namespace) - end + def find_or_create_namespace(namespace); end - def write(container, keys) - end + def write(container, keys); end end end diff --git a/lib/resource_registry/stores/file/list_path.rb b/lib/resource_registry/stores/file/list_path.rb index 649f5a63..c7a1e156 100644 --- a/lib/resource_registry/stores/file/list_path.rb +++ b/lib/resource_registry/stores/file/list_path.rb @@ -8,7 +8,7 @@ class ListPath send(:include, Dry::Monads[:result, :do]) def call(dir) - paths = ::Dir[::File.join(dir, '**', '*') ].reject { |p| ::File.directory? p } + paths = ::Dir[::File.join(dir, '**', '*')].reject { |p| ::File.directory? p } Success(paths) end end diff --git a/lib/resource_registry/stores/file/read.rb b/lib/resource_registry/stores/file/read.rb index 64d22f93..6762f474 100644 --- a/lib/resource_registry/stores/file/read.rb +++ b/lib/resource_registry/stores/file/read.rb @@ -17,11 +17,9 @@ def call(params) private def load(params) - begin - Success(::File.read(params.to_s)) - rescue Errno::ENOENT - Failure["No such file or directory", params: params] - end + Success(::File.read(params.to_s)) + rescue Errno::ENOENT + Failure["No such file or directory", params: params] end end diff --git a/lib/resource_registry/stores/mongoid/find.rb b/lib/resource_registry/stores/mongoid/find.rb new file mode 100644 index 00000000..e881fd5b --- /dev/null +++ b/lib/resource_registry/stores/mongoid/find.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module ResourceRegistry + module Stores + module Mongoid + # Instantiate a new Dry::Container object + class Find + send(:include, Dry::Monads[:result, :do]) + + # @param [String] key unique feature key + # @return [Dry::Container] A non-finalized Dry::Container with associated configuration values wrapped in Dry::Monads::Result + def call(key) + feature = yield find(key) + + Success(feature) + end + + private + + def find(key) + feature = ResourceRegistry::Mongoid::Feature.where(key: key).first + + Success(feature) + end + end + end + end +end + diff --git a/lib/resource_registry/stores/mongoid/persist.rb b/lib/resource_registry/stores/mongoid/persist.rb new file mode 100644 index 00000000..449c6a76 --- /dev/null +++ b/lib/resource_registry/stores/mongoid/persist.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module ResourceRegistry + module Stores + module Mongoid + # Instantiate a new Dry::Container object + class Persist + send(:include, Dry::Monads[:result, :do, :try]) + + # @param [ResourceRegistry::Entities::Registry] container the container instance to which the constant will be assigned + # @param [String] constant_name the name to assign to the container and its associated dependency injector + # @return [Dry::Container] A non-finalized Dry::Container with associated configuration values wrapped in Dry::Monads::Result + def call(entity, registry, params = {}) + record = yield find(entity, registry, params[:filter]) + record = yield persist(entity, record) + + Success(record) + end + + private + + def find(entity, registry, filter_params = nil) + record = if filter_params + registry[entity.key]{ filter_params }.success[:record] + else + ResourceRegistry::Mongoid::Feature.where(key: entity.key).first + end + + Success(record) + end + + def persist(entity, record) + if record.blank? + create(entity) + else + update(entity, record) + end + end + + def create(entity) + Try do + ResourceRegistry::Mongoid::Feature.new(entity.to_h).save + end.to_result + end + + def update(entity, record) + Try do + if record.class.to_s.match?(/ResourceRegistry/) + record.is_enabled = entity.is_enabled + entity.settings.each do |setting_entity| + if (setting = record.settings.detect{|s| s.key == setting_entity.key}) + setting.item = setting_entity.item + else + record.settings.build(setting_entity.to_h) + end + end + else + attributes = entity.settings.reduce({}) do |attrs, setting| + attrs[setting.key] = setting.item + attrs + end + record.assign_attributes(attributes) + end + record.save(validate: false) + record + end.to_result + end + end + end + end +end + diff --git a/lib/resource_registry/tasks/purge.rake b/lib/resource_registry/tasks/purge.rake index 23b9197e..9eb7bb3e 100644 --- a/lib/resource_registry/tasks/purge.rake +++ b/lib/resource_registry/tasks/purge.rake @@ -5,7 +5,7 @@ namespace :resource_registry do task :purge => :environment do if defined? ::Mongoid::Document - + ResourceRegistry::Mongoid::Feature.delete_all else ResourceRegistry::ActiveRecord::Feature.delete_all end @@ -13,4 +13,4 @@ namespace :resource_registry do puts "::: Settings Delete Complete :::" puts "*"*80 unless Rails.env.test? end -end +end \ No newline at end of file diff --git a/lib/resource_registry/validation/application_contract.rb b/lib/resource_registry/validation/application_contract.rb index a684e328..81ed7729 100644 --- a/lib/resource_registry/validation/application_contract.rb +++ b/lib/resource_registry/validation/application_contract.rb @@ -24,7 +24,7 @@ def apply_contract_for(evaluator) contract_klass = create_contract_klass(rule_keys) result = contract_klass.new.call(evaluator.value) - (result && result.failure?) ? { text: "invalid #{rule_keys[0]}", error: result.errors.to_h } : {} + result&.failure? ? { text: "invalid #{rule_keys[0]}", error: result.errors.to_h } : {} end # Construct a fully namespaced constant for contract based on naming conventions @@ -34,18 +34,27 @@ def create_contract_klass(rule_keys) module_name = klass_parts.reduce([]) { |memo, word| memo << word.capitalize }.join klass_name = module_name.chomp('s') - full_klass_name = ["ResourceRegistry", module_name, "Validation", klass_name + "Contract"].join('::') + full_klass_name = ["ResourceRegistry", module_name, "Validation", "#{klass_name}Contract"].join('::') ::Kernel.const_get(full_klass_name) end # @!macro ruleeach # Validates a nested array of $0 params # @!method rule(settings) - rule(:settings).each do + rule(:settings) do if key? && value - result = ResourceRegistry::Validation::SettingContract.new.call(value) - # Use dry-validation metadata error form to pass error hash along with text to calling service - key.failure(text: "invalid settings", error: result.errors.to_h) if result && result.failure? + setting_results = value.inject([]) do |results, setting_hash| + result = ResourceRegistry::Validation::SettingContract.new.call(setting_hash) + + if result.failure? + # Use dry-validation metadata error form to pass error hash along with text to calling service + key.failure(text: "invalid settings", error: result.errors.to_h) if result&.failure? + else + results << result.to_h + end + end + + values.merge!(settings: setting_results) end end @@ -59,12 +68,15 @@ def create_contract_klass(rule_keys) if key? && value result = ResourceRegistry::Validation::MetaContract.new.call(value) - # Use dry-validation error form to pass error hash along with text to calling service - # self.result.to_h.merge!({meta: result.to_h}) - key.failure(text: "invalid meta", error: result.errors.to_h) if result && result.failure? + if result.failure? + # Use dry-validation error form to pass error hash along with text to calling service + # self.result.to_h.merge!({meta: result.to_h}) + key.failure(text: "invalid meta", error: result.errors.to_h) + else + values.merge!(meta: result.to_h) + end end end - end end end diff --git a/lib/resource_registry/validation/configuration_contract.rb b/lib/resource_registry/validation/configuration_contract.rb index 9e7ad8c0..dc646c3f 100644 --- a/lib/resource_registry/validation/configuration_contract.rb +++ b/lib/resource_registry/validation/configuration_contract.rb @@ -39,12 +39,10 @@ class ConfigurationContract < ResourceRegistry::Validation::ApplicationContract end # rubocop:disable Style/RescueModifier - + # Verifies the Pathname exists rule(:root) do - if key? && value - value.realpath rescue key.failure("pathname not found: #{value}") - end + value.realpath rescue key.failure("pathname not found: #{value}") if key? && value end # rubocop:enable Style/RescueModifier diff --git a/lib/resource_registry/validation/feature_contract.rb b/lib/resource_registry/validation/feature_contract.rb index ebacee17..c53ab8f6 100644 --- a/lib/resource_registry/validation/feature_contract.rb +++ b/lib/resource_registry/validation/feature_contract.rb @@ -18,36 +18,33 @@ class FeatureContract < ResourceRegistry::Validation::ApplicationContract # @return [Dry::Monads::Result::Failure] if params fail validation params do required(:key).value(:symbol) - required(:namespace).maybe(:array) + required(:namespace_path).value(:hash) required(:is_enabled).value(:bool) optional(:item).value(:any) optional(:options).maybe(:hash) - optional(:meta).maybe(:hash) optional(:settings).array(:hash) - before(:value_coercer) do |result| + before(:value_coercer) do |feature| + feature.to_h.merge(meta: nil) if feature[:meta].blank? + end + end - settings = result[:settings]&.map(&:deep_symbolize_keys)&.collect do |setting| - setting.tap do |setting| - if setting[:meta] && setting[:meta][:content_type] == :duration - setting[:item] = Types::Duration[setting[:item]] - elsif setting[:item].is_a? String - dates = setting[:item].scan(/(\d{4}\-\d{2}\-\d{2})\.\.(\d{4}\-\d{2}\-\d{2})/).flatten - if dates.present? - dates = dates.collect{|str| Date.strptime(str, "%Y-%m-%d") } - setting[:item] = Range.new(*dates) - end - end - end + # @!macro [attach] rulemacro + # Validates a nested hash of $1 params + # @!method $0($1) + # @param [Symbol] $1 key + # @return [Dry::Monads::Result::Success] if nested $1 params pass validation + # @return [Dry::Monads::Result::Failure] if nested $1 params fail validation + rule(:namespace_path) do + if key? && value + result = ResourceRegistry::Validation::NamespacePathContract.new.call(value) + if result.failure? + # Use dry-validation error form to pass error hash along with text to calling service + key.failure(text: "invalid meta", error: result.errors.to_h) + else + values.merge!(namespace_path: result.to_h) end - - result.to_h.merge( - key: result[:key]&.to_sym, - meta: result[:meta]&.symbolize_keys, - settings: settings || [], - namespace: (result[:namespace] || []).map(&:to_sym) - ) end end end diff --git a/lib/resource_registry/validation/meta_contract.rb b/lib/resource_registry/validation/meta_contract.rb index 7502d5f5..cedec31d 100644 --- a/lib/resource_registry/validation/meta_contract.rb +++ b/lib/resource_registry/validation/meta_contract.rb @@ -20,10 +20,10 @@ class MetaContract < ResourceRegistry::Validation::ApplicationContract params do required(:label).value(:string) required(:content_type).value(:symbol) - required(:default).value(:any) + optional(:default).maybe(:any) optional(:value).maybe(:any) optional(:description).maybe(:string) - optional(:enum).maybe(:array) + optional(:enum).maybe(:any) optional(:is_required).maybe(:bool) optional(:is_visible).maybe(:bool) end diff --git a/lib/resource_registry/validation/namespace_contract.rb b/lib/resource_registry/validation/namespace_contract.rb new file mode 100644 index 00000000..85470f34 --- /dev/null +++ b/lib/resource_registry/validation/namespace_contract.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module ResourceRegistry + module Validation + # Schema and validation rules for the {ResourceRegistry::Namespace} domain model + class NamespaceContract < ResourceRegistry::Validation::ApplicationContract + + # @!method call(opts) + # @param [Hash] opts the parameters to validate using this contract + # @option opts [Symbol] :key required + # @option opts [Array] :path required + # @option opts [ResourceRegistry::Meta] :meta optional + # @option opts [Array] :feature_keys optional + # @option opts [Array] :features optional + # @option opts [Array] :namespaces optional + # @return [Dry::Monads::Result::Success] if params pass validation + # @return [Dry::Monads::Result::Failure] if params fail validation + params do + required(:key).value(:symbol) + required(:path).array(:symbol) + optional(:meta).maybe(:hash) + optional(:feature_keys).array(:symbol) + optional(:features).value(:array) + optional(:namespaces).array(:hash) + end + + rule(:features) do + if key? && value && value.none?{|feature| feature.is_a?(ResourceRegistry::Feature)} + feature_results = value.inject([]) do |results, feature_hash| + result = ResourceRegistry::Validation::FeatureContract.new.call(feature_hash) + + if result.failure? + key.failure(text: "invalid feature", error: result.errors.to_h) if result&.failure? + else + results << result.to_h + end + end + + values.merge!(features: feature_results) + end + end + + rule(:namespaces).each do + if key? && value + result = ResourceRegistry::Validation::NamespaceContract.new.call(value) + key.failure(text: "invalid namespace", error: result.errors.to_h) if result&.failure? + end + end + end + end +end \ No newline at end of file diff --git a/lib/resource_registry/validation/namespace_path_contract.rb b/lib/resource_registry/validation/namespace_path_contract.rb new file mode 100644 index 00000000..9c65d7e5 --- /dev/null +++ b/lib/resource_registry/validation/namespace_path_contract.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module ResourceRegistry + module Validation + # Schema and validation rules for the {ResourceRegistry::Namespace} domain model + class NamespacePathContract < ResourceRegistry::Validation::ApplicationContract + + # @!method call(opts) + # @param [Hash] opts the parameters to validate using this contract + # @option opts [Array] :path required + # @option opts [ResourceRegistry::Meta] :meta optional + # @return [Dry::Monads::Result::Success] if params pass validation + # @return [Dry::Monads::Result::Failure] if params fail validation + params do + required(:path).array(:symbol) + optional(:meta).maybe(:hash) + end + end + end +end \ No newline at end of file diff --git a/lib/resource_registry/validation/setting_contract.rb b/lib/resource_registry/validation/setting_contract.rb index 73b6448f..7431ff96 100644 --- a/lib/resource_registry/validation/setting_contract.rb +++ b/lib/resource_registry/validation/setting_contract.rb @@ -19,8 +19,21 @@ class SettingContract < ResourceRegistry::Validation::ApplicationContract optional(:options).maybe(:hash) optional(:meta).maybe(:hash) - before(:value_coercer) do |result| - result.to_h.merge!(meta: result[:meta].symbolize_keys) if result[:meta].is_a? Hash + before(:value_coercer) do |setting| + item = if setting[:meta] && setting[:meta][:content_type] == :duration + Types::Duration[setting[:item]] + elsif setting[:item].is_a? String + dates = setting[:item].scan(/(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})/).flatten + if dates.present? + dates = dates.collect{|str| Date.strptime(str, "%Y-%m-%d") } + Range.new(*dates) + end + elsif setting[:item].is_a?(Hash) && setting[:item][:begin] && setting[:item][:end] + dates = [setting[:item][:begin], setting[:item][:end]].collect{|str| Date.strptime(str, "%Y-%m-%d") } + Range.new(*dates) + end + + setting.to_h.merge(item: item) if item end end @@ -28,7 +41,7 @@ class SettingContract < ResourceRegistry::Validation::ApplicationContract if key? && value result = ResourceRegistry::Validation::MetaContract.new.call(value) # Use dry-validation metadata error form to pass error hash along with text to calling service - key.failure(text: "invalid meta", error: result.errors.to_h) if result && result.failure? + key.failure(text: "invalid meta", error: result.errors.to_h) if result&.failure? end end end diff --git a/lib/resource_registry/version.rb b/lib/resource_registry/version.rb index f7ef834e..1fe5eb24 100644 --- a/lib/resource_registry/version.rb +++ b/lib/resource_registry/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module ResourceRegistry - VERSION = "0.7.0" -end + VERSION = "0.9.0" +end \ No newline at end of file diff --git a/lib/system/config/templates/features/enterprise.yml b/lib/system/config/templates/features/enterprise.yml new file mode 100644 index 00000000..01ccec64 --- /dev/null +++ b/lib/system/config/templates/features/enterprise.yml @@ -0,0 +1,273 @@ +--- +registry: + - namespace: + - :enterprise + meta: + label: Enterprise + description: Organizations that share hosting services under this Enterprise and their top level configurations + features: + - key: :enterprise_owner_accounts + item: [] + is_enabled: true + meta: + label: Enterprise Owner Accounts + description: A list of accounts with enterprise admininstrator priviliges + content_type: :array_of_accounts + is_required: true + is_visible: true + - key: :enterprise_profile + is_enabled: true + settings: + - key: :enterprise_organization_name + item: nil + meta: + label: Enterprise Organization Name + description: Name of organization hosting this system instance + content_type: :string + is_required: false + is_visible: true + settings: + - key: :technical_contact + item: + name: nil + email: nil + phone: nil + meta: + label: Technical Contact + content_type: :contact + description: Contact information for person who can assist with system technical issues + is_required: false + is_visible: true + - key: :operational_contact + item: + name: nil + email: nil + phone: nil + meta: + label: Operational Contact + content_type: :contact + description: Contact information for person who can assist with system operational issues + is_required: false + is_visible: true + - key: :is_multitenant + item: false + meta: + label: Multitenant? + description: Is this Enterprise instance hosting services shared between multiple customers? + content_type: :boolean + default: false + is_required: true + is_visible: true + - namespace: + - :enterprise + - :tenants + meta: + label: Tenants + description: Organizations that share hosting services under this Enterprise and their top level configurations + features: + - key: :dchbx_tenant + item: :dchbx + is_enabled: true + meta: + label: Tenant Key + description: A short, unique identifier for this tenant. Lower case letters only. May not start with a number. + content_type: :symbol + is_required: true + is_visible: true + settings: + - key: :start_at + item: nil + meta: + label: Start At + Description: Date when this organization's account first activated + content_type: :date_time + is_required: false + is_visible: true + - key: :expire_at + item: nil + - namespace: + - :enterprise + - :subscriptions + - :dchbx + meta: + label: Tenant Subscriptions + description: Software products authorized for each Tenant + features: + - key: :enroll_app + item: nil + is_enabled: true + settings: + - key: :validator_id + item: nil + - key: :subscribed_at + item: nil + - key: :unsubscribed_at + item: nil + - namespace: + - :dchbx + meta: + label: DC HealthLink Tenant + description: Tenants manage features under their own namespace key + features: + - key: :dchbx_owner_accounts + item: [] + is_enabled: true + meta: + label: Owner Accounts + description: A list of accounts with admininstrator priviliges + content_type: :array_of_accounts + is_required: true + is_visible: true + - key: :dchbx_profile + is_enabled: true + settings: + - key: :dchbx_organization_name + item: "DC HealthLink" + meta: + label: Organization Name + description: Name of organization hosting this system instance + content_type: :string + is_required: false + is_visible: true + settings: + - key: :technical_contact + item: + name: nil + email: nil + phone: nil + meta: + label: Technical Contact + content_type: :contact + description: Contact information for person who can assist with system technical issues + is_required: false + is_visible: true + - key: :operational_contact + item: + name: nil + email: nil + phone: nil + meta: + label: Operational Contact + content_type: :contact + description: Contact information for person who can assist with system operational issues + is_required: false + is_visible: true + - key: :dchbx_profile + is_enabled: true + settings: + - key: :dchbx_organization_name + item: "DC HealthLink" + meta: + label: Enterprise Organization Name + description: Name of organization hosting this site + content_type: :string + is_required: false + is_visible: true + - namespace: + - :dchbx + - :enroll_app + features: + - key: :aca_shop_market + is_enabled: true + - key: :aca_individual_market + is_enabled: true + - key: :congressional_market + is_enabled: true + - namespace: + - :dchbx + - :enroll_app + - :site + - :brand + features: + - key: logo + is_enabled: true + meta: + type: :base_64 + description: Organization logo image that will appear on the portal page. For best results, use .png or .jpg 160h x 160w pixel image. + - key: fav_icon + is_enabled: true + meta: + type: :base_64 + description: Favorite icon is a 16x16 pixel image that will appear on the web browser tab, bookmarks, history, etc.. For best results, use .png or .jpg image 160h x 160w + - key: heading_typeface + item: https://fonts.googleapis.com/css?family=Lato:400,700,400italic" + is_enabled: true + meta: + type: :url + default: https://fonts.googleapis.com/css?family=Lato:400,700,400italic" + description: Google offers an extensive catalog of open source fonts that may be used with the Tool. See https://fonts.google.com/ + - key: body_typeface + item: https://fonts.googleapis.com/css?family=Lato:400,700,400italic" + is_enabled: true + meta: + type: :url + default: https://fonts.googleapis.com/css?family=Lato:400,700,400italic" + description: Google offers an extensive catalog of open source fonts that may be used with the Tool. See https://fonts.google.com/ + - key: color_pallette + is_enabled: true + settings: + - key: :primary_color + meta: + type: :swatch + default: "#007bff" + - key: :secondary_color + meta: + type: :swatch + default: "#6c757d" + - key: :success_color + meta: + type: :swatch + default: "#28a745" + - key: :danger_color + meta: + type: :swatch + default: "#dc3545" + - key: :warning_color + meta: + type: :swatch + default: "#ffc107" + - key: :info_color + meta: + type: :swatch + default: "#17a2b8" + - namespace: + - :dchbx + - :enroll_app + - :site + - :urls + features: + - key: :policies_url + item: "https://dchealthlink.com/" + is_enabled: true + meta: + content_type: :url + is_required: false + is_visible: true + - key: :faqs_url + item: "https://www.dchealthlink.com/Frequently-Asked-Questions" + is_enabled: true + meta: + content_type: :url + is_required: false + is_visible: true + - key: :help_url + item: "https://www.dchealthlink.com/help" + is_enabled: true + meta: + content_type: :url + is_required: false + is_visible: true + - key: :nondiscrimination_notice_url + item: "https://www.dchealthlink.com/nondiscrimination" + is_enabled: true + meta: + content_type: :url + is_required: false + is_visible: true + - key: :business_resource_center_url + item: "https://dchealthlink.com/smallbusiness/" + is_enabled: true + meta: + content_type: :url + is_required: false + is_visible: true diff --git a/resource_registry.gemspec b/resource_registry.gemspec index 516d8814..6d91bbae 100644 --- a/resource_registry.gemspec +++ b/resource_registry.gemspec @@ -32,40 +32,37 @@ Gem::Specification.new do |spec| spec.required_ruby_version = '>= 2.5.1' - spec.add_dependency 'dry-validation', '~> 1.2' - spec.add_dependency 'dry-struct', '~> 1.0' - spec.add_dependency 'dry-types', '~> 1.0' - spec.add_dependency 'dry-configurable', '0.9' - - spec.add_dependency 'dry-container', '~> 0.7' spec.add_dependency 'deep_merge', '>= 1.0.0' - # Dependency gems added for security purposes - spec.add_dependency 'nokogiri', ">= 1.9.1" - spec.add_dependency "rack", ">= 1.6.13" - spec.add_dependency 'dry-monads', '~> 1.2' + spec.add_dependency 'dry-configurable', '0.9' + spec.add_dependency 'dry-container', '~> 0.7' spec.add_dependency 'dry-matcher', '~> 0.7' - - spec.add_dependency "loofah", ">= 2.3.1" - spec.add_development_dependency "actionview", ">= 5.2.4.2" - # end of dependency gem security updates + spec.add_dependency 'dry-monads', '~> 1.2' + spec.add_dependency 'dry-struct', '~> 1.0' + spec.add_dependency 'dry-types', '~> 1.0' + spec.add_dependency 'dry-validation', '~> 1.2' spec.add_dependency 'i18n', '>= 0.7.0' spec.add_dependency 'ox', '~> 2.0' - spec.add_dependency 'bootsnap', '~> 1.0' + spec.add_dependency 'loofah', '>= 2.3.1' + spec.add_dependency 'nokogiri', '>= 1.11.1' + spec.add_dependency 'rack', '>= 2.2.3' + spec.add_dependency 'mime-types' + spec.add_dependency 'rgl'#, '~> 0.5.6' - spec.add_development_dependency "bundler", "~> 2.0" + spec.add_development_dependency "actionview", '>= 5.2.4.3' + spec.add_development_dependency 'activesupport', '~> 5.2.4.3' + spec.add_development_dependency 'bootsnap', '~> 1.0' + spec.add_development_dependency 'bundler', '~> 2.0' + spec.add_development_dependency 'database_cleaner', '~> 1.7' + spec.add_development_dependency 'mongoid', '~> 6.0' spec.add_development_dependency 'rake', '~> 12.0' spec.add_development_dependency 'rspec', '~> 3.9' spec.add_development_dependency 'rspec-rails', '~> 3.9' - spec.add_development_dependency 'mongoid', '~> 6.0' - spec.add_development_dependency 'activesupport', '~> 5.2.4' - spec.add_development_dependency "simplecov" #, '~> 1.0' - spec.add_development_dependency "database_cleaner", '~> 1.7' - spec.add_development_dependency "timecop", '~> 0.9' - spec.add_development_dependency "rubocop", '~> 0.74.0' - spec.add_development_dependency "yard", "~> 0.9" - spec.add_development_dependency 'pry-byebug' + spec.add_development_dependency 'rubocop', '~> 1.9.0' + spec.add_development_dependency 'simplecov' #, '~> 1.0' + spec.add_development_dependency 'timecop', '~> 0.9' + spec.add_development_dependency 'yard', '~> 0.9' end diff --git a/spec/rails_app/system/config/templates/features/aca_shop_market/aca_shop_market.yml b/spec/rails_app/system/config/templates/features/aca_shop_market/aca_shop_market.yml index 33709ddf..4f0006ed 100644 --- a/spec/rails_app/system/config/templates/features/aca_shop_market/aca_shop_market.yml +++ b/spec/rails_app/system/config/templates/features/aca_shop_market/aca_shop_market.yml @@ -89,6 +89,10 @@ registry: - features: - key: :employer_sic is_enabled: false + namespace: + - :features + - :enroll_app + - :aca_shop_market settings: - key: :effective_period item: {hours: -15} diff --git a/spec/rails_app/system/config/templates/features/aca_shop_market/feature_group.yml b/spec/rails_app/system/config/templates/features/aca_shop_market/feature_group.yml new file mode 100644 index 00000000..c4e7a063 --- /dev/null +++ b/spec/rails_app/system/config/templates/features/aca_shop_market/feature_group.yml @@ -0,0 +1,326 @@ +--- +registry: + - namespace: + path: + - :dchbx + - :enroll_app + - :aca_shop_market + meta: + label: ACA Shop + content_type: :feature_list + description: 'ACA Shop Market Settings' + is_required: true + is_visible: true + features: + - key: :health_packages + is_enabled: true + item: :features_display + meta: + label: Health Packages + content_type: :nav + default: :'' + description: 'Configurations for Health Product Packages' + is_required: true + is_visible: true + settings: + - key: :product_packages + item: + - :health_product_package_2021 + - :health_product_package_2020 + meta: + label: Product Packages + content_type: :feature_group + description: product packages + is_required: false + is_visible: true + - namespace: + - :dchbx + - :enroll_app + - :aca_shop_market + - :health_product_packages + features: + - key: :shop_product_package_clone + item: ResourceRegistry::Operations::Features::Clone.new + is_enabled: true + meta: + label: Shop Product Package Clone + content_type: :legend + default: nil + description: Clone shop product packages + is_required: false + is_visible: true + settings: + - key: :calender_year + item: nil + meta: + label: Calender Year + content_type: :select + description: Choose calender year for new product package + enum: <%= [Date.today.year + 1, Date.today.year + 2].reduce([]) {|values, date| values << [date, date]} %> + is_required: false + is_visible: true + - key: :health_product_package_2021 + is_enabled: true + item: :feature_collection + meta: + label: Health Product Package 2021 + content_type: :legend + default: true + description: Health Product Pacakge for 2021 + is_required: true + is_visible: true + settings: + - key: :calender_year + item: 2021 + - key: :contribution_models + item: + operation: ResourceRegistry::Operations::Namespaces::ListFeatures.new + params: + namespace: "enroll_app.aca_shop_market.benefit_market_catalog.catalog_2021.contribution_model_criteria" + registry: EnrollRegistry + meta: + label: Contribution Models + content_type: :feature_list_panel + description: Contribution Models + is_required: false + is_visible: true + - key: :renew + item: "/exchanges/configurations/shop_product_package_renew/edit" + meta: + label: Renew Product Package + content_type: :feature_action + description: Renew Product Packages for the given calender year + is_required: false + is_visible: true + - key: :health_product_package_2020 + is_enabled: true + item: :feature_collection + meta: + label: Health Product Package 2020 + content_type: :legend + default: true + description: Health Product Pacakge for 2020 + is_required: true + is_visible: true + settings: + - key: :calender_year + item: 2020 + - key: :contribution_models + item: + operation: ResourceRegistry::Operations::Namespaces::ListFeatures.new + params: + namespace: "enroll_app.aca_shop_market.benefit_market_catalog.catalog_2020.contribution_model_criteria" + registry: EnrollRegistry + meta: + label: Contribution Models + content_type: :feature_list_panel + description: Contribution Models + is_required: false + is_visible: true + - namespace: + - :enroll_app + - :aca_shop_market + - :benefit_market_catalog + - :catalog_2021 + - :contribution_model_criteria + features: + - key: :initial_sponsor_jan_default_2021 + item: :contribution_model_criterion + is_enabled: true + meta: + label: 2021 Initial Sponsor January Default + content_type: :legend + default: true + description: Contribution Criteria for January 2021 Initial Sponsors + is_required: true + is_visible: true + settings: + - key: :contribution_model_key + item: :zero_percent_sponsor_fixed_percent_contribution_model + meta: + label: Contribution Model Key + content_type: :select + description: Enter contribution model key + enum: [{zero_percent_sponsor_fixed_percent_contribution_model: "Zero Percent Sponsor Fixed Percent Contribution Model"}, {fifty_percent_sponsor_fixed_percent_contribution_model: "Fifty Percent Sponsor Fixed Percent Contribution Model"}] + is_required: true + is_visible: true + - key: :benefit_application_kind + item: :initial + meta: + label: Benefit Application Kind + content_type: :select + enum: [{initial: "Initial"}, {renewal: "Renewal"}] + default: '' + description: Enter benefit application kind + is_required: true + is_visible: true + - key: :effective_period + item: <%= Date.new(2021,1,1)..Date.new(2021,1,31) %> + meta: + label: Effective Period + content_type: :date_range + description: Please enter effective period for the contribution model + is_required: true + is_visible: true + - key: :order + item: 1 + - key: :default + item: false + - key: :renewal_criterion_key + item: :initial_sponsor_jan_default + meta: + label: Renewal Criterion Key + content_type: :text_field + description: Enter renewal criterion key + is_required: false + is_visible: true + - key: :initial_sponsor_default_2021 + item: :contribution_model_criterion + is_enabled: true + meta: + label: 2021 Initial Sponsor Default + content_type: :legend + default: true + description: Contribution Criteria for 2021 Initial Sponsors + is_required: true + is_visible: true + settings: + - key: :contribution_model_key + item: :zero_percent_sponsor_fixed_percent_contribution_model + meta: + label: Contribution Model Key + content_type: :select + description: Enter contribution model key + enum: [{zero_percent_sponsor_fixed_percent_contribution_model: "Zero Percent Sponsor Fixed Percent Contribution Model"}, {fifty_percent_sponsor_fixed_percent_contribution_model: "Fifty Percent Sponsor Fixed Percent Contribution Model"}] + is_required: true + is_visible: true + - key: :benefit_application_kind + item: :initial + meta: + label: Benefit Application Kind + content_type: :select + enum: [{initial: "Initial"}, {renewal: "Renewal"}] + default: '' + description: Enter benefit application kind + is_required: true + is_visible: true + - key: :effective_period + item: <%= Date.new(2021,2,1)..Date.new(2021,12,31) %> + meta: + label: Effective Period + content_type: :date_range + description: Please enter effective period for the contribution model + is_required: true + is_visible: true + - key: :order + item: 3 + - key: :default + item: true + - key: :renewal_criterion_key + item: :initial_sponsor_default + meta: + label: Renewal Criterion Key + content_type: :text_field + description: Enter renewal criterion key + is_required: false + is_visible: true + - key: :renewal_sponsor_jan_default_2021 + item: :contribution_model_criterion + is_enabled: true + meta: + label: 2021 Renewal January Sponsor Default + content_type: :legend + default: true + description: Contribution Criteria for January 2021 Renewal Sponsors + is_required: true + is_visible: true + settings: + - key: :contribution_model_key + item: :zero_percent_sponsor_fixed_percent_contribution_model + meta: + label: Contribution Model Key + content_type: :select + description: Enter contribution model key + enum: [{zero_percent_sponsor_fixed_percent_contribution_model: "Zero Percent Sponsor Fixed Percent Contribution Model"}, {fifty_percent_sponsor_fixed_percent_contribution_model: "Fifty Percent Sponsor Fixed Percent Contribution Model"}] + is_required: true + is_visible: true + - key: :benefit_application_kind + item: :renewal + meta: + label: Benefit Application Kind + content_type: :select + enum: [{initial: "Initial"}, {renewal: "Renewal"}] + default: '' + description: Enter benefit application kind + is_required: true + is_visible: true + - key: :effective_period + item: <%= Date.new(2021,1,1)..Date.new(2021,1,31) %> + meta: + label: Effective Period + content_type: :date_range + description: Please enter effective period for the contribution model + is_required: true + is_visible: true + - key: :order + item: 4 + - key: :default + item: false + - key: :renewal_criterion_key + item: :renewal_sponsor_jan_default + meta: + label: Renewal Criterion Key + content_type: :text_field + description: Enter renewal criterion key + is_required: false + is_visible: true + - key: :renewal_sponsor_default_2021 + item: :contribution_model_criterion + is_enabled: true + meta: + label: 2021 Renewal Sponsor Default + content_type: :legend + default: true + description: Contribution Criteria for 2021 Renewal Sponsors + is_required: true + is_visible: true + settings: + - key: :contribution_model_key + item: :zero_percent_sponsor_fixed_percent_contribution_model + meta: + label: Contribution Model Key + content_type: :select + description: Enter contribution model key + enum: [{zero_percent_sponsor_fixed_percent_contribution_model: "Zero Percent Sponsor Fixed Percent Contribution Model"}, {fifty_percent_sponsor_fixed_percent_contribution_model: "Fifty Percent Sponsor Fixed Percent Contribution Model"}] + is_required: true + is_visible: true + - key: :benefit_application_kind + item: :renewal + meta: + label: Benefit Application Kind + content_type: :select + enum: [{initial: "Initial"}, {renewal: "Renewal"}] + default: '' + description: Enter benefit application kind + is_required: true + is_visible: true + - key: :effective_period + item: <%= Date.new(2021,2,1)..Date.new(2021,12,31) %> + meta: + label: Effective Period + content_type: :date_range + description: Please enter effective period for the contribution model + is_required: true + is_visible: true + - key: :order + item: 2 + - key: :default + item: true + - key: :renewal_criterion_key + item: :renewal_sponsor_default + meta: + label: Renewal Criterion Key + content_type: :text_field + description: Enter renewal criterion key + is_required: false + is_visible: true \ No newline at end of file diff --git a/spec/rails_app/system/config/templates/features/aca_shop_market/model_features.yml b/spec/rails_app/system/config/templates/features/aca_shop_market/model_features.yml new file mode 100644 index 00000000..6bd8178c --- /dev/null +++ b/spec/rails_app/system/config/templates/features/aca_shop_market/model_features.yml @@ -0,0 +1,66 @@ +--- +registry: + - namespace: + path: + - :shop + - :2021 + meta: + label: 'Shop 2021' + content_type: :feature_list + description: 'Configuration settings for 2019 Contribution models' + is_required: true + is_visible: true + features: + - key: :health + is_enabled: true + meta: + label: 'Health' + content_type: :feature_enabled + description: Health Products Offered + is_required: true + is_visible: true + - key: :dental + is_enabled: true + meta: + label: 'Dental' + content_type: :feature_enabled + description: Dental Products Offered + is_required: true + is_visible: true + - key: :benefit_market_catalog + is_enabled: true + item: + class_name: "BenefitMarkets::BenefitMarketCatalog" + scope: + name: :by_kind_and_application_date + arguments: + - :aca_shop + - <%= Date.new(2020,1,1) %> + # where: + # arguments: + # "application_period.min": + # "$lte": <%#= Date.new(2020,1,1) %> + meta: + label: 'Health' + content_type: :model_attributes + settings: + - key: :title + item: + attribute: :title + constraints: {max_length: 20} + meta: + label: Catalog Title + content_type: :text_field + description: Enter market catalog title + is_required: false + is_visible: true + - key: :description + item: + attribute: :description + constraints: {max_length: 200} + meta: + label: Catalog Description + content_type: :text_field + description: Enter market catalog description + is_required: false + is_visible: true \ No newline at end of file diff --git a/spec/rails_app/system/config/templates/features/invalid/aca_shop_market.yml b/spec/rails_app/system/config/templates/features/invalid/aca_shop_market.yml new file mode 100644 index 00000000..34cfd880 --- /dev/null +++ b/spec/rails_app/system/config/templates/features/invalid/aca_shop_market.yml @@ -0,0 +1,14 @@ +--- +registry: + - features: + - key: :employer_sic_invalid + is_enabled: false + settings: + - key: :effective_period + item: {hours: -15} + meta: + label: Effective Period + is_required: false + is_visible: true + content_type: :duration + default: {hours: 5} \ No newline at end of file diff --git a/spec/resource_registry/feature_dsl_spec.rb b/spec/resource_registry/feature_dsl_spec.rb index 7e35c7c7..27ec7c28 100644 --- a/spec/resource_registry/feature_dsl_spec.rb +++ b/spec/resource_registry/feature_dsl_spec.rb @@ -13,7 +13,7 @@ def call(params) end let(:key) { :greeter_feature } - let(:namespace) { [:level_1, :level_2, :level_3]} + let(:namespace) { {path: [:level_1, :level_2, :level_3]} } let(:is_enabled) { false } let(:item) { Greeter.method(:new) } let(:options) { { name: "Dolly" } } @@ -29,7 +29,7 @@ def call(params) let(:min_feature_hash) do { key: key, - namespace: namespace, + namespace_path: namespace, is_enabled: is_enabled, item: item } @@ -38,7 +38,7 @@ def call(params) let(:feature_hash) do { key: key, - namespace: namespace, + namespace_path: namespace, is_enabled: is_enabled, item: item, options: options, @@ -139,7 +139,7 @@ def call(params) let(:dsl_sans_meta) { described_class.new(feature_sans_meta) } it "should return an empty hash" do - expect(dsl_sans_meta.meta).to eq Hash.new + expect(dsl_sans_meta.meta).to eq({}) end context "and meta key is not present" do diff --git a/spec/resource_registry/feature_spec.rb b/spec/resource_registry/feature_spec.rb index da28ee00..aa48a13f 100644 --- a/spec/resource_registry/feature_spec.rb +++ b/spec/resource_registry/feature_spec.rb @@ -13,14 +13,14 @@ def call(params) end let(:key) { :greeter_feature } - let(:namespace) { [:level_1,:level_2,:level_3]} + let(:namespace) { {path: [:level_1,:level_2,:level_3]} } let(:is_enabled) { false } let(:item) { Greeter.new } let(:options) { { name: "Dolly" } } let(:meta) { { label: "label", default: 42, content_type: :integer } } let(:settings) { [{ key: :service, item: "weather/forcast" }, { key: :retries, item: 4 }] } - let(:required_params) { { key: key, namespace: namespace, is_enabled: is_enabled, item: item } } + let(:required_params) { { key: key, namespace_path: namespace, is_enabled: is_enabled, item: item } } let(:optional_params) { { options: options, meta: meta, settings: settings } } let(:all_params) { required_params.merge(optional_params) } @@ -65,7 +65,7 @@ def call(params) end context "Given hash params include a class as the item value" do - let(:greet_message) { "Hello " + options[:name] } + let(:greet_message) { "Hello #{options[:name]}" } it "should invoke the class with the passed options parameters" do setting = described_class.new(all_params) diff --git a/spec/resource_registry/operations/features/renew_spec.rb b/spec/resource_registry/operations/features/renew_spec.rb new file mode 100644 index 00000000..090fd072 --- /dev/null +++ b/spec/resource_registry/operations/features/renew_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ResourceRegistry::Operations::Features::Renew do + include RegistryDataSeed + + subject { described_class.new.call(params) } + + context 'When valid feature hash passed' do + + let(:registry) { ResourceRegistry::Registry.new } + let!(:register) { ResourceRegistry::Operations::Registries::Create.new.call(path: feature_group_template_path, registry: registry) } + + let(:params) do + { + params: { + target_feature: 'health_product_package_2021', + settings: {calender_year: "2022"} + }, + registry: registry + } + end + + it "should return success with hash output" do + stub_const("EnrollRegistry", registry) + + expect(subject).to be_a Dry::Monads::Result::Success + expect(registry[:health_product_package_2022]).to be_truthy + end + end +end \ No newline at end of file diff --git a/spec/resource_registry/operations/features/update_spec.rb b/spec/resource_registry/operations/features/update_spec.rb index 78409886..e793d86e 100644 --- a/spec/resource_registry/operations/features/update_spec.rb +++ b/spec/resource_registry/operations/features/update_spec.rb @@ -20,6 +20,7 @@ { :key => :aca_shop_market, :is_enabled => true, + :namespace_path => {:path => [:features, :enroll_app]}, :settings => [ { enroll_prior_to_effective_on_max: { days: 10 } }, { enroll_after_effective_on_max: { days: 60 } }, diff --git a/spec/resource_registry/operations/graphs/create_spec.rb b/spec/resource_registry/operations/graphs/create_spec.rb new file mode 100644 index 00000000..28eec448 --- /dev/null +++ b/spec/resource_registry/operations/graphs/create_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ResourceRegistry::Operations::Graphs::Create do + include RegistryDataSeed + + subject { described_class.new.call(namespaces, registry) } + + let(:registry) { ResourceRegistry::Registry.new } + let(:features) { ResourceRegistry::Operations::Registries::Create.new.call(path: feature_group_template_path, registry: registry).success } + let(:namespace_types) { %w[feature_list nav] } + let(:namespaces) { ResourceRegistry::Serializers::Namespaces::Serialize.new.call(features: features, namespace_types: namespace_types).success } + let(:features_with_meta) { features.select{|f| f.meta.to_h.present?} } + let(:matched_features) { features_with_meta.select{|feature| namespace_types.include?(feature.namespace_path&.meta&.content_type&.to_s)} } + + context 'When a valid namespace passed' do + + it 'should return success monad' do + expect(subject).to be_a Dry::Monads::Result::Success + end + + it 'should return a directed acyclic graph' do + value = subject.success + expect(value).to be_a RGL::DirectedAdjacencyGraph + expect(value.directed?).to be_truthy + expect(value.cycles).to be_empty + end + + it 'should create vertices with valid namespace paths' do + graph = subject.success + namespace_paths = matched_features.collect{|feature| feature.namespace_path.path}.uniq + + namespace_paths.inject([]){|vertex_paths, path| vertex_paths + path_to_vertex_path(path)}.uniq.each do |vertex_path| + matched_vertex = graph.vertices.detect{|vertex| vertex.path == vertex_path} + expect(matched_vertex).to be_present + end + end + end + + def path_to_vertex_path(path = []) + return if path.empty? + paths = [] + vertex_paths = [] + loop do + current = path.shift + paths.push(current) + vertex_paths << paths.dup + break if path.empty? + end + vertex_paths + end + + context 'When invalid namespaces passed' do + let(:feature1) do + ResourceRegistry::Feature.new({ + :key => :health_packages, + :namespace_path => { + :path => [:dchbx, :enroll_app, :aca_shop_market], + :meta => { + :label => "ACA Shop", + :content_type => :feature_list, + :is_required => true, + :is_visible => true + } + }, + :is_enabled => true, + :item => :features_display, + :meta => { + :label => "Health Packages", + :content_type => :legend, + :is_required => true, + :is_visible => true + } + }) + end + + let(:feature2) do + ResourceRegistry::Feature.new({ + :key => :health_packages_two, + :namespace_path => { + :path => [:aca_shop_market, :dchbx, :enroll_app], + :meta => { + :label => "ACA Shop", + :content_type => :feature_list, + :is_required => true, + :is_visible => true + } + }, + :is_enabled => true, + :item => :features_display, + :meta => { + :label => "Health Packages", + :content_type => :legend, + :is_required => true, + :is_visible => true + } + }) + end + + let(:features) { [feature1, feature2] } + + # Pending + it 'should return with error' do + subject + end + end +end \ No newline at end of file diff --git a/spec/resource_registry/operations/namespaces/build_spec.rb b/spec/resource_registry/operations/namespaces/build_spec.rb new file mode 100644 index 00000000..64633713 --- /dev/null +++ b/spec/resource_registry/operations/namespaces/build_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ResourceRegistry::Operations::Namespaces::Build do + include RegistryDataSeed + + subject { described_class.new.call(feature, content_types) } + + let(:registry) { ResourceRegistry::Registry.new } + let(:features) { ResourceRegistry::Operations::Registries::Create.new.call(path: feature_template_path, registry: registry).success } + + let(:namespace_content_type) { :legend } + + let(:namespace_path) do + { + :path => [:features, :enroll_app], + :meta => { + :label => "SHOP", + :content_type => namespace_content_type, + :default => true, + :is_required => false, + :is_visible => true + } + } + end + + let(:feature_meta) do + { + :label => "Enable ACA SHOP Market", + :content_type => :boolean, + :default => true, + :is_required => false, + :is_visible => true + } + end + + let(:feature) do + ResourceRegistry::Feature.new({ + :key => :employer_sic, + :namespace_path => namespace_path, + :is_enabled => false, + :meta => feature_meta + }) + end + + context 'When feature with missing meta passed' do + let(:content_types) { nil } + let(:feature_meta) { {} } + + it 'should fail with error' do + result = subject + + expect(result).to be_a Dry::Monads::Result::Failure + expect(result.failure).to include("feature meta can't be empty") + end + end + + context 'When namespace content_types argument passed' do + let(:content_types) { %w[feature_list nav] } + + context 'and feature namespace content_type mismatched' do + let(:namespace_content_type) {:legend} + + it 'should fail with error' do + result = subject + + expect(result).to be_a Dry::Monads::Result::Failure + expect(result.failure).to include("namesapce content type should be #{content_types}") + end + end + + context 'and feature namespace content_type matched' do + let(:namespace_content_type) {:nav} + + it 'should pass and return namespace' do + result = subject + + expect(result).to be_a Dry::Monads::Result::Success + output = result.success + expect(output[:path]).to eq(feature.namespace_path.path) + expect(output[:feature_keys]).to include(feature.key) + end + end + end + + context 'When namespace content_types argument not passed' do + let(:content_types) { nil } + + context 'and feature has meta and namespace_path' do + it 'should pass and return namespace' do + result = subject + + expect(result).to be_a Dry::Monads::Result::Success + output = result.success + expect(output[:path]).to eq(feature.namespace_path.path) + expect(output[:feature_keys]).to include(feature.key) + end + end + end +end \ No newline at end of file diff --git a/spec/resource_registry/operations/namespaces/create_spec.rb b/spec/resource_registry/operations/namespaces/create_spec.rb new file mode 100644 index 00000000..fd7cf949 --- /dev/null +++ b/spec/resource_registry/operations/namespaces/create_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ResourceRegistry::Operations::Namespaces::Create do + include RegistryDataSeed + + subject { described_class.new.call(params) } + + let(:feature) do + ResourceRegistry::Feature.new({ + :key => :health_packages, + :namespace_path => { + :path => [:dchbx, :enroll_app, :aca_shop_market], + :meta => { + :label => "ACA Shop", + :content_type => :feature_list, + :is_required => true, + :is_visible => true + } + }, + :is_enabled => true, + :item => :features_display, + :meta => { + :label => "Health Packages", + :content_type => :legend, + :is_required => true, + :is_visible => true + } + }) + end + + context 'When valid params passed' do + let(:params) do + { + key: feature.namespace_path.path.map(&:to_s).join('_'), + path: feature.namespace_path.path, + feature_keys: [feature.key], + meta: feature.namespace_path.meta.to_h + } + end + + it "should return namespace entity" do + subject + expect(subject).to be_a Dry::Monads::Result::Success + expect(subject.success).to be_a ResourceRegistry::Namespace + end + end + + context 'When invalid params passed' do + let(:params) do + { + key: feature.namespace_path.path.map(&:to_s).join('_'), + feature_keys: [feature.key], + meta: feature.namespace_path.meta.to_h + } + end + + it "should return error" do + subject + expect(subject).to be_a Dry::Monads::Result::Failure + expect(subject.failure.errors.to_h).to eq({:path => ["is missing"]}) + end + end +end \ No newline at end of file diff --git a/spec/resource_registry/operations/namespaces/form_spec.rb b/spec/resource_registry/operations/namespaces/form_spec.rb new file mode 100644 index 00000000..2f1bf60f --- /dev/null +++ b/spec/resource_registry/operations/namespaces/form_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ResourceRegistry::Operations::Namespaces::Form do + include RegistryDataSeed + + subject { described_class.new.call(namespace: [:shop, :"2021"], registry: registry) } + + context 'When a valid namespace passed' do + let(:registry) do + registry = ResourceRegistry::Registry.new + registry.register('configuration.load_path', features_folder_path) + registry + end + + before do + ResourceRegistry::Operations::Registries::Load.new.call(registry: registry) + end + + it "should return namespace entity" do + subject + + expect(subject).to be_a Dry::Monads::Result::Success + expect(subject.value!).to be_a ResourceRegistry::Namespace + end + end +end diff --git a/spec/resource_registry/operations/namespaces/list_features_spec.rb b/spec/resource_registry/operations/namespaces/list_features_spec.rb new file mode 100644 index 00000000..539d9be4 --- /dev/null +++ b/spec/resource_registry/operations/namespaces/list_features_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ResourceRegistry::Operations::Namespaces::ListFeatures do + include RegistryDataSeed + + subject { described_class.new.call(params) } + + context 'When valid feature hash passed' do + + let(:registry) { ResourceRegistry::Registry.new } + let(:register) { ResourceRegistry::Operations::Registries::Create.new.call(path: feature_group_template_path, registry: registry) } + let(:namespace) {"enroll_app.aca_shop_market.benefit_market_catalog.catalog_2021.contribution_model_criteria"} + let(:params) { {namespace: namespace, registry: 'EnrollRegistry'} } + + before { register } + + it "should return success with features" do + stub_const("EnrollRegistry", registry) + + expect(subject).to be_a Dry::Monads::Result::Success + expect(subject.success.map(&:key)).to eq registry.features_by_namespace(namespace) + end + end +end \ No newline at end of file diff --git a/spec/resource_registry/operations/namespaces/update_features_spec.rb b/spec/resource_registry/operations/namespaces/update_features_spec.rb new file mode 100644 index 00000000..b202b33c --- /dev/null +++ b/spec/resource_registry/operations/namespaces/update_features_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ResourceRegistry::Operations::Namespaces::UpdateFeatures do + include RegistryDataSeed + + subject { described_class.new.call(namespace: params, registry: registry) } + + context 'When valid feature hash passed' do + + let(:registry) do + registry = ResourceRegistry::Registry.new + registry.register('configuration.load_path', features_folder_path) + registry + end + + before do + ResourceRegistry::Operations::Registries::Load.new.call(registry: registry) + end + + let(:params) do + { + features: { + :"0" => {key: 'health', is_enabled: 'true', namespace: 'shop.2021'}, + :"1" => {key: 'dental', is_enabled: 'true', namespace: 'shop.2021'}, + :"2" => { + key: 'benefit_market_catalog', + namespace: 'shop.2021', + is_enabled: 'true', + settings: { + :"0" => {title: "DC Health Link SHOP Benefit Catalog"}, + :"1" => {description: "Test Description"} + } + } + } + } + end + + it "should return success with hash output" do + subject + end + end +end diff --git a/spec/resource_registry/operations/registries/create_spec.rb b/spec/resource_registry/operations/registries/create_spec.rb index 9f3adfeb..040e1cfd 100644 --- a/spec/resource_registry/operations/registries/create_spec.rb +++ b/spec/resource_registry/operations/registries/create_spec.rb @@ -8,15 +8,24 @@ subject { described_class.new.call(path: path, registry: registry) } context 'When valid feature hash passed' do - let(:path) { feature_template_path } let(:registry) { ResourceRegistry::Registry.new } - it "should return success with hash output" do subject expect(subject).to be_a Dry::Monads::Result::Success - expect(subject.value!).to be_a ResourceRegistry::Registry + expect(subject.value!).to include(a_kind_of(ResourceRegistry::Feature)) + end + end + + context 'When invalid features' do + let(:path) { invalid_feature_template_path } + let(:registry) { ResourceRegistry::Registry.new } + + it "should return success with hash output" do + subject + expect(subject).to be_a Dry::Monads::Result::Failure + expect(subject.failure.errors[:namespace_path]).to eq [{:text => "invalid meta", :error => {:path => ["must be an array"]}}] end end -end +end \ No newline at end of file diff --git a/spec/resource_registry/registry_spec.rb b/spec/resource_registry/registry_spec.rb index 3228d13a..7e2faad2 100644 --- a/spec/resource_registry/registry_spec.rb +++ b/spec/resource_registry/registry_spec.rb @@ -60,9 +60,9 @@ def call(params) end let(:key) { :greeter_feature } - let(:namespace) { [:level_1, :level_2, :level_3]} + let(:namespace) { {path: [:level_1, :level_2, :level_3]} } let(:namespace_str) { 'level_1.level_2.level_3'} - let(:namespace_key) { namespace_str + '.' + key.to_s } + let(:namespace_key) { "#{namespace_str}.#{key}" } let(:is_enabled) { false } let(:item) { Greeter.new } @@ -91,7 +91,7 @@ def call(params) let(:feature_hash) do { key: key, - namespace: namespace, + namespace_path: namespace, is_enabled: is_enabled, item: item, meta: meta @@ -127,7 +127,7 @@ def call(params) end context "given feature with no namespace" do - let(:namespace) { [] } + let(:namespace) { {path: []} } it "should register feature under root" do registry.register_feature(feature) @@ -212,26 +212,26 @@ def call(params) end describe '#enabled?' do - context "Given features without a namespace that is enabled" do - let(:vessel) { ResourceRegistry::Feature.new(key: :vessel, is_enabled: true) } + # context "Given features without a namespace that is enabled" do + # let(:vessel) { ResourceRegistry::Feature.new(key: :vessel, is_enabled: true) } - before { registry.register_feature(vessel) } + # before { registry.register_feature(vessel) } - it "the feature should be enabled" do - expect(registry.feature_enabled?(:vessel)).to be_truthy - end - end + # it "the feature should be enabled" do + # expect(registry.feature_enabled?(:vessel)).to be_truthy + # end + # end context "Given an enabled feature with all ancestors enabled" do let(:boat) do ResourceRegistry::Feature.new(key: :boat, - namespace: [:vessel], + namespace_path: {path: [:vessel]}, is_enabled: true) end let(:sailboat) do ResourceRegistry::Feature.new(key: :sailboat, - namespace: [:vessel, :boat], + namespace_path: {path: [:vessel, :boat]}, is_enabled: true) end @@ -253,7 +253,7 @@ def call(params) context "and an enabled feature with a break in ancestor namespace" do let(:canoe) do ResourceRegistry::Feature.new(key: :canoe, - namespace: [:vessel, :boat, :paddleboat], + namespace_path: {path: [:vessel, :boat, :paddleboat]}, is_enabled: false) end before { registry.register_feature(canoe) } @@ -268,14 +268,14 @@ def call(params) context "Given an ancestor feature is disabled" do let(:powerboat) do ResourceRegistry::Feature.new(key: :powerboat, - namespace: [:vessel, :boat], + namespace_path: {path: [:vessel, :boat]}, is_enabled: false) end context "and a child of that feature is enabled" do let(:trawler) do ResourceRegistry::Feature.new(key: :trawler, - namespace: [:vessel, :boat, :powerboat], + namespace_path: {path: [:vessel, :boat, :powerboat]}, is_enabled: true) end @@ -285,9 +285,8 @@ def call(params) end it "the child feature should be disabled" do - expect(registry.feature_enabled?(:trawler)).to be_truthy + expect(registry.feature_enabled?(:trawler)).to be_falsey end - end end end @@ -303,17 +302,16 @@ def call(params) describe '#features_by_namespace' do context "Given features registered in different namespaces" do - let(:sail_ns) { [:vessel, :boat, :sail] } - let(:sail_ns_str) { sail_ns.map(&:to_s).join('.') } - let(:boat_ns) { [:vessel, :boat] } - let(:boat_ns_str) { boat_ns.map(&:to_s).join('.') } - - - let(:ski) { ResourceRegistry::Feature.new(key: :ski, namespace: boat_ns, is_enabled: true) } - let(:trawler) { ResourceRegistry::Feature.new(key: :trawler, namespace: boat_ns, is_enabled: true) } - let(:sloop) { ResourceRegistry::Feature.new(key: :sloop, namespace: sail_ns, is_enabled: true) } - let(:ketch) { ResourceRegistry::Feature.new(key: :ketch, namespace: sail_ns, is_enabled: true) } - let(:yawl) { ResourceRegistry::Feature.new(key: :yawl, namespace: sail_ns, is_enabled: true) } + let(:sail_ns) { {path: [:vessel, :boat, :sail]} } + let(:sail_ns_str) { sail_ns[:path].map(&:to_s).join('.') } + let(:boat_ns) { {path: [:vessel, :boat]} } + let(:boat_ns_str) { boat_ns[:path].map(&:to_s).join('.') } + + let(:ski) { ResourceRegistry::Feature.new(key: :ski, namespace_path: boat_ns, is_enabled: true) } + let(:trawler) { ResourceRegistry::Feature.new(key: :trawler, namespace_path: boat_ns, is_enabled: true) } + let(:sloop) { ResourceRegistry::Feature.new(key: :sloop, namespace_path: sail_ns, is_enabled: true) } + let(:ketch) { ResourceRegistry::Feature.new(key: :ketch, namespace_path: sail_ns, is_enabled: true) } + let(:yawl) { ResourceRegistry::Feature.new(key: :yawl, namespace_path: sail_ns, is_enabled: true) } let(:sail_features) { [:sloop, :ketch, :yawl] } let(:boat_features) { [:ski, :trawler] } diff --git a/spec/resource_registry/serializers/namespaces/serialize_spec.rb b/spec/resource_registry/serializers/namespaces/serialize_spec.rb new file mode 100644 index 00000000..00d1b8af --- /dev/null +++ b/spec/resource_registry/serializers/namespaces/serialize_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ResourceRegistry::Serializers::Namespaces::Serialize do + include RegistryDataSeed + + subject { described_class.new.call(features: features, namespace_types: namespace_types) } + + context 'When features set passed' do + + let(:registry) { ResourceRegistry::Registry.new } + let(:features) { ResourceRegistry::Operations::Registries::Create.new.call(path: feature_group_template_path, registry: registry).success } + let(:features_with_meta) { features.select{|f| f.meta.to_h.present?} } + + context "when namespace_types ignored" do + let(:namespace_types) { [] } + + it "should return namespace hash with meta defined features" do + result = subject.success + + expect(subject).to be_a Dry::Monads::Result::Success + features_with_meta.each do |feature| + namespace_key = feature.namespace_path.path.map(&:to_s).join('_') + namespace_hash = result.detect{|values| values[:key] == namespace_key} + expect(namespace_hash).to be_present + expect(namespace_hash[:feature_keys]).to include(feature.key) + end + end + end + + context "when namespace_types passed" do + let(:namespace_types) { %w[feature_list nav] } + + it "should return namespace hash with meta defined and namespace_type matching features" do + result = subject.success + expected_features = features_with_meta.select{|feature| namespace_types.include?(feature.namespace_path&.meta&.content_type&.to_s)} + + expect(subject).to be_a Dry::Monads::Result::Success + expected_features.each do |feature| + namespace_key = feature.namespace_path.path.map(&:to_s).join('_') + namespace_hash = result.detect{|values| values[:key] == namespace_key} + expect(namespace_hash).to be_present + expect(namespace_hash[:feature_keys]).to include(feature.key) + end + end + end + end +end \ No newline at end of file diff --git a/spec/resource_registry/setting_spec.rb b/spec/resource_registry/setting_spec.rb index 91e1e168..7b448e5c 100644 --- a/spec/resource_registry/setting_spec.rb +++ b/spec/resource_registry/setting_spec.rb @@ -59,7 +59,7 @@ def call(params) end context "Given hash params include a class as the item value" do - let(:greet_message) { "Hello " + options[:name] } + let(:greet_message) { "Hello #{options[:name]}" } it "should invoke the class with the passed options parameters" do setting = described_class.new(all_params) diff --git a/spec/resource_registry/validation/feature_contract_spec.rb b/spec/resource_registry/validation/feature_contract_spec.rb index 6659f93f..4d581165 100644 --- a/spec/resource_registry/validation/feature_contract_spec.rb +++ b/spec/resource_registry/validation/feature_contract_spec.rb @@ -6,14 +6,14 @@ describe "Feature core parameters" do let(:key) { :my_feature } - let(:namespace) { [:level_1, :level_2, :level_3]} + let(:namespace) { {path: [:level_1, :level_2, :level_3]} } let(:is_enabled) { false } let(:item) { ->(val){ val.to_sym } } let(:options) { { name: "Dolly" } } let(:meta) { { label: "label", default: 42, content_type: :integer } } let(:settings) { [{ key: :service, item: "weather/forecast" }, { key: :retries, item: 4 }] } - let(:required_params) { { key: key, namespace: namespace, is_enabled: is_enabled, item: item } } + let(:required_params) { { key: key, namespace_path: namespace, is_enabled: is_enabled, item: item } } let(:optional_params) { { options: options, meta: meta, settings: settings } } let(:all_params) { required_params.merge(optional_params) } @@ -30,7 +30,7 @@ context "and a non-boolean value is passed to :is_enabled" do let(:invalid_is_enabled) { "blue" } - let(:invalid_params) { { key: key, namespace: namespace, is_enabled: invalid_is_enabled } } + let(:invalid_params) { { key: key, namespace_path: namespace, is_enabled: invalid_is_enabled } } let(:error_message) { "must be boolean" } it "should should fail validation" do @@ -40,10 +40,22 @@ expect(result.errors.first.text).to eq error_message end end + + context 'namespace missing' do + let(:invalid_params) { required_params.merge(namespace_path: nil) } + + it "should should fail validation" do + result = subject.call(invalid_params) + + expect(result.success?).to be_falsey + expect(result.errors[:namespace_path]).to eq ["must be a hash"] + end + end end context "Given valid parameters" do - let(:required_attr_defaults) { {meta: nil, settings: []} } + # let(:required_attr_defaults) { {meta: nil, settings: []} } + let(:required_attr_defaults) { {meta: nil} } let(:required_attr_out) { required_params.merge(required_attr_defaults) } context "and required parameters only" do @@ -63,7 +75,7 @@ context "and key is passed as string" do let(:key_string) { "my_feature" } - let(:params) { { key: key_string, namespace: namespace, is_enabled: is_enabled } } + let(:params) { { key: key_string, namespace_path: namespace, is_enabled: is_enabled } } it "should coerce stringified key into symbol" do result = subject.call(params) @@ -74,8 +86,8 @@ end context "and passing namespace values in as strings" do - let(:namespace_strings) { namespace.map(&:to_s) } - let(:params) { { key: key, namespace: namespace_strings, is_enabled: is_enabled, item: item } } + let(:namespace_strings) { {path: ['level_1', 'level_2', 'level_3']} } + let(:params) { { key: key, namespace_path: namespace_strings, is_enabled: is_enabled, item: item } } it "should coerce stringified key into symbol" do result = subject.call(params) diff --git a/spec/resource_registry/validation/meta_contract_spec.rb b/spec/resource_registry/validation/meta_contract_spec.rb index 8c4c26b2..43227085 100644 --- a/spec/resource_registry/validation/meta_contract_spec.rb +++ b/spec/resource_registry/validation/meta_contract_spec.rb @@ -26,7 +26,7 @@ let(:all_params) { required_params.merge(optional_params) } context "Validation with invalid input" do - let(:required_params_error) { { :default => ["is missing"], :label => ["is missing"], :content_type => ["is missing"] } } + let(:required_params_error) { { :label => ["is missing"], :content_type => ["is missing"] } } context "Given hash params are empty" do diff --git a/spec/resource_registry/validation/namespace_contract_spec.rb b/spec/resource_registry/validation/namespace_contract_spec.rb new file mode 100644 index 00000000..e69de29b diff --git a/spec/support/registry_data_seed.rb b/spec/support/registry_data_seed.rb index 4abb4367..70eb2797 100644 --- a/spec/support/registry_data_seed.rb +++ b/spec/support/registry_data_seed.rb @@ -27,10 +27,18 @@ def configuration_file_path Pathname.pwd.join('spec', 'db', 'seedfiles', 'config', 'config.yml') end + def invalid_feature_template_path + Pathname.pwd.join('spec', 'rails_app', 'system', 'config', 'templates', 'features','invalid', 'aca_shop_market.yml') + end + def feature_template_path Pathname.pwd.join('spec', 'rails_app', 'system', 'config', 'templates', 'features','aca_shop_market', 'aca_shop_market.yml') end + def feature_group_template_path + Pathname.pwd.join('spec', 'rails_app', 'system', 'config', 'templates', 'features','aca_shop_market', 'feature_group.yml') + end + def option_files_dir Pathname.pwd.join('spec', 'db', 'seedfiles', 'client') end