plutonium 0.66.0 → 0.68.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.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-dashboard/SKILL.md +17 -2
  3. data/.claude/skills/plutonium-resource/SKILL.md +17 -10
  4. data/.claude/skills/plutonium-ui/SKILL.md +13 -3
  5. data/CHANGELOG.md +35 -0
  6. data/SECURITY.md +1 -1
  7. data/app/assets/plutonium.css +1 -1
  8. data/app/assets/plutonium.js +56 -3
  9. data/app/assets/plutonium.js.map +4 -4
  10. data/app/assets/plutonium.min.js +32 -32
  11. data/app/assets/plutonium.min.js.map +4 -4
  12. data/config/brakeman.ignore +1 -1
  13. data/config/locales/en/dashboard.yml +1 -0
  14. data/config/locales/en/js.yml +4 -0
  15. data/config/locales/en/ui.yml +19 -0
  16. data/docs/reference/dashboard/dsl.md +54 -3
  17. data/docs/reference/resource/definition.md +14 -7
  18. data/docs/reference/resource/query.md +11 -1
  19. data/docs/reference/ui/displays.md +19 -2
  20. data/docs/reference/ui/forms.md +77 -4
  21. data/gemfiles/rails_7.gemfile +1 -0
  22. data/gemfiles/rails_7.gemfile.lock +13 -3
  23. data/gemfiles/rails_8.0.gemfile +1 -0
  24. data/gemfiles/rails_8.0.gemfile.lock +10 -1
  25. data/gemfiles/rails_8.1.gemfile +1 -0
  26. data/gemfiles/rails_8.1.gemfile.lock +10 -1
  27. data/lib/plutonium/auth/rodauth.rb +7 -0
  28. data/lib/plutonium/core/controller.rb +0 -2
  29. data/lib/plutonium/dashboard/base.rb +80 -1
  30. data/lib/plutonium/dashboard/card.rb +12 -13
  31. data/lib/plutonium/dashboard/controller.rb +28 -10
  32. data/lib/plutonium/dashboard/dsl.rb +59 -2
  33. data/lib/plutonium/dashboard/evaluation.rb +30 -0
  34. data/lib/plutonium/dashboard/filter.rb +114 -0
  35. data/lib/plutonium/dashboard/link.rb +35 -0
  36. data/lib/plutonium/dashboard/route_resolution.rb +6 -2
  37. data/lib/plutonium/helpers/content_helper.rb +0 -3
  38. data/lib/plutonium/query/filter.rb +2 -1
  39. data/lib/plutonium/query/filters/tags.rb +36 -0
  40. data/lib/plutonium/resource/query_object.rb +6 -0
  41. data/lib/plutonium/routing/dashboard_registration.rb +5 -0
  42. data/lib/plutonium/ui/avatar.rb +2 -2
  43. data/lib/plutonium/ui/component/resolves_tags.rb +13 -0
  44. data/lib/plutonium/ui/dashboard/board.rb +4 -1
  45. data/lib/plutonium/ui/dashboard/frame.rb +1 -1
  46. data/lib/plutonium/ui/dashboard/metric.rb +18 -5
  47. data/lib/plutonium/ui/dashboard/toolbar.rb +76 -0
  48. data/lib/plutonium/ui/display/base.rb +50 -2
  49. data/lib/plutonium/ui/display/components/badge.rb +1 -1
  50. data/lib/plutonium/ui/display/components/binary.rb +20 -0
  51. data/lib/plutonium/ui/display/components/boolean.rb +1 -1
  52. data/lib/plutonium/ui/display/components/code.rb +23 -0
  53. data/lib/plutonium/ui/display/components/color.rb +14 -6
  54. data/lib/plutonium/ui/display/components/duration.rb +31 -0
  55. data/lib/plutonium/ui/display/components/file_size.rb +22 -0
  56. data/lib/plutonium/ui/display/components/key_value.rb +37 -0
  57. data/lib/plutonium/ui/display/components/list.rb +33 -0
  58. data/lib/plutonium/ui/display/components/number.rb +32 -0
  59. data/lib/plutonium/ui/display/components/progress.rb +36 -0
  60. data/lib/plutonium/ui/display/components/rating.rb +36 -0
  61. data/lib/plutonium/ui/display/components/relative_time.rb +25 -0
  62. data/lib/plutonium/ui/display/components/rich_text.rb +23 -0
  63. data/lib/plutonium/ui/display/components/tags.rb +18 -0
  64. data/lib/plutonium/ui/display/options/inferred_types.rb +13 -0
  65. data/lib/plutonium/ui/display/theme.rb +24 -2
  66. data/lib/plutonium/ui/form/base.rb +50 -1
  67. data/lib/plutonium/ui/form/components/binary.rb +40 -0
  68. data/lib/plutonium/ui/form/components/key_value_store.rb +22 -71
  69. data/lib/plutonium/ui/form/components/list.rb +46 -0
  70. data/lib/plutonium/ui/form/components/rating.rb +51 -0
  71. data/lib/plutonium/ui/form/components/rich_text.rb +20 -0
  72. data/lib/plutonium/ui/form/components/secure_association.rb +31 -8
  73. data/lib/plutonium/ui/form/components/secure_association_checkboxes.rb +30 -0
  74. data/lib/plutonium/ui/form/components/secure_association_choices.rb +123 -0
  75. data/lib/plutonium/ui/form/components/secure_association_radio_buttons.rb +45 -0
  76. data/lib/plutonium/ui/form/components/secure_association_toggles.rb +18 -0
  77. data/lib/plutonium/ui/form/components/tags.rb +45 -0
  78. data/lib/plutonium/ui/form/components/tags_filter.rb +40 -0
  79. data/lib/plutonium/ui/form/components/uppy.rb +1 -1
  80. data/lib/plutonium/ui/form/options/inferred_types.rb +16 -15
  81. data/lib/plutonium/ui/form/theme.rb +34 -4
  82. data/lib/plutonium/ui/grid/card.rb +25 -5
  83. data/lib/plutonium/ui/layout/base.rb +10 -1
  84. data/lib/plutonium/ui/options/rich_text_field.rb +18 -0
  85. data/lib/plutonium/ui/options/secret_field.rb +32 -0
  86. data/lib/plutonium/ui/options/tags_field.rb +23 -0
  87. data/lib/plutonium/ui/page/dashboard.rb +1 -0
  88. data/lib/plutonium/ui/table/components/scopes_bar.rb +1 -1
  89. data/lib/plutonium/ui/table/components/scopes_pills.rb +1 -1
  90. data/lib/plutonium/ui/table/components/toolbar.rb +2 -2
  91. data/lib/plutonium/ui/table/components/view_switcher.rb +4 -2
  92. data/lib/plutonium/ui/table/display_theme.rb +30 -2
  93. data/lib/plutonium/version.rb +1 -1
  94. data/package.json +1 -1
  95. data/plutonium.gemspec +1 -1
  96. data/src/css/components.css +43 -0
  97. data/src/css/flatpickr.css +91 -0
  98. data/src/js/controllers/easymde_controller.js +1 -0
  99. data/src/js/controllers/register_controllers.js +2 -0
  100. data/src/js/controllers/slim_select_controller.js +32 -3
  101. data/src/js/controllers/timeago_controller.js +40 -0
  102. metadata +35 -4
