flipside 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 10dcbfd4da7a00053fc1e9486c54312a0010096deeec169368e251dd173a6c41
4
+ data.tar.gz: 0327eac1507849ce0ae681a37af3ca5b09af9d642331619879cfd2e876f97462
5
+ SHA512:
6
+ metadata.gz: 1380f6f03f37f95a4f567ea840534e371a963fa1380bde5424981651d558761c642f67ba9cbc9bc14fac90ee0dc9393020a3bb13ca809b0e5206cea7c542ffe9
7
+ data.tar.gz: f9827baa63d88ec20224c8e5bb356af5ac03271714a0db706359e53b157a28e03108a2be5c729fd83e58a39d443dc548b4e7bdcdebe591fce706b7839bae8d55
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2024-11-22
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 Sammy Henningsson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # Flipside
2
+
3
+ **Flipside** is a gem for managing feature flags in your Rails applications.
4
+
5
+ ---
6
+
7
+ ## Features
8
+
9
+ - enable or disable features globally
10
+ - enable features for specific records (e.g. users, organizations)
11
+ - enabled features for objects responding `true` to a certain method
12
+ - Setting a start and end time for when the feature is active
13
+
14
+ ---
15
+
16
+ ## Installation
17
+
18
+ Install the gem and add to the application's Gemfile by executing:
19
+
20
+ $ bundle add flipside
21
+
22
+ If bundler is not being used to manage dependencies, install the gem by executing:
23
+
24
+ $ gem install flipside
25
+
26
+ Then run
27
+
28
+ $ rails generate flipside:install
29
+
30
+ This will create a migration file. Run the migration, to add the flipside tables
31
+
32
+ $ rails db:migrate
33
+
34
+ ## Usage
35
+
36
+ 1. Defining Features
37
+
38
+ Feature are created by running this (in a console or from code):
39
+ ```ruby
40
+ Flipside::Feature.create(name: "MyFeature", description: "Some optional description about what this feature do")
41
+ ```
42
+
43
+ By default features are turned off. If you would like it turned on from the beginning you could pass in `enabled: true`.
44
+
45
+ 2. Checking Feature Status
46
+
47
+ #### Globally
48
+
49
+ Check if a feature is enabled globally:
50
+
51
+ ```ruby
52
+ Flipside.enabled? "MyFeature"
53
+ ```
54
+
55
+ #### For a Specific Record
56
+
57
+ Check if a feature is enabled for a specific record (e.g. a user, an organization or a user responding `true` to `user.admin?`):
58
+
59
+ ```ruby
60
+ Flipside.enabled? "MyFeature", user
61
+ ```
62
+
63
+ ## Configuration
64
+ TODO
65
+
66
+ ## Development
67
+
68
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rspec` to run the tests.
69
+
70
+ 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 the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
71
+
72
+ ## Contributing
73
+
74
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[sammyhenningsson]/flipside.
75
+
76
+ ## License
77
+
78
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flipside
4
+ module Checks
5
+ def add_check(&block)
6
+ checks << block
7
+ end
8
+
9
+ def checks
10
+ @checks ||= []
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "models/flipside/entity"
4
+
5
+ module Flipside
6
+ module Entities
7
+ def self.included(base)
8
+ base.has_many :entities, foreign_key: :feature_id
9
+
10
+ base.add_check do |entity|
11
+ entity && entities.where(flippable: entity).exists?
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flipside
4
+ # A join table to map entities to a Feature
5
+ class Entity < ::ActiveRecord::Base
6
+ self.table_name = "flipside_entities"
7
+
8
+ belongs_to :feature
9
+ belongs_to :flippable, polymorphic: true
10
+ end
11
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "models/flipside/checks"
4
+ require "models/flipside/entities"
5
+ require "models/flipside/roles"
6
+
7
+ module Flipside
8
+ class Feature < ::ActiveRecord::Base
9
+ extend Checks
10
+ include Entities
11
+ include Roles
12
+
13
+ self.table_name = "flipside_features"
14
+
15
+ def enabled?(object = nil)
16
+ return false unless active?
17
+ return true if enabled
18
+
19
+ self.class.checks.any? { |check| instance_exec(object, &check) }
20
+ end
21
+
22
+ def active?
23
+ activated? && !deactivated?
24
+ end
25
+
26
+ private
27
+
28
+ def activated?
29
+ return true if activated_at.nil?
30
+
31
+ activated_at <= Time.current
32
+ end
33
+
34
+ def deactivated?
35
+ return false if deactivated_at.nil?
36
+
37
+ deactivated_at <= Time.current
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flipside
4
+ # This class piggybacks on the Role class. They both need the same data, so
5
+ # it feels a bit unnecessary to have two similar db tables.
6
+ class Key < Role
7
+ KEY_CLASS = "_FlipsideKey_"
8
+
9
+ after_initialize { self.class_name = KEY_CLASS }
10
+ attr_readonly :class_name
11
+
12
+ alias_attribute :key, :method
13
+ end
14
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "models/flipside/key"
4
+
5
+ module Flipside
6
+ module Keys
7
+ def self.included(base)
8
+ validate_roles! base
9
+ base.add_check { |key| enabled_for? key.to_s }
10
+ end
11
+
12
+ def self.validate_roles!(base)
13
+ return if base.ancestors.include? Roles
14
+ raise "Internal error in Flipside: Roles module has not been loaded"
15
+ end
16
+
17
+ def enabled_for?(key)
18
+ return false if key.blank?
19
+
20
+ roles.where(
21
+ class_name: Key::KEY_CLASS,
22
+ method: key
23
+ ).exists?
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flipside
4
+ # A join table to map roles to a Feature
5
+ class Role < ::ActiveRecord::Base
6
+ self.table_name = "flipside_roles"
7
+
8
+ belongs_to :feature
9
+ end
10
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "models/flipside/role"
4
+
5
+ module Flipside
6
+ module Roles
7
+ def self.included(base)
8
+ base.has_many :roles, foreign_key: :feature_id
9
+ base.add_check { |object| has_role? object }
10
+ end
11
+
12
+ def has_role?(object)
13
+ return false unless object
14
+
15
+ methods = lookup_methods_for(object)
16
+ methods.any? { |method| object.public_send(method) }
17
+ end
18
+
19
+ def lookup_methods_for(object)
20
+ roles
21
+ .where(class_name: object.class.to_s)
22
+ .pluck(:method)
23
+ .select { |method| object.respond_to?(method) }
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,40 @@
1
+ require "flipside/config/registered_entity"
2
+
3
+ module Flipside
4
+ module Config
5
+ module Entities
6
+ def register_entity(class_name:, search_by:, display_as:, identified_by: :id)
7
+ registered_entities[class_name.to_s] = RegisteredEntity.new(
8
+ class_name:,
9
+ search_by:,
10
+ display_as:,
11
+ identified_by:
12
+ )
13
+ end
14
+
15
+ def entity_classes
16
+ registered_entities.keys
17
+ end
18
+
19
+ def search_entity(class_name:, query:)
20
+ registered_entities.fetch(class_name.to_s).search(query)
21
+ end
22
+
23
+ def find_entity(class_name:, identifier:)
24
+ registered_entities.fetch(class_name.to_s).find(identifier)
25
+ end
26
+
27
+ def display_entity(entity)
28
+ registered_entities
29
+ .fetch(entity.class.to_s)
30
+ .display(entity)
31
+ end
32
+
33
+ private
34
+
35
+ def registered_entities
36
+ @registered_entities ||= {}
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,60 @@
1
+ require "flipside/search_result"
2
+
3
+ module Flipside
4
+ class RegisteredEntity
5
+ attr_reader :class_name, :search_by, :display_as, :identified_by
6
+
7
+ def initialize(class_name:, identified_by: :id, search_by: nil, display_as: nil)
8
+ @class_name = class_name
9
+ @search_by = search_by
10
+ @display_as = display_as
11
+ @identified_by = identified_by
12
+ end
13
+
14
+ def search(query)
15
+ Array(lookup_proc.call(query)).map do |entity|
16
+ SearchResult.new(
17
+ entity,
18
+ display(entity),
19
+ entity.public_send(identified_by)
20
+ )
21
+ end
22
+ end
23
+
24
+ def find(identifier)
25
+ class_name.constantize.find_by!("#{identified_by}": identifier)
26
+ end
27
+
28
+ def display(entity)
29
+ display_proc.call(entity)
30
+ end
31
+
32
+ private
33
+
34
+ def lookup_proc
35
+ @lookup_proc ||=
36
+ case search_by
37
+ when Proc
38
+ search_by
39
+ when Symbol
40
+ ->(query) { class_name.constantize.where("#{search_by}": query) }
41
+ else
42
+ ->(query) { class_name.constantize.where("#{identified_by}": query) }
43
+ end
44
+ end
45
+
46
+ def display_proc
47
+ @display_proc ||=
48
+ case display_as
49
+ when Proc
50
+ display_as
51
+ when Symbol
52
+ ->(entity) { entity.public_send(display_as) }
53
+ when String
54
+ ->(entity) { display_as }
55
+ else
56
+ ->(entity) { entity.public_send(identified_by) }
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,43 @@
1
+ require "flipside/search_result"
2
+
3
+ module Flipside
4
+ class RegisteredRole
5
+ attr_reader :class_name, :method_name, :display_as
6
+
7
+ def initialize(class_name:, method_name:, display_as: nil)
8
+ @class_name = class_name
9
+ @method_name = method_name
10
+ @display_as = display_as || method_name.to_s
11
+ end
12
+
13
+ def to_result
14
+ SearchResult.new(
15
+ nil,
16
+ display,
17
+ method_name
18
+ )
19
+ end
20
+
21
+ def match?(query)
22
+ query = query.to_s.downcase
23
+ method_name.to_s.downcase.include?(query) ||
24
+ display.to_s.downcase.include?(query)
25
+ end
26
+
27
+ def display
28
+ display_proc.call
29
+ end
30
+
31
+ private
32
+
33
+ def display_proc
34
+ @display_proc ||=
35
+ case display_as
36
+ when Proc
37
+ display_as
38
+ else
39
+ -> { display_as }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,33 @@
1
+ require "flipside/config/registered_role"
2
+
3
+ module Flipside
4
+ module Config
5
+ module Roles
6
+ def register_role(class_name:, method_name:, display_as: nil)
7
+ registered_roles[class_name.to_s] ||= []
8
+ registered_roles[class_name.to_s] << RegisteredRole.new(
9
+ class_name:,
10
+ method_name:,
11
+ display_as:
12
+ )
13
+ end
14
+
15
+ def role_classes
16
+ registered_roles.keys
17
+ end
18
+
19
+ def search_role(class_name:, query:)
20
+ registered_roles.fetch(class_name.to_s).filter_map do |registered_role|
21
+ next unless registered_role.match? query
22
+ registered_role.to_result
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def registered_roles
29
+ @registered_roles ||= {}
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,111 @@
1
+ require 'forwardable'
2
+
3
+ class FeaturePresenter
4
+ extend Forwardable
5
+ attr_reader :feature, :base_path
6
+
7
+ def_delegators :@feature, :name, :description, :enabled, :entities, :roles
8
+
9
+ def initialize(feature, base_path)
10
+ @feature = feature
11
+ @base_path = base_path
12
+ end
13
+
14
+ def href
15
+ File.join(base_path, "feature", name)
16
+ end
17
+
18
+ def toggle_path
19
+ File.join(href, "toggle")
20
+ end
21
+
22
+ def back_path
23
+ base_path
24
+ end
25
+
26
+ def entities_path
27
+ File.join(href, "entities")
28
+ end
29
+
30
+ def add_entity_path
31
+ File.join(href, "add_entity")
32
+ end
33
+
34
+ def remove_entity_path
35
+ File.join(href, "remove_entity")
36
+ end
37
+
38
+ def roles_path
39
+ File.join(href, "roles")
40
+ end
41
+
42
+ def add_role_path
43
+ File.join(href, "add_role")
44
+ end
45
+
46
+ def remove_role_path
47
+ File.join(href, "remove_role")
48
+ end
49
+
50
+ def status
51
+ if feature.active?
52
+ "active"
53
+ else
54
+ "inactive"
55
+ end
56
+ end
57
+
58
+ def status_color
59
+ if feature.active?
60
+ deactivates_soon? ? "bg-orange-600" : "bg-green-600"
61
+ elsif activates_soon?
62
+ "bg-yellow-600"
63
+ else
64
+ "bg-zinc-600"
65
+ end
66
+ end
67
+
68
+ def activated_at
69
+ feature.activated_at&.strftime("%Y-%m-%d %H:%M")
70
+ end
71
+
72
+ def deactivated_at
73
+ feature.deactivated_at&.strftime("%Y-%m-%d %H:%M")
74
+ end
75
+
76
+ def activates_soon?(period = 60 * 60 * 24)
77
+ return false if feature.active?
78
+ return false if feature.activated_at.nil?
79
+
80
+ feature.activated_at <= Time.now + period
81
+ end
82
+
83
+ def deactivates_soon?(period = 60 * 60 * 24)
84
+ return false unless feature.active?
85
+ return false if feature.deactivated_at.nil?
86
+
87
+ feature.deactivated_at <= Time.now + period
88
+ end
89
+
90
+ def entity_count_str
91
+ count = feature.entities.count
92
+ if count == 1
93
+ "Enabled for one entity"
94
+ elsif count.positive?
95
+ "Enabled for #{count} entities"
96
+ else
97
+ ""
98
+ end
99
+ end
100
+
101
+ def role_count_str
102
+ count = feature.roles.count
103
+ if count == 1
104
+ "Enabled for one role"
105
+ elsif count.positive?
106
+ "Enabled for #{count} roles"
107
+ else
108
+ ""
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,11 @@
1
+ module Flipside
2
+ class SearchResult
3
+ attr_reader :object, :display_as, :identifier
4
+
5
+ def initialize(object, display_as, identifier)
6
+ @object = object
7
+ @display_as = display_as
8
+ @identifier = identifier
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flipside
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,37 @@
1
+ <div data-controller="modal">
2
+ <button
3
+ class="p-2 rounded-lg hover:bg-slate-600"
4
+ data-action="click->modal#open">
5
+ <%= feature.public_send(attribute) || "Not set" %>
6
+ </button>
7
+
8
+ <dialog data-modal-target="dialog" class="hidden fixed inset-0 flex items-center justify-center bg-black/50">
9
+ <div class="flex flex-col bg-slate-500 rounded-lg shadow-lg w-full max-w-xl p-6">
10
+ <div class="flex justify-end space-x-2">
11
+ <button
12
+ type="button"
13
+ class="px-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400"
14
+ data-action="modal#close">
15
+ X
16
+ </button>
17
+ </div>
18
+ <div class="mt-4">
19
+ <form action="<%= feature.href %>" method="post">
20
+ <input type="hidden" name="_method" value="put" />
21
+ <%# <input type="hidden" name="feature_name" value="<%= feature.name %1>" /> %>
22
+ <label class="font-bold text-slate-800">Update <%= attribute %></label>
23
+ <input
24
+ type="datetime-local"
25
+ class="block mt-1 p-2 rounded-lg bg-slate-300 text-slate-600 border border-slate-400"
26
+ name="<%= attribute %>"
27
+ value="<%= feature.public_send(attribute) %>" />
28
+ <button
29
+ type="submit"
30
+ class="block mt-4 mx-auto p-2 rounded-lg text-slate-300 bg-blue-900 border border-blue-950 hover:bg-blue-800">
31
+ Save
32
+ </button>
33
+ </form>
34
+ </div>
35
+ </div>
36
+ </dialog>
37
+ </div>
@@ -0,0 +1,10 @@
1
+ <div class="p-4 grid grid-cols-12 gap-4 cursor-pointer text-slate-300">
2
+ <div class="col-span-3 text-left text-xl"><%= feature.name %></div>
3
+ <div class="col-span-4 text-left text-lg"><%= feature.description&.capitalize %></div>
4
+ <div class="col-span-4 text-right text-lg font-normal">
5
+ <span class="inline-block py-1 px-2 min-w-20 text-center rounded-lg <%= feature.status_color %>"><%= feature.status %></span>
6
+ </div>
7
+ <div class="text-right">
8
+ <%= erb :_toggle_button, locals: {feature:} %>
9
+ </div>
10
+ </div>
@@ -0,0 +1,9 @@
1
+ <% if result.present? %>
2
+ <% result.map do |r| %>
3
+ <option value="<%= r.identifier %>" class="text-slate-800 p-2 rounded-md hover:bg-slate-300">
4
+ <%= r.display_as %>
5
+ </option>
6
+ <% end %>
7
+ <% else %>
8
+ <div class="text-slate-800 p-2">No <%= class_name %> matched "<%= query %>"</div>
9
+ <% end %>
@@ -0,0 +1,12 @@
1
+ <% color = feature.enabled ? "bg-blue-700" : "bg-gray-200" %>
2
+ <% translate = feature.enabled ? "translate-x-5" : "translate-x-0" %>
3
+
4
+ <button type="button"
5
+ class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent <%= color %>"
6
+ data-controller="toggle"
7
+ data-action="click->toggle#switch:prevent"
8
+ data-toggle-url-value="<%= feature.toggle_path %>"
9
+ data-toggle-enabled-value="<%= feature.enabled %>"
10
+ >
11
+ <span aria-hidden="true" class="pointer-events-none inline-block size-5 <%= translate %> transform rounded-full bg-white shadow"></span>
12
+ </button>
@@ -0,0 +1,68 @@
1
+ <div class="m-12">
2
+ <a href="<%= feature.href %>" class="p-2 rounded-lg text-xl text-slate-300 bg-blue-900 border border-blue-950 hover:bg-blue-800">Back</a>
3
+ <div class="mt-12 w-1/3 m-auto bg-gray-800 text-slate-400 p-8 rounded-lg shadow-lg">
4
+ <h1 class="text-2xl font-bold mb-6">Entities for <span class="font-extrabold text-slate-300"><%= feature.name %></span></h1>
5
+ <h2 class="text-xl font-bold">Add entity</h2>
6
+ <form action="<%= feature.add_entity_path %>" method="post">
7
+ <div data-controller="search" data-search-url-value="/flipside/search_entity" class="w-full flex p-0 gap-2 items-center">
8
+ <select
9
+ name="class_name"
10
+ data-search-target="param"
11
+ data-search-param="class_name"
12
+ data-action="search#clearAll"
13
+ class="border text-slate-600 border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
14
+ <% Flipside.entity_classes.each do |class_name| %>
15
+ <option value="<%= class_name %>"><%= class_name %></option>
16
+ <% end %>
17
+ </select>
18
+
19
+ <input
20
+ type="hidden"
21
+ data-search-target="value"
22
+ name="identifier" />
23
+ <div class="ml-2 relative flex-grow">
24
+ <input
25
+ type="text"
26
+ autocomplete="off"
27
+ data-action="search#search"
28
+ data-search-target="input"
29
+ placeholder="Search..."
30
+ class="w-full border text-slate-600 border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
31
+ <div
32
+ class="mt-2 rounded-md absolute left-0 top-full w-full bg-slate-400 bg-border shadow-lg z-10"
33
+ data-search-target="results"
34
+ data-action="click->search#select" >
35
+ </div>
36
+ </div>
37
+ <button
38
+ type="submit"
39
+ disabled="true"
40
+ data-search-target="addButton"
41
+ class="ml-2 p-2 bg-gray-600 text-gray-700 font-semibold rounded" >
42
+ Add
43
+ </button>
44
+ </div>
45
+ </form>
46
+
47
+ <div class="mt-8">
48
+ <h2 class="text-xl font-bold">Enabled entities</h2>
49
+ <ul>
50
+ <% feature.entities.each do |entity| %>
51
+ <li class="mt-2 p-2 bg-slate-700 text-slate-300 border border-slate-600 rounded-lg">
52
+ <div class="flex justify-between items-center">
53
+ <div>
54
+ <%= Flipside.display_entity(entity.flippable) %> (<%= entity.flippable_type %>)
55
+ </div>
56
+ <div>
57
+ <form action="<%= feature.remove_entity_path %>" method="post">
58
+ <input type="hidden" name="entity_id" value="<%= entity.id %>" />
59
+ <button type="submit" class="p-2 bg-gray-300 text-gray-700 font-semibold rounded hover:bg-gray-400">Remove</button>
60
+ </form>
61
+ </div>
62
+ </div>
63
+ </li>
64
+ <% end %>
65
+ </ul>
66
+ </div>
67
+ </div>
68
+ </div>
@@ -0,0 +1,68 @@
1
+ <div class="m-12">
2
+ <a href="<%= feature.href %>" class="p-2 rounded-lg text-xl text-slate-300 bg-blue-900 border border-blue-950 hover:bg-blue-800">Back</a>
3
+ <div class="mt-12 w-1/3 m-auto bg-gray-800 text-slate-400 p-8 rounded-lg shadow-lg">
4
+ <h1 class="text-2xl font-bold mb-6">Roles for <span class="font-extrabold text-slate-300"><%= feature.name %></span></h1>
5
+ <h2 class="text-xl font-bold">Add Role</h2>
6
+ <form action="<%= feature.add_role_path %>" method="post">
7
+ <div data-controller="search" data-search-url-value="/flipside/search_role" class="w-full flex p-0 gap-2 items-center">
8
+ <select
9
+ name="class_name"
10
+ data-search-target="param"
11
+ data-search-param="class_name"
12
+ data-action="search#clearAll"
13
+ class="border text-slate-600 border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
14
+ <% Flipside.role_classes.each do |class_name| %>
15
+ <option value="<%= class_name %>"><%= class_name %></option>
16
+ <% end %>
17
+ </select>
18
+
19
+ <input
20
+ type="hidden"
21
+ data-search-target="value"
22
+ name="identifier" />
23
+ <div class="ml-2 relative flex-grow">
24
+ <input
25
+ type="text"
26
+ autocomplete="off"
27
+ data-action="search#search"
28
+ data-search-target="input"
29
+ placeholder="Search..."
30
+ class="w-full border text-slate-600 border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" />
31
+ <div
32
+ class="mt-2 rounded-md absolute left-0 top-full w-full bg-slate-400 bg-border shadow-lg z-10"
33
+ data-search-target="results"
34
+ data-action="click->search#select" >
35
+ </div>
36
+ </div>
37
+ <button
38
+ type="submit"
39
+ disabled="true"
40
+ data-search-target="addButton"
41
+ class="ml-2 p-2 bg-gray-600 text-gray-700 font-semibold rounded" >
42
+ Add
43
+ </button>
44
+ </div>
45
+ </form>
46
+
47
+ <div class="mt-8">
48
+ <h2 class="text-xl font-bold">Enabled Roles</h2>
49
+ <ul>
50
+ <% feature.roles.each do |role| %>
51
+ <li class="mt-2 p-2 bg-slate-700 text-slate-300 border border-slate-600 rounded-lg">
52
+ <div class="flex justify-between items-center">
53
+ <div>
54
+ <%= "#{role.class_name}##{role.method}" %>
55
+ </div>
56
+ <div>
57
+ <form action="<%= feature.remove_role_path %>" method="post">
58
+ <input type="hidden" name="role_id" value="<%= role.id %>" />
59
+ <button type="submit" class="p-2 bg-gray-300 text-gray-700 font-semibold rounded hover:bg-gray-400">Remove</button>
60
+ </form>
61
+ </div>
62
+ </div>
63
+ </li>
64
+ <% end %>
65
+ </ul>
66
+ </div>
67
+ </div>
68
+ </div>
@@ -0,0 +1,15 @@
1
+ <div class="mt-12 w-3/4 m-auto bg-gray-800 text-slate-400 p-8 pb-24 rounded-lg shadow-lg">
2
+ <h1 class="text-3xl text-center font-bold mb-6">Flipside feature flags</h1>
3
+
4
+ <div class="mt-12 w-3/4 m-auto">
5
+ <ul>
6
+ <% features.each do |feature| %>
7
+ <li class="border-b border-slate-500 hover:font-semibold">
8
+ <a href=<%= feature.href %>>
9
+ <%= erb :_feature_item, locals: {feature:} %>
10
+ </a>
11
+ </li>
12
+ <% end %>
13
+ </ul>
14
+ </div>
15
+ </div>
@@ -0,0 +1,25 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <script src="https://cdn.tailwindcss.com"></script>
7
+ <script type="importmap">
8
+ {
9
+ "imports": {
10
+ "@hotwired/stimulus": "https://cdn.jsdelivr.net/npm/@hotwired/stimulus/dist/stimulus.js",
11
+ "toggle_controller": "/flipside/toggle_controller.js",
12
+ "search_controller": "/flipside/search_controller.js",
13
+ "modal_controller": "/flipside/modal_controller.js"
14
+ }
15
+ }
16
+ </script>
17
+ </head>
18
+ <body class="w-full h-full bg-slate-600">
19
+ <div id="main">
20
+ <%= yield %>
21
+ </div>
22
+
23
+ <script src="/flipside/index.js" type="module" ></script>
24
+ </body>
25
+ </html>
@@ -0,0 +1 @@
1
+ <h1 class="mt-12 text-center text-slate-300 text-3xl font-semibold">This resource could not be found!</h1>
@@ -0,0 +1,55 @@
1
+ <div class="m-12">
2
+ <a href="<%= feature.back_path %>" class="p-2 rounded-lg text-xl text-slate-300 bg-blue-900 border border-blue-950 hover:bg-blue-800">Back</a>
3
+ <div class="mt-12 w-1/2 m-auto bg-gray-800 text-slate-400 p-8 rounded-lg shadow-lg">
4
+ <h1 class="text-2xl font-bold mb-6">Feature Details</h1>
5
+ <div class="mt-4 grid grid-cols-1 gap-4">
6
+ <div class="pb-6 border-b border-gray-600">
7
+ <div class="mt-2 flex justify-between items-center">
8
+ <span class="font-semibold">Name:</span>
9
+ <span class="font-bold text-lg text-slate-300"><%= feature.name %></span>
10
+ </div>
11
+ <div class="mt-2 flex justify-between items-center">
12
+ <span class="font-semibold">Description:</span>
13
+ <span><%= feature.description %></span>
14
+ </div>
15
+ </div>
16
+ <div class="mt-2 flex justify-between items-center">
17
+ <span class="font-semibold">Enabled for all:</span>
18
+ <span><%= erb :_toggle_button, locals: {feature:} %></span>
19
+ </div>
20
+ <div class="mt-2 flex justify-between items-center">
21
+ <span class="font-semibold">Active From:</span>
22
+ <span>
23
+ <%= erb :_datetime_modal, locals: {feature:, attribute: :activated_at} %>
24
+ </span>
25
+ </div>
26
+ <div class="mt-2 flex justify-between items-center">
27
+ <span class="font-semibold">Active Until:</span>
28
+ <span>
29
+ <%= erb :_datetime_modal, locals: {feature:, attribute: :deactivated_at} %>
30
+ </span>
31
+ </div>
32
+ <div class="mt-2 grid grid-cols-3 items-center">
33
+ <span class="font-semibold">Entities:</span>
34
+ <span class="text-center"><%= feature.entity_count_str %></span>
35
+ <span class="text-right text-xl text-slate-400">
36
+ <a
37
+ href="<%= feature.entities_path %>"
38
+ class="px-2 py-1 rounded-lg bg-blue-900 border border-blue-950 hover:bg-blue-800" >
39
+ Edit
40
+ </a>
41
+ </span>
42
+ </div><div class="mt-2 grid grid-cols-3 items-center">
43
+ <span class="font-semibold">Roles:</span>
44
+ <span class="text-center"><%= feature.role_count_str %></span>
45
+ <span class="text-right text-xl text-slate-400">
46
+ <a
47
+ href="<%= feature.roles_path %>"
48
+ class="px-2 py-1 rounded-lg bg-blue-900 border border-blue-950 hover:bg-blue-800" >
49
+ Edit
50
+ </a>
51
+ </span>
52
+ </div>
53
+ </div>
54
+ </div>
55
+ </div>
@@ -0,0 +1,99 @@
1
+ require "sinatra"
2
+ require "uri"
3
+ require "flipside/feature_presenter"
4
+
5
+ module Flipside
6
+ class Web < Sinatra::Base
7
+ not_found do
8
+ erb :not_found
9
+ end
10
+
11
+ get "/" do
12
+ features = Flipside::Feature.order(:name).map do |feature|
13
+ FeaturePresenter.new(feature, base_path)
14
+ end
15
+
16
+ erb :index, locals: {features:}
17
+ end
18
+
19
+ get "/feature/:name" do
20
+ erb :show, locals: {feature: FeaturePresenter.new(feature, base_path)}
21
+ end
22
+
23
+ put "/feature/:name/toggle" do
24
+ content_type :text
25
+
26
+ if feature.update(enabled: !feature.enabled)
27
+ 204
28
+ else
29
+ [422, "Failed to update feature"]
30
+ end
31
+ end
32
+
33
+ put "/feature/:name" do
34
+ kwargs = params.slice("activated_at", "deactivated_at")
35
+ feature.update(**kwargs)
36
+ redirect to("/feature/#{params["name"]}"), 303
37
+ end
38
+
39
+ get "/feature/:name/entities" do
40
+ erb :feature_entities, locals: {feature: FeaturePresenter.new(feature, base_path)}
41
+ end
42
+
43
+ get '/search_entity' do
44
+ class_name = params[:class_name]
45
+ query = URI.decode_www_form_component(params[:q])
46
+ result = Flipside.search_entity(class_name:, query:)
47
+
48
+ erb :_search_result, locals: {result:, class_name:, query:}
49
+ end
50
+
51
+ post "/feature/:name/add_entity" do
52
+ name, class_name, identifier = params.values_at("name", "class_name", "identifier")
53
+
54
+ entity = Flipside.find_entity(class_name:, identifier:)
55
+ Flipside.add_entity(name: , entity:)
56
+ redirect to("/feature/#{name}/entities"), 303
57
+ end
58
+
59
+ post "/feature/:name/remove_entity" do
60
+ Flipside.remove_entity(name: params["name"], entity_id: params["entity_id"])
61
+ redirect to("/feature/#{params["name"]}/entities"), 303
62
+ end
63
+
64
+ get "/feature/:name/roles" do
65
+ erb :feature_roles, locals: {feature: FeaturePresenter.new(feature, base_path)}
66
+ end
67
+
68
+ get '/search_role' do
69
+ class_name = params[:class_name]
70
+ query = URI.decode_www_form_component(params[:q])
71
+ result = Flipside.search_role(class_name:, query:)
72
+
73
+ erb :_search_result, locals: {result:, class_name:, query:}
74
+ end
75
+
76
+ post "/feature/:name/add_role" do
77
+ name, class_name, method_name = params.values_at("name", "class_name", "identifier")
78
+ Flipside.add_role(name:, class_name:, method_name:)
79
+
80
+ redirect to("/feature/#{name}/roles"), 303
81
+ end
82
+
83
+ post "/feature/:name/remove_role" do
84
+ Flipside.remove_role(name: params["name"], role_id: params["role_id"])
85
+
86
+ redirect to("/feature/#{params["name"]}/roles"), 303
87
+ end
88
+
89
+ def feature
90
+ @feature ||= Flipside::Feature.find_by!(name: params["name"])
91
+ rescue
92
+ halt 404, "This feature does not exist"
93
+ end
94
+
95
+ def base_path
96
+ @base_path ||= request.script_name
97
+ end
98
+ end
99
+ end
data/lib/flipside.rb ADDED
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "flipside/version"
4
+ require "flipside/web"
5
+ require "flipside/config/entities"
6
+ require "flipside/config/roles"
7
+ require "models/flipside/feature"
8
+
9
+ module Flipside
10
+ extend Config::Entities
11
+ extend Config::Roles
12
+
13
+ class Error < StandardError; end
14
+
15
+ class NoSuchFeauture < Error
16
+ def initialize(name)
17
+ super("There's no feature named '#{name}'")
18
+ end
19
+ end
20
+
21
+ class << self
22
+ def enabled?(name, object = nil)
23
+ feature = find_by(name:)
24
+ return false unless feature
25
+
26
+ feature.enabled? object
27
+ end
28
+
29
+ def enable!(name)
30
+ feature = find_by!(name:)
31
+ feature.update(enabled: true)
32
+ end
33
+
34
+ def add_entity(name:, entity:)
35
+ feature = find_by!(name:)
36
+ Entity.find_or_create_by(feature:, flippable: entity)
37
+ end
38
+
39
+ def remove_entity(name:, entity_id:)
40
+ feature = find_by!(name:)
41
+ feature.entities.find_by(id: entity_id)&.destroy
42
+ end
43
+
44
+ def add_role(name:, class_name:, method_name:)
45
+ feature = find_by!(name:)
46
+ Role.find_or_create_by(feature:, class_name:, method: method_name)
47
+ end
48
+
49
+ def remove_role(name:, role_id:)
50
+ feature = find_by!(name:)
51
+ feature.roles.find_by(id: role_id)&.destroy
52
+ end
53
+
54
+ def find_by(name:)
55
+ Feature.find_by(name:)
56
+ end
57
+
58
+ def find_by!(name:)
59
+ find_by(name:) || raise(NoSuchFeauture.new(name))
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,20 @@
1
+ # require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module Flipside
5
+ module Generators
6
+ class InstallGenerator < Rails::Generators::Base
7
+ include ActiveRecord::Generators::Migration
8
+
9
+ # Makes this generator available as "rails generate feature_flags:install"
10
+ source_root File.expand_path("templates", __dir__)
11
+
12
+ def create_migration_file
13
+ # Check if the migration file already exists to avoid duplicates
14
+ unless ActiveRecord::Base.connection.table_exists?("flipside")
15
+ migration_template "20241122_create_flipside_migration.rb", "db/migrate/create_flipside_migration.rb"
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,28 @@
1
+ class CreateFlipsideMigration < ActiveRecord::Migration[6.1]
2
+ def change
3
+ create_table :flipside_features do |t|
4
+ t.string :name, null: false
5
+ t.string :description
6
+ t.boolean :enabled, default: false, null: false
7
+ t.datetime :activated_at
8
+ t.datetime :deactivated_at
9
+ t.timestamps
10
+ end
11
+
12
+ create_table :flipside_entities do |t|
13
+ t.belongs_to :feature, null: false
14
+ t.bigint :flippable_id, null: false
15
+ t.string :flippable_type, null: false
16
+ t.timestamps
17
+ end
18
+
19
+ create_table :flipside_roles do |t|
20
+ t.belongs_to :feature, null: false
21
+ t.string :class_name, null: false
22
+ t.string :method, null: false
23
+ t.timestamps
24
+ end
25
+
26
+ add_index :flipside_features, :name, unique: true
27
+ end
28
+ end
metadata ADDED
@@ -0,0 +1,146 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: flipside
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Sammy Henningsson
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-01-25 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '6.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '6.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: sinatra
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: byebug
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: sqlite3
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '2.1'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '2.1'
83
+ description: Create simple feature toggles.
84
+ email:
85
+ - sammy.henningsson@apoex.se
86
+ executables: []
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - CHANGELOG.md
91
+ - LICENSE.txt
92
+ - README.md
93
+ - app/models/flipside/checks.rb
94
+ - app/models/flipside/entities.rb
95
+ - app/models/flipside/entity.rb
96
+ - app/models/flipside/feature.rb
97
+ - app/models/flipside/key.rb
98
+ - app/models/flipside/keys.rb
99
+ - app/models/flipside/role.rb
100
+ - app/models/flipside/roles.rb
101
+ - lib/flipside.rb
102
+ - lib/flipside/config/entities.rb
103
+ - lib/flipside/config/registered_entity.rb
104
+ - lib/flipside/config/registered_role.rb
105
+ - lib/flipside/config/roles.rb
106
+ - lib/flipside/feature_presenter.rb
107
+ - lib/flipside/search_result.rb
108
+ - lib/flipside/version.rb
109
+ - lib/flipside/views/_datetime_modal.erb
110
+ - lib/flipside/views/_feature_item.erb
111
+ - lib/flipside/views/_search_result.erb
112
+ - lib/flipside/views/_toggle_button.erb
113
+ - lib/flipside/views/feature_entities.erb
114
+ - lib/flipside/views/feature_roles.erb
115
+ - lib/flipside/views/index.erb
116
+ - lib/flipside/views/layout.erb
117
+ - lib/flipside/views/not_found.erb
118
+ - lib/flipside/views/show.erb
119
+ - lib/flipside/web.rb
120
+ - lib/generators/flipside/install/install_generator.rb
121
+ - lib/generators/flipside/install/templates/20241122_create_flipside_migration.rb
122
+ homepage:
123
+ licenses:
124
+ - MIT
125
+ metadata: {}
126
+ post_install_message:
127
+ rdoc_options: []
128
+ require_paths:
129
+ - app
130
+ - lib
131
+ required_ruby_version: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - ">="
134
+ - !ruby/object:Gem::Version
135
+ version: 3.0.0
136
+ required_rubygems_version: !ruby/object:Gem::Requirement
137
+ requirements:
138
+ - - ">="
139
+ - !ruby/object:Gem::Version
140
+ version: '0'
141
+ requirements: []
142
+ rubygems_version: 3.5.11
143
+ signing_key:
144
+ specification_version: 4
145
+ summary: Feature flags.
146
+ test_files: []