plutonium 0.26.2 → 0.26.4

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.
@@ -238,10 +238,10 @@ end
238
238
 
239
239
  # app/models/organization_customer.rb
240
240
  class OrganizationCustomer < ::ResourceRecord
241
+ enum :role, member: 0, owner: 1
241
242
 
242
243
  belongs_to :organization
243
244
  belongs_to :customer
244
- enum role: { member: 0, admin: 1 }
245
245
  end
246
246
  ```
247
247
 
@@ -70,6 +70,10 @@ module Pu
70
70
  normalized_entity_membership_name,
71
71
  ["#{normalized_name}_id", "#{normalized_auth_account_name}_id"]
72
72
  )
73
+
74
+ add_default_to_role_column
75
+ add_role_enum_to_model
76
+ add_unique_validation_to_model
73
77
  end
74
78
 
75
79
  private
@@ -88,10 +92,9 @@ module Pu
88
92
  migration_dir = File.join("db", "migrate")
89
93
  migration_file = Dir[File.join(migration_dir, "*_create_#{model_name.pluralize}.rb")].first
90
94
 
91
- if migration_file && File.exist?(migration_file)
95
+ modify_file_if_exists(migration_file) do |file|
92
96
  index_definition = build_index_definition(model_name, index_columns)
93
97
  insert_into_file migration_file, indent(index_definition, 4), before: /^ end\s*$/
94
- success "Added unique index to #{model_name.pluralize}"
95
98
  end
96
99
  end
97
100
 
@@ -118,6 +121,38 @@ module Pu
118
121
  "role:integer"
119
122
  ]
120
123
  end
124
+
125
+ def add_default_to_role_column
126
+ migration_dir = File.join("db", "migrate")
127
+ migration_file = Dir[File.join(migration_dir, "*_create_#{normalized_entity_membership_name.pluralize}.rb")].first
128
+
129
+ modify_file_if_exists(migration_file) do |file|
130
+ gsub_file file, /t\.integer :role, null: false/, "t.integer :role, null: false, default: 0 # Member by default"
131
+ end
132
+ end
133
+
134
+ def add_role_enum_to_model
135
+ model_file = File.join("app", "models", "#{normalized_entity_membership_name}.rb")
136
+
137
+ modify_file_if_exists(model_file) do |file|
138
+ enum_definition = "\nenum :role, member: 0, owner: 1"
139
+ insert_into_file file, indent(enum_definition, 2), before: /^\s*# add model configurations above\./
140
+ end
141
+ end
142
+
143
+ def add_unique_validation_to_model
144
+ model_file = File.join("app", "models", "#{normalized_entity_membership_name}.rb")
145
+
146
+ modify_file_if_exists(model_file) do |file|
147
+ validation_definition = "validates :#{normalized_auth_account_name}, uniqueness: {scope: :#{normalized_name}_id, message: \"is already a member of this entity\"}\n"
148
+ insert_into_file file, indent(validation_definition, 2), before: /^\s*# add validations above\./
149
+ end
150
+ end
151
+
152
+ def modify_file_if_exists(file_path)
153
+ return unless file_path && File.exist?(file_path)
154
+ yield(file_path)
155
+ end
121
156
  end
122
157
  end
123
158
  end
@@ -54,7 +54,6 @@ module Pu
54
54
  has_many :#{normalized_entity_membership_name.pluralize}
55
55
  has_many :#{normalized_name.pluralize}, through: :#{normalized_entity_membership_name.pluralize}
56
56
  RUBY
57
- success "Added relationship to #{normalized_entity_name} model"
58
57
  end
59
58
 
60
59
  customer_model_path = File.join("app", "models", "#{normalized_name}.rb")
@@ -63,7 +62,6 @@ module Pu
63
62
  has_many :#{normalized_entity_membership_name.pluralize}
64
63
  has_many :#{normalized_entity_name.pluralize}, through: :#{normalized_entity_membership_name.pluralize}
65
64
  RUBY
66
- success "Added relationship to #{normalized_name} model"
67
65
  end
68
66
  end
69
67
 
@@ -154,7 +154,8 @@ module Plutonium
154
154
  max_file_num: attributes.fetch(:size, field.multiple? ? field.limit : 1),
155
155
  min_file_num: nil,
156
156
  allowed_file_types: nil,
157
- required_meta_fields: nil
157
+ required_meta_fields: nil,
158
+ endpoint: attributes[:endpoint] || "/upload"
158
159
  }.each do |key, default_value|
159
160
  value = attributes.key?(key) ? attributes.delete(key) : default_value
160
161
  direct_upload_options[:data][:"attachment_input_#{key}_value"] = value
@@ -31,6 +31,32 @@ module Plutonium
31
31
  }
32
32
  end
33
33
 
34
+ def render_actions
35
+ input name: "return_to", value: request.params[:return_to], type: :hidden, hidden: true
36
+
37
+ actions_wrapper {
38
+ if object.respond_to?(:new_record?)
39
+ if object.new_record?
40
+ button(
41
+ type: :submit,
42
+ name: "return_to",
43
+ value: request.url,
44
+ class: "px-4 py-2 bg-secondary-600 text-white rounded-md hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-secondary-500"
45
+ ) { "Create and add another" }
46
+ else
47
+ button(
48
+ type: :submit,
49
+ name: "return_to",
50
+ value: request.url,
51
+ class: "px-4 py-2 bg-secondary-600 text-white rounded-md hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-secondary-500"
52
+ ) { "Update and continue editing" }
53
+ end
54
+ end
55
+
56
+ render submit_button
57
+ }
58
+ end
59
+
34
60
  def form_action
35
61
  return @form_action unless object.present? && @form_action != false && helpers.present?
36
62
 
@@ -0,0 +1,15 @@
1
+ module Plutonium
2
+ module UI
3
+ module Layout
4
+ class BasicLayout < Base
5
+ private
6
+
7
+ def page_title
8
+ helpers.make_page_title(
9
+ helpers.controller.instance_variable_get(:@page_title)
10
+ )
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.26.2"
2
+ VERSION = "0.26.4"
3
3
  NEXT_MAJOR_VERSION = VERSION.split(".").tap { |v|
4
4
  v[1] = v[1].to_i + 1
5
5
  v[2] = 0
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radioactive-labs/plutonium",
3
- "version": "0.4.8",
3
+ "version": "0.4.10",
4
4
  "description": "Core assets for the Plutonium gem",
5
5
  "type": "module",
6
6
  "main": "src/js/core.js",
@@ -10,6 +10,7 @@ import DomElement from "../support/dom_element"
10
10
  export default class extends Controller {
11
11
  static values = {
12
12
  identifier: String,
13
+ endpoint: String,
13
14
 
14
15
  maxFileSize: { type: Number, default: null },
15
16
  minFileSize: { type: Number, default: null },
@@ -25,6 +26,8 @@ export default class extends Controller {
25
26
  //======= Lifecycle
26
27
 
27
28
  connect() {
29
+ if (this.uppy) return;
30
+
28
31
  // initialize
29
32
  this.uploadedFiles = []
30
33
 
@@ -37,10 +40,48 @@ export default class extends Controller {
37
40
  this.#buildTriggers()
38
41
  // init state
39
42
  this.#onAttachmentsChanged()
43
+
44
+ // Just recreate Uppy after morphing - preserve existing attachments
45
+ this.element.addEventListener("turbo:morph-element", (event) => {
46
+ if (event.target === this.element && !this.morphing) {
47
+ this.morphing = true;
48
+ requestAnimationFrame(() => {
49
+ this.#handleMorph();
50
+ this.morphing = false;
51
+ });
52
+ }
53
+ });
40
54
  }
41
55
 
42
56
  disconnect() {
43
- this.uppy = null
57
+ this.#cleanupUppy();
58
+ }
59
+
60
+ #handleMorph() {
61
+ if (!this.element.isConnected) return;
62
+
63
+ // Clean up the old instance
64
+ this.#cleanupUppy();
65
+
66
+ // Recreate everything - Uppy, triggers, etc.
67
+ this.uploadedFiles = []
68
+ this.element.style["display"] = "none"
69
+ this.configureUppy()
70
+ this.#buildTriggers()
71
+ this.#onAttachmentsChanged()
72
+ }
73
+
74
+ #cleanupUppy() {
75
+ if (this.uppy) {
76
+ this.uppy.destroy();
77
+ this.uppy = null;
78
+ }
79
+
80
+ // Clean up triggers
81
+ if (this.triggerContainer && this.triggerContainer.parentNode) {
82
+ this.triggerContainer.parentNode.removeChild(this.triggerContainer);
83
+ this.triggerContainer = null;
84
+ }
44
85
  }
45
86
 
46
87
  attachmentPreviewOutletConnected(outlet, element) {
@@ -75,7 +116,7 @@ export default class extends Controller {
75
116
  #configureUploader() {
76
117
  this.uppy
77
118
  .use(XHRUpload, {
78
- endpoint: '/upload', // path to the upload endpoint
119
+ endpoint: this.endpointValue, // path to the upload endpoint
79
120
  })
80
121
  }
81
122
 
@@ -4,21 +4,60 @@ import { marked } from 'marked';
4
4
 
5
5
  // Connects to data-controller="easymde"
6
6
  export default class extends Controller {
7
+ static targets = ["textarea"]
8
+
7
9
  connect() {
10
+ if (this.easyMDE) return
11
+
12
+ this.originalValue = this.element.value
8
13
  this.easyMDE = new EasyMDE(this.#buildOptions())
9
- this.element.setAttribute("data-action", "turbo:morph-element->easymde#reconnect")
14
+
15
+ // Store the editor content before morphing
16
+ this.element.addEventListener("turbo:before-morph-element", (event) => {
17
+ if (event.target === this.element && this.easyMDE) {
18
+ this.storedValue = this.easyMDE.value()
19
+ }
20
+ })
21
+
22
+ // Restore after morphing
23
+ this.element.addEventListener("turbo:morph-element", (event) => {
24
+ if (event.target === this.element) {
25
+ requestAnimationFrame(() => this.#handleMorph())
26
+ }
27
+ })
10
28
  }
11
29
 
12
30
  disconnect() {
13
31
  if (this.easyMDE) {
14
- this.easyMDE.toTextArea()
32
+ try {
33
+ // Only call toTextArea if the element is still in the DOM
34
+ if (this.element.isConnected && this.element.parentNode) {
35
+ this.easyMDE.toTextArea()
36
+ }
37
+ } catch (error) {
38
+ console.warn('EasyMDE cleanup error:', error)
39
+ }
15
40
  this.easyMDE = null
16
41
  }
17
42
  }
18
-
19
- reconnect() {
20
- this.disconnect()
21
- this.connect()
43
+
44
+ #handleMorph() {
45
+ if (!this.element.isConnected) return
46
+
47
+ // Don't call toTextArea during morph - just clean up references
48
+ if (this.easyMDE) {
49
+ // Skip toTextArea cleanup - it causes DOM errors during morphing
50
+ this.easyMDE = null
51
+ }
52
+
53
+ // Recreate the editor
54
+ this.easyMDE = new EasyMDE(this.#buildOptions())
55
+
56
+ // Restore the stored value if we have it
57
+ if (this.storedValue !== undefined) {
58
+ this.easyMDE.value(this.storedValue)
59
+ this.storedValue = undefined
60
+ }
22
61
  }
23
62
 
24
63
  #buildOptions() {
@@ -3,14 +3,21 @@ import { Controller } from "@hotwired/stimulus";
3
3
  // Connects to data-controller="flatpickr"
4
4
  export default class extends Controller {
5
5
  connect() {
6
- this.modal = document.querySelector("[data-controller=remote-modal]");
6
+ if (this.picker) return;
7
7
 
8
+ this.modal = document.querySelector("[data-controller=remote-modal]");
8
9
  this.picker = new flatpickr(this.element, this.#buildOptions());
9
10
 
10
- this.element.setAttribute(
11
- "data-action",
12
- "turbo:morph-element->flatpickr#reconnect"
13
- );
11
+ // Just recreate Flatpickr after morphing - the DOM will have correct value
12
+ this.element.addEventListener("turbo:morph-element", (event) => {
13
+ if (event.target === this.element && !this.morphing) {
14
+ this.morphing = true;
15
+ requestAnimationFrame(() => {
16
+ this.#handleMorph();
17
+ this.morphing = false;
18
+ });
19
+ }
20
+ });
14
21
  }
15
22
 
16
23
  disconnect() {
@@ -20,9 +27,18 @@ export default class extends Controller {
20
27
  }
21
28
  }
22
29
 
23
- reconnect() {
24
- this.disconnect();
25
- this.connect();
30
+ #handleMorph() {
31
+ if (!this.element.isConnected) return;
32
+
33
+ // Clean up the old instance
34
+ if (this.picker) {
35
+ this.picker.destroy();
36
+ this.picker = null;
37
+ }
38
+
39
+ // Recreate the picker - it will pick up the current DOM value
40
+ this.modal = document.querySelector("[data-controller=remote-modal]");
41
+ this.picker = new flatpickr(this.element, this.#buildOptions());
26
42
  }
27
43
 
28
44
  #buildOptions() {
@@ -12,10 +12,20 @@ export default class extends Controller {
12
12
  }
13
13
 
14
14
  inputTargetConnected() {
15
- if (!this.hasInputTarget) return;
15
+ if (!this.hasInputTarget || this.iti) return;
16
16
 
17
17
  this.iti = window.intlTelInput(this.inputTarget, this.#buildOptions())
18
- this.inputTarget.setAttribute("data-action", "turbo:morph-element->intl-tel-input#reconnect")
18
+
19
+ // Just recreate IntlTelInput after morphing - the DOM will have correct value
20
+ this.element.addEventListener("turbo:morph-element", (event) => {
21
+ if (event.target === this.element && !this.morphing) {
22
+ this.morphing = true;
23
+ requestAnimationFrame(() => {
24
+ this.#handleMorph();
25
+ this.morphing = false;
26
+ });
27
+ }
28
+ });
19
29
  }
20
30
 
21
31
  inputTargetDisconnected() {
@@ -25,9 +35,17 @@ export default class extends Controller {
25
35
  }
26
36
  }
27
37
 
28
- reconnect() {
29
- this.inputTargetDisconnected()
30
- this.inputTargetConnected()
38
+ #handleMorph() {
39
+ if (!this.inputTarget || !this.inputTarget.isConnected) return;
40
+
41
+ // Clean up the old instance
42
+ if (this.iti) {
43
+ this.iti.destroy();
44
+ this.iti = null;
45
+ }
46
+
47
+ // Recreate the intl tel input - it will pick up the current DOM value
48
+ this.iti = window.intlTelInput(this.inputTarget, this.#buildOptions());
31
49
  }
32
50
 
33
51
  #buildOptions() {
@@ -3,6 +3,19 @@ import { Controller } from "@hotwired/stimulus";
3
3
  // Connects to data-controller="slim-select"
4
4
  export default class extends Controller {
5
5
  connect() {
6
+ if (this.slimSelect) return;
7
+
8
+ this.#setupSlimSelect();
9
+
10
+ // Just recreate SlimSelect after morphing - the DOM will have correct selections
11
+ this.element.addEventListener("turbo:morph-element", (event) => {
12
+ if (event.target === this.element) {
13
+ requestAnimationFrame(() => this.#handleMorph());
14
+ }
15
+ });
16
+ }
17
+
18
+ #setupSlimSelect() {
6
19
  const settings = {};
7
20
  const modal = document.querySelector('[data-controller="remote-modal"]');
8
21
 
@@ -48,11 +61,6 @@ export default class extends Controller {
48
61
 
49
62
  // Add mutation observer to track aria-expanded attribute
50
63
  this.setupAriaObserver();
51
-
52
- this.element.setAttribute(
53
- "data-action",
54
- "turbo:morph-element->slim-select#reconnect"
55
- );
56
64
  }
57
65
 
58
66
  handleDropdownPosition() {
@@ -162,6 +170,20 @@ export default class extends Controller {
162
170
  }
163
171
 
164
172
  disconnect() {
173
+ this.#cleanupSlimSelect();
174
+ }
175
+
176
+ #handleMorph() {
177
+ if (!this.element.isConnected) return;
178
+
179
+ // Clean up the old instance without DOM manipulation
180
+ this.#cleanupSlimSelect();
181
+
182
+ // Recreate the select - it will automatically pick up the current DOM selections
183
+ this.#setupSlimSelect();
184
+ }
185
+
186
+ #cleanupSlimSelect() {
165
187
  // Clean up event listeners
166
188
  if (this.element) {
167
189
  if (this.boundHandleDropdownOpen) {
@@ -208,11 +230,4 @@ export default class extends Controller {
208
230
  this.modifiedSelectWrapper = null;
209
231
  }
210
232
  }
211
-
212
- reconnect() {
213
- this.disconnect();
214
- // dispatch this on the next frame.
215
- // there's some funny issue where my elements get removed from the DOM
216
- setTimeout(() => this.connect(), 10);
217
- }
218
233
  }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: plutonium
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.26.2
4
+ version: 0.26.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Froelich
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-07-22 00:00:00.000000000 Z
11
+ date: 2025-07-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: zeitwerk
@@ -854,6 +854,7 @@ files:
854
854
  - lib/plutonium/ui/form/theme.rb
855
855
  - lib/plutonium/ui/frame_navigator_panel.rb
856
856
  - lib/plutonium/ui/layout/base.rb
857
+ - lib/plutonium/ui/layout/basic_layout.rb
857
858
  - lib/plutonium/ui/layout/header.rb
858
859
  - lib/plutonium/ui/layout/resource_layout.rb
859
860
  - lib/plutonium/ui/layout/rodauth_layout.rb