@@ -120,7 +120,7 @@
120
120
  {
121
121
  "warning_type": "Dangerous Eval",
122
122
  "warning_code": 13,
123
- "fingerprint": "e5f68f8342a094ffd2124c1650428135e0549da39950158725414bfca0bb8f1e",
123
+ "fingerprint": "616e2daa0e04bcadd42f0af563dedff5450d69e0c9a0f4147c528ffbf98e3d8a",
124
124
  "check_name": "Evaluation",
125
125
  "message": "Dynamic string evaluated as code",
126
126
  "file": "lib/plutonium/auth/rodauth.rb",
@@ -23,3 +23,4 @@ en:
23
23
  increase: "Up %{change}"
24
24
  decrease: "Down %{change}"
25
25
  unchanged: "No change"
26
+ points: "%{change} pts"
@@ -13,6 +13,9 @@ en:
13
13
  copied: "Copied!"
14
14
  slim_select:
15
15
  search_failed: "Search failed"
16
+ # Chip inputs that accept new values (`as: :list`, tags)
17
+ add_placeholder: "Type to add…"
18
+ add_search_placeholder: "Search or add new"
16
19
  dirty_form_guard:
17
20
  unsaved_changes: "You have unsaved changes that will be lost. Continue?"
18
21
  discard_changes: "Discard your changes?"
@@ -35,6 +38,7 @@ en:
35
38
  searchText: "No Results"
36
39
  searchPlaceholder: "Search"
37
40
  searchingText: "Searching..."
41
+ addableText: 'Press "Enter" to add {value}'
38
42
  # flatpickr: `locale: "de"` (the host must load the matching l10n bundle)
39
43
  # uppy: `strings: {...}` (Uppy locale strings, merged over the English defaults)
40
44
  # intl_tel_input: `{...}` (intl-tel-input `i18n` option)
@@ -86,8 +86,27 @@ en:
86
86
  key_placeholder: "Key"
87
87
  value_placeholder: "Value"
88
88
  add_pair: "+ Add Pair"
89
+ remove_pair: "Remove pair"
89
90
  password:
90
91
  keep_current_hint: "Leave blank to keep the current value."
91
92
  reenter_hint: "Re-enter the new value to save it."
92
93
  attachment:
93
94
  delete: "Delete"
95
+ rating:
96
+ clear: "Clear"
97
+ association_choices:
98
+ none: "None"
99
+ empty: "No options available"
100
+ binary:
101
+ replace_hint: "Stored: %{size}. Choose a file to replace it."
102
+ display:
103
+ binary: "Binary data (%{size})"
104
+ code:
105
+ copy: "Copy"
106
+ duration:
107
+ days: "%{count}d"
108
+ hours: "%{count}h"
109
+ minutes: "%{count}m"
110
+ seconds: "%{count}s"
111
+ rating:
112
+ label: "%{value} out of %{max}"
@@ -30,6 +30,49 @@ Default reload interval for every lazy card. `nil` (the default) disables it.
30
30
 
31
31
  Page width, one of `:sm`, `:md`, `:lg`, `:xl`, `:full`. Default `:full`.
32
32
 
33
+ ### filter(key, choices: nil, scopes: nil, default: nil, label: nil)
34
+
35
+ A page-level control every card reads, such as a period or a region.
36
+
37
+ ```ruby
38
+ filter :period, choices: {"7" => "Last 7 days", "30" => "Last 30 days", "90" => "Last 90 days"}, default: "30"
39
+
40
+ metric(:orders) { orders.where(created_at: filter_value(:period).to_i.days.ago..).count }
41
+ ```
42
+
43
+ - `choices:` maps each value to its label (a string or a lazy `t` proc), in display order. `default:` must be one of the values and is the first when omitted.
44
+ - The key cannot be one the card route or `url_for` uses (`card`, `format`, `anchor`, `host`, `use_route`, ...), which raises when declared, or a path segment of the dashboard's route (the entity param on a path-scoped portal, a `(:locale)` scope), which raises on the request.
45
+ - The value comes from the query string under the filter's key. A value that is not one of the choices reads as the default.
46
+ - Every lazy card's frame URL carries every filter's value, so a card's own request reads what the page read.
47
+ - The page renders each filter as a segmented row of links above the cards, so a choice is a URL that survives a reload and can be shared. Other query parameters on the page are kept.
48
+ - A card's `href:` is not rewritten with the filter values. Use a scope filter (below) to link a card to the matching list.
49
+
50
+ #### Scope filters
51
+
52
+ `scopes:` takes a resource instead of `choices:`. The choices are "All" plus the scopes the resource's definition declares, labelled and gated (`condition:`) the way the index's scope pills are. The default is `default:`, else the definition's `default_scope`, else "All". A default whose `condition:` hides it from the current user reads as "All" for that user.
53
+
54
+ ```ruby
55
+ filter :status, scopes: Post
56
+
57
+ metric(:posts, href: -> { filtered_url(Post) }) { filtered(Post).count }
58
+ chart(:by_author) { filtered(Post).group(:author_name).count }
59
+ ```
60
+
61
+ - `filtered(Post)` is `authorized_resource_scope(Post)` with the chosen scope applied through the definition's own scope, the same code the index runs, so the card's number matches the list.
62
+ - `filtered_url(Post)` is the Post index on that scope (`q[scope]=...`). With "All" chosen and a `default_scope` on the definition, it sends an explicit blank scope so the index shows everything.
63
+ - Without a scope filter for the resource, `filtered` is the authorized scope as is and `filtered_url` the plain index.
64
+ - A scope's block runs against the dashboard's controller. `current_user`, `current_scoped_entity` and the portal's helpers work there; resource-controller helpers such as `current_parent` do not.
65
+ - One scope filter per resource; a second raises when declared. A `default:` that is not one of the definition's scopes, or a definition scope named `:all` (the "All" choice's value), raises on the request.
66
+
67
+ ### link(key, href:, label: nil, icon: nil, condition: nil)
68
+
69
+ A button in the toolbar above the cards, such as the dashboard's settings or a full report. `href:` and `condition:` take the same forms as a card's and are evaluated on the dashboard instance. A link whose `href:` resolves to nil is left out.
70
+
71
+ ```ruby
72
+ link :settings, icon: Phlex::TablerIcons::Settings, href: -> { resource_url_for(Setting) },
73
+ condition: -> { allowed_to?(:update?, Setting) }
74
+ ```
75
+
33
76
  ## Cards
34
77
 
35
78
  ```ruby
@@ -70,8 +113,8 @@ The block returns a value or a hash.
70
113
  | Hash key | Meaning |
71
114
  |---|---|
72
115
  | `value` | The number or string. `nil` renders `plutonium.dashboard.metric.empty` |
73
- | `previous` | Computes `change` as a percentage from this value; a zero previous yields no percentage |
74
- | `change` | Numeric percentage points (`2.5` → `+2.5%`) or a string shown as-is |
116
+ | `previous` | Computes `change` from this value: a percentage of it, or the difference with `change_unit: :points`. A zero previous yields no percentage (a neutral dash when there is a caption); with `:points` the difference still shows. The change is rounded to one decimal before its trend is read |
117
+ | `change` | A number (`2.5` → `+2.5%`, or `+2.5 pts` with `change_unit: :points`) or a string shown as-is |
75
118
  | `trend` | `:up`, `:down`, `:flat`; inferred from the sign of `change` |
76
119
  | `change_label` | Caption after the change |
77
120
 
@@ -83,6 +126,7 @@ The block returns a value or a hash.
83
126
  | `prefix:` / `suffix:` | String | Wrapped around the formatted value |
84
127
  | `positive:` | `:up` (default), `:down` | Which direction is good; drives the change colour |
85
128
  | `change_label:` | String | Same as the hash key |
129
+ | `change_unit:` | `:percent` (default), `:points` | How the change reads. Use `:points` for a value that is itself a percentage: 50% to 55.6% reads `+5.6 pts`, not `+11.2%` |
86
130
 
87
131
  ### chart
88
132
 
@@ -106,6 +150,11 @@ No kind-specific options. The block is `instance_exec`ed in `Plutonium::UI::Dash
106
150
  |---|---|
107
151
  | `authorize?` | Override to gate the page and every card. Default `true` |
108
152
  | `visible_cards` | Cards whose `condition:` passes |
153
+ | `filter_value(key)` | The filter's value for this request, or its default |
154
+ | `filter_values` | `{key => value}` for every filter; what each card's frame URL carries |
155
+ | `filtered(resource_class)` | The authorized scope with the resource's scope filter applied |
156
+ | `filtered_url(resource_class)` | The resource's index on the scope that filter has chosen |
157
+ | `visible_links` | Toolbar links whose `condition:` passes |
109
158
  | `visible_card!(key)` | The card, or `Plutonium::Dashboard::UnknownCardError` (404) |
110
159
  | `refresh_for(card)` | The card's interval, else the dashboard's; `nil` for a card declared `refresh: false` |
111
160
  | `view_context` / `helpers` | The Rails view context |
