hotsheet 0.1.0 → 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ebb74d345ffa27e7320c021ce0108b934414cc5e93fae4e58893a5e556e389b4
4
- data.tar.gz: c45e89321ccd3da4ad7354ae2d6695035d31451a2c38990a6c14f6cc4d793173
3
+ metadata.gz: 2b1cc53c691cd9b61d1361342ba5ce7be20d3992dafa47bd770cd12cf0976158
4
+ data.tar.gz: 2db750a0be71817cb2d2ce9dd02153d559813e810cade288b85d8f41728fe509
5
5
  SHA512:
6
- metadata.gz: d7d422c0bfe713d39d3e2d4566b116e1e5cc75825bbca187bae4e9d73fb75ebd35a442020b8ad1b2f3e05796cd1726be86b9b7ff9f28838ef4bae6c86bfcefcc
7
- data.tar.gz: 82a933e5d47a1b60806dca93db6b5e041fc46a39fc2e0f57955c5bac3cf5dc45b7c3218bfd16a18e685faa1cbac4a2160daef3a30333ecafda78400d57737414
6
+ metadata.gz: 8e8e1ad31860f0145d8d6128107c70fcee0cd5c8cf2b86d4ad29dabd6a6619e41d5e71371268e27465d5d27d46ed6191e96ce86a40dd87958462e0225f1824f4
7
+ data.tar.gz: eddc23e4ce169c549aa43446e77c7206bc3d20c1bbe52c9472052be98e90fc14fa69e1a92c879b874151717c5befca37b0d8b69905b154d9a769f6f57bb71642
data/CHANGELOG.md CHANGED
@@ -1,6 +1,11 @@
1
- ## [Unreleased](https://github.com/renuo/hotsheet/compare/v0.1.0..HEAD)
1
+ <!-- ## [Unreleased](https://github.com/renuo/hotsheet/compare/v0.1.0..HEAD) -->
2
2
 
3
- -
3
+ ## [0.1.1](https://github.com/renuo/hotsheet/releases/tag/v0.1.1) (2025-02-03)
4
+
5
+ - Improve configuration file usage and logic ([@simon-isler])
6
+ - Configure compatible Ruby/Rails versions for testing ([@ignaciosy])
7
+ - Improve flash messages layout ([@ignaciosy])
8
+ - Form inputs are now always visible for usage simplicity ([@ignaciosy])
4
9
 
5
10
  ## [0.1.0](https://github.com/renuo/hotsheet/releases/tag/v0.1.0) (2024-11-05)
6
11
 
data/README.md CHANGED
@@ -5,6 +5,13 @@
5
5
  This gem allows you to mount a view to manage your database using a table view
6
6
  where you can edit database records inline.
7
7
 
