plutonium 0.51.0 → 0.53.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 +4 -4
- data/.claude/skills/plutonium-app/SKILL.md +2 -0
- data/.claude/skills/plutonium-auth/SKILL.md +6 -4
- data/.claude/skills/plutonium-behavior/SKILL.md +1 -1
- data/.claude/skills/plutonium-resource/SKILL.md +6 -4
- data/.claude/skills/plutonium-tenancy/SKILL.md +31 -7
- data/.claude/skills/plutonium-testing/SKILL.md +3 -1
- data/.claude/skills/plutonium-ui/SKILL.md +32 -8
- data/CHANGELOG.md +33 -0
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +258 -11
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +39 -39
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/plutonium/_resource_header.html.erb +2 -1
- data/docs/.vitepress/config.ts +2 -2
- data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +53 -0
- data/docs/.vitepress/theme/components/HomeCta.vue +108 -0
- data/docs/.vitepress/theme/components/HomeHero.vue +70 -0
- data/docs/.vitepress/theme/components/HomeInTheBox.vue +74 -0
- data/docs/.vitepress/theme/components/HomePillars.vue +42 -0
- data/docs/.vitepress/theme/components/HomeStopWriting.vue +49 -0
- data/docs/.vitepress/theme/components/HomeWalkthrough.vue +111 -0
- data/docs/.vitepress/theme/components/SectionLanding.vue +115 -0
- data/docs/.vitepress/theme/custom.css +144 -0
- data/docs/.vitepress/theme/index.ts +58 -1
- data/docs/getting-started/index.md +33 -50
- data/docs/getting-started/tutorial/02-first-resource.md +17 -8
- data/docs/getting-started/tutorial/03-authentication.md +31 -23
- data/docs/getting-started/tutorial/05-custom-actions.md +9 -4
- data/docs/getting-started/tutorial/06-nested-resources.md +7 -1
- data/docs/getting-started/tutorial/07-author-portal.md +8 -0
- data/docs/getting-started/tutorial/08-customizing-ui.md +4 -0
- data/docs/guides/authentication.md +11 -6
- data/docs/guides/authorization.md +3 -3
- data/docs/guides/creating-packages.md +8 -11
- data/docs/guides/custom-actions.md +8 -2
- data/docs/guides/customizing-ui.md +259 -0
- data/docs/guides/index.md +49 -32
- data/docs/guides/multi-tenancy.md +14 -6
- data/docs/guides/nested-resources.md +69 -0
- data/docs/guides/search-filtering.md +6 -0
- data/docs/guides/testing.md +5 -1
- data/docs/guides/theming.md +14 -1
- data/docs/guides/user-invites.md +10 -4
- data/docs/guides/user-profile.md +8 -0
- data/docs/index.md +10 -219
- data/docs/public/asciinema/home-scaffold.cast +305 -0
- data/docs/public/images/components/avatar.png +0 -0
- data/docs/public/images/guides/custom-actions-bulk.png +0 -0
- data/docs/public/images/guides/multi-tenancy-dashboard.png +0 -0
- data/docs/public/images/guides/multi-tenancy-welcome.png +0 -0
- data/docs/public/images/guides/nested-inputs.png +0 -0
- data/docs/public/images/guides/nested-resources-tab.png +0 -0
- data/docs/public/images/guides/search-filtering-index.png +0 -0
- data/docs/public/images/guides/search-filtering-panel.png +0 -0
- data/docs/public/images/guides/theming-after.png +0 -0
- data/docs/public/images/guides/theming-before.png +0 -0
- data/docs/public/images/guides/user-invites-landing.png +0 -0
- data/docs/public/images/guides/user-profile-edit.png +0 -0
- data/docs/public/images/guides/user-profile-show.png +0 -0
- data/docs/public/images/home-index.png +0 -0
- data/docs/public/images/home-new.png +0 -0
- data/docs/public/images/home-show.png +0 -0
- data/docs/public/images/tutorial/02-empty-index.png +0 -0
- data/docs/public/images/tutorial/02-index-with-posts.png +0 -0
- data/docs/public/images/tutorial/02-new-form-modal.png +0 -0
- data/docs/public/images/tutorial/02-new-form.png +0 -0
- data/docs/public/images/tutorial/03-create-account.png +0 -0
- data/docs/public/images/tutorial/03-login.png +0 -0
- data/docs/public/images/tutorial/04-admin-index.png +0 -0
- data/docs/public/images/tutorial/05-actions-menu.png +0 -0
- data/docs/public/images/tutorial/05-row-actions.png +0 -0
- data/docs/public/images/tutorial/06-comments-tab.png +0 -0
- data/docs/public/images/tutorial/06-post-with-comments.png +0 -0
- data/docs/public/images/tutorial/07-author-dashboard.png +0 -0
- data/docs/public/images/tutorial/07-author-portal.png +0 -0
- data/docs/public/images/tutorial/08-customized-index.png +0 -0
- data/docs/reference/app/generators.md +4 -4
- data/docs/reference/auth/accounts.md +7 -8
- data/docs/reference/auth/index.md +1 -1
- data/docs/reference/behavior/policies.md +2 -2
- data/docs/reference/configuration.md +61 -0
- data/docs/reference/index.md +67 -55
- data/docs/reference/resource/actions.md +2 -1
- data/docs/reference/resource/definition.md +5 -4
- data/docs/reference/tenancy/entity-scoping.md +14 -8
- data/docs/reference/tenancy/index.md +1 -1
- data/docs/reference/tenancy/invites.md +12 -5
- data/docs/reference/ui/components.md +53 -0
- data/docs/reference/ui/forms.md +1 -1
- data/docs/reference/ui/pages.md +6 -5
- data/docs/reference/ui/tables.md +8 -4
- data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md +1648 -0
- data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md.tasks.json +109 -0
- data/docs/superpowers/specs/2026-05-15-public-pages-overhaul-design.md +263 -0
- data/docs/superpowers/specs/2026-05-29-avatar-component-design.md +153 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/assets/assets_generator.rb +10 -0
- data/lib/generators/pu/invites/install_generator.rb +44 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +1 -0
- data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +7 -3
- data/lib/generators/pu/profile/conn_generator.rb +2 -2
- data/lib/generators/pu/res/conn/conn_generator.rb +33 -6
- data/lib/generators/pu/res/model/templates/model.rb.tt +4 -0
- data/lib/generators/pu/rodauth/account_generator.rb +2 -1
- data/lib/generators/pu/rodauth/admin_generator.rb +0 -2
- data/lib/generators/pu/rodauth/migration_generator.rb +0 -2
- data/lib/generators/pu/rodauth/views_generator.rb +0 -2
- data/lib/generators/pu/saas/membership/USAGE +4 -1
- data/lib/generators/pu/saas/setup_generator.rb +16 -4
- data/lib/generators/pu/saas/welcome/templates/app/controllers/welcome_controller.rb.tt +1 -1
- data/lib/plutonium/action/base.rb +43 -63
- data/lib/plutonium/configuration.rb +7 -0
- data/lib/plutonium/definition/actions.rb +10 -11
- data/lib/plutonium/definition/base.rb +29 -0
- data/lib/plutonium/helpers/assets_helper.rb +0 -30
- data/lib/plutonium/helpers/content_helper.rb +0 -44
- data/lib/plutonium/helpers/display_helper.rb +0 -62
- data/lib/plutonium/helpers/turbo_helper.rb +17 -2
- data/lib/plutonium/helpers.rb +0 -2
- data/lib/plutonium/resource/controllers/crud_actions.rb +4 -4
- data/lib/plutonium/resource/controllers/interactive_actions.rb +3 -3
- data/lib/plutonium/resource/definition.rb +0 -42
- data/lib/plutonium/ui/action_button.rb +4 -3
- data/lib/plutonium/ui/avatar.rb +182 -0
- data/lib/plutonium/ui/component/kit.rb +2 -0
- data/lib/plutonium/ui/component/methods.rb +1 -0
- data/lib/plutonium/ui/form/base.rb +32 -2
- data/lib/plutonium/ui/form/components/secure_association.rb +14 -8
- data/lib/plutonium/ui/form/interaction.rb +1 -1
- data/lib/plutonium/ui/form/resource.rb +58 -0
- data/lib/plutonium/ui/form/theme.rb +8 -4
- data/lib/plutonium/ui/grid/card.rb +10 -26
- data/lib/plutonium/ui/modal/base.rb +36 -1
- data/lib/plutonium/ui/modal/centered.rb +24 -6
- data/lib/plutonium/ui/modal/slideover.rb +26 -11
- data/lib/plutonium/ui/nav_user.rb +3 -23
- data/lib/plutonium/ui/page/edit.rb +7 -4
- data/lib/plutonium/ui/page/interactive_action.rb +5 -3
- data/lib/plutonium/ui/page/new.rb +7 -4
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +1 -1
- data/lib/plutonium/ui/table/components/filter_form.rb +12 -4
- data/lib/plutonium/version.rb +1 -1
- data/package.json +4 -1
- data/src/css/components.css +38 -1
- data/src/css/slim_select.css +3 -2
- data/src/js/controllers/dirty_form_guard_controller.js +165 -0
- data/src/js/controllers/form_controller.js +5 -4
- data/src/js/controllers/register_controllers.js +2 -0
- data/src/js/controllers/remote_modal_controller.js +53 -19
- data/src/js/turbo/index.js +1 -0
- data/src/js/turbo/turbo_confirm.js +128 -0
- data/yarn.lock +108 -1
- metadata +52 -6
- data/lib/plutonium/helpers/attachment_helper.rb +0 -73
- data/lib/plutonium/helpers/table_helper.rb +0 -35
- /data/lib/generators/pu/rodauth/templates/app/views/rodauth_mailer/{password_changed.text.erb → change_password_notify.text.erb} +0 -0
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
module Plutonium
|
|
2
2
|
module Helpers
|
|
3
3
|
module DisplayHelper
|
|
4
|
-
# def tooltip(text)
|
|
5
|
-
# text = sanitize text
|
|
6
|
-
# "title=\"#{text}\" data-controller=\"tooltip\" data-bs-title=\"#{text}\"".html_safe
|
|
7
|
-
# end
|
|
8
|
-
|
|
9
4
|
def resource_name(resource_class, count = 1)
|
|
10
5
|
resource_class.model_name.human.pluralize(count)
|
|
11
6
|
end
|
|
@@ -32,59 +27,10 @@ module Plutonium
|
|
|
32
27
|
end
|
|
33
28
|
end
|
|
34
29
|
|
|
35
|
-
def display_field(value:, helper: nil, **options)
|
|
36
|
-
return "-" unless value.present?
|
|
37
|
-
|
|
38
|
-
stack_multiple = options.key?(:stack_multiple) ? options.delete(:stack_multiple) : helper != :display_name_of
|
|
39
|
-
|
|
40
|
-
# clean options list
|
|
41
|
-
options.select! { |k, _v| !k.starts_with? "pu_" }
|
|
42
|
-
|
|
43
|
-
if value.respond_to?(:each) && stack_multiple
|
|
44
|
-
tag.ul class: "list-unstyled m-0" do
|
|
45
|
-
value.each do |val|
|
|
46
|
-
rendered = display_field_value(value: val, helper:, **options)
|
|
47
|
-
concat tag.li(rendered)
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
else
|
|
51
|
-
rendered = display_field_value(value:, helper:, **options)
|
|
52
|
-
tag.span rendered
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
|
|
56
30
|
def display_datetime_value(value)
|
|
57
31
|
timeago value
|
|
58
32
|
end
|
|
59
33
|
|
|
60
|
-
def display_field_value(value:, helper: nil, title: nil, **)
|
|
61
|
-
title = (title != false) ? title || display_name_of(value) : nil
|
|
62
|
-
rendered = helper.present? ? send(helper, value, **) : value
|
|
63
|
-
tag.span rendered, title:
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def display_association_value(association)
|
|
67
|
-
display_name = display_name_of(association)
|
|
68
|
-
if registered_resources.include?(association.class)
|
|
69
|
-
link_to display_name, resource_url_for(association, parent: nil),
|
|
70
|
-
class: "font-medium text-primary-600 dark:text-primary-500"
|
|
71
|
-
else
|
|
72
|
-
display_name
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def display_numeric_value(value)
|
|
77
|
-
number_with_delimiter value
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def display_boolean_value(value)
|
|
81
|
-
tag.input type: :checkbox, class: "form-check-input", checked: value, disabled: true
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def display_url_value(value)
|
|
85
|
-
link_to nil, value, class: "font-medium text-primary-600 dark:text-primary-500", target: :blank
|
|
86
|
-
end
|
|
87
|
-
|
|
88
34
|
def display_name_of(obj, separator: ", ")
|
|
89
35
|
return unless obj.present?
|
|
90
36
|
|
|
@@ -103,14 +49,6 @@ module Plutonium
|
|
|
103
49
|
# Oh well. Just convert it to a string.
|
|
104
50
|
obj.to_s
|
|
105
51
|
end
|
|
106
|
-
|
|
107
|
-
def display_clamped_quill(value)
|
|
108
|
-
clamp_content quill(value)
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def display_attachment_value(value, **, &)
|
|
112
|
-
attachment_preview(value, **, &)
|
|
113
|
-
end
|
|
114
52
|
end
|
|
115
53
|
end
|
|
116
54
|
end
|
|
@@ -16,8 +16,23 @@ module Plutonium
|
|
|
16
16
|
# modal frame specifically.
|
|
17
17
|
def in_secondary_modal? = current_turbo_frame == Plutonium::REMOTE_MODAL_SECONDARY_FRAME
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
# Returns a turbo-frame-scoped element id. Two identically-named forms
|
|
20
|
+
# can be on the page simultaneously (e.g. a primary modal opens a
|
|
21
|
+
# secondary modal, each rendering an `id="resource-form"`). When the
|
|
22
|
+
# server later replies with `turbo_stream.replace("resource-form", ...)`,
|
|
23
|
+
# Turbo would pick the FIRST element matching the id — which is rarely
|
|
24
|
+
# the one the user actually submitted. Append a frame suffix so each
|
|
25
|
+
# frame's form has a unique id and the controller can target precisely.
|
|
26
|
+
#
|
|
27
|
+
# @param base [String, Symbol] the base id
|
|
28
|
+
# @return [String] the scoped id (no suffix outside any modal frame)
|
|
29
|
+
def turbo_scoped_dom_id(base)
|
|
30
|
+
base = base.to_s
|
|
31
|
+
case current_turbo_frame
|
|
32
|
+
when Plutonium::REMOTE_MODAL_FRAME then "#{base}-primary"
|
|
33
|
+
when Plutonium::REMOTE_MODAL_SECONDARY_FRAME then "#{base}-secondary"
|
|
34
|
+
else base
|
|
35
|
+
end
|
|
21
36
|
end
|
|
22
37
|
end
|
|
23
38
|
end
|
data/lib/plutonium/helpers.rb
CHANGED
|
@@ -3,10 +3,8 @@ module Plutonium
|
|
|
3
3
|
def self.included(base)
|
|
4
4
|
base.class_eval do
|
|
5
5
|
include Plutonium::Helpers::ApplicationHelper
|
|
6
|
-
include Plutonium::Helpers::AttachmentHelper
|
|
7
6
|
include Plutonium::Helpers::ContentHelper
|
|
8
7
|
include Plutonium::Helpers::DisplayHelper
|
|
9
|
-
include Plutonium::Helpers::TableHelper
|
|
10
8
|
include Plutonium::Helpers::TurboHelper
|
|
11
9
|
include Plutonium::Helpers::TurboStreamActionsHelper
|
|
12
10
|
include Plutonium::Helpers::AssetsHelper
|
|
@@ -53,7 +53,7 @@ module Plutonium
|
|
|
53
53
|
|
|
54
54
|
respond_to do |format|
|
|
55
55
|
if params[:pre_submit]
|
|
56
|
-
format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form(action: :new))) }
|
|
56
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("resource-form"), view_context.render(build_form(action: :new))) }
|
|
57
57
|
format.html { render :new, status: :unprocessable_content }
|
|
58
58
|
elsif resource_record!.save
|
|
59
59
|
format.turbo_stream do
|
|
@@ -71,7 +71,7 @@ module Plutonium
|
|
|
71
71
|
location: redirect_url_after_submit
|
|
72
72
|
end
|
|
73
73
|
else
|
|
74
|
-
format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form(action: :new))), status: :unprocessable_content }
|
|
74
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("resource-form"), view_context.render(build_form(action: :new))), status: :unprocessable_content }
|
|
75
75
|
format.html { render :new, status: :unprocessable_content }
|
|
76
76
|
format.any do
|
|
77
77
|
@errors = resource_record!.errors
|
|
@@ -100,7 +100,7 @@ module Plutonium
|
|
|
100
100
|
|
|
101
101
|
respond_to do |format|
|
|
102
102
|
if params[:pre_submit]
|
|
103
|
-
format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form(action: :edit))) }
|
|
103
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("resource-form"), view_context.render(build_form(action: :edit))) }
|
|
104
104
|
format.html { render :edit, status: :unprocessable_content }
|
|
105
105
|
elsif resource_record!.save
|
|
106
106
|
format.turbo_stream do
|
|
@@ -116,7 +116,7 @@ module Plutonium
|
|
|
116
116
|
render :show, status: :ok, location: redirect_url_after_submit
|
|
117
117
|
end
|
|
118
118
|
else
|
|
119
|
-
format.turbo_stream { render turbo_stream: turbo_stream.replace("resource-form", view_context.render(build_form(action: :edit))), status: :unprocessable_content }
|
|
119
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("resource-form"), view_context.render(build_form(action: :edit))), status: :unprocessable_content }
|
|
120
120
|
format.html { render :edit, status: :unprocessable_content }
|
|
121
121
|
format.any do
|
|
122
122
|
@errors = resource_record!.errors
|
|
@@ -38,7 +38,7 @@ module Plutonium
|
|
|
38
38
|
|
|
39
39
|
if params[:pre_submit]
|
|
40
40
|
respond_to do |format|
|
|
41
|
-
format.turbo_stream { render turbo_stream: turbo_stream.replace("interaction-form", view_context.render(@interaction.build_form)) }
|
|
41
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("interaction-form"), view_context.render(@interaction.build_form)) }
|
|
42
42
|
format.html { render :interactive_record_action, formats: [:html], status: :unprocessable_content }
|
|
43
43
|
end
|
|
44
44
|
return
|
|
@@ -87,7 +87,7 @@ module Plutonium
|
|
|
87
87
|
|
|
88
88
|
if params[:pre_submit]
|
|
89
89
|
respond_to do |format|
|
|
90
|
-
format.turbo_stream { render turbo_stream: turbo_stream.replace("interaction-form", view_context.render(@interaction.build_form)) }
|
|
90
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("interaction-form"), view_context.render(@interaction.build_form)) }
|
|
91
91
|
format.html { render :interactive_resource_action, status: :unprocessable_content }
|
|
92
92
|
end
|
|
93
93
|
return
|
|
@@ -134,7 +134,7 @@ module Plutonium
|
|
|
134
134
|
|
|
135
135
|
if params[:pre_submit]
|
|
136
136
|
respond_to do |format|
|
|
137
|
-
format.turbo_stream { render turbo_stream: turbo_stream.replace("interaction-form", view_context.render(@interaction.build_form)) }
|
|
137
|
+
format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("interaction-form"), view_context.render(@interaction.build_form)) }
|
|
138
138
|
format.html { render :interactive_bulk_action, formats: [:html], status: :unprocessable_content }
|
|
139
139
|
end
|
|
140
140
|
return
|
|
@@ -1,48 +1,6 @@
|
|
|
1
1
|
module Plutonium
|
|
2
2
|
module Resource
|
|
3
3
|
class Definition < Plutonium::Definition::Base
|
|
4
|
-
class_attribute :modal_mode, default: :slideover, instance_accessor: false
|
|
5
|
-
|
|
6
|
-
VALID_MODAL_MODES = [:centered, :slideover, false].freeze
|
|
7
|
-
|
|
8
|
-
# Sets how :new / :edit actions render.
|
|
9
|
-
# - :slideover (default) — slide-in panel from the right
|
|
10
|
-
# - :centered — centered dialog
|
|
11
|
-
# - false — no modal; new/edit are full standalone pages
|
|
12
|
-
def self.modal(mode)
|
|
13
|
-
unless VALID_MODAL_MODES.include?(mode)
|
|
14
|
-
raise ArgumentError, "modal must be one of #{VALID_MODAL_MODES.inspect}, got #{mode.inspect}"
|
|
15
|
-
end
|
|
16
|
-
self.modal_mode = mode
|
|
17
|
-
configure_crud_modal_targets!
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
# Re-derives the default :new / :edit actions so their turbo_frame
|
|
21
|
-
# matches the current `modal_mode`. Called when `.modal` is set
|
|
22
|
-
# and once at Resource::Definition load (so the default
|
|
23
|
-
# :slideover state propagates to the action records). Subclasses
|
|
24
|
-
# inherit those records via DefineableProps#inherited (deep_dup);
|
|
25
|
-
# calling `.modal` on a subclass re-runs this method locally.
|
|
26
|
-
def self.configure_crud_modal_targets!
|
|
27
|
-
target = (modal_mode == false) ? nil : Plutonium::REMOTE_MODAL_FRAME
|
|
28
|
-
[:new, :edit].each do |name|
|
|
29
|
-
action = defined_actions[name]
|
|
30
|
-
next unless action
|
|
31
|
-
next if action.turbo_frame == target
|
|
32
|
-
defined_actions[name] = action.with(turbo_frame: target)
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def modal
|
|
37
|
-
self.class.modal_mode
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# Apply the default modal target ("remote_modal") to :new / :edit
|
|
41
|
-
# so resources that never call `.modal` still get the slideover
|
|
42
|
-
# behavior. Subclasses inherit the configured actions via
|
|
43
|
-
# DefineableProps' deep_dup; calling `.modal` on a subclass
|
|
44
|
-
# re-runs the configuration locally.
|
|
45
|
-
configure_crud_modal_targets!
|
|
46
4
|
end
|
|
47
5
|
end
|
|
48
6
|
end
|
|
@@ -50,7 +50,7 @@ module Plutonium
|
|
|
50
50
|
link_to(
|
|
51
51
|
url_with_return_to,
|
|
52
52
|
class: button_classes,
|
|
53
|
-
data: {turbo_frame: @action.turbo_frame}.merge(@extra_data)
|
|
53
|
+
data: {turbo_frame: @action.turbo_frame(current_definition)}.merge(@extra_data)
|
|
54
54
|
) do
|
|
55
55
|
render_button_content
|
|
56
56
|
end
|
|
@@ -67,7 +67,7 @@ module Plutonium
|
|
|
67
67
|
data: {
|
|
68
68
|
turbo: @action.turbo,
|
|
69
69
|
turbo_confirm: @action.confirmation.presence,
|
|
70
|
-
turbo_frame: @action.turbo_frame
|
|
70
|
+
turbo_frame: @action.turbo_frame(current_definition)
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
) do
|
|
@@ -84,7 +84,8 @@ module Plutonium
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
# Add turbo frame if specified
|
|
87
|
-
|
|
87
|
+
frame = @action.turbo_frame(current_definition)
|
|
88
|
+
link_attrs[:data] = {turbo_frame: frame} if frame
|
|
88
89
|
|
|
89
90
|
# Add confirmation and method for non-GET requests
|
|
90
91
|
if @action.confirmation || @action.route_options.method != :get
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "cgi"
|
|
5
|
+
|
|
6
|
+
module Plutonium
|
|
7
|
+
module UI
|
|
8
|
+
# Renders a profile/avatar image for a subject.
|
|
9
|
+
#
|
|
10
|
+
# Avatar(user) # Navii fallback seeded from the record
|
|
11
|
+
# Avatar(user, src: :photo) # user.photo if present, else Navii fallback
|
|
12
|
+
# Avatar(user, src: user.photo) # pass the attachment/uploader/URL directly
|
|
13
|
+
# Avatar("acme-team") # a String subject is a deterministic seed
|
|
14
|
+
# Avatar("https://.../p.png") # a URL-shaped subject is shown as the image
|
|
15
|
+
# Avatar(src: "https://.../p.png") # a bare image, no subject/fallback
|
|
16
|
+
#
|
|
17
|
+
# The positional +subject+ is the identity the fallback is derived from: a
|
|
18
|
+
# record or a String, hashed to an opaque, PII-free seed. As a convenience, a
|
|
19
|
+
# URL-shaped String subject is treated as +src+ (the image) instead.
|
|
20
|
+
# +src+ is the image to show and may be:
|
|
21
|
+
# - a Symbol naming a method on the subject (e.g. +:avatar+ -> +subject.avatar+).
|
|
22
|
+
# This is a contract: the subject must respond to it (raises NoMethodError
|
|
23
|
+
# otherwise), so only use a Symbol +src+ with a record subject.
|
|
24
|
+
# - an ActiveStorage attachment, active_shrine/Shrine uploader, or URL String
|
|
25
|
+
#
|
|
26
|
+
# Resolution order: the resolved +src+, then a Navii avatar seeded from the
|
|
27
|
+
# subject, then a generic user icon when there is nothing to show.
|
|
28
|
+
class Avatar < Plutonium::UI::Component::Base
|
|
29
|
+
# Pixel dimensions per semantic size, plus the matching Tailwind width/height
|
|
30
|
+
# utilities (needed because the preflight resets `img { height: auto }`, so
|
|
31
|
+
# width/height attributes alone don't pin the rendered size).
|
|
32
|
+
SIZES = {xs: 24, sm: 32, md: 40, lg: 48, xl: 64}.freeze
|
|
33
|
+
SIZE_CLASSES = {xs: "w-6 h-6", sm: "w-8 h-8", md: "w-10 h-10", lg: "w-12 h-12", xl: "w-16 h-16"}.freeze
|
|
34
|
+
|
|
35
|
+
# Resolve an image value to a URL string. Supports:
|
|
36
|
+
# - ActiveStorage attachments -> helpers.url_for (they aren't routable via #url)
|
|
37
|
+
# - active_shrine / other ActiveStorage-style wrappers -> value.url
|
|
38
|
+
# - Bare Shrine::UploadedFile, CarrierWave, etc. (respond to :url) -> value.url
|
|
39
|
+
# - Plain URL strings ("https://..." or "/uploads/...")
|
|
40
|
+
#
|
|
41
|
+
# Exposed as a module method so collaborators (e.g. Grid::Card) can reuse
|
|
42
|
+
# the resolution without instantiating the component.
|
|
43
|
+
def self.resolve_image_src(value, helpers = nil)
|
|
44
|
+
return nil if value.nil?
|
|
45
|
+
|
|
46
|
+
# ActiveStorage is the only supported source that must go through Rails
|
|
47
|
+
# routing rather than its own #url. It has to be matched *before* the
|
|
48
|
+
# generic attached?/url checks, because ActiveStorage-compatible wrappers
|
|
49
|
+
# (e.g. active_shrine) respond to BOTH attached? and url, and those should
|
|
50
|
+
# resolve via their own #url instead.
|
|
51
|
+
if active_storage_attachment?(value)
|
|
52
|
+
return value.attached? ? helpers&.url_for(value) : nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if value.respond_to?(:attached?) # active_shrine & other AS-style wrappers
|
|
56
|
+
value.attached? ? value.url : nil
|
|
57
|
+
elsif value.respond_to?(:url) # bare Shrine::UploadedFile, CarrierWave, ...
|
|
58
|
+
value.url
|
|
59
|
+
elsif value.is_a?(String) && value.start_with?("http", "/")
|
|
60
|
+
value
|
|
61
|
+
end
|
|
62
|
+
rescue ArgumentError, URI::InvalidURIError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.active_storage_attachment?(value)
|
|
67
|
+
defined?(ActiveStorage::Attached) && value.is_a?(ActiveStorage::Attached)
|
|
68
|
+
end
|
|
69
|
+
private_class_method :active_storage_attachment?
|
|
70
|
+
|
|
71
|
+
def initialize(subject = nil, src: nil, size: :md, alt: nil, **attributes)
|
|
72
|
+
# A URL-shaped positional subject is really an image, not an identity:
|
|
73
|
+
# route it to src so Avatar("https://…/p.png") shows the image rather
|
|
74
|
+
# than hashing the URL into a seed.
|
|
75
|
+
if src.nil? && subject.is_a?(String) && subject.start_with?("http", "/")
|
|
76
|
+
src = subject
|
|
77
|
+
subject = nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
@subject = subject
|
|
81
|
+
@src = src
|
|
82
|
+
@size = size
|
|
83
|
+
@alt = alt
|
|
84
|
+
@attributes = attributes
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def view_template
|
|
88
|
+
url = resolved_src || navii_url
|
|
89
|
+
|
|
90
|
+
if url
|
|
91
|
+
img(
|
|
92
|
+
src: url, alt: alt_text.to_s, width: pixel_size, height: pixel_size, loading: "lazy",
|
|
93
|
+
**sized_attributes("rounded-full object-cover bg-[var(--pu-surface-alt)] shrink-0")
|
|
94
|
+
)
|
|
95
|
+
else
|
|
96
|
+
div(**sized_attributes("rounded-full bg-[var(--pu-surface-alt)] text-[var(--pu-text-muted)] flex items-center justify-center shrink-0")) do
|
|
97
|
+
render Phlex::TablerIcons::User.new(class: "w-2/3 h-2/3")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# Merge the component's base classes, the size class, and the caller's class;
|
|
105
|
+
# add an inline dimension style for raw-pixel (Integer) sizes.
|
|
106
|
+
def sized_attributes(base)
|
|
107
|
+
attrs = @attributes.dup
|
|
108
|
+
attrs[:class] = tokens(base, size_class, attrs.delete(:class))
|
|
109
|
+
attrs[:style] = [size_style, attrs[:style]].compact.join("; ") if size_style
|
|
110
|
+
attrs
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def pixel_size
|
|
114
|
+
@size.is_a?(Symbol) ? SIZES.fetch(@size) : @size
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def size_class
|
|
118
|
+
@size.is_a?(Symbol) ? SIZE_CLASSES.fetch(@size) : nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def size_style
|
|
122
|
+
"width: #{@size}px; height: #{@size}px" unless @size.is_a?(Symbol)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def resolved_src
|
|
126
|
+
value = image_src_value
|
|
127
|
+
return nil if value.nil?
|
|
128
|
+
|
|
129
|
+
# Only reach for the Rails helper proxy when we have an attachment-style
|
|
130
|
+
# source (ActiveStorage needs helpers.url_for; the resolver ignores it
|
|
131
|
+
# for active_shrine and other #url-bearing sources).
|
|
132
|
+
resolver_helpers = value.respond_to?(:attached?) ? helpers : nil
|
|
133
|
+
self.class.resolve_image_src(value, resolver_helpers)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# A Symbol src names a method on the subject (e.g. :avatar -> subject.avatar);
|
|
137
|
+
# anything else is the attachment/uploader/URL itself.
|
|
138
|
+
def image_src_value
|
|
139
|
+
@src.is_a?(Symbol) ? @subject&.public_send(@src) : @src
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def navii_url
|
|
143
|
+
seed = navii_seed
|
|
144
|
+
return nil unless seed
|
|
145
|
+
|
|
146
|
+
host = Plutonium.configuration.navii_host_url
|
|
147
|
+
"#{host}/avatar/#{CGI.escape(seed)}?size=#{pixel_size}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The value sent to Navii is ALWAYS a hash of the subject's identity, so no
|
|
151
|
+
# plaintext (model names, ids, emails, or caller-provided seed strings) ever
|
|
152
|
+
# reaches the external service. Determinism is preserved: same identity ->
|
|
153
|
+
# same hash -> same avatar.
|
|
154
|
+
def navii_seed
|
|
155
|
+
identity = subject_identity
|
|
156
|
+
return nil unless identity
|
|
157
|
+
|
|
158
|
+
Digest::SHA256.hexdigest(identity)[0, 16]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Stable identity string for the subject: a String subject verbatim, or
|
|
162
|
+
# "Class:id" for a record. Hashed by #navii_seed before it leaves the app.
|
|
163
|
+
def subject_identity
|
|
164
|
+
case @subject
|
|
165
|
+
when nil then nil
|
|
166
|
+
when String then @subject
|
|
167
|
+
else "#{@subject.class.name}:#{@subject.id}" if @subject.respond_to?(:id) && @subject.id.present?
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def alt_text
|
|
172
|
+
return @alt if @alt
|
|
173
|
+
|
|
174
|
+
case @subject
|
|
175
|
+
when nil then nil
|
|
176
|
+
when String then @subject
|
|
177
|
+
else helpers&.display_name_of(@subject)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -50,6 +50,8 @@ module Plutonium
|
|
|
50
50
|
self.class.method_defined?(build_method) || super
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
def BuildAvatar(...) = Plutonium::UI::Avatar.new(...)
|
|
54
|
+
|
|
53
55
|
def BuildBreadcrumbs(...) = Plutonium::UI::Breadcrumbs.new(...)
|
|
54
56
|
|
|
55
57
|
def BuildSkeletonTable(...) = Plutonium::UI::SkeletonTable.new(...)
|
|
@@ -149,8 +149,38 @@ module Plutonium
|
|
|
149
149
|
def initialize_attributes
|
|
150
150
|
super
|
|
151
151
|
|
|
152
|
-
|
|
153
|
-
attributes[
|
|
152
|
+
# Only fall back to :resource_form when the caller didn't already
|
|
153
|
+
# name the form. Phlexi moves an explicit `attributes[:id]` onto
|
|
154
|
+
# `@dom_id` before this runs, so a blind `||=` here would clobber
|
|
155
|
+
# things like the filter slideover's `id: "filter-form"` —
|
|
156
|
+
# producing two `<form id="resource-form">` on the page and
|
|
157
|
+
# silently breaking the modal pre_submit re-render (Turbo's
|
|
158
|
+
# `getElementById` finds the filter form first).
|
|
159
|
+
attributes[:id] ||= "resource-form" if @dom_id.nil?
|
|
160
|
+
attributes["data-controller"] = form_data_controller
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# `dirty-form-guard` is attached unconditionally — it self-disables
|
|
164
|
+
# outside a <dialog>. Branching on `in_modal?` here would fail:
|
|
165
|
+
# Phlex forbids view-context access before rendering begins.
|
|
166
|
+
def form_data_controller
|
|
167
|
+
"form dirty-form-guard"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Scope the form id to the current turbo frame at render time (we
|
|
171
|
+
# can't do this in `initialize_attributes` — Phlex hasn't started
|
|
172
|
+
# rendering yet, so `view_context` and the request headers aren't
|
|
173
|
+
# accessible). Primary and secondary modals can each host a form
|
|
174
|
+
# without colliding on document-level turbo-stream `replace target=`
|
|
175
|
+
# lookups. See Helpers::TurboHelper#turbo_scoped_dom_id.
|
|
176
|
+
#
|
|
177
|
+
# Also force-replace the id (Phlexi's `mix` would otherwise prepend
|
|
178
|
+
# `@namespace.dom_id`, producing space-separated ids like
|
|
179
|
+
# "q filter-form" which break document.getElementById lookups).
|
|
180
|
+
def form_attributes
|
|
181
|
+
attrs = super
|
|
182
|
+
attrs[:id] = turbo_scoped_dom_id(attributes[:id]) if attributes[:id]
|
|
183
|
+
attrs
|
|
154
184
|
end
|
|
155
185
|
end
|
|
156
186
|
end
|
|
@@ -24,15 +24,20 @@ module Plutonium
|
|
|
24
24
|
def render_add_button
|
|
25
25
|
return if @add_action == false
|
|
26
26
|
|
|
27
|
+
# Two stacking levels are supported (primary + secondary modal).
|
|
28
|
+
# Hide the "+" entirely once we're already inside the secondary —
|
|
29
|
+
# there's no tertiary frame to escalate to.
|
|
30
|
+
return if in_secondary_modal?
|
|
31
|
+
|
|
27
32
|
url, turbo_frame = add_url_and_frame
|
|
28
33
|
return unless url
|
|
29
34
|
|
|
30
|
-
# When the parent form is already inside
|
|
31
|
-
# "+" to the secondary frame so the stacked dialog
|
|
32
|
-
# top of the original form rather than replacing it.
|
|
33
|
-
# crud controller mirrors this on success — closing the
|
|
34
|
-
# secondary modal and reloading the primary so the
|
|
35
|
-
#
|
|
35
|
+
# When the parent form is already inside the primary modal,
|
|
36
|
+
# route the "+" to the secondary frame so the stacked dialog
|
|
37
|
+
# opens on top of the original form rather than replacing it.
|
|
38
|
+
# The crud controller mirrors this on success — closing the
|
|
39
|
+
# secondary modal and reloading the primary so the association
|
|
40
|
+
# select picks up the new record.
|
|
36
41
|
if turbo_frame == Plutonium::REMOTE_MODAL_FRAME && in_modal?
|
|
37
42
|
turbo_frame = Plutonium::REMOTE_MODAL_SECONDARY_FRAME
|
|
38
43
|
end
|
|
@@ -65,12 +70,13 @@ module Plutonium
|
|
|
65
70
|
end
|
|
66
71
|
|
|
67
72
|
return unless registered_resources.include?(klass)
|
|
68
|
-
|
|
73
|
+
target_definition = resource_definition(klass)
|
|
74
|
+
action = target_definition.defined_actions[:new]
|
|
69
75
|
return unless action
|
|
70
76
|
return unless @skip_authorization || action.permitted_by?(policy_for(record: klass))
|
|
71
77
|
|
|
72
78
|
url = route_options_to_url(action.route_options, klass)
|
|
73
|
-
[with_return_to(url), action.turbo_frame]
|
|
79
|
+
[with_return_to(url), action.turbo_frame(target_definition)]
|
|
74
80
|
end
|
|
75
81
|
|
|
76
82
|
def with_return_to(url)
|
|
@@ -31,12 +31,70 @@ module Plutonium
|
|
|
31
31
|
render_actions
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
# Mirrors Phlexi::Form::Base#view_template (phlexi-form ~> 0.14)
|
|
35
|
+
# — keep these in sync if upgrading. We override so the guard
|
|
36
|
+
# dialog renders inside the <form> tag (where the JS controller
|
|
37
|
+
# looks for it via `dirty-form-guard-target`) even when a
|
|
38
|
+
# subclass overrides `form_template`. Without this, the
|
|
39
|
+
# controller silently falls back to `window.confirm`.
|
|
40
|
+
def view_template(&block)
|
|
41
|
+
captured_body = capture { form_template(&block) }
|
|
42
|
+
captured_guard = capture { render_dirty_form_guard_dialog if in_modal? }
|
|
43
|
+
form_tag do
|
|
44
|
+
form_errors
|
|
45
|
+
raw(safe(captured_body))
|
|
46
|
+
raw(safe(captured_guard))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
34
50
|
def form_class
|
|
35
51
|
in_modal? ? "flex-1 flex flex-col min-h-0" : super
|
|
36
52
|
end
|
|
37
53
|
|
|
38
54
|
private
|
|
39
55
|
|
|
56
|
+
# Nested inside the form so showModal() stacks it in the browser's
|
|
57
|
+
# top layer above the surrounding slideover/centered modal — no
|
|
58
|
+
# z-index juggling required.
|
|
59
|
+
def render_dirty_form_guard_dialog
|
|
60
|
+
dialog(
|
|
61
|
+
class:
|
|
62
|
+
"pu-dialog " \
|
|
63
|
+
"top-1/2 -translate-y-1/2 left-1/2 -translate-x-1/2 " \
|
|
64
|
+
"w-full max-w-md p-0 " \
|
|
65
|
+
"open:flex flex-col " \
|
|
66
|
+
"opacity-0 scale-95 data-[open]:opacity-100 data-[open]:scale-100 " \
|
|
67
|
+
"transition-[opacity,transform] duration-200 ease-out",
|
|
68
|
+
data: {"dirty-form-guard-target": "confirmDialog"},
|
|
69
|
+
# Modern Chrome refuses user-agent close requests (Esc, backdrop);
|
|
70
|
+
# older browsers fall back to the JS controller's interception.
|
|
71
|
+
closedby: "none",
|
|
72
|
+
"aria-labelledby": "pu-dirty-guard-title",
|
|
73
|
+
"aria-describedby": "pu-dirty-guard-desc"
|
|
74
|
+
) do
|
|
75
|
+
div(class: "px-6 pt-5 pb-4 border-b border-[var(--pu-border)]") do
|
|
76
|
+
h2(id: "pu-dirty-guard-title", class: "text-lg font-semibold text-[var(--pu-text)]") do
|
|
77
|
+
"Discard changes?"
|
|
78
|
+
end
|
|
79
|
+
p(id: "pu-dirty-guard-desc", class: "mt-1 text-sm text-[var(--pu-text-muted)]") do
|
|
80
|
+
"You have unsaved changes. Closing this form now will lose them."
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
div(class: "flex items-center justify-end gap-2 px-6 py-4") do
|
|
84
|
+
button(
|
|
85
|
+
type: "button",
|
|
86
|
+
class: "pu-btn pu-btn-md pu-btn-outline",
|
|
87
|
+
data: {action: "dirty-form-guard#keepEditing"}
|
|
88
|
+
) { "Keep editing" }
|
|
89
|
+
button(
|
|
90
|
+
type: "button",
|
|
91
|
+
class: "pu-btn pu-btn-md pu-btn-danger",
|
|
92
|
+
data: {action: "dirty-form-guard#discard"}
|
|
93
|
+
) { "Discard changes" }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
40
98
|
def render_fields
|
|
41
99
|
fields_wrapper {
|
|
42
100
|
resource_fields.each { |name|
|
|
@@ -14,7 +14,7 @@ module Plutonium
|
|
|
14
14
|
inner_wrapper: "w-full",
|
|
15
15
|
|
|
16
16
|
# Form errors
|
|
17
|
-
form_errors_wrapper: "flex items-start gap-3
|
|
17
|
+
form_errors_wrapper: "flex items-start gap-3 m-4 p-4 text-base text-danger-800 rounded-[var(--pu-radius-lg)] bg-danger-50 border border-danger-200 dark:bg-danger-950/30 dark:border-danger-800 dark:text-danger-300",
|
|
18
18
|
form_errors_message: "font-semibold",
|
|
19
19
|
form_errors_list: "mt-2 list-disc list-inside text-sm",
|
|
20
20
|
|
|
@@ -30,11 +30,15 @@ module Plutonium
|
|
|
30
30
|
valid_input: "pu-input pu-input-valid",
|
|
31
31
|
neutral_input: "",
|
|
32
32
|
|
|
33
|
-
# Checkbox
|
|
33
|
+
# Checkbox / Boolean
|
|
34
34
|
checkbox: "pu-checkbox",
|
|
35
|
+
boolean: "pu-checkbox",
|
|
36
|
+
valid_boolean: "pu-checkbox",
|
|
37
|
+
invalid_boolean: "pu-checkbox pu-input-invalid",
|
|
35
38
|
|
|
36
39
|
# Radio buttons
|
|
37
|
-
radio_button: "pu-
|
|
40
|
+
radio_button: "pu-radio",
|
|
41
|
+
collection_radio_buttons: "flex flex-col gap-2",
|
|
38
42
|
|
|
39
43
|
# Color
|
|
40
44
|
color: "pu-color-input appearance-none bg-transparent border-none cursor-pointer w-12 h-12 rounded-lg",
|
|
@@ -46,7 +50,7 @@ module Plutonium
|
|
|
46
50
|
file: "pu-input py-2 [&::file-selector-button]:mr-4 [&::file-selector-button]:px-4 [&::file-selector-button]:py-2 [&::file-selector-button]:bg-[var(--pu-surface-alt)] [&::file-selector-button]:border-0 [&::file-selector-button]:rounded-md [&::file-selector-button]:text-sm [&::file-selector-button]:font-semibold [&::file-selector-button]:text-[var(--pu-text-muted)] [&::file-selector-button]:hover:bg-[var(--pu-border)] [&::file-selector-button]:cursor-pointer [&::file-selector-button]:transition-colors",
|
|
47
51
|
|
|
48
52
|
# Hint themes
|
|
49
|
-
hint: "pu-hint whitespace-pre",
|
|
53
|
+
hint: "pu-hint whitespace-pre-wrap",
|
|
50
54
|
|
|
51
55
|
# Error themes
|
|
52
56
|
error: "text-xs text-danger-600 mt-1",
|