flipside 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []