wor-simple_crud 0.6.0 → 0.8.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/README.md +116 -8
  4. data/lib/simple_crud/action_context.rb +16 -2
  5. data/lib/simple_crud/index_context.rb +21 -10
  6. data/lib/simple_crud/persistence_context.rb +16 -5
  7. data/lib/simple_crud/relation_source.rb +107 -0
  8. data/lib/simple_crud/rspec/config.rb +2 -0
  9. data/lib/simple_crud/rspec/helpers/controller_options.rb +1 -1
  10. data/lib/simple_crud/rspec/helpers/policies.rb +3 -3
  11. data/lib/simple_crud/rspec/helpers/requests.rb +4 -1
  12. data/lib/simple_crud/rspec/helpers/settings.rb +10 -1
  13. data/lib/simple_crud/rspec.rb +6 -0
  14. data/lib/simple_crud/serializer.rb +2 -7
  15. data/lib/simple_crud/simple_crud_controller.rb +28 -7
  16. data/lib/simple_crud/version.rb +1 -1
  17. data/lib/spec/matchers/render_or_redirect.rb +7 -0
  18. data/lib/spec/shared_examples/simple_crud_for_create.rb +1 -1
  19. data/lib/spec/shared_examples/simple_crud_for_create_with_block.rb +2 -2
  20. data/lib/spec/shared_examples/simple_crud_for_create_with_build.rb +8 -2
  21. data/lib/spec/shared_examples/simple_crud_for_destroy.rb +11 -9
  22. data/lib/spec/shared_examples/simple_crud_for_destroy_with_block.rb +1 -1
  23. data/lib/spec/shared_examples/simple_crud_for_edit.rb +16 -14
  24. data/lib/spec/shared_examples/simple_crud_for_edit_with_block.rb +22 -0
  25. data/lib/spec/shared_examples/simple_crud_for_edit_with_finder.rb +34 -0
  26. data/lib/spec/shared_examples/simple_crud_for_index_with_scope.rb +15 -12
  27. data/lib/spec/shared_examples/simple_crud_for_nested_resource.rb +21 -0
  28. data/lib/spec/shared_examples/simple_crud_for_new_with_block.rb +1 -1
  29. data/lib/spec/shared_examples/simple_crud_for_new_with_build.rb +11 -5
  30. data/lib/spec/shared_examples/simple_crud_for_owned_resource.rb +45 -0
  31. data/lib/spec/shared_examples/simple_crud_for_show.rb +10 -8
  32. data/lib/spec/shared_examples/simple_crud_for_show_with_block.rb +1 -1
  33. data/lib/spec/shared_examples/simple_crud_for_update.rb +14 -12
  34. data/lib/spec/shared_examples/simple_crud_for_update_with_block.rb +40 -0
  35. data/lib/spec/shared_examples/simple_crud_for_update_with_finder.rb +7 -4
  36. data/lib/spec/shared_examples/simple_crud_not_found_with_finder.rb +9 -7
  37. data/spec/block_create_redirect/dummy_models_controller_spec.rb +11 -0
  38. data/spec/block_edit/dummy_models_controller_spec.rb +7 -0
  39. data/spec/block_finder/dummy_models_controller_spec.rb +2 -0
  40. data/spec/block_policy_show/dummy_models_controller_spec.rb +7 -0
  41. data/spec/block_redirect/dummy_models_controller_spec.rb +1 -0
  42. data/spec/block_update/dummy_models_controller_spec.rb +11 -0
  43. data/spec/dummy/app/controllers/block_create_redirect/dummy_models_controller.rb +13 -0
  44. data/spec/dummy/app/controllers/block_edit/dummy_models_controller.rb +9 -0
  45. data/spec/dummy/app/controllers/block_policy_show/dummy_models_controller.rb +12 -0
  46. data/spec/dummy/app/controllers/block_update/dummy_models_controller.rb +9 -0
  47. data/spec/dummy/app/controllers/html_notice/dummy_models_controller.rb +11 -0
  48. data/spec/dummy/app/controllers/owned/dummy_models_controller.rb +21 -0
  49. data/spec/dummy/app/controllers/parented/dummy_models_controller.rb +24 -0
  50. data/spec/dummy/app/controllers/scoped_order/dummy_models_controller.rb +11 -0
  51. data/spec/dummy/app/controllers/scoped_symbol/dummy_models_controller.rb +11 -0
  52. data/spec/dummy/app/controllers/singular/dummy_models_controller.rb +12 -0
  53. data/spec/dummy/app/models/dummy_model.rb +4 -0
  54. data/spec/dummy/app/serializers/dummy_model_plain_serializer.rb +8 -0
  55. data/spec/dummy/app/views/block_edit/dummy_models/edit.html.erb +1 -0
  56. data/spec/dummy/app/views/block_policy_show/dummy_models/show.html.erb +1 -0
  57. data/spec/dummy/app/views/html_notice/dummy_models/edit.html.erb +1 -0
  58. data/spec/dummy/config/routes.rb +40 -0
  59. data/spec/finder/dummy_models_controller_spec.rb +6 -0
  60. data/spec/html_finder/dummy_models_controller_spec.rb +1 -0
  61. data/spec/html_notice/dummy_models_controller_spec.rb +38 -0
  62. data/spec/owned/dummy_models_controller_spec.rb +12 -0
  63. data/spec/parented/dummy_models_controller_spec.rb +39 -0
  64. data/spec/scoped_order/dummy_models_controller_spec.rb +10 -0
  65. data/spec/scoped_symbol/dummy_models_controller_spec.rb +7 -0
  66. data/spec/simple_crud/action_context_spec.rb +15 -0
  67. data/spec/simple_crud/index_context_spec.rb +49 -0
  68. data/spec/simple_crud/relation_source_spec.rb +125 -0
  69. data/spec/simple_crud/serializer_spec.rb +21 -0
  70. data/spec/simple_crud/show_context_spec.rb +5 -0
  71. data/spec/simple_crud_controller_spec.rb +15 -0
  72. data/spec/singular/dummy_models_controller_spec.rb +10 -0
  73. metadata +34 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b40c7867a484ffc3807b8dceac29ac1f6cc49ccca6d8a9bb067f93f839703e0c