8
+ - Look at and modify your DB within the app itself (no rails console required!).
9
+ - Give controlled DB access to your admin users without having to create CRUD views for each table.
10
+ - Lightweight and fast. We keep usage simple and configuration to the minimum.
11
+
12
+ ![demo_gif](https://github.com/user-attachments/assets/debf45a1-c6d2-4a1f-a734-37559bb095de)
13
+
14
+
8
15
  ## Installation
9
16
 
10
17
  Add this line to your application's Gemfile:
@@ -22,6 +29,10 @@ bin/rails g hotsheet:install
22
29
 
23
30
  ## Usage
24
31
 
32
+ After installing, you can directly go to `/hotsheet` within your app by default.
33
+
34
+ ### Configuration
35
+
25
36
  You can configure which models this gem should manage, and specify which
26
37
  attributes (database columns) should be editable in the Hotsheet. This can be
27
38
  done by configuring the initializer file created by the install command:
@@ -41,16 +52,15 @@ end
41
52
 
42
53
  See [Contributing Guide] for information on how to set up hotsheet locally.
43
54
 
44
- ## Roadmap / Planned Features
55
+ ## Roadmap
56
+
57
+ This is a newly created gem which we are actively working on, and we will firstly focus on:
58
+
59
+ 1. Single-user experience (styles and usability)
60
+ 2. Configuration and access permissions
61
+ 3. Concurrent users (broadcasting, conflict resolution)
45
62
 
46
- - Fetch all models in the application by default
47
- - Support live updates (show when someone has the intention to edit a resource) via ActionCable
48
- - Conflict resolution strategy (locking / merging / latest change etc.)
49
- - Fine grained access / permissions (cancancan)
50
- - Type-specific input fields
51
- - Undo feature (or confirm / discard changes icons)
52
- - Configure visibility of non-editable (excluded) fields
53
- - Use importmap-rails for JS dependencies
63
+ Feel free to look at our [planned enhancements](https://github.com/renuo/hotsheet/issues?q=is%3Aopen+is%3Aissue+label%3Aenhancement) or add your own.
54
64
 
55
65
  ## License
56
66
 
@@ -1,6 +1,8 @@
1
1
  import { Application } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js"
2
2
  import EditableAttributeController from "./controllers/editable_attribute_controller"
3
+ import FlashController from "./controllers/flash_controller"
3
4
 
4
5
  window.Stimulus = Application.start()
5
6
 
6
7
  Stimulus.register("editable-attribute", EditableAttributeController)
8
+ Stimulus.register("flash", FlashController)
@@ -5,18 +5,12 @@ export default class extends Controller {
5
5
  broadcastUrl: String,
6
6
  resourceName: String,
7
7
  resourceId: Number,
8
+ resourceInitialValue: String
8
9
  }
9
10
 
10
- static targets = ["readonlyAttribute", "attributeForm", "attributeFormInput"]
11
+ static targets = ["attributeFormInput"]
11
12
 
12
- displayInputField() {
13
- // this.broadcastEditIntent()
14
- this.readonlyAttributeTarget.style.display = "none"
15
- this.attributeFormTarget.style.display = "block"
16
- this.attributeFormInputTarget.focus()
17
- }
18
-
19
- broadcastEditIntent() {
13
+ broadcastEditIntent() { // TODO: trigger on input focus
20
14
  const headers = {
21
15
  "Content-Type": "application/json",
22
16
  "X-CSRF-Token": document.querySelector("meta[name=csrf-token]").content,
@@ -35,14 +29,8 @@ export default class extends Controller {
35
29
  // Prevent standard submission triggered by Enter press
36
30
  event.preventDefault()
37
31
 
38
- const previousValue = this.readonlyAttributeTarget.innerText.trim()
39
32
  const newValue = this.attributeFormInputTarget.value
40
-
41
- if (previousValue && previousValue === newValue) {
42
- this.readonlyAttributeTarget.style.display = "block"
43
- this.attributeFormTarget.style.display = "none"
44
- return
45
- }
33
+ if (this.resourceInitialValueValue === newValue) return;
46
34
 
47
35
  // It's important to use requestSubmit() instead of simply submit() as the latter will circumvent the
48
36
  // Turbo mechanism, causing the PATCH request to be submitted as HTML instead of TURBO_STREAM
@@ -0,0 +1,8 @@
1
+ import { Controller } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js"
2
+
3
+ export default class extends Controller {
4
+ close() {
5
+ this.element.addEventListener("animationend", () => { this.element.remove(); });
6
+ this.element.classList.add("closing");
7
+ }
8
+ }
@@ -8,18 +8,28 @@
8
8
  */
9
9
 
10
10
  :root {
11
- --bg-color: lightgray;
12
11
  --sidebar-width: 12rem;
12
+ --main-padding-x: 2rem;
13
+ --main-padding-y: 2rem;
14
+
15
+ --body-font: 400 16px system-ui, Roboto, Helvetica, Arial, sans-serif;
16
+ --bg-color: lightgray;
17
+ --success-color: green;
18
+ --alert-color: yellow;
19
+ --notice-color: blue;
20
+ --error-color: red;
21
+
22
+ --td-padding: 0.5rem;
13
23
  }
14
24
 
15
25
  body {
16
- font: 400 16px system-ui, Roboto, Helvetica, Arial, sans-serif;
26
+ font: var(--body-font);
17
27
  margin: 0;
18
28
  }
19
29
 
20
30
  main {
21
31
  margin-left: var(--sidebar-width);
22
- padding: 1rem;
32
+ padding: var(--main-padding-y) var(--main-padding-x);
23
33
  }
24
34
 
25
35
  aside {
@@ -36,7 +46,7 @@ aside {
36
46
  display: flex;
37
47
  flex-direction: column;
38
48
  margin: 0;
39
- padding: 0.25rem;
49
+ padding: 0.5rem;
40
50
 
41
51
  a {
42
52
  border-radius: 0.5rem;
@@ -65,12 +75,12 @@ table {
65
75
  th,
66
76
  td {
67
77
  border: 1px solid var(--bg-color);
68
- padding: 0.5rem;
69
78
  text-align: left;
70
79
  }
71
80
 
72
81
  th {
73
82
  background-color: var(--bg-color);
83
+ padding: var(--td-padding);
74
84
  }
75
85
 
76
86
  .readonly-attribute {
@@ -81,6 +91,63 @@ table {
81
91
  .editable-input {
82
92
  background-color: transparent;
83
93
  border: none;
84
- width: 100%;
94
+ font: var(--body-font);
95
+ padding: var(--td-padding);
96
+ width: calc(100% - var(--td-padding) * 2);
97
+ }
98
+ }
99
+
100
+ .flash-container {
101
+ position: fixed;
102
+ display: flex;
103
+ flex-direction: column;
104
+ bottom: 1rem;
105
+ right: 1rem;
106
+ gap: 1rem;
107
+ max-width: calc(100% - var(--sidebar-width) - var(--main-padding-x));
108
+
109
+ .flash {
110
+ border-radius: 0.4rem;
111
+ padding: 0.75rem 1.25rem;
112
+ display: flex;
113
+ justify-content: space-between;
114
+ align-items: center;
115
+ gap: 1rem;
116
+
117
+ &.success {
118
+ background-color: rgb(from var(--success-color) r g b / 80%);
119
+ }
120
+
121
+ &.alert {
122
+ background-color: rgb(from var(--alert-color) r g b / 80%);
123
+ }
124
+
125
+ &.notice {
126
+ background-color: rgb(from var(--notice-color) r g b / 80%);
127
+ }
128
+
129
+ &.error {
130
+ background-color: rgb(from var(--error-color) r g b / 80%);
131
+ }
132
+
133
+ .btn-close {
134
+ background-color: transparent;
135
+ border: none;
136
+ cursor: pointer;
137
+ font-size: 1.5rem;
138
+ }
139
+
140
+ &.closing {
141
+ animation: fade-out 0.5s ease-in forwards;
142
+ }
143
+ }
144
+ }
145
+
146
+ @keyframes fade-out {
147
+ from {
148
+ opacity: 1;
149
+ }
150
+ to {
151
+ opacity: 0;
85
152
  }
86
153
  }
@@ -17,7 +17,7 @@ module Hotsheet
17
17
  record = model.find params[:id]
18
18
 
19
19
  if record.update model_params
20
- flash[:notice] = t("hotsheet.success", record: model.model_name.human)
20
+ flash[:success] = t("hotsheet.success", record: model.model_name.human)
21
21
  else
22
22
  flash[:alert] = t("hotsheet.error", record: model.model_name.human,
23
23
  errors: record.errors.full_messages.join(", "))
@@ -33,7 +33,7 @@ module Hotsheet
33
33
  end
34
34
 
35
35
  def model_params
36
- params.require(model.name.underscore).permit(*model.editable_attributes)
36
+ params.require(model.name.underscore).permit(*Hotsheet.editable_attributes_for(model: model))
37
37
  end
38
38
 
39
39
  def model
@@ -1,24 +1,15 @@
1
1
  <%# locals: (attribute:, model:, record:) %>
2
2
 
3
3
  <%= turbo_frame_tag "#{dom_id record}-#{attribute}" do %>
4
- <div data-controller="editable-attribute"
5
- data-editable-attribute-broadcast-url-value="<%= broadcast_edit_intent_path %>"
6
- data-editable-attribute-resource-name-value="<%= model.table_name %>"
7
- data-editable-attribute-resource-id-value="<%= record.id %>">
8
- <div class="readonly-attribute"
9
- data-editable-attribute-target="readonlyAttribute"
10
- data-action="click->editable-attribute#displayInputField"
11
- role="button">
12
- <%= record.public_send attribute %>
13
- </div>
14
-
15
- <div data-editable-attribute-target="attributeForm" style="display:none">
16
- <%= form_for record, url: "#{root_path}#{model.table_name}/#{record.id}" do |f| %>
17
- <%= f.text_field attribute, class: "editable-input", data: {
18
- editable_attribute_target: "attributeFormInput",
19
- action: "keydown.enter->editable-attribute#submitForm blur->editable-attribute#submitForm"
20
- } %>
21
- <% end %>
22
- </div>
23
- </div>
4
+ <%= form_for record, url: "#{root_path}#{model.table_name}/#{record.id}",
5
+ html: { 'data-controller': "editable-attribute",
6
+ 'data-editable-attribute-broadcast-url-value': broadcast_edit_intent_path,
7
+ 'data-editable-attribute-resource-name-value': model.table_name,
8
+ 'data-editable-attribute-resource-id-value': record.id,
9
+ 'data-editable-attribute-resource-initial-value-value': record.public_send(attribute) } do |f| %>
10
+ <%= f.text_field attribute, class: "editable-input", data: {
11
+ editable_attribute_target: "attributeFormInput",
12
+ action: "keydown.enter->editable-attribute#submitForm blur->editable-attribute#submitForm"
13
+ } %>
14
+ <% end %>
24
15
  <% end %>
@@ -6,7 +6,7 @@
6
6
  <table>
7
7
  <thead>
8
8
  <tr>
9
- <% @model.editable_attributes.each do |attribute| %>
9
+ <% Hotsheet.editable_attributes_for(model: @model).each do |attribute| %>
10
10
  <th><%= attribute %></th>
11
11
  <% end %>
12
12
  </tr>
@@ -14,7 +14,7 @@
14
14
  <tbody>
15
15
  <% @records.each do |record| %>
16
16
  <tr>
17
- <% @model.editable_attributes.each do |attribute| %>
17
+ <% Hotsheet.editable_attributes_for(model: @model).each do |attribute| %>
18
18
  <td>
19
19
  <%= render "editable_attribute", model: @model, record: record, attribute: attribute %>
20
20
  </td>
@@ -1,7 +1,10 @@
1
1
  <%# locals: () %>
2
2
 
3
- <% flash.each do |type, msg| %>
4
- <div class='flash <%= type %>'>
5
- <span><%= msg %></span>
6
- </div>
7
- <% end %>
3
+ <div class='flash-container'>
4
+ <% flash.each do |type, msg| %>
5
+ <div class='flash <%= type %>' data-controller='flash'>
6
+ <span><%= msg %></span>
7
+ <button class='btn-close' data-action='click->flash#close'>&times;</button>
8
+ </div>
9
+ <% end %>
10
+ </div>
@@ -14,8 +14,8 @@
14
14
  </head>
15
15
  <body>
16
16
  <%= render "layouts/hotsheet/sidebar" %>
17
- <%= render "layouts/hotsheet/flash" %>
18
17
  <main>
18
+ <%= render "layouts/hotsheet/flash" %>
19
19
  <%= yield %>
20
20
  </main>
21
21
  </body>
@@ -6,14 +6,54 @@ module Hotsheet
6
6
 
7
7
  config_accessor(:models) { {} }
8
8
 
9
- def model(name, &block)
10
- model_config = ModelConfig.new
11
- yield model_config if block
9
+ def initialize
10
+ self.models = {}
11
+ end
12
+
13
+ def model(name)
14
+ model_config = ModelConfig.new(name).tap do |config|
15
+ yield(config) if block_given?
16
+ Rails.application.config.to_prepare { config.validate! }
17
+ end
12
18
  models[name.to_s] = model_config
13
19
  end
14
20
 
15
21
  class ModelConfig
16
- attr_accessor :included_attributes, :excluded_attributes
22
+ attr_accessor :included_attributes, :excluded_attributes, :model_name
23
+
24
+ def initialize(model_name)
25
+ @model_name = model_name
26
+ end
27
+
28
+ def validate!
29
+ return unless ActiveRecord::Base.connection.table_exists?(model_class.table_name)
30
+ return if included_attributes.nil? && excluded_attributes.nil?
31
+
32
+ ensure_only_one_attribute_set
33
+ validate_attribute_existence
34
+ end
35
+
36
+ private
37
+
38
+ def ensure_only_one_attribute_set
39
+ return if included_attributes.blank? || excluded_attributes.blank?
40
+
41
+ raise "Can only specify either included or excluded attributes for '#{model_name}'"
42
+ end
43
+
44
+ def validate_attribute_existence
45
+ all_attributes = model_class.column_names
46
+
47
+ [included_attributes, excluded_attributes].flatten.compact.each do |attr|
48
+ unless all_attributes.include?(attr.to_s)
49
+ raise "Attribute '#{attr}' doesn't exist on model '#{model_name}'"
50
+ end
51
+ end
52
+ end
53
+
54
+ def model_class
55
+ model_name.to_s.constantize
56
+ end
17
57
  end
18
58
  end
19
59
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hotsheet
4
+ class << self
5
+ def editable_attributes_for(model:)
6
+ @editable_attributes_for ||= {}
7
+ @editable_attributes_for[model] ||= fetch_editable_attributes(model)
8
+ end
9
+
10
+ private
11
+
12
+ def fetch_editable_attributes(model)
13
+ model_config = Hotsheet.configuration.models[model.to_s]
14
+
15
+ if model_config&.included_attributes
16
+ model_config.included_attributes.map(&:to_s)
17
+ elsif model_config&.excluded_attributes
18
+ model.column_names - model_config.excluded_attributes.map(&:to_s)
19
+ else
20
+ model.column_names
21
+ end
22
+ end
23
+ end
24
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hotsheet
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
data/lib/hotsheet.rb CHANGED
@@ -3,9 +3,10 @@
3
3
  require "sprockets/railtie"
4
4
  require "turbo-rails"
5
5
 
6
- require "hotsheet/configuration"
7
6
  require "hotsheet/engine"
8
7
  require "hotsheet/version"
8
+ require "hotsheet/configuration"
9
+ require "hotsheet/editable_attributes"
9
10
 
10
11
  module Hotsheet
11
12
  class Error < StandardError; end
@@ -15,8 +16,8 @@ module Hotsheet
15
16
  @configuration ||= Configuration.new
16
17
  end
17
18
 
18
- def configure
19
- yield configuration
19
+ def configure(&block)
20
+ @configuration = Configuration.new.tap(&block)
20
21
  end
21
22
 
22
23
  def models
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hotsheet
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Renuo AG
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-11-08 00:00:00.000000000 Z
11
+ date: 2025-02-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -80,7 +80,7 @@ dependencies:
80
80
  - - ">="
81
81
  - !ruby/object:Gem::Version
82
82
  version: '0'
83
- description: This gem allows you to mount a view to manage your database using atable
83
+ description: This gem allows you to mount a view to manage your database using a table
84
84
  view where you can edit DB records inline.
85
85
  email:
86
86
  - ignacio.sfeir@renuo.ch
@@ -100,6 +100,7 @@ files:
100
100
  - app/assets/javascripts/hotsheet/channels/inline_edit_channel.js
101
101
  - app/assets/javascripts/hotsheet/controllers/application.js
102
102
  - app/assets/javascripts/hotsheet/controllers/editable_attribute_controller.js
103
+ - app/assets/javascripts/hotsheet/controllers/flash_controller.js
103
104
  - app/assets/stylesheets/hotsheet/application.css
104
105
  - app/channels/application_cable/channel.rb
105
106
  - app/channels/application_cable/connection.rb
@@ -113,7 +114,6 @@ files:
113
114
  - app/views/layouts/hotsheet/_sidebar.html.erb
114
115
  - app/views/layouts/hotsheet/application.html.erb
115
116
  - config/initializers/hotsheet/content_security_policy.rb
116
- - config/initializers/hotsheet/editable_attributes.rb
117
117
  - config/initializers/hotsheet/pagy.rb
118
118
  - config/locales/en.yml
119
119
  - config/routes.rb
@@ -121,6 +121,7 @@ files:
121
121
  - lib/generators/templates/hotsheet.rb
122
122
  - lib/hotsheet.rb
123
123
  - lib/hotsheet/configuration.rb
124
+ - lib/hotsheet/editable_attributes.rb
124
125
  - lib/hotsheet/engine.rb
125
126
  - lib/hotsheet/version.rb
126
127
  - vendor/assets/stylesheets/hotsheet/pagy.css
@@ -1,43 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Rails.application.config.after_initialize do # rubocop:disable Metrics/BlockLength
4
- # Only run this initializer when running the Rails server
5
- next unless Rails.env.test? || defined? Rails::Server
6
-
7
- Hotsheet.configuration.models.each_key do |model| # rubocop:disable Metrics/BlockLength
8
- model.constantize.class_eval do
9
- class << self
10
- def editable_attributes
11
- @editable_attributes ||= fetch_editable_attributes
12
- end
13
-
14
- private
15
-
16
- def fetch_editable_attributes
17
- config = Hotsheet.configuration.models[name]
18
- excluded_attributes = config.excluded_attributes
19
-
20
- if config.included_attributes.present?
21
- raise "Can only specify either included or excluded attributes" if excluded_attributes.present?
22
-
23
- attrs_to_s(config.included_attributes)
24
- elsif excluded_attributes.present?
25
- column_names - attrs_to_s(excluded_attributes)
26
- else
27
- column_names
28
- end
29
- end
30
-
31
- def attrs_to_s(attrs)
32
- attrs.map do |attr|
33
- raise "Attribute '#{attr}' doesn't exist on model '#{name}'" if column_names.exclude? attr.to_s
34
-
35
- attr.to_s
36
- end
37
- end
38
- end
39
- end
40
-
41
- model.constantize.editable_attributes
42
- end
43
- end