@@ -117,7 +166,9 @@ No kind-specific options. The block is `instance_exec`ed in `Plutonium::UI::Dash
117
166
  |---|---|
118
167
  | `cards` | All declared cards, in order |
119
168
  | `find_card(key)` / `find_card!(key)` | Lookup by key |
169
+ | `filters` / `find_filter(key)` | Declared filters |
170
+ | `links` | Declared toolbar links |
120
171
  | `label`, `description`, `icon` | Resolved presentation |
121
172
  | `i18n_key`, `route_name` | `admin_portal/sales`, `sales` |
122
173
 
123
- Subclasses inherit the parent's cards and options by copy, so a portal-specific subclass can add cards without touching the parent.
174
+ Subclasses inherit the parent's cards, filters, links and options by copy, so a portal-specific subclass can add cards without touching the parent.
@@ -72,6 +72,7 @@ Plutonium detects, from the model:
72
72
  | `boolean` | `:boolean` |
73
73
  | `date`, `datetime`, `time` | `:date` / `:datetime` / `:time` |
74
74
  | `json`, `jsonb` | `:json` |
75
+ | `binary` | `:binary` |
75
76
 
76
77
  Validations on the model inform the UI too: `validates :title, presence: true` → required field; `validates :role, inclusion: { in: [...] }` → select choices.
77
78
 
@@ -99,19 +100,20 @@ end
99
100
 
100
101
  | Category | Types |
101
102
  |---|---|
102
- | Text | `:string`, `:text`, `:email`, `:url`, `:tel`, `:password` |
103
- | Rich text | `:markdown` (EasyMDE editor) |
104
- | Numeric | `:number`, `:integer`, `:decimal`, `:range` |
103
+ | Text | `:string`, `:text`, `:email`, `:url`, `:phone`, `:password` (alias `:secret`) |
104
+ | Rich text | `:rich_text` (Action Text editor: Trix, or Lexxy once installed; **default** for `has_rich_text`), `:markdown` (EasyMDE) |
105
+ | Numeric | `:number`, `:integer`, `:decimal`, `:range`, `:rating` (star picker, `max:`) |
105
106
  | Boolean | `:toggle` / `:switch` (switch: **default** for boolean columns), `:boolean` (plain checkbox) |
106
107
  | Date/Time | `:date`, `:time`, `:datetime` |
107
- | Selection | `:select`, `:slim_select`, `:radio_buttons`, `:check_boxes` |
108
- | Files | `:file`, `:uppy`, `:attachment` |
108
+ | Selection | `:select`, `:slim_select`, `:collection_radio_buttons`, `:collection_checkboxes` |
109
+ | Lists | `:list` (chip input for array values; `suggestions:`, `addable:`, `limit:`), `:tags` (no_fly_list `has_tags` contexts, **auto-detected**) |
110
+ | Files | `:file`, `:uppy`, `:attachment` (Active Storage), `:binary` (bytes in a `binary` column) |
109
111
  | Associations | `:association`, `:secure_association`, `:belongs_to`, `:has_many`, `:has_one` |
110
- | Special | `:hidden`, `:color`, `:phone` |
112
+ | Special | `:hidden`, `:color`, `:json`, `:key_value` |
111
113
 
112
114
  ### Display types (show / index)
113
115
 
114
- `:string`, `:text`, `:email`, `:url`, `:phone`, `:markdown`, `:number`, `:integer`, `:decimal`, `:boolean`, `:badge`, `:currency`, `:date`, `:time`, `:datetime`, `:association`, `:attachment`, `:color`
116
+ `:string`, `:text`, `:email`, `:url`, `:phone`, `:markdown`, `:number`, `:integer`, `:decimal`, `:boolean`, `:badge`, `:currency`, `:date`, `:time`, `:datetime`, `:association`, `:attachment`, `:color`, `:key_value`, `:binary`, `:list`, `:tags`, `:rich_text`, `:relative_time`, `:code`, `:duration`, `:file_size`, `:progress`, `:rating`
115
117
 
116
118
  #### Auto-inferred display formatting
117
119
 
@@ -121,6 +123,9 @@ These render automatically; declare an `as:` only to override or pass options:
121
123
  |---|---|---|
122
124
  | `boolean` | Yes/No pill (`:boolean`) | green "Yes" / neutral "No"; override with `true_label:` / `false_label:` |
123
125
  | `enum` | status badge (`:badge`) | known statuses auto-colored; unknown values get a stable decorative color; override per-value with `colors:` |
126
+ | `binary` | size (`:binary`) | `Binary data (12 KB)`, never the bytes |
127
+ | `has_rich_text` | HTML (`:rich_text`) | rendered and sanitized by Action Text |
128
+ | no_fly_list `has_tags` | chips (`:tags`) | declare by context name (`:labels`), not `labels_list` |
124
129
  | `has_cents` decimal | currency (`:currency`) | delimited, 2 decimals; symbol from `unit:` on `has_cents` (model-wide) or per-display, else `config.default_currency_unit` / the i18n default (see below) |
125
130
 
126
131
  ```ruby
@@ -1006,6 +1011,8 @@ end
1006
1011
 
1007
1012
  Grid slots, `:image`, `:header`, `:subheader`, `:body`, `:meta`, `:footer`, are all optional. `:meta` accepts an array; the rest are single fields. Slots pointing at policy-blocked fields collapse silently.
1008
1013
 
1014
+ In the `:media` layout a record whose image does not resolve keeps the cover's space, with a neutral icon, so cards in a row stay one height. An image may be an attachment, an `http` or root-relative URL, or a `data:image/` URI.
1015
+
1009
1016
  Only declare `index_views` explicitly to **disable** one (e.g. `index_views :grid` to drop the table view).
1010
1017
 
1011
1018
  ## Custom page classes
@@ -82,7 +82,7 @@ For large tables, write an explicit `search` block backed by a trigram or full-t
82
82
 
83
83
  ## Filters
84
84
 
85
- Six built-in filter types. Use the shorthand symbol or the full class name.
85
+ Seven built-in filter types. Use the shorthand symbol or the full class name.
86
86
 
87
87
  `search`, `scope`, `sort_fields`, and `sort_directions` are reserved filter names; they're built-in controls in the `q[<name>]` namespace, so `filter :scope` raises `ArgumentError`.
88
88
 