4
- data.tar.gz: 5a72d84147fc1f149695fe0244a11e7f99f2c9b4debb0b99a291e16439b873ac
3
+ metadata.gz: 31af4e1bcfb9f45e6bcbe33a2454c96b27b2adab13ff87674a9bde462d21d2f4
4
+ data.tar.gz: 7fadec0262d373a35c02cd015e566a97d312c5afb110abe34f58e3ca99d88d2e
5
5
  SHA512:
6
- metadata.gz: 2354c67d6866dd82b70172279fd47a65bced5a950ce4385df97a1d3ab3373724d7f49a2ee7185e9cca3f86f403892e732afeade705c74337d7ae32ed53815632
7
- data.tar.gz: 851104267914cd47c8312723af3361a13e5ee5fa2a323f73add55a0a09e04d965492f870c66ffc81f24c5851239afd35485b367633192c80ce8b3d8d90a7753a
6
+ metadata.gz: 56aa1239c57e89eaa7a3944bf2c6303033806815e9671357249958b0eb954b431a9798898fdffa4e8d70432ee650ecad9a3ab6888aff38f0398ea69454302cb3
7
+ data.tar.gz: 69f04e8600f4e4d10b7687c94a14a3f06b4434d12572ab969ce5f67bbb9b886e4e6ffe1d572e9a7e89f936fe999ffc435bbcc358eda12fefb0108d4f2756e67e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  ## Change log
2
2
 
