plutonium 0.33.1 → 0.34.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/# Plutonium: The pre-alpha demo.md +4 -2
- data/.claude/skills/assets/SKILL.md +416 -0
- data/.claude/skills/connect-resource/SKILL.md +112 -0
- data/.claude/skills/controller/SKILL.md +302 -0
- data/.claude/skills/create-resource/SKILL.md +240 -0
- data/.claude/skills/definition/SKILL.md +218 -0
- data/.claude/skills/definition-actions/SKILL.md +386 -0
- data/.claude/skills/definition-fields/SKILL.md +474 -0
- data/.claude/skills/definition-query/SKILL.md +334 -0
- data/.claude/skills/forms/SKILL.md +439 -0
- data/.claude/skills/installation/SKILL.md +300 -0
- data/.claude/skills/interaction/SKILL.md +382 -0
- data/.claude/skills/model/SKILL.md +267 -0
- data/.claude/skills/model-features/SKILL.md +286 -0
- data/.claude/skills/nested-resources/SKILL.md +274 -0
- data/.claude/skills/package/SKILL.md +191 -0
- data/.claude/skills/policy/SKILL.md +352 -0
- data/.claude/skills/portal/SKILL.md +400 -0
- data/.claude/skills/resource/SKILL.md +281 -0
- data/.claude/skills/rodauth/SKILL.md +452 -0
- data/.claude/skills/views/SKILL.md +563 -0
- data/Appraisals +46 -4
- data/CHANGELOG.md +32 -1
- data/app/assets/plutonium.css +2 -2
- data/config/brakeman.ignore +239 -0
- data/config/initializers/action_policy.rb +1 -1
- data/docs/.vitepress/config.ts +132 -47
- data/docs/concepts/architecture.md +226 -0
- data/docs/concepts/auto-detection.md +254 -0
- data/docs/concepts/index.md +61 -0
- data/docs/concepts/packages-portals.md +304 -0
- data/docs/concepts/resources.md +224 -0
- data/docs/cookbook/blog.md +412 -0
- data/docs/cookbook/index.md +289 -0
- data/docs/cookbook/saas.md +481 -0
- data/docs/getting-started/index.md +56 -0
- data/docs/getting-started/installation.md +146 -0
- data/docs/getting-started/tutorial/01-setup.md +118 -0
- data/docs/getting-started/tutorial/02-first-resource.md +180 -0
- data/docs/getting-started/tutorial/03-authentication.md +246 -0
- data/docs/getting-started/tutorial/04-authorization.md +170 -0
- data/docs/getting-started/tutorial/05-custom-actions.md +202 -0
- data/docs/getting-started/tutorial/06-nested-resources.md +147 -0
- data/docs/getting-started/tutorial/07-customizing-ui.md +254 -0
- data/docs/getting-started/tutorial/index.md +64 -0
- data/docs/guides/adding-resources.md +420 -0
- data/docs/guides/authentication.md +551 -0
- data/docs/guides/authorization.md +468 -0
- data/docs/guides/creating-packages.md +380 -0
- data/docs/guides/custom-actions.md +523 -0
- data/docs/guides/index.md +45 -0
- data/docs/guides/multi-tenancy.md +302 -0
- data/docs/guides/nested-resources.md +411 -0
- data/docs/guides/search-filtering.md +266 -0
- data/docs/guides/theming.md +321 -0
- data/docs/index.md +67 -26
- data/docs/public/CLAUDE.md +64 -21
- data/docs/reference/assets/index.md +496 -0
- data/docs/reference/controller/index.md +363 -0
- data/docs/reference/definition/actions.md +400 -0
- data/docs/reference/definition/fields.md +350 -0
- data/docs/reference/definition/index.md +252 -0
- data/docs/reference/definition/query.md +342 -0
- data/docs/reference/generators/index.md +469 -0
- data/docs/reference/index.md +49 -0
- data/docs/reference/interaction/index.md +445 -0
- data/docs/reference/model/features.md +248 -0
- data/docs/reference/model/index.md +219 -0
- data/docs/reference/policy/index.md +385 -0
- data/docs/reference/portal/index.md +382 -0
- data/docs/reference/views/forms.md +396 -0
- data/docs/reference/views/index.md +479 -0
- data/gemfiles/rails_7.gemfile +9 -2
- data/gemfiles/rails_7.gemfile.lock +146 -111
- data/gemfiles/rails_8.0.gemfile +20 -0
- data/gemfiles/rails_8.0.gemfile.lock +417 -0
- data/gemfiles/rails_8.1.gemfile +20 -0
- data/gemfiles/rails_8.1.gemfile.lock +419 -0
- data/lib/generators/pu/gem/dotenv/templates/.env +2 -0
- data/lib/generators/pu/gem/dotenv/templates/config/initializers/001_ensure_required_env.rb +3 -1
- data/lib/generators/pu/lib/plutonium_generators/model_generator_base.rb +13 -16
- data/lib/generators/pu/pkg/portal/USAGE +65 -0
- data/lib/generators/pu/pkg/portal/portal_generator.rb +22 -9
- data/lib/generators/pu/res/conn/USAGE +71 -0
- data/lib/generators/pu/res/model/USAGE +106 -110
- data/lib/generators/pu/res/model/templates/model.rb.tt +6 -2
- data/lib/generators/pu/res/scaffold/USAGE +85 -0
- data/lib/generators/pu/rodauth/install_generator.rb +2 -6
- data/lib/generators/pu/rodauth/templates/config/initializers/url_options.rb +17 -0
- data/lib/generators/pu/skills/sync/USAGE +14 -0
- data/lib/generators/pu/skills/sync/sync_generator.rb +66 -0
- data/lib/plutonium/action_policy/sti_policy_lookup.rb +1 -1
- data/lib/plutonium/core/controller.rb +2 -2
- data/lib/plutonium/interaction/base.rb +1 -0
- data/lib/plutonium/package/engine.rb +2 -2
- data/lib/plutonium/query/adhoc_block.rb +6 -2
- data/lib/plutonium/query/model_scope.rb +1 -1
- data/lib/plutonium/railtie.rb +4 -0
- data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +1 -1
- data/lib/plutonium/resource/query_object.rb +38 -8
- data/lib/plutonium/ui/table/components/scopes_bar.rb +39 -34
- data/lib/plutonium/version.rb +1 -1
- data/lib/tasks/release.rake +19 -4
- data/package.json +1 -1
- metadata +76 -39
- data/brakeman.ignore +0 -28
- data/docs/api-examples.md +0 -49
- data/docs/guide/claude-code-guide.md +0 -74
- data/docs/guide/deep-dive/authorization.md +0 -189
- data/docs/guide/deep-dive/multitenancy.md +0 -256
- data/docs/guide/deep-dive/resources.md +0 -390
- data/docs/guide/getting-started/01-installation.md +0 -165
- data/docs/guide/index.md +0 -28
- data/docs/guide/introduction/01-what-is-plutonium.md +0 -211
- data/docs/guide/introduction/02-core-concepts.md +0 -440
- data/docs/guide/tutorial/01-project-setup.md +0 -75
- data/docs/guide/tutorial/02-creating-a-feature-package.md +0 -45
- data/docs/guide/tutorial/03-defining-resources.md +0 -90
- data/docs/guide/tutorial/04-creating-a-portal.md +0 -101
- data/docs/guide/tutorial/05-customizing-the-ui.md +0 -128
- data/docs/guide/tutorial/06-adding-custom-actions.md +0 -101
- data/docs/guide/tutorial/07-implementing-authorization.md +0 -90
- data/docs/markdown-examples.md +0 -85
- data/docs/modules/action.md +0 -244
- data/docs/modules/authentication.md +0 -236
- data/docs/modules/configuration.md +0 -599
- data/docs/modules/controller.md +0 -443
- data/docs/modules/core.md +0 -316
- data/docs/modules/definition.md +0 -1308
- data/docs/modules/display.md +0 -759
- data/docs/modules/form.md +0 -495
- data/docs/modules/generator.md +0 -400
- data/docs/modules/index.md +0 -167
- data/docs/modules/interaction.md +0 -642
- data/docs/modules/package.md +0 -151
- data/docs/modules/policy.md +0 -176
- data/docs/modules/portal.md +0 -710
- data/docs/modules/query.md +0 -297
- data/docs/modules/resource_record.md +0 -618
- data/docs/modules/routing.md +0 -690
- data/docs/modules/table.md +0 -301
- data/docs/modules/ui.md +0 -631
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
# Recipe: Blog Application
|
|
2
|
+
|
|
3
|
+
Build a full-featured blog with posts, comments, categories, and multi-user support.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
This recipe covers:
|
|
8
|
+
- Post and comment management
|
|
9
|
+
- Categories and tags
|
|
10
|
+
- User roles (admin, author, reader)
|
|
11
|
+
- Publication workflow
|
|
12
|
+
- SEO features
|
|
13
|
+
|
|
14
|
+
## Architecture
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
packages/
|
|
18
|
+
├── blogging/ # Feature package
|
|
19
|
+
│ ├── models/
|
|
20
|
+
│ │ ├── post.rb
|
|
21
|
+
│ │ ├── comment.rb
|
|
22
|
+
│ │ ├── category.rb
|
|
23
|
+
│ │ └── tag.rb
|
|
24
|
+
│ ├── definitions/
|
|
25
|
+
│ ├── policies/
|
|
26
|
+
│ └── interactions/
|
|
27
|
+
├── admin_portal/ # Admin interface
|
|
28
|
+
└── public_portal/ # Public blog
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Models
|
|
32
|
+
|
|
33
|
+
### Post
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
module Blogging
|
|
37
|
+
class Post < Blogging::ResourceRecord
|
|
38
|
+
belongs_to :author, class_name: 'User'
|
|
39
|
+
belongs_to :category
|
|
40
|
+
has_many :comments, dependent: :destroy
|
|
41
|
+
has_many :taggings, dependent: :destroy
|
|
42
|
+
has_many :tags, through: :taggings
|
|
43
|
+
|
|
44
|
+
validates :title, presence: true, length: { maximum: 200 }
|
|
45
|
+
validates :body, presence: true
|
|
46
|
+
validates :slug, presence: true, uniqueness: true
|
|
47
|
+
|
|
48
|
+
scope :published, -> { where(published: true) }
|
|
49
|
+
scope :draft, -> { where(published: false) }
|
|
50
|
+
scope :featured, -> { where(featured: true) }
|
|
51
|
+
scope :recent, -> { order(published_at: :desc) }
|
|
52
|
+
|
|
53
|
+
before_validation :generate_slug, on: :create
|
|
54
|
+
|
|
55
|
+
def publish!
|
|
56
|
+
update!(published: true, published_at: Time.current)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def reading_time
|
|
60
|
+
words_per_minute = 200
|
|
61
|
+
(body.to_plain_text.split.size / words_per_minute.to_f).ceil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def generate_slug
|
|
67
|
+
self.slug ||= title&.parameterize
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Comment
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
module Blogging
|
|
77
|
+
class Comment < Blogging::ResourceRecord
|
|
78
|
+
belongs_to :post
|
|
79
|
+
belongs_to :author, class_name: 'User'
|
|
80
|
+
belongs_to :parent, class_name: 'Comment', optional: true
|
|
81
|
+
has_many :replies, class_name: 'Comment', foreign_key: :parent_id
|
|
82
|
+
|
|
83
|
+
validates :body, presence: true
|
|
84
|
+
|
|
85
|
+
scope :approved, -> { where(approved: true) }
|
|
86
|
+
scope :pending, -> { where(approved: false) }
|
|
87
|
+
scope :root, -> { where(parent_id: nil) }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Category
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
module Blogging
|
|
96
|
+
class Category < Blogging::ResourceRecord
|
|
97
|
+
has_many :posts
|
|
98
|
+
|
|
99
|
+
validates :name, presence: true, uniqueness: true
|
|
100
|
+
validates :slug, presence: true, uniqueness: true
|
|
101
|
+
|
|
102
|
+
before_validation :generate_slug
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def generate_slug
|
|
107
|
+
self.slug ||= name&.parameterize
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Definitions
|
|
114
|
+
|
|
115
|
+
### Post Definition
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
module Blogging
|
|
119
|
+
class PostDefinition < Plutonium::Resource::Definition
|
|
120
|
+
# Form fields
|
|
121
|
+
field :title
|
|
122
|
+
field :slug, hint: "URL-friendly version (auto-generated if blank)"
|
|
123
|
+
field :body, as: :rich_text
|
|
124
|
+
field :excerpt, as: :text
|
|
125
|
+
field :category
|
|
126
|
+
field :tags, as: :select, multiple: true, collection: -> { Tag.pluck(:name, :id) }
|
|
127
|
+
field :featured_image, as: :file, accept: "image/*"
|
|
128
|
+
field :published, as: :switch
|
|
129
|
+
field :featured, as: :switch
|
|
130
|
+
field :meta_title
|
|
131
|
+
field :meta_description, as: :text
|
|
132
|
+
|
|
133
|
+
# Table columns
|
|
134
|
+
column :title, sortable: true
|
|
135
|
+
column :category
|
|
136
|
+
column :author
|
|
137
|
+
column :published
|
|
138
|
+
column :featured
|
|
139
|
+
column :published_at, sortable: true
|
|
140
|
+
|
|
141
|
+
# Search
|
|
142
|
+
search do |scope, query|
|
|
143
|
+
scope.where("title ILIKE :q OR body ILIKE :q", q: "%#{query}%")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Scopes
|
|
147
|
+
scope :all, default: true
|
|
148
|
+
scope :published, -> { where(published: true) }, badge: true
|
|
149
|
+
scope :drafts, -> { where(published: false) }, badge: true
|
|
150
|
+
scope :featured, -> { where(featured: true) }
|
|
151
|
+
|
|
152
|
+
# Filters
|
|
153
|
+
filter :category, as: :select, collection: -> { Category.pluck(:name, :id) }
|
|
154
|
+
filter :author, as: :select, collection: -> { User.pluck(:name, :id) }
|
|
155
|
+
filter :published, as: :boolean
|
|
156
|
+
filter :created_at, as: :date_range
|
|
157
|
+
|
|
158
|
+
# Actions
|
|
159
|
+
action :publish, interaction: PublishPost, condition: ->(p) { !p.published? }
|
|
160
|
+
action :unpublish, interaction: UnpublishPost, condition: ->(p) { p.published? }
|
|
161
|
+
action :feature, interaction: FeaturePost, condition: ->(p) { !p.featured? }
|
|
162
|
+
|
|
163
|
+
# Associations
|
|
164
|
+
association :comments, fields: [:body, :author, :approved, :created_at]
|
|
165
|
+
|
|
166
|
+
# Eager loading
|
|
167
|
+
includes :author, :category, :tags
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Comment Definition
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
module Blogging
|
|
176
|
+
class CommentDefinition < Plutonium::Resource::Definition
|
|
177
|
+
field :body, as: :text
|
|
178
|
+
field :post, as: :hidden
|
|
179
|
+
field :author, as: :hidden
|
|
180
|
+
field :approved, as: :switch
|
|
181
|
+
|
|
182
|
+
column :body
|
|
183
|
+
column :author
|
|
184
|
+
column :approved
|
|
185
|
+
column :created_at
|
|
186
|
+
|
|
187
|
+
scope :all, default: true
|
|
188
|
+
scope :approved, -> { where(approved: true) }
|
|
189
|
+
scope :pending, -> { where(approved: false) }, badge: true
|
|
190
|
+
|
|
191
|
+
action :approve, interaction: ApproveComment, condition: ->(c) { !c.approved? }
|
|
192
|
+
action :reject, interaction: RejectComment, condition: ->(c) { c.approved? }
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Interactions
|
|
198
|
+
|
|
199
|
+
### Publish Post
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
module Blogging
|
|
203
|
+
class PublishPost < Plutonium::Interaction::Base
|
|
204
|
+
presents model_class: Post
|
|
205
|
+
presents label: "Publish"
|
|
206
|
+
presents icon: Phlex::TablerIcons::Send
|
|
207
|
+
|
|
208
|
+
validate :has_content
|
|
209
|
+
|
|
210
|
+
def execute
|
|
211
|
+
resource.update!(
|
|
212
|
+
published: true,
|
|
213
|
+
published_at: Time.current
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Notify subscribers
|
|
217
|
+
NotifySubscribersJob.perform_later(resource.id)
|
|
218
|
+
|
|
219
|
+
succeed(resource).with_message("Post published!")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
private
|
|
223
|
+
|
|
224
|
+
def has_content
|
|
225
|
+
errors.add(:base, "Post must have content") if resource.body.blank?
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Approve Comment
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
module Blogging
|
|
235
|
+
class ApproveComment < Plutonium::Interaction::Base
|
|
236
|
+
presents model_class: Comment
|
|
237
|
+
presents label: "Approve"
|
|
238
|
+
|
|
239
|
+
def execute
|
|
240
|
+
resource.update!(approved: true)
|
|
241
|
+
|
|
242
|
+
# Notify comment author
|
|
243
|
+
CommentApprovedMailer.notify(resource).deliver_later
|
|
244
|
+
|
|
245
|
+
succeed(resource).with_message("Comment approved")
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## Policies
|
|
252
|
+
|
|
253
|
+
### Post Policy
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
module Blogging
|
|
257
|
+
class PostPolicy < Plutonium::Resource::Policy
|
|
258
|
+
def read?
|
|
259
|
+
record.published? || author? || admin?
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def create?
|
|
263
|
+
user.present? && (user.author? || user.admin?)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def update?
|
|
267
|
+
author? || admin?
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def destroy?
|
|
271
|
+
author? || admin?
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def publish?
|
|
275
|
+
(author? || admin?) && !record.published?
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def relation_scope(relation)
|
|
279
|
+
if admin?
|
|
280
|
+
relation
|
|
281
|
+
elsif user&.author?
|
|
282
|
+
relation.where(author: user).or(relation.where(published: true))
|
|
283
|
+
else
|
|
284
|
+
relation.where(published: true)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
private
|
|
289
|
+
|
|
290
|
+
def author?
|
|
291
|
+
record.author_id == user&.id
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def admin?
|
|
295
|
+
user&.admin?
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
## Portal Configuration
|
|
302
|
+
|
|
303
|
+
### Admin Portal
|
|
304
|
+
|
|
305
|
+
Engine:
|
|
306
|
+
|
|
307
|
+
```ruby
|
|
308
|
+
module AdminPortal
|
|
309
|
+
class Engine < Rails::Engine
|
|
310
|
+
include Plutonium::Portal::Engine
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Authentication (in controller concern):
|
|
316
|
+
|
|
317
|
+
```ruby
|
|
318
|
+
# packages/admin_portal/app/controllers/admin_portal/concerns/controller.rb
|
|
319
|
+
include Plutonium::Auth::Rodauth(:admin)
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Admin policy override:
|
|
323
|
+
|
|
324
|
+
```ruby
|
|
325
|
+
# packages/admin_portal/app/policies/admin_portal/blogging/post_policy.rb
|
|
326
|
+
module AdminPortal
|
|
327
|
+
module Blogging
|
|
328
|
+
class PostPolicy < ::Blogging::PostPolicy
|
|
329
|
+
def read?
|
|
330
|
+
true
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def destroy?
|
|
334
|
+
true
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def relation_scope(relation)
|
|
338
|
+
relation
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### Public Portal
|
|
346
|
+
|
|
347
|
+
Engine:
|
|
348
|
+
|
|
349
|
+
```ruby
|
|
350
|
+
module PublicPortal
|
|
351
|
+
class Engine < Rails::Engine
|
|
352
|
+
include Plutonium::Portal::Engine
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Authentication (in controller concern):
|
|
358
|
+
|
|
359
|
+
```ruby
|
|
360
|
+
# packages/public_portal/app/controllers/public_portal/concerns/controller.rb
|
|
361
|
+
include Plutonium::Auth::Rodauth(:user)
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
Public policy override:
|
|
365
|
+
|
|
366
|
+
```ruby
|
|
367
|
+
# packages/public_portal/app/policies/public_portal/blogging/post_policy.rb
|
|
368
|
+
module PublicPortal
|
|
369
|
+
module Blogging
|
|
370
|
+
class PostPolicy < ::Blogging::PostPolicy
|
|
371
|
+
def create?
|
|
372
|
+
false
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def update?
|
|
376
|
+
false
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def relation_scope(relation)
|
|
380
|
+
relation.published
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
## Usage
|
|
388
|
+
|
|
389
|
+
```bash
|
|
390
|
+
# Generate the structure
|
|
391
|
+
rails generate pu:pkg:package blogging
|
|
392
|
+
rails generate pu:res:scaffold Post title:string slug:string body:text published:boolean --package blogging
|
|
393
|
+
rails generate pu:res:scaffold Comment body:text approved:boolean post:belongs_to --package blogging
|
|
394
|
+
rails generate pu:res:scaffold Category name:string slug:string --package blogging
|
|
395
|
+
|
|
396
|
+
# Create portals
|
|
397
|
+
rails generate pu:pkg:portal admin
|
|
398
|
+
rails generate pu:pkg:portal public
|
|
399
|
+
|
|
400
|
+
# Connect resources
|
|
401
|
+
rails generate pu:res:conn Post --package blogging --portal admin
|
|
402
|
+
rails generate pu:res:conn Comment --package blogging --portal admin --parent post
|
|
403
|
+
|
|
404
|
+
rails db:migrate
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
## Next Steps
|
|
408
|
+
|
|
409
|
+
- Add image uploads with Active Storage
|
|
410
|
+
- Implement RSS feeds
|
|
411
|
+
- Add social sharing
|
|
412
|
+
- Set up full-text search with PostgreSQL
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# Cookbook
|
|
2
|
+
|
|
3
|
+
Real-world recipes and patterns for building applications with Plutonium.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
These recipes show complete implementations of common application patterns. Each recipe includes:
|
|
8
|
+
- Architecture decisions
|
|
9
|
+
- Code examples
|
|
10
|
+
- Best practices
|
|
11
|
+
|
|
12
|
+
## Recipes
|
|
13
|
+
|
|
14
|
+
### [Blog Application](./blog)
|
|
15
|
+
A content management system with posts, comments, categories, and multi-user support.
|
|
16
|
+
|
|
17
|
+
### [SaaS Application](./saas)
|
|
18
|
+
Multi-tenant application with organizations, team management, and subscription handling.
|
|
19
|
+
|
|
20
|
+
## Quick Patterns
|
|
21
|
+
|
|
22
|
+
### Basic CRUD with Authorization
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
# Model
|
|
26
|
+
class Article < ResourceRecord
|
|
27
|
+
belongs_to :author, class_name: 'User'
|
|
28
|
+
validates :title, :body, presence: true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Definition
|
|
32
|
+
class ArticleDefinition < Plutonium::Resource::Definition
|
|
33
|
+
field :title
|
|
34
|
+
field :body, as: :rich_text
|
|
35
|
+
field :published, as: :switch
|
|
36
|
+
|
|
37
|
+
column :title, sortable: true
|
|
38
|
+
column :author
|
|
39
|
+
column :published
|
|
40
|
+
column :created_at, sortable: true
|
|
41
|
+
|
|
42
|
+
search { |scope, q| scope.where("title ILIKE ?", "%#{q}%") }
|
|
43
|
+
scope :all, default: true
|
|
44
|
+
scope :published, -> { where(published: true) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Policy
|
|
48
|
+
class ArticlePolicy < Plutonium::Resource::Policy
|
|
49
|
+
def update?
|
|
50
|
+
owner? || admin?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def destroy?
|
|
54
|
+
owner? || admin?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def owner?
|
|
60
|
+
record.author_id == user.id
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Nested Resource Pattern
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
# Parent
|
|
69
|
+
class Project < ResourceRecord
|
|
70
|
+
has_many :tasks, dependent: :destroy
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Child
|
|
74
|
+
class Task < ResourceRecord
|
|
75
|
+
belongs_to :project
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Parent policy enables association panel
|
|
79
|
+
class ProjectPolicy < Plutonium::Resource::Policy
|
|
80
|
+
def permitted_associations
|
|
81
|
+
%i[tasks]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Custom Action Pattern
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
# Interaction
|
|
90
|
+
class CompleteTask < Plutonium::Interaction::Base
|
|
91
|
+
presents model_class: Task
|
|
92
|
+
presents label: "Mark Complete"
|
|
93
|
+
|
|
94
|
+
attribute :completion_notes, :text
|
|
95
|
+
|
|
96
|
+
def execute
|
|
97
|
+
resource.update!(
|
|
98
|
+
completed: true,
|
|
99
|
+
completed_at: Time.current,
|
|
100
|
+
completion_notes: completion_notes
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
succeed(resource).with_message("Task completed!")
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Register in definition
|
|
108
|
+
class TaskDefinition < Plutonium::Resource::Definition
|
|
109
|
+
action :complete,
|
|
110
|
+
interaction: CompleteTask,
|
|
111
|
+
condition: ->(task) { !task.completed? }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Authorize in policy
|
|
115
|
+
class TaskPolicy < Plutonium::Resource::Policy
|
|
116
|
+
def complete?
|
|
117
|
+
owner? && !record.completed?
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Multi-Portal Pattern
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
# Admin portal - full access
|
|
126
|
+
module AdminPortal
|
|
127
|
+
class TaskPolicy < ::TaskPolicy
|
|
128
|
+
def index?
|
|
129
|
+
true
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def destroy?
|
|
133
|
+
true
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def relation_scope(relation)
|
|
137
|
+
relation
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# User portal - limited access
|
|
143
|
+
module UserPortal
|
|
144
|
+
class TaskPolicy < ::TaskPolicy
|
|
145
|
+
def index?
|
|
146
|
+
true
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def destroy?
|
|
150
|
+
owner?
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def relation_scope(relation)
|
|
154
|
+
relation.where(user: user)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Architecture Patterns
|
|
161
|
+
|
|
162
|
+
### Feature Package Organization
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
packages/
|
|
166
|
+
├── core/ # Shared models (User, Organization)
|
|
167
|
+
├── projects/ # Project management feature
|
|
168
|
+
├── billing/ # Subscription & payments
|
|
169
|
+
├── notifications/ # Email & push notifications
|
|
170
|
+
├── admin_portal/ # Admin interface
|
|
171
|
+
└── customer_portal/ # Customer interface
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Service Objects with Interactions
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
# Complex operations go in Interactions
|
|
178
|
+
class CreateProjectWithTasks < Plutonium::Interaction::Base
|
|
179
|
+
presents model_class: Project
|
|
180
|
+
|
|
181
|
+
attribute :name, :string
|
|
182
|
+
attribute :tasks, :json # Array of task attributes
|
|
183
|
+
|
|
184
|
+
validates :name, presence: true
|
|
185
|
+
|
|
186
|
+
def execute
|
|
187
|
+
project = Project.create!(name: name, user: context[:user])
|
|
188
|
+
|
|
189
|
+
tasks.each do |task_attrs|
|
|
190
|
+
project.tasks.create!(task_attrs)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
succeed(project).with_message("Project created with #{tasks.size} tasks")
|
|
194
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
195
|
+
fail!(e.message)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Event-Driven Updates
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
class PublishArticle < Plutonium::Interaction::Base
|
|
204
|
+
presents model_class: Article
|
|
205
|
+
|
|
206
|
+
def execute
|
|
207
|
+
resource.update!(published: true, published_at: Time.current)
|
|
208
|
+
|
|
209
|
+
# Trigger downstream effects
|
|
210
|
+
ArticlePublishedJob.perform_later(resource.id)
|
|
211
|
+
NotifySubscribersJob.perform_later(resource.id)
|
|
212
|
+
UpdateSearchIndexJob.perform_later(resource.id)
|
|
213
|
+
|
|
214
|
+
succeed(resource)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Common Customizations
|
|
220
|
+
|
|
221
|
+
### Custom Display Component
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
class StatusBadge < Plutonium::UI::Component::Base
|
|
225
|
+
COLORS = {
|
|
226
|
+
'draft' => 'gray',
|
|
227
|
+
'pending' => 'yellow',
|
|
228
|
+
'approved' => 'green',
|
|
229
|
+
'rejected' => 'red'
|
|
230
|
+
}.freeze
|
|
231
|
+
|
|
232
|
+
def initialize(status:)
|
|
233
|
+
@status = status
|
|
234
|
+
@color = COLORS.fetch(status, 'gray')
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def view_template
|
|
238
|
+
span(class: "px-2 py-1 text-xs rounded bg-#{@color}-100 text-#{@color}-800") do
|
|
239
|
+
@status.titleize
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Use in definition
|
|
245
|
+
column :status do |record|
|
|
246
|
+
render StatusBadge.new(status: record.status)
|
|
247
|
+
end
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Custom Form Section
|
|
251
|
+
|
|
252
|
+
```ruby
|
|
253
|
+
class ProjectForm < Plutonium::UI::Form::Resource
|
|
254
|
+
def form_template
|
|
255
|
+
div(class: "space-y-8") do
|
|
256
|
+
section("Basic Information") do
|
|
257
|
+
render_field :name
|
|
258
|
+
render_field :description
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
section("Settings") do
|
|
262
|
+
render_field :visibility
|
|
263
|
+
render_field :notifications_enabled
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
section("Team") do
|
|
267
|
+
render_field :team_members, as: :nested
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
render_submit_button
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
private
|
|
275
|
+
|
|
276
|
+
def section(title, &block)
|
|
277
|
+
div(class: "bg-white rounded-lg shadow p-6") do
|
|
278
|
+
h3(class: "text-lg font-medium mb-4") { title }
|
|
279
|
+
yield
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## Next Steps
|
|
286
|
+
|
|
287
|
+
- Explore the [Guides](/guides/) for detailed how-tos
|
|
288
|
+
- Check the [Reference](/reference/) for complete API documentation
|
|
289
|
+
- Visit our [GitHub](https://github.com/radioactive-labs/plutonium-core) for more examples
|