@@ -94,6 +94,7 @@ Six built-in filter types. Use the shorthand symbol or the full class name.
94
94
  | Date range | `:date_range` | `from`, `to` | `from_label:`, `to_label:` |
95
95
  | Select | `:select` | `value` | `choices:`, `multiple:` |
96
96
  | Association | `:association` | `value` | `class_name:`, `multiple:` |
97
+ | Tags | `:tags` | `value` | `match:` (`:any` default, or `:all`) |
97
98
 
98
99
  ### Text predicates
99
100
 
@@ -149,6 +150,15 @@ filter :author, with: :association, class_name: User
149
150
  filter :tags, with: :association, class_name: Tag, multiple: true
150
151
  ```
151
152
 
153
+ ### Tags
154
+
155
+ For a [no_fly_list](https://github.com/radioactive-labs/no_fly_list) `has_tags` context. The options are the tags used by records the user can see (the policy scope), so a tenant never sees another tenant's tags.
156
+
157
+ ```ruby
158
+ filter :labels, with: :tags # records with any chosen tag
159
+ filter :labels, with: :tags, match: :all # records with every chosen tag
160
+ ```
161
+
152
162
  ### Custom filter (lambda)
153
163
 
154
164
  For simple one-offs:
@@ -78,6 +78,18 @@ Some types render with richer components automatically; you only declare an `as:
78
78
  | `:badge` | colored status pill | `enum` columns | `colors:` (per-value override) |
79
79
  | `:currency` | delimited, 2-decimal money | `has_cents` decimal accessors | `unit:`, `options:` |
80
80
  | `:color` | swatch + value | - | - |
81
+ | `:key_value` | two-column list of keys and values; nested hashes/arrays as compact JSON | `hstore` columns | - |
82
+ | `:binary` | `Binary data (12 KB)`, never the bytes | `binary` columns | - |
83
+ | `:number` | delimited, at the column's scale (`1,999.50`) | `float` / `decimal` columns | - |
84
+ | `:rich_text` | Action Text HTML, sanitized | `has_rich_text` fields | - |
85
+ | `:tags` | chips | no_fly_list `has_tags` contexts | - |
86
+ | `:list` | chips | - | - |
87
+ | `:relative_time` | "3 minutes ago", kept live; absolute time on hover | - | - |
88
+ | `:code` | monospace value with a Copy button | - | - |
89
+ | `:duration` | seconds as `2h 15m` | - | - |
90
+ | `:file_size` | bytes as `12 KB` | - | - |
91
+ | `:progress` | bar + percentage | - | `max:` (default 100) |
92
+ | `:rating` | stars | - | `max:` (default 5) |
81
93
 
82
94
  ```ruby
83
95
  class OrderDefinition < ResourceDefinition
@@ -85,12 +97,17 @@ class OrderDefinition < ResourceDefinition
85
97
  display :total, as: :currency, unit: "£"
86
98
  display :total, as: :currency, unit: :currency_symbol # Symbol → read off each record
87
99
  display :shipped, as: :boolean, true_label: "Sent", false_label: "Pending"
100
+ field :settings, as: :key_value # key/value editor on the form, key/value list on show
101
+ display :last_seen_at, as: :relative_time
102
+ display :api_reference, as: :code
103
+ display :completion, as: :progress, max: 1 # stored as 0..1
104
+ field :score, as: :rating # star picker on the form, stars on show
88
105
  end
89
106
  ```
90
107
 
91
108
  **Badge colors.** Known statuses (`active`, `pending`, `failed`, …) are auto-colored by meaning. Unknown values get a stable decorative color (same value → same color). Override per-value with `colors:`; valid variants: `:neutral`, `:primary`, `:secondary`, `:success`, `:danger`, `:warning`, `:info`, `:accent`.
92
109
 
93
- **Currency.** No symbol is shown unless you pass `unit:`, a literal string (`"£"`) or a Symbol read off the record for per-row currencies. `has_cents` decimal accessors infer `:currency` automatically (still symbol-less until you set `unit:`).
110
+ **Currency.** The symbol comes from a per-display `unit:` (a literal `"£"`, or a Symbol read off the record for per-row currencies), else the model's `has_cents ... unit:`, else `Plutonium.configuration.default_currency_unit` / the i18n `number.currency.format.unit` (`$` in `en`). Pass `unit: false` for no symbol. `has_cents` decimal accessors infer `:currency` automatically.
94
111
 
95
112
  ## Theming
96
113
 
@@ -115,7 +132,7 @@ end
115
132
 
116
133
  ### Theme keys
117
134
 
118
- `fields_wrapper`, `fields_inner`, `sections_wrapper`, `section_grid`, `label`, `description`, `string`, `text`, `link`, `email`, `phone`, `markdown`, `json`, `boolean`, `badge`, `currency`, `color`, plus the shared section-chrome keys (`section_wrapper`, `section_header`, `section_summary`, `section_accent`, `section_heading`, `section_description`, `section_caret`, `section_body`).
135
+ `fields_wrapper`, `fields_inner`, `sections_wrapper`, `section_grid`, `label`, `description`, `string`, `text`, `link`, `email`, `phone`, `markdown`, `json`, `key_value` (+ `key_value_key`, `key_value_value`, `key_value_nested`), `list` / `list_item`, `rich_text`, `relative_time`, `code` (+ `code_value`, `code_copy`), `duration`, `file_size`, `progress` (+ `progress_track`, `progress_bar`, `progress_label`), `rating` (+ `rating_filled`, `rating_empty`), `binary`, `boolean`, `badge`, `currency`, `color`, plus the shared section-chrome keys (`section_wrapper`, `section_header`, `section_summary`, `section_accent`, `section_heading`, `section_description`, `section_caret`, `section_body`).
119
136
 
