plutonium 0.42.0 → 0.43.1

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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-controller/SKILL.md +38 -1
  3. data/.claude/skills/plutonium-definition/SKILL.md +14 -0
  4. data/.claude/skills/plutonium-forms/SKILL.md +16 -1
  5. data/.claude/skills/plutonium-profile/SKILL.md +276 -0
  6. data/.claude/skills/plutonium-views/SKILL.md +23 -1
  7. data/CHANGELOG.md +42 -0
  8. data/app/assets/plutonium.css +2 -2
  9. data/app/views/plutonium/_resource_header.html.erb +6 -27
  10. data/app/views/plutonium/_resource_sidebar.html.erb +1 -2
  11. data/app/views/resource/_resource_details.rabl +3 -2
  12. data/app/views/resource/index.rabl +3 -2
  13. data/app/views/resource/show.rabl +3 -2
  14. data/docs/guides/user-profile.md +322 -0
  15. data/docs/reference/controller/index.md +38 -1
  16. data/docs/reference/definition/index.md +16 -0
  17. data/docs/reference/views/forms.md +15 -0
  18. data/docs/reference/views/index.md +23 -1
  19. data/gemfiles/rails_7.gemfile.lock +1 -1
  20. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  21. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  22. data/lib/generators/pu/core/assets/assets_generator.rb +12 -0
  23. data/lib/generators/pu/core/install/templates/app/controllers/resource_controller.rb.tt +11 -0
  24. data/lib/generators/pu/core/typespec/templates/common.tsp.tt +95 -0
  25. data/lib/generators/pu/core/typespec/templates/main.tsp.tt +27 -0
  26. data/lib/generators/pu/core/typespec/templates/main_multi.tsp.tt +25 -0
  27. data/lib/generators/pu/core/typespec/templates/model.tsp.tt +226 -0
  28. data/lib/generators/pu/core/typespec/typespec_generator.rb +342 -0
  29. data/lib/generators/pu/invites/USAGE +0 -1
  30. data/lib/generators/pu/invites/install_generator.rb +62 -15
  31. data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +2 -2
  32. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +2 -0
  33. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +1 -0
  34. data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +5 -5
  35. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +4 -4
  36. data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +1 -1
  37. data/lib/generators/pu/lib/plutonium_generators/generator.rb +29 -0
  38. data/lib/generators/pu/lib/plutonium_generators/model_generator_base.rb +6 -23
  39. data/lib/generators/pu/pkg/portal/portal_generator.rb +5 -1
  40. data/lib/generators/pu/profile/USAGE +59 -0
  41. data/lib/generators/pu/profile/concerns/profile_arguments.rb +27 -0
  42. data/lib/generators/pu/profile/conn/USAGE +33 -0
  43. data/lib/generators/pu/profile/conn_generator.rb +167 -0
  44. data/lib/generators/pu/profile/install_generator.rb +119 -0
  45. data/lib/generators/pu/profile/setup/USAGE +42 -0
  46. data/lib/generators/pu/profile/setup_generator.rb +73 -0
  47. data/lib/generators/pu/rodauth/account_generator.rb +2 -4
  48. data/lib/generators/pu/rodauth/install_generator.rb +2 -2
  49. data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +3 -0
  50. data/lib/generators/pu/saas/api_client_generator.rb +0 -2
  51. data/lib/generators/pu/saas/membership_generator.rb +68 -19
  52. data/lib/generators/pu/saas/setup_generator.rb +7 -2
  53. data/lib/generators/pu/saas/user_generator.rb +0 -2
  54. data/lib/plutonium/auth/rodauth.rb +8 -0
  55. data/lib/plutonium/core/controller.rb +7 -4
  56. data/lib/plutonium/core/controllers/authorizable.rb +5 -1
  57. data/lib/plutonium/definition/base.rb +7 -0
  58. data/lib/plutonium/helpers/display_helper.rb +6 -0
  59. data/lib/plutonium/profile/security_section.rb +118 -0
  60. data/lib/plutonium/resource/controller.rb +17 -7
  61. data/lib/plutonium/resource/controllers/interactive_actions.rb +11 -25
  62. data/lib/plutonium/resource/controllers/presentable.rb +46 -3
  63. data/lib/plutonium/resource/record/associated_with.rb +7 -1
  64. data/lib/plutonium/routing/mapper_extensions.rb +18 -18
  65. data/lib/plutonium/routing/route_set_extensions.rb +23 -2
  66. data/lib/plutonium/ui/breadcrumbs.rb +111 -131
  67. data/lib/plutonium/ui/dyna_frame/content.rb +12 -2
  68. data/lib/plutonium/ui/form/resource.rb +26 -19
  69. data/lib/plutonium/ui/page/base.rb +14 -14
  70. data/lib/plutonium/ui/table/components/scopes_bar.rb +2 -74
  71. data/lib/plutonium/ui/table/components/selection_column.rb +6 -2
  72. data/lib/plutonium/ui/table/resource.rb +3 -2
  73. data/lib/plutonium/version.rb +1 -1
  74. data/lib/tasks/release.rake +6 -6
  75. data/package.json +1 -1
  76. metadata +17 -3
  77. data/lib/generators/pu/rodauth/concerns/gem_helpers.rb +0 -19
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Common TypeSpec definitions for <%= Rails.application.class.module_parent_name %> API
3
+ * Generated by Plutonium TypeSpec Generator
4
+ */
5
+
6
+ import "@typespec/http";
7
+
8
+ using TypeSpec.Http;
9
+
10
+ /**
11
+ * Signed Global ID - cryptographically signed resource identifier
12
+ * Format: gid://app/Model/id--signature
13
+ */
14
+ @pattern("gid://.*--.*")
15
+ @doc("Signed Global ID for secure resource identification")
16
+ scalar SignedGlobalId extends string;
17
+
18
+ /**
19
+ * Base fields included in all resource responses
20
+ */
21
+ @doc("Common fields present in all API resource responses")
22
+ model ResourceBase {
23
+ @doc("Signed Global ID for secure references")
24
+ sgid: SignedGlobalId;
25
+
26
+ @doc("API URL for this resource")
27
+ url: url;
28
+ }
29
+
30
+ /**
31
+ * Validation error detail
32
+ */
33
+ model ValidationError {
34
+ @doc("Attribute that failed validation")
35
+ attribute: string;
36
+
37
+ @doc("Error detail/code")
38
+ detail: string;
39
+
40
+ @doc("Human-readable error message")
41
+ message: string;
42
+
43
+ @doc("Full error message including attribute name")
44
+ full_message: string;
45
+ }
46
+
47
+ /**
48
+ * Error response wrapper
49
+ */
50
+ model ErrorResponse {
51
+ @doc("List of validation errors")
52
+ errors: ValidationError[];
53
+ }
54
+
55
+ /**
56
+ * Pagination metadata
57
+ */
58
+ model PaginationMeta {
59
+ @doc("Current page number")
60
+ current_page: int32;
61
+
62
+ @doc("Total number of pages")
63
+ total_pages: int32;
64
+
65
+ @doc("Total number of records")
66
+ total_count: int64;
67
+
68
+ @doc("Number of records per page")
69
+ per_page: int32;
70
+ }
71
+
72
+ /**
73
+ * Standard list query parameters
74
+ */
75
+ model ListQueryParams {
76
+ @query
77
+ @doc("Page number for pagination")
78
+ page?: int32 = 1;
79
+
80
+ @query
81
+ @doc("Number of items per page")
82
+ per_page?: int32 = 25;
83
+
84
+ @query
85
+ @doc("Sort field")
86
+ sort?: string;
87
+
88
+ @query
89
+ @doc("Sort direction")
90
+ direction?: "asc" | "desc";
91
+
92
+ @query
93
+ @doc("Search query")
94
+ q?: string;
95
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * <%= @current_portal[:name] %> API Specification
3
+ * Generated by Plutonium TypeSpec Generator
4
+ */
5
+
6
+ import "@typespec/http";
7
+ import "@typespec/rest";
8
+ import "@typespec/openapi3";
9
+
10
+ <%- if @single_portal -%>
11
+ import "./common.tsp";
12
+ <%- else -%>
13
+ import "../common.tsp";
14
+ <%- end -%>
15
+ <%- @current_portal[:resources].each do |resource| -%>
16
+ import "./models/<%= resource[:file_name] %>.tsp";
17
+ <%- end -%>
18
+
19
+ using TypeSpec.Http;
20
+ using TypeSpec.Rest;
21
+
22
+ @service({
23
+ title: "<%= @current_portal[:name] %> API",
24
+ version: "1.0.0",
25
+ })
26
+ @server("http://localhost:3000<%= @current_portal[:route_path] %>", "Development server")
27
+ namespace <%= @current_portal[:name].gsub('::', '') %>;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * <%= Rails.application.class.module_parent_name %> API Specification
3
+ * Generated by Plutonium TypeSpec Generator
4
+ *
5
+ * This file imports all portal APIs.
6
+ */
7
+
8
+ import "@typespec/http";
9
+ import "@typespec/rest";
10
+ import "@typespec/openapi3";
11
+
12
+ import "./common.tsp";
13
+ <%- @portals.each do |portal| -%>
14
+ import "./<%= portal[:file_name] %>/main.tsp";
15
+ <%- end -%>
16
+
17
+ using TypeSpec.Http;
18
+ using TypeSpec.Rest;
19
+
20
+ @service({
21
+ title: "<%= Rails.application.class.module_parent_name %> API",
22
+ version: "1.0.0",
23
+ })
24
+ @server("http://localhost:3000", "Development server")
25
+ namespace <%= Rails.application.class.module_parent_name %>;
@@ -0,0 +1,226 @@
1
+ <%-
2
+ # Helper methods for template
3
+ def optional_marker(column)
4
+ column[:null] ? '?' : ''
5
+ end
6
+
7
+ resource = @current_resource
8
+ model_name = resource[:typespec_name]
9
+ all_columns = resource[:columns]
10
+ all_column_names = all_columns.map { |c| c[:name] }
11
+
12
+ # Extract special columns if they exist
13
+ primary_key_column = all_columns.find { |c| c[:name] == resource[:primary_key] }
14
+ created_at_column = all_columns.find { |c| c[:name] == 'created_at' }
15
+ updated_at_column = all_columns.find { |c| c[:name] == 'updated_at' }
16
+
17
+ # Regular columns exclude primary key and timestamps
18
+ columns = all_columns.reject { |c| [resource[:primary_key], 'created_at', 'updated_at'].include?(c[:name]) }
19
+ associations = resource[:associations]
20
+ enums = resource[:enums] || {}
21
+ definition = resource[:definition]
22
+ inputs = definition ? definition[:inputs] : []
23
+
24
+ # Separate association columns (foreign keys) from regular columns
25
+ belongs_to_assocs = associations.select { |a| a[:macro] == 'belongs_to' }
26
+ has_many_assocs = associations.select { |a| a[:macro] == 'has_many' }
27
+ has_one_assocs = associations.select { |a| a[:macro] == 'has_one' }
28
+
29
+ # Foreign key columns to exclude from regular attributes
30
+ fk_columns = belongs_to_assocs.map { |a| a[:foreign_key] }.compact
31
+ regular_columns = columns.reject { |c| fk_columns.include?(c[:name]) }
32
+
33
+ # Check if we have definition-based inputs
34
+ has_definition_inputs = inputs.any?
35
+ -%>
36
+ /**
37
+ * <%= model_name %> resource
38
+ * Table: <%= resource[:table_name] %>
39
+ * Route: <%= resource[:route_path] %>
40
+ */
41
+
42
+ <%- if @single_portal -%>
43
+ import "../common.tsp";
44
+ <%- else -%>
45
+ import "../../common.tsp";
46
+ <%- end -%>
47
+
48
+ using TypeSpec.Http;
49
+
50
+ <%- enums.each do |enum_name, values| -%>
51
+ /** <%= enum_name.humanize %> options */
52
+ enum <%= model_name %><%= enum_name.camelize %> {
53
+ <%- values.each do |value| -%>
54
+ <%= value %>,
55
+ <%- end -%>
56
+ }
57
+
58
+ <%- end -%>
59
+ /**
60
+ * <%= model_name %> response model
61
+ */
62
+ model <%= model_name %> extends ResourceBase {
63
+ <%- if primary_key_column -%>
64
+ @doc("Unique identifier")
65
+ <%= resource[:primary_key] %>: <%= resource[:primary_key_type] %>;
66
+ <%- end -%>
67
+ <%- if created_at_column -%>
68
+ @doc("Creation timestamp")
69
+ created_at: utcDateTime;
70
+ <%- end -%>
71
+ <%- if updated_at_column -%>
72
+ @doc("Last update timestamp")
73
+ updated_at: utcDateTime;
74
+ <%- end -%>
75
+ <%- if (primary_key_column || created_at_column || updated_at_column) && regular_columns.any? -%>
76
+
77
+ <%- end -%>
78
+ <%- regular_columns.each do |column| -%>
79
+ <%- if enums.key?(column[:name]) -%>
80
+ <%= column[:name] %><%= optional_marker(column) %>: <%= model_name %><%= column[:name].camelize %>;
81
+ <%- else -%>
82
+ <%= column[:name] %><%= optional_marker(column) %>: <%= column[:typespec_type] %>;
83
+ <%- end -%>
84
+ <%- end -%>
85
+
86
+ <%- belongs_to_assocs.each do |assoc| -%>
87
+ <%- if assoc[:polymorphic] -%>
88
+ /** Polymorphic association type */
89
+ <%= assoc[:name] %>_type?: string;
90
+ /** Polymorphic association ID */
91
+ <%= assoc[:name] %>_id?: <%= assoc[:foreign_key_type] %>;
92
+ /** Polymorphic association SGID */
93
+ <%= assoc[:name] %>_sgid?: SignedGlobalId;
94
+ <%- else -%>
95
+ /** <%= assoc[:name].humanize %> ID */
96
+ <%= assoc[:foreign_key] || "#{assoc[:name]}_id" %>?: <%= assoc[:foreign_key_type] %>;
97
+ /** <%= assoc[:name].humanize %> SGID */
98
+ <%= assoc[:name] %>_sgid?: SignedGlobalId;
99
+ <%- end -%>
100
+ <%- end -%>
101
+
102
+ <%- has_many_assocs.each do |assoc| -%>
103
+ /** <%= assoc[:name].to_s.singularize.humanize %> IDs */
104
+ <%= assoc[:name].to_s.singularize %>_ids?: <%= assoc[:foreign_key_type] %>[];
105
+ /** <%= assoc[:name].to_s.singularize.humanize %> SGIDs */
106
+ <%= assoc[:name].to_s.singularize %>_sgids?: SignedGlobalId[];
107
+ <%- end -%>
108
+ <%- has_one_assocs.each do |assoc| -%>
109
+ /** <%= assoc[:name].humanize %> ID */
110
+ <%= assoc[:name] %>_id?: <%= assoc[:foreign_key_type] %>;
111
+ /** <%= assoc[:name].humanize %> SGID */
112
+ <%= assoc[:name] %>_sgid?: SignedGlobalId;
113
+ <%- end -%>
114
+ }
115
+
116
+ /**
117
+ * <%= model_name %> input model for create/update operations
118
+ <%- if has_definition_inputs -%>
119
+ * Based on definition: <%= definition[:class_name] %>
120
+ <%- end -%>
121
+ */
122
+ model <%= model_name %>Input {
123
+ <%- if has_definition_inputs -%>
124
+ <%# Use definition inputs -%>
125
+ <%- inputs.each do |input| -%>
126
+ <%- next if input[:nested] # Skip nested inputs for now -%>
127
+ <%- if input[:is_association] -%>
128
+ <%- if input[:is_polymorphic] -%>
129
+ /** Polymorphic <%= input[:name] %> (SGID) */
130
+ <%= input[:name] %>_sgid<%= input[:required] ? '' : '?' %>: SignedGlobalId;
131
+ <%- elsif input[:association_macro] == 'has_many' || input[:association_macro] == 'has_and_belongs_to_many' -%>
132
+ /** <%= input[:name].singularize.humanize %> SGIDs */
133
+ <%= input[:name].singularize %>_sgids<%= input[:required] ? '' : '?' %>: SignedGlobalId[];
134
+ <%- else -%>
135
+ /** <%= input[:name].humanize %> (SGID) */
136
+ <%= input[:name] %>_sgid<%= input[:required] ? '' : '?' %>: SignedGlobalId;
137
+ <%- end -%>
138
+ <%- else -%>
139
+ <%= input[:name] %><%= input[:required] ? '' : '?' %>: <%= input[:typespec_type] %>;
140
+ <%- end -%>
141
+ <%- end -%>
142
+ <%- else -%>
143
+ <%# Fallback to column-based inputs -%>
144
+ <%- regular_columns.each do |column| -%>
145
+ <%- next if column[:name].end_with?('_count') # skip counter caches -%>
146
+ <%- if enums.key?(column[:name]) -%>
147
+ <%= column[:name] %><%= optional_marker(column) %>: <%= model_name %><%= column[:name].camelize %>;
148
+ <%- else -%>
149
+ <%= column[:name] %><%= optional_marker(column) %>: <%= column[:typespec_type] %>;
150
+ <%- end -%>
151
+ <%- end -%>
152
+
153
+ <%- belongs_to_assocs.each do |assoc| -%>
154
+ <%- if assoc[:polymorphic] -%>
155
+ /** Polymorphic <%= assoc[:name] %> (SGID) */
156
+ <%= assoc[:name] %>_sgid?: SignedGlobalId;
157
+ <%- else -%>
158
+ /** <%= assoc[:name].humanize %> (SGID) */
159
+ <%= assoc[:name] %>_sgid?: SignedGlobalId;
160
+ <%- end -%>
161
+ <%- end -%>
162
+
163
+ <%- has_many_assocs.each do |assoc| -%>
164
+ /** <%= assoc[:name].to_s.singularize.humanize %> SGIDs */
165
+ <%= assoc[:name].to_s.singularize %>_sgids?: SignedGlobalId[];
166
+ <%- end -%>
167
+ <%- end -%>
168
+ }
169
+
170
+ /**
171
+ * <%= model_name %> list response
172
+ */
173
+ model <%= model_name %>ListResponse {
174
+ <%= model_name.underscore.pluralize %>: <%= model_name %>[];
175
+ }
176
+
177
+ /**
178
+ * <%= model_name %> CRUD operations
179
+ */
180
+ @route("<%= resource[:route_path] %>")
181
+ @tag("<%= model_name.pluralize %>")
182
+ interface <%= model_name %>Operations {
183
+ /**
184
+ * List all <%= model_name.underscore.humanize.pluralize.downcase %>
185
+ */
186
+ @get
187
+ list(...ListQueryParams): <%= model_name %>ListResponse;
188
+
189
+ /**
190
+ * Get a single <%= model_name.underscore.humanize.downcase %>
191
+ */
192
+ @route("{id}")
193
+ @get
194
+ show(@path id: <%= resource[:primary_key_type] %>): <%= model_name %>;
195
+
196
+ /**
197
+ * Create a new <%= model_name.underscore.humanize.downcase %>
198
+ */
199
+ @post
200
+ create(@body input: <%= model_name %>Input): {
201
+ @statusCode statusCode: 201;
202
+ @body body: <%= model_name %>;
203
+ } | {
204
+ @statusCode statusCode: 422;
205
+ @body body: ErrorResponse;
206
+ };
207
+
208
+ /**
209
+ * Update a <%= model_name.underscore.humanize.downcase %>
210
+ */
211
+ @route("{id}")
212
+ @patch
213
+ update(@path id: <%= resource[:primary_key_type] %>, @body input: <%= model_name %>Input): <%= model_name %> | {
214
+ @statusCode statusCode: 422;
215
+ @body body: ErrorResponse;
216
+ };
217
+
218
+ /**
219
+ * Delete a <%= model_name.underscore.humanize.downcase %>
220
+ */
221
+ @route("{id}")
222
+ @delete
223
+ destroy(@path id: <%= resource[:primary_key_type] %>): {
224
+ @statusCode statusCode: 204;
225
+ };
226
+ }