3
+ ### V0.8.0
4
+
5
+ Shared examples:
6
+ * New examples: `update with block`, `edit with finder` and `edit with block`.
7
+ * `required_attribute: nil` skips the invalid-attribute cases in `create`, `update` and their block variants, for models with nothing to validate. `update with finder` and `owned resource` skip their `required_attribute` assertions as well.
8
+ * `singular: true` covers singular resources. Requests carry no id, the "does not exist" examples are skipped, and the controller's own finder loads the record.
9
+ * `index with scope` handles scopes that exclude nothing. With `other_scoped_attributes: nil` it asserts every scoped record comes back, and `scoped_order: ->(records) { ... }` asserts their order.
10
+ * `create with block` and `destroy with block` accept a redirect or a 2xx render, so a block that redirects on success (the README's HTML example) passes. `edit` and `update with finder` accept the same when they use a block.
11
+ * `new with build` and `create with build` assert ownership by the current user only when an `owner_association` is configured. For `parent:`-built records they check that the hook built the record.
12
+ * `make_policies_succeed` takes `permissive: true`. The `show`, `new` and `edit` "with block" examples use it, so a block can ask the policy about other actions, such as `update?`, without failing the strict double.
13
+
14
+ ### V0.7.0
15
+
16
+ Serializer support:
17
+ * `serializer:` accepts a plain class exposing a `.render` class method, with no base class or gem dependency. simple_crud calls `serializer.render(record, **options)`, so a `def self.render(record, key: default)` signature works untouched. It checks this after Blueprinter's `render_as_hash` and before the ActiveModelSerializers fallback, leaving existing Blueprinter and AMS apps unaffected.
18
+
19
+ New options:
20
+ * `owned_by:` (Symbol): names an association on `Config.user_method` (`current_user.posts` for `owned_by: :posts`) and supplies the default `finder:`/`build:`/index `scope:` from it, so an owned resource stops spelling out `finder: ->(params) { current_user.posts.find(params[:id]) }` and `build: -> { current_user.posts.build }` on every action. An explicit `finder:`/`build:`/`scope:` overrides the default for that option alone. On `:index`, `owned_by:` takes precedence over the Pundit `policy_scope`, and an explicit `scope:` overrides both. With no current user, an owned `:index` returns no records and an owned `:show`/`:update`/`:destroy` returns `not_found`.
21
+ * `parent:` names a nested route's parent and derives the same defaults from `parent.public_send(parent_association)`. A Symbol resolves a controller method first, then an `@ivar`, so a conventional `before_action` works unchanged; a `Proc` runs in controller context. `parent_association:` defaults to the controller model's plural (`ExamsController` gives `@category.exams`). An explicit `finder:`/`build:`/`scope:` overrides the default, and a nil parent fails closed (404 on find/build, empty index). `parent:` and `owned_by:` both resolve a relation from an owner, so setting both raises `ArgumentError`.
22
+
23
+ Behavior changes:
24
+ * `scope:` on `:index` accepts a `Symbol` naming a class method on the model, called as `klass.send(scope, user, params)`. Query composition can live on the model instead of inline in the controller.
25
+ * `scope:` lambdas run with the controller as `self`, so a scope can read `@ivars` and route-parent state. Arity 0 receives no arguments (it previously errored by being passed two), arity 1 the user, anything else the user and params.
26
+ * `simple_crud_for` accepts an Array of actions to declare several at once: `simple_crud_for %i[show create update destroy], owned_by: :tasks, serializer: TaskSerializer`.
27
+ * `notice:`/`alert:` on HTML `:create`/`:update`/`:destroy`: set a flash message on the success redirect (`notice`) or the failure re-render (`alert`).
28
+
3
29
  ### V0.6.0
4
30
 
5
31
  Serializer support:
data/README.md CHANGED
@@ -18,6 +18,8 @@ SimpleCrud
18
18
  - [Serializer](#serializer)
19
19
  - [HTML](#html)
20
20
  - [Finder](#finder)
21
+ - [Owned by](#owned-by)
22
+ - [Parent](#parent)
21
23
  - [Cache](#cache)
22
24
  - [Controller-level defaults](#controller-level-defaults)
23
25
  - [Shared examples](#shared-examples)
@@ -94,6 +96,12 @@ simple_crud_for :new
94
96
  simple_crud_for :edit
95
97
  ```
96
98
 
99
+ Declare several actions with the same options at once by passing an Array:
100
+ ```ruby
101
+ simple_crud_for %i[show create update destroy], owned_by: :tasks, serializer: TaskSerializer
102
+ simple_crud_for :destroy, status: :no_content # per-action override on top of the batch
103
+ ```
104
+
97
105
  Each method supports different options, as in:
98
106
  ```
99
107
  simple_crud_for :index, paginate: false, authorize: false, serializer: CustomSerializer
@@ -108,11 +116,13 @@ simple_crud_for :index, paginate: false, authorize: false, serializer: CustomSer
108
116
  - Status: only valid for `:create`, `:update` and `:destroy`. Overrides the success status (`:created`, `:ok` and `:ok` by default). `status: :no_content` responds with an empty body
109
117
  - After_persist: only valid for `:create`, `:update` and `:destroy`. A `Proc`/`lambda` `->(record, saved) { ... }` that runs with the controller as `self`, after the write and before rendering. Use it for side effects like cache invalidation. It still runs when you pass a render block
110
118
  - Html: renders the action's ERB template instead of JSON (valid for `:index`, `:show`, `:new`, `:edit`, `:create`, `:update` and `:destroy`). Only meaningful in controllers that render templates
111
- - Scope: only valid for `:index`. A `Proc`/`lambda` taking `current_user` (plus the controller's `params` if it takes a second argument) that returns the relation to list, overriding the default `policy_scope`. The user is resolved via `SimpleCrud::Config.user_method` (`:current_user` by default; set it to e.g. `:current_admin`)
119
+ - Scope: only valid for `:index`. A `Proc`/`lambda` that runs with the controller as `self` (so `@ivars` and route-parent state are available) and returns the relation to list, overriding the default `policy_scope`. It takes `current_user` (plus the controller's `params` when it takes a second argument); an arity-0 lambda gets no arguments. The user is resolved via `SimpleCrud::Config.user_method` (`:current_user` by default; set it to e.g. `:current_admin`)
112
120
  - Finder: only valid for `:show`, `:update`, `:destroy` and `:edit`. A `Proc`/`lambda` (invoked with the controller's params) or a `Symbol` naming a class method on the model, used to look up the record instead of `klass.find(params[:id])`.
113
121
  - Build: only valid for `:new` and `:create`. A `Proc`/`lambda` that builds the record (invoked with the controller as `self`, so `current_user`, `params` and any instance variables are available), for building nested or owner-scoped records like `current_user.projects.build`. `:create` then assigns the permitted params to the built record before saving
114
122
  - Raise_on_invalid: only valid for `:create` and `:update`. Keeps the strict `create!`/`update!` semantics (raising on invalid input) instead of returning `422` with the validation errors
115
123
  - Redirect: only valid for HTML-mode `:create`, `:update` and `:destroy`. A `Proc`/`lambda` (called with the record) or a literal path used as the success redirect target. Defaults to the record (`:create`/`:update`) or the model's collection path (`:destroy`)
124
+ - Notice: only valid for HTML-mode `:create`, `:update` and `:destroy`. A flash message set on the success redirect. No-op when the controller has no `flash`
125
+ - Alert: only valid for HTML-mode `:create`, `:update` and `:destroy`. A `flash.now` message set before re-rendering on failure. No-op when the controller has no `flash`
116
126
 
117
127
  #### Controller-level defaults
118
128
 
@@ -203,12 +213,19 @@ end
203
213
 
204
214
  ```
205
215
 
206
- When `authorize: true`, the `:index` action paginates the Pundit `policy_scope` of the model (falling back to the full relation when no `Scope` is defined) instead of `klass.all`, so "only my records" scoping works out of the box. Override the scope per action with the `scope:` option, a callable that receives the user resolved via `SimpleCrud::Config.user_method` (`nil` when there is none, and `params` too when it takes a second argument):
216
+ When `authorize: true`, the `:index` action paginates the Pundit `policy_scope` of the model (falling back to the full relation when no `Scope` is defined) instead of `klass.all`, so "only my records" scoping works out of the box. Override the scope per action with `scope:`: a callable that receives the user resolved via `SimpleCrud::Config.user_method` (`nil` when there is none, and `params` too when it takes a second argument), or a `Symbol` naming a class method on the model so query composition lives on the model:
207
217
 
208
218
  ```ruby
209
219
  SimpleCrud.configure { |c| c.user_method = :current_admin } # non-Devise naming conventions
210
220
  simple_crud_for :index, scope: ->(user) { Model.visible_to(user) }
211
221
  simple_crud_for :index, scope: ->(user, params) { Model.where(status: params[:status]).visible_to(user) }
222
+ simple_crud_for :index, scope: :visible_to # calls Model.visible_to(user, params)
223
+ ```
224
+
225
+ A `scope:` lambda runs with the controller as `self`, so it can reach `@ivars` and route-parent context. Arity 0 receives no arguments, arity 1 the user, anything else the user and params:
226
+
227
+ ```ruby
228
+ simple_crud_for :index, scope: -> { @category.exams.includes(:questions).order(:title) }
212
229
  ```
213
230
 
214
231
  Prefer CanCanCan or Action Policy instead? Both have adapters ready to go:
@@ -272,7 +289,7 @@ simple_crud_defaults authenticate: false, authenticate_headers: true
272
289
  ```
273
290
 
274
291
  #### Serializer
275
- `serializer:` picks the serializer for JSON responses on every action. Pass either an [ActiveModelSerializers](https://github.com/rails-api/active_model_serializers) serializer class, which simple_crud builds per record with `serializer.new(record, serializer_options)`, or a [Blueprinter](https://github.com/blueprinter/blueprinter) blueprint class, which it renders with `blueprint.render_as_hash(record, serializer_options)`.
292
+ `serializer:` picks the serializer for JSON responses on every action. Pass an [ActiveModelSerializers](https://github.com/rails-api/active_model_serializers) serializer class, which simple_crud builds per record with `serializer.new(record, serializer_options)`; a [Blueprinter](https://github.com/blueprinter/blueprinter) blueprint class, rendered with `blueprint.render_as_hash(record, serializer_options)`; or any plain class of your own that responds to `.render`, called as `serializer.render(record, **serializer_options)`, with no base class or extra gem required.
276
293
 
277
294
  Without `serializer:`, the response uses the record's `as_json`. The shared examples expect an `:id` attribute. Paginated `:index` with Blueprinter needs wor-paginate ≥ 0.5.
278
295
 
@@ -286,6 +303,13 @@ class AuthorBlueprint < Blueprinter::Base
286
303
  fields :email, :first_name, :last_name
287
304
  end
288
305
 
306
+ # A plain class works too, with no dependency on either gem above:
307
+ class AuthorPlainSerializer
308
+ def self.render(author, **)
309
+ { id: author.id, email: author.email, first_name: author.first_name, last_name: author.last_name }
310
+ end
311
+ end
312
+
289
313
  simple_crud_for :show, serializer: AuthorBlueprint
290
314
  simple_crud_for :index, serializer: AuthorBlueprint, serializer_options: -> { { current_user: current_user } }
291
315
  ```
@@ -306,11 +330,13 @@ simple_crud_for :index, html: true
306
330
  simple_crud_for :show, html: true
307
331
  simple_crud_for :new, html: true
308
332
  simple_crud_for :edit, html: true
309
- simple_crud_for :create, html: true
310
- simple_crud_for :update, html: true
311
- simple_crud_for :destroy, html: true
333
+ simple_crud_for :create, html: true, notice: 'Created!', alert: 'Could not create.'
334
+ simple_crud_for :update, html: true, redirect: ->(record) { record_path(record) }, notice: 'Saved!'
335
+ simple_crud_for :destroy, html: true, notice: 'Removed.'
312
336
  ```
313
337
 
338
+ `notice:` sets a flash message on the success redirect; `alert:` sets a `flash.now` message before the failure re-render. Both are skipped when the controller has no `flash` (e.g. `ActionController::API`).
339
+
314
340
  Or pass a block that renders explicitly, overriding the auto-render. The block receives the records for `:index`, the record for `:show`/`:new`, or the record plus a saved flag for `:create`/`:update`/`:destroy`. Blocks do not change the `html:` default; pass it explicitly so the shared examples know which request format to use:
315
341
 
316
342
  ```ruby
@@ -357,6 +383,63 @@ simple_crud_for :destroy, finder: ->(params) { current_user.models.find(params[:
357
383
 
358
384
  When omitted it defaults to `klass.find(params[:id])`, and `not_found` is still returned whenever the finder finds no record.
359
385
 
386
+ #### Owned by
387
+ `owned_by:` covers a resource scoped to the current user's own records. It names an association on `Config.user_method` (`current_user` by default) and derives `finder:`, `build:` and the index `scope:` from it:
388
+
389
+ ```ruby
390
+ class PostsController < ApplicationController
391
+ simple_crud_defaults owned_by: :posts
392
+ simple_crud_for :index
393
+ simple_crud_for :show
394
+ simple_crud_for :create
395
+ simple_crud_for :update
396
+ simple_crud_for :destroy
397
+ end
398
+ ```
399
+
400
+ This is equivalent to writing, on every action:
401
+
402
+ ```ruby
403
+ simple_crud_for :index, scope: ->(user) { user.posts }
404
+ simple_crud_for :show, finder: ->(params) { current_user.posts.find(params[:id]) }
405
+ simple_crud_for :create, build: -> { current_user.posts.build }
406
+ simple_crud_for :update, finder: ->(params) { current_user.posts.find(params[:id]) }
407
+ simple_crud_for :destroy, finder: ->(params) { current_user.posts.find(params[:id]) }
408
+ ```
409
+
410
+ An explicit `finder:`, `build:` or `scope:` on an action overrides the `owned_by:` default for that option alone, so an owned `:show`/`:update`/`:destroy` can use a hand-written `:index` scope that needs extra filtering or eager loading. On `:index`, `owned_by:` takes precedence over the Pundit `policy_scope`; an explicit `scope:` overrides both. With no current user, an owned `:index` returns no records and an owned `:show`/`:update`/`:destroy` returns `not_found`, instead of falling back to the unscoped relation.
411
+
412
+ Keep the owner's foreign key out of strong params. If `owned_by: :posts` sets the owner through the built association, `params.permit(:user_id)` lets a request override it.
413
+
414
+ #### Parent
415
+ `parent:` covers a nested route. It derives the same `finder:`, `build:` and index `scope:` from an association on a parent resource instead of on the current user. Both options resolve an association on some owner, so setting them together raises `ArgumentError`.
416
+
417
+ `parent:` takes a `Symbol` that resolves a controller method first, then an `@ivar`. A `before_action` that sets `@category` works unchanged:
418
+
419
+ ```ruby
420
+ class ExamsController < ApplicationController
421
+ before_action :set_category
422
+
423
+ simple_crud_defaults parent: :category # reads the category method, else @category
424
+ simple_crud_for %i[index show new create], serializer: ExamSerializer, html: true
425
+
426
+ private
427
+
428
+ def set_category = @category = Category.find_by!(slug: params.expect(:category_slug))
429
+ end
430
+ ```
431
+
432
+ The association comes from the controller's model, so `ExamsController` gets `@category.exams`. Pass `parent_association:` when the name differs, or a `Proc` (run in controller context) for a parent that isn't an ivar or method:
433
+
434
+ ```ruby
435
+ simple_crud_for :show, parent: :category, parent_association: :published_exams
436
+ simple_crud_for :show, parent: -> { @listing.interview }
437
+ ```
438
+
439
+ As with `owned_by:`, an explicit `finder:`/`build:`/`scope:` overrides the derived default for that option, and a nil parent fails closed: `:show`/`:update`/`:destroy` return `not_found` and `:index` returns no records.
440
+
441
+ The same strong-params caveat applies: `:create` builds through the parent association and then assigns the permitted params, so never permit the parent's foreign key (for a `@category.exams` controller, don't permit `:category_id`).
442
+
360
443
  #### Cache
361
444
  Pass `cache: { key:, ttl: }` on `:show` or `:index` to skip the DB on cache hits.
362
445
 
@@ -428,11 +511,14 @@ The `create` and `update` examples cover the `422` response with validation erro
428
511
  ```ruby
429
512
  include_examples 'simple crud for new' # the :new action
430
513
  include_examples 'simple crud for edit' # the :edit action
514
+ include_examples 'simple crud for edit with finder' # finder on :edit
515
+ include_examples 'simple crud for edit with block' # render block on :edit
431
516
  include_examples 'simple crud for index with block' # render block
432
517
  include_examples 'simple crud for index with scope' # scope: ->(user) { ... }
433
518
  include_examples 'simple crud for show with block' # render block on :show
434
519
  include_examples 'simple crud for new with block' # render block on :new
435
520
  include_examples 'simple crud for create with block' # render block on :create
521
+ include_examples 'simple crud for update with block' # render block on :update
436
522
  include_examples 'simple crud for destroy with block' # render block on :destroy
437
523
  include_examples 'simple crud for show with finder' # finder on :show
438
524
  include_examples 'simple crud for update with finder' # finder on :update
@@ -441,6 +527,8 @@ include_examples 'simple crud for new with build' # build: -> { ... } on
441
527
  include_examples 'simple crud for create with build' # build: -> { ... } on :create
442
528
  ```
443
529
 
530
+ The `create with block`, `destroy with block`, `edit` and `update with finder` examples accept a 2xx render or a redirect. The `show`, `new` and `edit` block examples let the block ask the policy about other actions, such as `update?`. `new with build` and `create with build` check ownership by the current user only when you configure an `owner_association`.
531
+
444
532
  It's not needed to specify paginate: true and such, since the shared examples will use the configuration that was originally passed to simple_crud_for
445
533
 
446
534
  #### Adopting the shared examples
@@ -465,7 +553,7 @@ SimpleCrud::RSpec.configure do |config|
465
553
  # override model_attributes directly for multi-key or non-user-owned models).
466
554
  config.owner_association = :owner
467
555
  config.model_attributes = -> { { owner: current_user } }
468
- config.required_attribute = :title
556
+ config.required_attribute = :title # nil skips the invalid-attribute cases
469
557
  config.required_error = "Title can't be blank"
470
558
 
471
559
  # Redirect-based apps: unauthenticated requests get a redirect, not a 401.
@@ -504,7 +592,7 @@ SimpleCrud::RSpec.configure do |config|
504
592
  end
505
593
  ```
506
594
 
507
- Each setting has a sensible default, so you only override what differs. Callable settings (`current_user`, `authenticate`, `create_record`, `create_records`, `params_for`, `model_attributes`, `scoped_attributes`, `other_scoped_attributes`, `policy_class`, `serializer_class`) run in the example-group context, so they can call `request`, `create`, `current_user`, etc.; `created_record_check` receives the persisted record. The examples are controller-agnostic (they issue requests by action name, not hardcoded paths), so they work for namespaced and nested controllers alike. If you keep `assert_html_template` on (the default), add `gem 'rails-controller-testing'` for the `render_template` matcher.
595
+ Each setting has a sensible default, so you only override what differs. Callable settings (`current_user`, `authenticate`, `create_record`, `create_records`, `params_for`, `model_attributes`, `scoped_attributes`, `other_scoped_attributes`, `policy_class`, `serializer_class`) run in the example-group context, so they can call `request`, `create`, `current_user`, etc.; `created_record_check` receives the persisted record and `scoped_order` the created records. The examples are controller-agnostic (they issue requests by action name, not hardcoded paths), so they work for namespaced and nested controllers alike. If you keep `assert_html_template` on (the default), add `gem 'rails-controller-testing'` for the `render_template` matcher.
508
596
 
509
597
  #### Per-controller overrides via metadata
510
598
 
@@ -523,6 +611,26 @@ end
523
611
 
524
612
  Callable settings resolve in the example context, so their lambdas can close over the spec's `let`s; that's how records land in the same project the route params point at.
525
613
 
614
+ A scope that only orders excludes nothing. Set `other_scoped_attributes: nil` and `simple crud for index with scope` asserts that every scoped record comes back. `scoped_order` also asserts the order. It takes the created records and returns them in the expected order:
615
+
616
+ ```ruby
617
+ RSpec.describe UsersController, type: :controller, simple_crud: {
618
+ other_scoped_attributes: nil,
619
+ scoped_order: ->(records) { records.sort_by(&:email) }
620
+ } do
621
+ include_examples 'simple crud for index with scope'
622
+ end
623
+ ```
624
+
625
+ `singular: true` is a per-controller setting for singular resources (`resource :profile`). Requests carry no id, the "model does not exist" examples are skipped, and the controller's own finder, such as `finder: ->(_params) { current_user.profile }`, loads the record:
626
+
627
+ ```ruby
628
+ RSpec.describe ProfilesController, type: :controller, simple_crud: { singular: true } do
629
+ include_examples 'simple crud for show'
630
+ include_examples 'simple crud for update'
631
+ end
632
+ ```
633
+
526
634
  ## Contributing
527
635
 
528
636
  1. Fork it
@@ -57,11 +57,25 @@ module SimpleCrud
57
57
  end
58
58
 
59
59
  def find_by_id
60
- klass.find(controller.params[:id])
60
+ finder_scope.find(controller.params[:id])
61
61
  end
62
62
 
63
63
  def build_record
64
- parameters[:build] ? controller.instance_exec(&parameters[:build]) : klass.new
64
+ return controller.instance_exec(&parameters[:build]) if parameters[:build]
65
+ return relation_source.relation!.build if relation_source.configured?
66
+
67
+ klass.new
68
+ end
69
+
70
+ # Falls back to klass when owned_by:/parent: is unset.
71
+ def finder_scope
72
+ return klass unless relation_source.configured?
73
+
74
+ relation_source.relation!
75
+ end
76
+
77
+ def relation_source
78
+ @relation_source ||= SimpleCrud::RelationSource.new(controller, klass, parameters)
65
79
  end
66
80
 
67
81
  def permitted_params
@@ -46,20 +46,31 @@ module SimpleCrud
46
46
  end
47
47
 
48
48
  def index_relation
49
- if parameters[:scope]
50
- call_scope
51
- elsif parameters[:authorize]
52
- SimpleCrud::Config.authorization_adapter.policy_scope(controller, klass)
53
- else
54
- klass.all
55
- end
49
+ return call_scope if parameters[:scope]
50
+ return relation_source.relation || klass.none if relation_source.configured?
51
+
52
+ default_index_relation
53
+ end
54
+
55
+ def default_index_relation
56
+ return SimpleCrud::Config.authorization_adapter.policy_scope(controller, klass) if parameters[:authorize]
57
+
58
+ klass.all
56
59
  end
57
60
 
58
61
  def call_scope
59
- user_method = SimpleCrud::Config.user_method
60
- user = controller.respond_to?(user_method, true) ? controller.send(user_method) : nil
61
62
  scope = parameters[:scope]
62
- scope.arity == 1 ? scope.call(user) : scope.call(user, controller.params)
63
+ return klass.send(scope, relation_source.user, controller.params) unless scope.respond_to?(:call)
64
+
65
+ controller.instance_exec(*scope_arguments(scope), &scope)
66
+ end
67
+
68
+ def scope_arguments(scope)
69
+ case scope.arity
70
+ when 0 then []
71
+ when 1 then [relation_source.user]
72
+ else [relation_source.user, controller.params]
73
+ end
63
74
  end
64
75
 
65
76
  def index_records(relation, opts)
@@ -19,11 +19,22 @@ module SimpleCrud
19
19
  end
20
20
 
21
21
  def render_html_redirect(record, saved, options)
22
- if saved
23
- controller.redirect_to(redirect_target(record, options[:redirect]))
24
- else
25
- controller.render(options[:failure_template])
26
- end
22
+ return render_html_failure(options) unless saved
23
+
24
+ set_flash(:notice, parameters[:notice])
25
+ controller.redirect_to(redirect_target(record, options[:redirect]))
26
+ end
27
+
28
+ def render_html_failure(options)
29
+ set_flash(:alert, parameters[:alert], now: true)
30
+ controller.render(options[:failure_template])
31
+ end
32
+
33
+ def set_flash(key, message, now: false)
34
+ return if message.nil? || !controller.respond_to?(:flash)
35
+
36
+ target = now ? controller.flash.now : controller.flash
37
+ target[key] = message
27
38
  end
28
39
 
29
40
  def redirect_target(record, target)
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleCrud
4
+ # Resolves the owner shared by owned_by: and parent:.
5
+ class RelationSource
6
+ def initialize(controller, klass, parameters)
7
+ @controller = controller
8
+ @klass = klass
9
+ @parameters = parameters
10
+ end
11
+
12
+ def configured?
13
+ !owned_by.nil? || !parent.nil?
14
+ end
15
+
16
+ # nil when the owner can't be resolved.
17
+ def relation
18
+ return owner.relation if owned_by
19
+ return parent_source.relation if parent
20
+
21
+ nil
22
+ end
23
+
24
+ def relation!
25
+ relation || raise(ActiveRecord::RecordNotFound, "no #{description} to scope the record to")
26
+ end
27
+
28
+ def user
29
+ Source.user(controller)
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :controller, :klass, :parameters
35
+
36
+ def owned_by = parameters[:owned_by]
37
+
38
+ def parent = parameters[:parent]
39
+
40
+ def description
41
+ owned_by ? "owner for #{owned_by}" : "parent for #{parent}"
42
+ end
43
+
44
+ def owner
45
+ OwnerSource.new(controller, parameters[:owned_by])
46
+ end
47
+
48
+ def parent_source
49
+ ParentSource.new(controller, klass, parameters)
50
+ end
51
+
52
+ # Shared by both relation sources.
53
+ module Source
54
+ def self.user(controller)
55
+ user_method = SimpleCrud::Config.user_method
56
+ controller.respond_to?(user_method, true) ? controller.send(user_method) : nil
57
+ end
58
+ end
59
+
60
+ # nil without a user.
61
+ class OwnerSource
62
+ def initialize(controller, association)
63
+ @controller = controller
64
+ @association = association
65
+ end
66
+
67
+ def relation
68
+ Source.user(@controller)&.public_send(@association)
69
+ end
70
+ end
71
+
72
+ # A Symbol resolves method then @ivar.
73
+ class ParentSource
74
+ def initialize(controller, klass, parameters)
75
+ @controller = controller
76
+ @klass = klass
77
+ @parameters = parameters
78
+ end
79
+
80
+ def relation
81
+ resolved&.public_send(association)
82
+ end
83
+
84
+ private
85
+
86
+ def resolved
87
+ reference = @parameters[:parent]
88
+ return @controller.instance_exec(&reference) if reference.respond_to?(:call)
89
+
90
+ by_method_or_ivar(reference)
91
+ end
92
+
93
+ def by_method_or_ivar(reference)
94
+ return @controller.send(reference) if @controller.respond_to?(reference, true)
95
+
96
+ ivar = :"@#{reference}"
97
+ return @controller.instance_variable_get(ivar) if @controller.instance_variable_defined?(ivar)
98
+
99
+ raise ArgumentError, "parent: #{reference} is neither a controller method nor an @#{reference} ivar"
100
+ end
101
+
102
+ def association
103
+ @parameters[:parent_association] || @klass.model_name.plural.to_sym
104
+ end
105
+ end
106
+ end
107
+ end
@@ -43,6 +43,8 @@ module SimpleCrud
43
43
  owner_association ? attributes.merge(owner_association => other_user) : {}
44
44
  },
45
45
  required_attribute: :name,
46
+ singular: false,
47
+ scoped_order: nil,
46
48
  required_error: "Name can't be blank",
47
49
  finder_key: :slug,
48
50
  params_key: nil,
@@ -11,7 +11,7 @@ module SimpleCrud
11
11
  end
12
12
 
13
13
  %i[paginate authorize authenticate serializer serializer_options status after_persist html finder
14
- scope build raise_on_invalid block authenticate_headers].each do |option|
14
+ scope build raise_on_invalid block authenticate_headers owned_by parent].each do |option|
15
15
  define_method("check_#{option}") { |method| get_option(method, option) }
16
16
  end
17
17
 
@@ -15,9 +15,9 @@ module SimpleCrud
15
15
  .and_return(instance_double(policy_class_object, "#{method}?" => false))
16
16
  end
17
17
 
18
- def make_policies_succeed(method)
19
- allow(policy_class_object).to receive(:new)
20
- .and_return(instance_double(policy_class_object, "#{method}?" => true))
18
+ def make_policies_succeed(method, permissive: false)
19
+ policy = instance_double(policy_class_object, "#{method}?" => true)
20
+ allow(policy_class_object).to receive(:new).and_return(permissive ? policy.as_null_object : policy)
21
21
  end
22
22
  end
23
23
  end
@@ -29,8 +29,11 @@ module SimpleCrud
29
29
  end
30
30
 
31
31
  # The params that identify a record for the given action, using the
32
- # configured finder_key when the action has a custom finder.
32
+ # configured finder_key when the action has a custom finder. A singular
33
+ # resource has no id, so it identifies nothing.
33
34
  def record_param(action, record, not_found: false)
35
+ return {} if singular
36
+
34
37
  key = check_finder(action) ? finder_key : :id
35
38
  value = not_found ? "nonexistent-#{key}" : record.public_send(key)
36
39
  { key => value }
@@ -24,10 +24,19 @@ module SimpleCrud
24
24
  end
25
25
 
26
26
  %i[owner_association required_attribute required_error finder_key params_key invalid_status
27
- unauthenticated_status assert_html_template scoped_attributes other_scoped_attributes].each do |name|
27
+ unauthenticated_status assert_html_template scoped_attributes other_scoped_attributes
28
+ singular].each do |name|
28
29
  define_method(name) { resolve(setting(name)) }
29
30
  end
30
31
 
32
+ def invalid_case?(action)
33
+ !required_attribute.nil? && !check_raise_on_invalid(action)
34
+ end
35
+
36
+ def scoped_order
37
+ setting(:scoped_order)
38
+ end
39
+
31
40
  def created_record_check
32
41
  setting(:created_record_check)
33
42
  end
@@ -11,6 +11,9 @@ require_relative '../spec/shared_examples/simple_crud_for_destroy'
11
11
  require_relative '../spec/shared_examples/simple_crud_for_create'
12
12
  require_relative '../spec/shared_examples/simple_crud_for_new'
13
13
  require_relative '../spec/shared_examples/simple_crud_for_edit'
14
+ require_relative '../spec/shared_examples/simple_crud_for_edit_with_finder'
15
+ require_relative '../spec/shared_examples/simple_crud_for_edit_with_block'
16
+ require_relative '../spec/shared_examples/simple_crud_for_update_with_block'
14
17
  require_relative '../spec/shared_examples/unauthorized_when_not_logged_in'
15
18
  require_relative '../spec/shared_examples/simple_crud_renders_template'
16
19
  require_relative '../spec/shared_examples/simple_crud_without_authenticated_user'
@@ -27,8 +30,11 @@ require_relative '../spec/shared_examples/simple_crud_for_destroy_with_finder'
27
30
  require_relative '../spec/shared_examples/simple_crud_not_found_with_finder'
28
31
  require_relative '../spec/shared_examples/simple_crud_for_new_with_build'
29
32
  require_relative '../spec/shared_examples/simple_crud_for_create_with_build'
33
+ require_relative '../spec/shared_examples/simple_crud_for_owned_resource'
34
+ require_relative '../spec/shared_examples/simple_crud_for_nested_resource'
30
35
  require_relative '../spec/shared_examples/authorization_adapter_authorize'
31
36
  require_relative '../spec/matchers/have_been_serialized_with'
37
+ require_relative '../spec/matchers/render_or_redirect'
32
38
 
33
39
  module SimpleCrud
34
40
  # Opt-in wiring for the shared examples: see .install!
@@ -1,17 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SimpleCrud
4
- # Renders a record with the app's serializer stack.
5
- #
6
- # Blueprinter blueprints respond to `render_as_hash` and receive the
7
- # render options, so a blueprint can read `url_builder:` (or any other
8
- # option) from them. For anything else, simple_crud assumes an
9
- # ActiveModelSerializers-style serializer and calls `new(record, options)`.
10
- # A `nil` serializer renders `record.as_json`.
4
+ # Precedence: Blueprinter, then a plain class .render, then ActiveModelSerializers.
11
5
  module Serializer
12
6
  def self.render(serializer, record, options = {})
13
7
  return record.as_json if serializer.nil?
14
8
  return serializer.render_as_hash(record, options) if serializer.respond_to?(:render_as_hash)
9
+ return serializer.render(record, **options) if serializer.is_a?(Class) && serializer.respond_to?(:render)
15
10
 
16
11
  serializer.new(record, options).as_json
17
12
  end