120
137
  ::: warning `fields_wrapper` is the card, `fields_inner` is the grid
121
138
  `fields_wrapper` is merged into a `Plutonium::UI::Block`, which supplies `pu-card` itself, grid classes put there style the card, not the fields. Override **`fields_inner`** for the unsectioned grid, and **`section_grid`** for the grid inside a [`display_layout`](/reference/resource/definition#display-layout) section.
@@ -150,8 +150,8 @@ render field(:title).wrapped(class: "col-span-full") { |f| f.input_tag }
150
150
  | Tag | Input |
151
151
  |---|---|
152
152
  | `input_tag` | text (auto-detected type) |
153
- | `string_tag`, `text_tag`, `number_tag`, `email_tag`, `password_tag`, `url_tag`, `tel_tag`, `hidden_tag` | standard HTML inputs |
154
- | `checkbox_tag`, `select_tag`, `radio_button_tag` | standard |
153
+ | `string_tag`, `text_tag`, `number_tag`, `email_tag`, `password_tag`, `url_tag`, `hidden_tag` | standard HTML inputs |
154
+ | `checkbox_tag`, `select_tag`, `radio_button_tag`, `collection_radio_buttons_tag`, `collection_checkboxes_tag` | standard |
155
155
  | `toggle_tag` / `switch_tag` | switch-styled boolean (`as: :toggle` / `:switch`), the **default** for boolean columns; same behavior as a checkbox. Use `checkbox_tag` (`as: :boolean`) for a plain checkbox. |
156
156
 
157
157
  ### Plutonium-enhanced tags
@@ -164,12 +164,19 @@ render field(:title).wrapped(class: "col-span-full") { |f| f.input_tag }
164
164
  | `phone_tag` / `int_tel_input_tag` | intl-tel-input phone field |
165
165
  | `currency_tag` | Money input (number field + optional unit prefix) |
166
166
  | `uppy_tag` / `file_tag` | Uppy file upload |
167
+ | `binary_tag` | Plain file input whose bytes go into a `binary` column |
168
+ | `rich_text_tag` | Action Text editor (Trix, or Lexxy once installed) |
169
+ | `list_tag` | Chip input for array values |
170
+ | `tags_tag` | Chip input for a no_fly_list `has_tags` context |
171
+ | `rating_tag` | Star picker (radio buttons, no JS); an optional rating gets a Clear option |
167
172
  | `secure_association_tag` | Association with policy-checked options (inline `+` add, typeahead) |
168
173
  | `belongs_to_tag` / `has_many_tag` / `has_one_tag` | Association selects |
174
+ | `association_checkboxes_tag` / `association_toggles_tag` | has_many / habtm as a list of checkboxes or switches (see [Association inputs](#association-inputs)) |
175
+ | `association_radio_buttons_tag` | belongs_to / has_one as a radio group |
169
176
  | `key_value_store_tag` | Key/value pairs editor |
170
177
 
171
178
  ```ruby
172
- render field(:published_at).wrapped { |f| f.flatpickr_tag(min_date: Date.today, enable_time: true) }
179
+ render field(:published_at).wrapped { |f| f.flatpickr_tag }
173
180
 
174
181
  render field(:avatar).wrapped do |f|
175
182
  f.uppy_tag(allowed_file_types: %w[.jpg .png], max_file_size: 5.megabytes)
@@ -237,21 +244,66 @@ The sentinel is guarded client-side by the `password-sentinel` Stimulus controll
237
244
  - ends with `_password`, `_digest`, `_hash`, `_token`, `_key`, or `_salt`;
238
245
  - contains `secret`.
239
246
 
247
+ The same names are masked everywhere the value would otherwise print: the show page, table cells, the wizard summary, and grid and kanban card slots all render `••••••••` instead of the value. A `field`/`display` `as:` overrides the heuristic on those surfaces too.
248
+
240
249
  This is a naming convenience, **not** a security guarantee, tune it per field:
241
250
 
242
251
  ```ruby
243
252
  # Opt OUT: render the value as a normal, readable text input
244
- field :api_token, as: :string # a token the admin needs to copy
253
+ field :api_token, as: :string # a token the admin needs to copy (shown on the show page and cards too)
245
254
  field :content_hash, as: :string # a checksum, not a secret
246
255
  field :public_key, as: :string # *_key matches, but a public key is not secret
247
256
 
248
257
  # Opt IN: mask a secret the heuristic still misses (e.g. no telltale name)
249
258
  field :recovery_phrase, as: :password
259
+ field :pin, as: :secret # alias of :password
250
260
  ```
251
261
 
252
262
  > [!WARNING]
253
263
  > The heuristic is name-based and best-effort. A secret column with an unconventional name (e.g. `recovery_phrase`, `pin`) still renders its value into the page unless you set `as: :password`. Audit secret-bearing columns explicitly.
254
264
 
265
+ ### Rich text (Action Text and Lexxy) {#rich-text-fields}
266
+
267
+ A `has_rich_text` field renders Action Text's own editor tag, so it gets whichever editor the app has set up: Trix by default, or [Lexxy](https://lexxy.dev) once the `lexxy` gem is in the bundle (it takes over Action Text's helpers on Rails 8.0/8.1 and registers itself as the editor on 8.2). Lexxy needs Rails 8.0.2 or later. The editor's JavaScript and CSS come from the app's bundle, as Action Text's or Lexxy's install instructions describe. The show page renders the HTML through Action Text, which sanitizes it.
268
+
269
+ ### Lists and tags {#list-fields}
270
+
271
+ `as: :list` edits an array value (a JSON or array column) as chips. Options go on `input`:
272
+
273
+ ```ruby
274
+ field :nicknames, as: :list
275
+ input :nicknames, suggestions: %w[Basin Tub], limit: 5 # addable: false allows only suggestions
276
+ ```
277
+
278
+ Removing every chip saves an empty array.
279
+
280
+ [no_fly_list](https://github.com/radioactive-labs/no_fly_list) `has_tags` contexts are detected automatically. Permit them by context name (`labels`, not `labels_list`) and they render as chips on the form, the show page and the table:
281
+
282
+ ```ruby
283
+ class Article < ResourceRecord
284
+ include NoFlyList::TaggableRecord
285
+ has_tags :labels, scope: :organization # per-tenant vocabulary
286
+ has_tags :topics, restrict_to_existing: true, limit: 3
287
+ end
288
+
289
+ # policy
290
+ def permitted_attributes_for_create = %i[title labels topics]
291
+ ```
292
+
293
+ - The input suggests tags that already exist in the record's tag scope.
294
+ - `restrict_to_existing` only allows picking existing tags. Plutonium doesn't decide where that vocabulary is managed: create the tags however suits the app (register the tag model as a resource, seed them, or add them in an interaction).
295
+ - `limit` caps how many tags can be chosen.
296
+ - Add `filter :labels, with: :tags` for a table filter.
297
+ - The table reads tags through the context's association, so preload it (`includes(:labels)`) to avoid a query per row.
298
+
299
+ ### Binary columns {#binary-fields}
300
+
301
+ A `binary` column renders `binary_tag`: a plain file input. The uploaded file's bytes are written to the column, and the show page and table print its size (`Binary data (12 KB)`), never the bytes.
302
+
303
+ - Saving without choosing a file keeps the stored bytes. There is no control to clear the column.
304
+ - Browsers can't refill a file input, so after a failed validation the user has to choose the file again.
305
+ - The whole file is read into memory and stored in the row. Use Active Storage (`has_one_attached` + `:uppy`) for anything that isn't small.
306
+
255
307
  ### Wrapped vs unwrapped
256
308
 
257
309
  - `wrapped`: includes label, hint, and error rendering. Use for normal form fields.
@@ -283,6 +335,27 @@ input :author, label_method: :email
283
335
  For large target tables, write an explicit `search` block on the target resource definition; the fallback's leading-wildcard `LIKE` can't use a b-tree index.
284
336
  :::
285
337
 
338
+ ### List variants: checkboxes, toggles, radio buttons
339
+
340
+ For a short list of options, render one control per record instead of a dropdown:
341
+
342
+ ```ruby
343
+ input :tags, as: :association_checkboxes # has_many / habtm
344
+ input :tags, as: :association_toggles # same, styled as switches
345
+ input :category, as: :association_radio_buttons # belongs_to / has_one
346
+ ```
347
+
348
+ The variants share the select's security: options come from the target's policy scope (or `choices:`), values are signed global ids, and submitted ids are checked against the authorized relation. Like the select, they always render the records the field holds now, even past `choice_limit:` or outside the viewer's scope, and accept them back on submit, so saving a form never drops a value the viewer did not touch. The inline `+` add button renders next to the list, and `add_action:`, `skip_authorization:`, `choices:` and `label_method:` work the same way.
349
+
350
+ How they differ from the select:
351
+
352
+ - **The list is capped like the select.** `choice_limit:` defaults to the select's cap. There is no typeahead, so a record past the cap can't be picked: use them for short lists, or raise `choice_limit:`.
353
+ - **Unchecking everything clears the association.** The checkbox list submits a blank value, so an empty selection saves as no records.
354
+ - **Optional radio groups get a "None" choice.** A radio group can't be cleared otherwise. Picking it clears the association. On a has_one, Active Record detaches the old record (or destroys it, with `dependent: :destroy`) as soon as the value is assigned, before the form validates, the same as picking a different record. It is left out when the field is required (a checked "None" would satisfy the browser's `required`), and `include_blank: false` drops it on an optional one. Its label is the field's `placeholder:` if set.
355
+ - **The macro has to fit.** Checkboxes and toggles take a has_many or habtm; radio buttons take a belongs_to or has_one. A mismatch raises `ArgumentError` when the input is built, on render and on submit, as do a polymorphic association and `group_method:`.
356
+
357
+ The list and its controls are themed by `association_checkboxes`, `association_toggles`, `association_radio_buttons` (the list), `association_checkbox`, `association_toggle`, `association_radio_button` (each control) and `association_choice_label`.
358
+
286
359
  ## Submit buttons
287
360
 
288
361
  Default `render_actions` produces the primary submit, plus an optional "Save and add another" / "Update and continue editing" secondary button.
@@ -3,6 +3,7 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gem "rotp"
6
+ gem "no_fly_list", github: "radioactive-labs/no_fly_list", branch: "feat/scoped-tags"
6
7
  gem "rails", "~> 7.2"
7
8
  gem "sqlite3"
8
9
  gem "puma", ">= 5.0"
@@ -1,7 +1,15 @@
1
+ GIT
2
+ remote: https://github.com/radioactive-labs/no_fly_list.git
3
+ revision: e1e88d9c7d898ef269e3cabfa12da2fb327459cc
4
+ branch: feat/scoped-tags
5
+ specs:
6
+ no_fly_list (0.7.4)
7
+ activerecord (>= 7.2)
8
+
1
9
  PATH
2
10
  remote: ..
3
11
  specs:
4
- plutonium (0.64.0)
12
+ plutonium (0.66.0)
5
13
  action_policy (~> 0.7.0)
6
14
  csv
7
15
  listen (~> 3.8)
@@ -490,6 +498,7 @@ DEPENDENCIES
490
498
  importmap-rails
491
499
  minitest
492
500
  minitest-reporters
501
+ no_fly_list!
493
502
  plutonium!
494
503
  propshaft
495
504
  puma (>= 5.0)
@@ -521,7 +530,7 @@ CHECKSUMS
521
530
  activerecord (7.2.3.2) sha256=e62d24ba7e5f820d24ed122eb9225491b0aaa8398778783b400067c3467285c7
522
531
  activestorage (7.2.3.2) sha256=863cb4b21b83505555be2addb939fefb856ab05f6c6d24bab00d6f1a7e4edbfc
523
532
  activesupport (7.2.3.2) sha256=ddc90d11dd88086d78ace03407fe3c2a3f5c8853c3c3046313db5ed823ef3312
524
- acts_as_list (1.2.6)
533
+ acts_as_list (1.2.6) sha256=8345380900b7bee620c07ad00991ccee59af3d8c9e8574f426e321da2865fdc8
525
534
  addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
526
535
  ansi (1.6.0) sha256=ac9ea0c0ea8d32fb4e271348e609963ac78882f34b73836c2a02b3622e666658
527
536
  appraisal (2.5.0) sha256=36989221be127913b0dba8d114da2001e6b2dceea7bd4951200eaba764eed3ce
@@ -580,6 +589,7 @@ CHECKSUMS
580
589
  net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8
581
590
  net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736
582
591
  nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
592
+ no_fly_list (0.7.4)
583
593
  nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f
584
594
  nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af
585
595
  nokogiri (1.19.4-arm-linux-gnu) sha256=a301313e38bb065d68239e79734bcd6f56fb6efaacebde29e9abf2a4735340ca
@@ -601,7 +611,7 @@ CHECKSUMS
601
611
  phlexi-form (0.14.3) sha256=47d69c4a49ba22279ba541b9b110e81d62acc4602513f21bb5c3d29fa9c8d5ca
602
612
  phlexi-menu (0.4.1) sha256=f6eeb1eb9597caadeee33a53f1520b3613a6ac616cf1e395e9d734a412895fb2
603
613
  phlexi-table (0.2.0) sha256=73bee135adbe73fcdb6fdd2672444373556dd9e6bba61e7b5b20ca8b7a1f822c
604
- plutonium (0.64.0)
614
+ plutonium (0.66.0)
605
615
  pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
606
616
  prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
607
617
  prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
@@ -3,6 +3,7 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gem "rotp"
6
+ gem "no_fly_list", github: "radioactive-labs/no_fly_list", branch: "feat/scoped-tags"
6
7
  gem "rails", "~> 8.0.0"
7
8
  gem "sqlite3"
8
9
  gem "puma", ">= 5.0"
@@ -1,7 +1,15 @@
1
+ GIT
2
+ remote: https://github.com/radioactive-labs/no_fly_list.git
3
+ revision: e1e88d9c7d898ef269e3cabfa12da2fb327459cc
4
+ branch: feat/scoped-tags
5
+ specs:
6
+ no_fly_list (0.7.4)
7
+ activerecord (>= 7.2)
8
+
1
9
  PATH
2
10
  remote: ..
3
11
  specs:
4
- plutonium (0.62.2)
12
+ plutonium (0.66.0)
5
13
  action_policy (~> 0.7.0)
6
14
  csv
7
15
  listen (~> 3.8)
@@ -457,6 +465,7 @@ DEPENDENCIES
457
465
  importmap-rails
458
466
  minitest
459
467
  minitest-reporters
468
+ no_fly_list!
460
469
  plutonium!
461
470
  propshaft
462
471
  puma (>= 5.0)
@@ -3,6 +3,7 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gem "rotp"
6
+ gem "no_fly_list", github: "radioactive-labs/no_fly_list", branch: "feat/scoped-tags"
6
7
  gem "rails", "~> 8.1.0"
7
8
  gem "sqlite3"
8
9
  gem "puma", ">= 5.0"
@@ -1,7 +1,15 @@
1
+ GIT
2
+ remote: https://github.com/radioactive-labs/no_fly_list.git
3
+ revision: e1e88d9c7d898ef269e3cabfa12da2fb327459cc
4
+ branch: feat/scoped-tags
5
+ specs:
6
+ no_fly_list (0.7.4)
7
+ activerecord (>= 7.2)
8
+
1
9
  PATH
2
10
  remote: ..
3
11
  specs:
4
- plutonium (0.65.0)
12
+ plutonium (0.66.0)
5
13
  action_policy (~> 0.7.0)
6
14
  csv
7
15
  listen (~> 3.8)
@@ -459,6 +467,7 @@ DEPENDENCIES
459
467
  importmap-rails
460
468
  minitest
461
469
  minitest-reporters
470
+ no_fly_list!
462
471
  plutonium!
463
472
  propshaft
464
473
  puma (>= 5.0)
@@ -24,6 +24,13 @@ module Plutonium
24
24
  rodauth.rails_account
25
25
  end
26
26
 
27
+ # A request Rodauth authenticated with a valid JWT reads its session
28
+ # from the token, not the cookie, so there is nothing for a forged
29
+ # request to ride on. Everything else gets the CSRF token check.
30
+ def verified_request?
31
+ super || (rodauth.features.include?(:jwt) && rodauth.valid_jwt?)
32
+ end
33
+
27
34
  def logout_url
28
35
  rodauth.logout_path
29
36
  end
@@ -10,8 +10,6 @@ module Plutonium
10
10
  included do
11
11
  add_flash_types :success, :warning, :error
12
12
 
13
- protect_from_forgery with: :null_session, if: -> { request.headers["Authorization"].present? }
14
-
15
13
  rescue_from ::ActionPolicy::Unauthorized do |exception|
16
14
  respond_to do |format|
17
15
  format.any(:html, :turbo_stream) do