activeadmin-graphql 0.3.3 → 0.4.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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -1
  3. data/docs/changelogs/20261012200000_host-customization-hooks.md +27 -0
  4. data/docs/changelogs/20261013080000_release-0-4-0.md +48 -0
  5. data/docs/graphql-api.md +53 -6
  6. data/docs/issues/20261012200000_host-customization-gaps.md +26 -0
  7. data/lib/active_admin/graphql/capability_definition_dsl.rb +9 -4
  8. data/lib/active_admin/graphql/collection_query_policy.rb +103 -0
  9. data/lib/active_admin/graphql/integration.rb +24 -0
  10. data/lib/active_admin/graphql/member_action_capability.rb +14 -0
  11. data/lib/active_admin/graphql/member_action_dry_run.rb +121 -0
  12. data/lib/active_admin/graphql/resource_capability_dsl.rb +4 -3
  13. data/lib/active_admin/graphql/resource_capability_maps.rb +20 -2
  14. data/lib/active_admin/graphql/resource_definition_dsl.rb +17 -6
  15. data/lib/active_admin/graphql/run_action_capability.rb +62 -0
  16. data/lib/active_admin/graphql/run_action_control_extension.rb +72 -0
  17. data/lib/active_admin/graphql/run_action_field.rb +103 -0
  18. data/lib/active_admin/graphql/run_action_mutation_config.rb +8 -0
  19. data/lib/active_admin/graphql/run_action_mutation_dsl.rb +18 -0
  20. data/lib/active_admin/graphql/run_action_preview.rb +20 -0
  21. data/lib/active_admin/graphql/schema_builder/mutation_batch.rb +2 -2
  22. data/lib/active_admin/graphql/schema_builder/mutation_collection.rb +4 -3
  23. data/lib/active_admin/graphql/schema_builder/mutation_member.rb +8 -3
  24. data/lib/active_admin/graphql/schema_builder/resolvers.rb +46 -7
  25. data/lib/active_admin/graphql/schema_builder/types_object.rb +2 -1
  26. data/lib/active_admin/graphql/schema_builder/wire.rb +2 -1
  27. data/lib/active_admin/graphql/schema_field.rb +85 -10
  28. data/lib/active_admin/graphql/version.rb +1 -1
  29. data/lib/active_admin/graphql.rb +7 -0
  30. metadata +11 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 36bec145bd90193ae32e34aa54b7a6ed01dff13a8b104269b771f797262b2480
4
- data.tar.gz: 82724e08af33a67a1a9146813965e5d836006620ff503099842addcdeaab16cb
3
+ metadata.gz: c03a6abe474dea238c1dec40f75bb03b2f3203e4874d4639e2327a0562c9a9ce
4
+ data.tar.gz: 3bb9bd9cf8b55e796cf87cf8f51dca817b41b5ae9a515132ff0e56a218afe6db
5
5
  SHA512:
6
- metadata.gz: 0b9ddc6e9d85447807e5cbbf9d1e5c9b5c87b756eaa8325cb57fe8ce291e221b40e1afd200ddb683a73cba036b83b6eac6f8f5ab3e0ff9788c1864e7f550cc16
7
- data.tar.gz: 806443d03640f31e48d893998cd9f693bafd5efce162798ff406ed2eee2ad43d7c0c588467b8fbd37fdab4a3a6049a9efcbadffbe337469cac7bc5ff958bf36f
6
+ metadata.gz: 9e0a6930217123a83432a072410be640d618371c062eb83e27c86786461218f4d48d524edd9a7c00749914ac3c216d015d565fbc60a7f5fcdf7fc0d2f57d48b7
7
+ data.tar.gz: 639363956ab8b19349df04e7a5c5f9f6610705206b3d7d8f82838cde74b4dc10ab5e7b53f993def3c2de016885d1a1ecd267698252b877f655c87b49ef1a8f9f
data/CHANGELOG.md CHANGED
@@ -1,6 +1,22 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.4.0 (2026-10-13)
4
+
5
+ - Add field `capability_mode` (`:omit` / `:null`) and namespace hooks
6
+ `graphql_field_capability_mode_default` and `graphql_field_visible` (RFC 0009).
7
+ - Add `member_action_mutation … dry_run: true` with `preview`, `extra_params`,
8
+ `ok` / `refuse`, and optional `graphql_run_action_audit` (RFC 0010).
9
+ - Add `graphql_collection_query_policy` and
10
+ `CollectionQueryPolicy.require_visible_fields!`, inferring filter grants from
11
+ resource field capabilities by default (RFC 0011).
12
+ - Add `graphql_member_action_capability_default`,
13
+ `graphql_batch_action_capability_default`,
14
+ `graphql_collection_action_capability_default`, and `capability:` on
15
+ `batch_action_mutation` / `member_action_mutation` /
16
+ `collection_action_mutation` (RFC 0012).
17
+ - Add `dry_run:` / `confirm:` / `preview:` on `SchemaField`, preview
18
+ short-circuit via `RunActionControlExtension`, and
19
+ `RunActionField.define` DSL sugar for custom run-action fields (RFC 0013).
4
20
 
5
21
  ## 0.3.3 (2026-10-12)
6
22
 
@@ -0,0 +1,27 @@
1
+ # Host customization hooks
2
+
3
+ ## Decisions
4
+
5
+ Ship generic namespace hooks and DSL for field capability null mode, member-action
6
+ dry-run with preview, collection query policy, default run-action capabilities,
7
+ and confirm/dry-run/preview plus RunActionField.define for custom mutations.
8
+ Product allowlists and auth grants stay in the host application.
9
+
10
+ ## Effects
11
+
12
+ RFCs 0009 through 0013 claimed and drafted. Implementation under 0.4.0 in
13
+ CHANGELOG.md. Request specs cover null-mode introspection, dry-run short-circuit,
14
+ collection filter refusal, default capability omit, and confirm_required.
15
+
16
+ ## Next
17
+
18
+ Metadata prepared in docs/changelogs/20261013080000_release-0-4-0.md. Run make
19
+ release when ready to push the gem and tag. Host apps can drop prepends that
20
+ duplicated these mechanics after upgrading.
21
+
22
+ ## Source
23
+
24
+ RFCs 0009–0013. Specs under spec/requests/graphql_capability_mode_spec.rb,
25
+ graphql_member_action_dry_run_spec.rb, graphql_collection_query_policy_spec.rb,
26
+ graphql_run_action_capability_default_spec.rb,
27
+ graphql_run_action_confirm_spec.rb.
@@ -0,0 +1,48 @@
1
+ # Release 0.4.0 metadata
2
+
3
+ ## Participants
4
+
5
+ Andrei Makarov
6
+
7
+ ## Decisions
8
+
9
+ Cut 0.4.0 after published 0.3.3. User-visible work since the 0.3.3 tag:
10
+ field capability null mode and visibility hooks (RFC 0009), member-action
11
+ dry-run with preview and optional audit (RFC 0010), collection query policy
12
+ with filter grants inferred from field capabilities (RFC 0011), default
13
+ run-action capabilities and per-mutation `capability:` (RFC 0012), and
14
+ confirm / dry-run / preview plus `RunActionField.define` for custom
15
+ run-action fields (RFC 0013). Product allowlists and auth grants stay in the
16
+ host application.
17
+
18
+ CHANGELOG Unreleased bullets moved under 0.4.0 dated 2026-10-13.
19
+
20
+ Version constant is 0.4.0. Path gem pins in Gemfile.lock and appraisal
21
+ lockfiles match.
22
+
23
+ make release (usr/bin/release.rb) still owns gem build, gem push, git tag, and
24
+ GitHub release. This pass only prepares metadata.
25
+
26
+ ## Effects
27
+
28
+ lib/active_admin/graphql/version.rb VERSION is 0.4.0.
29
+
30
+ CHANGELOG host-customization bullets live under 0.4.0.
31
+
32
+ PATH specs for activeadmin-graphql in Gemfile.lock,
33
+ gemfiles/rails72.gemfile.lock, gemfiles/rails8ruby4.gemfile.lock, and
34
+ gemfiles/rails8ruby34.gemfile.lock are 0.4.0.
35
+
36
+ ## Next
37
+
38
+ Run make release from a clean tree when ready to push the gem and tag 0.4.0.
39
+
40
+ ## Source
41
+
42
+ docs/changelogs/20261012200000_host-customization-hooks.md
43
+ rfcs/0009-field-capability-mode.md
44
+ rfcs/0010-member-action-dry-run.md
45
+ rfcs/0011-collection-query-policy.md
46
+ rfcs/0012-run-action-capability-defaults.md
47
+ rfcs/0013-run-action-confirm-dry-run.md
48
+ usr/bin/release.rb
data/docs/graphql-api.md CHANGED
@@ -108,6 +108,22 @@ Each namespace inherits these options (defaults in parentheses):
108
108
  Cannot be combined with named `graphql_visibility` `profiles:`; schema build
109
109
  forces `dynamic: true` and omits `visibility_profile` from the request context
110
110
  so introspection uses the live capability set.
111
+ * `graphql_field_capability_mode_default` — `:omit` or `:null` when a field has a
112
+ capability but no per-field mode (RFC 0009). Default `:omit`.
113
+ * `graphql_field_visible` — optional `Proc` `(context, meta) -> Boolean` to omit
114
+ fields after capability checks (RFC 0009). Meta includes `graphql_type_name`,
115
+ `field_name`, and `resource` when known.
116
+ * `graphql_run_action_audit` — optional `Proc` `(context, meta) -> void` after
117
+ member-action dry-run, refused preview, or completed run (RFC 0010).
118
+ * `graphql_collection_query_policy` — optional `Proc` `(context, meta) -> void`
119
+ at the start of collection resolve (RFC 0011). Meta includes `aa_resource`,
120
+ `q`, `scope`, `order`, and `filter`.
121
+ * `graphql_member_action_capability_default` — optional capability symbol applied
122
+ to member-action mutation fields when the resource map has no entry (RFC 0012).
123
+ * `graphql_batch_action_capability_default` — same for the aggregate batch-action
124
+ field and per-action authorize checks (RFC 0012).
125
+ * `graphql_collection_action_capability_default` — same for collection-action
126
+ mutation fields (RFC 0012).
111
127
  * `graphql_configure_schema` — `Proc` receiving the schema class after build
112
128
  (extensions, extra types, and so on).
113
129
 
@@ -284,14 +300,27 @@ admin.graphql_capabilities = lambda { |ctx|
284
300
 
285
301
  A gated member fails closed when the capability is missing from that set:
286
302
  resolve returns null for the field (graphql-ruby unauthorized field). Enable
287
- `graphql_visibility` so introspection and validation also omit ungated members.
303
+ `graphql_visibility` so introspection and validation also omit ungated members
304
+ when the field mode is `:omit` (default). Use `capability_mode: :null` (or
305
+ `capability :pii, fields: %i[body], mode: :null`) so the field stays in the
306
+ schema and resolves to `null` without the grant (RFC 0009).
307
+
288
308
  `fields:` applies to generated attributes and wired `belongs_to` associations.
289
309
  Unknown field names raise at schema build. Capabilities do not replace the
290
310
  authorization adapter.
291
311
 
292
- Custom `SchemaField` definitions may pass `capability: :name` the same way.
293
- Do not use named visibility `profiles:` together with `graphql_capabilities`
294
- (cached profile context ignores per-request grants).
312
+ Custom `SchemaField` definitions may pass `capability: :name` and
313
+ `capability_mode: :null` the same way. Set `graphql_field_visible` for a second
314
+ omit gate (for example a request allowlist). Do not use named visibility
315
+ `profiles:` together with `graphql_capabilities` (cached profile context ignores
316
+ per-request grants).
317
+
318
+ Collection list fields may call a host `graphql_collection_query_policy`. The
319
+ helper `ActiveAdmin::GraphQL::CollectionQueryPolicy.require_visible_fields!`
320
+ rejects Ransack `q` keys for hidden fields and requires capabilities from the
321
+ resource field map by default (needed for `capability_mode: :null` columns).
322
+ Optional `field_grants` overrides inference; `infer_field_grants: false` turns
323
+ it off (RFC 0011).
295
324
 
296
325
  ## Schema overview
297
326
 
@@ -572,8 +601,12 @@ graphql do
572
601
  type(Members::MemberRunPayload)
573
602
  end
574
603
 
575
- member_action_mutation(:send_invoice) do
604
+ member_action_mutation(:send_invoice, dry_run: true, capability: :support_act) do
576
605
  type(Members::SendInvoicePayload)
606
+ preview do |record, **|
607
+ record.may_send_invoice? ? ok("would_run:send_invoice:#{record.id}") : refuse("not_ready")
608
+ end
609
+ extra_params :note
577
610
  arguments do
578
611
  argument :note, GraphQL::Types::String, required: false, camelize: false
579
612
  end
@@ -583,7 +616,21 @@ end
583
616
 
584
617
  Call the named field as `posts_member_send_invoice(id: "...", note: "…")`. Extra
585
618
  arguments are merged with `params` for the controller and passed as keyword args
586
- to a custom `resolve` block.
619
+ to a custom `resolve` block. With `dry_run: true`, the field gains a Boolean
620
+ `dry_run` argument: preview runs first, dry-run returns without
621
+ `run_member_action`, and a refused preview never runs the action (RFC 0010).
622
+ `extra_params` allowlists which GraphQL kwargs forward into the AA request
623
+ (`id` cannot be rebound). `capability:` on batch / member / collection mutation
624
+ DSL overrides the resource map; otherwise the matching
625
+ `graphql_*_action_capability_default` applies (RFC 0012).
626
+
627
+ Custom mutations in `graphql_configure_schema` may use
628
+ `ActiveAdmin::GraphQL::RunActionField.define` (or `SchemaField` keywords
629
+ `dry_run:`, `confirm:`, `preview:`) for confirm/dry-run wiring (RFC 0013). When
630
+ `confirm` is required and both flags are false, the field returns
631
+ `body: "confirm_required"`. When `preview` is set, dry-run and refused preview
632
+ return without calling `resolve`; without `preview`, dry-run still calls
633
+ `resolve` with `dry_run: true`.
587
634
 
588
635
  With no symbol argument, `member_action_mutation { … }` configures only the
589
636
  aggregate `posts_member_action` field. With a symbol, it configures the
@@ -0,0 +1,26 @@
1
+ # Host customization gaps
2
+
3
+ ## Decisions
4
+
5
+ The gem owns mechanics: when a field is visible, null, or omitted; dry-run
6
+ argument wiring; filter-key policy hooks; default member-action capability;
7
+ confirm/dry_run injection on custom fields. Hosts own product grants, domain
8
+ preview bodies, and audit storage.
9
+
10
+ Capabilities remain grant names in context[:capabilities]. Named visibility
11
+ profiles stay incompatible with graphql_capabilities.
12
+
13
+ ## Effects
14
+
15
+ RFCs 0009–0013 specify the contract. Implementation landed on the feature
16
+ branch with request coverage for each hook.
17
+
18
+ ## Next
19
+
20
+ Publish a release. Hosts migrate off local prepends in the order: capability
21
+ null mode and dry-run DSL, then collection policy, then field_visible and
22
+ capability defaults, then confirm helpers.
23
+
24
+ ## Source
25
+
26
+ RFCs 0009–0013. docs/changelogs/20261012200000_host-customization-hooks.md.
@@ -2,7 +2,7 @@
2
2
 
3
3
  module ActiveAdmin
4
4
  module GraphQL
5
- # Block body for +capability :name do ... end+ (RFC 0008).
5
+ # Block body for +capability :name do ... end+ (RFC 0008, RFC 0009).
6
6
  class CapabilityDefinitionDSL
7
7
  QUERY_ALIASES = {
8
8
  index: :collection,
@@ -14,13 +14,18 @@ module ActiveAdmin
14
14
  delete: :destroy
15
15
  }.freeze
16
16
 
17
- def initialize(config, capability_name)
17
+ def initialize(config, capability_name, mode: nil)
18
18
  @config = config
19
19
  @capability_name = capability_name.to_sym
20
+ @mode = mode
20
21
  end
21
22
 
22
- def fields(*names)
23
- @config.assign_field_capabilities!(@capability_name, names)
23
+ def fields(*names, mode: nil)
24
+ @config.assign_field_capabilities!(
25
+ @capability_name,
26
+ names,
27
+ mode: mode.nil? ? @mode : mode
28
+ )
24
29
  end
25
30
 
26
31
  def queries(*names)
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module GraphQL
5
+ # Built-in helpers for +graphql_collection_query_policy+ (RFC 0011).
6
+ module CollectionQueryPolicy
7
+ module_function
8
+
9
+ def require_visible_fields!(
10
+ context:,
11
+ aa_resource:,
12
+ q: nil,
13
+ order: nil,
14
+ allow_q_keys: [],
15
+ field_grants: {},
16
+ infer_field_grants: true
17
+ )
18
+ allow = Array(allow_q_keys).map(&:to_s)
19
+ grants = merge_field_grants(aa_resource, field_grants, infer: infer_field_grants)
20
+
21
+ normalize_q(q).each_key do |raw_key|
22
+ key = raw_key.to_s
23
+ next if allow.include?(key)
24
+
25
+ field_name = field_from_predicate_key(key)
26
+ unless field_name
27
+ raise ::GraphQL::ExecutionError, "unsupported collection filter key #{key.inspect}"
28
+ end
29
+
30
+ assert_field_filterable!(context, aa_resource, field_name, grants)
31
+ end
32
+
33
+ assert_order_filterable!(context, aa_resource, order, grants) if order.present?
34
+ end
35
+
36
+ def merge_field_grants(aa_resource, field_grants, infer: true)
37
+ explicit = field_grants.to_h.transform_keys(&:to_s).transform_values(&:to_sym)
38
+ return explicit unless infer
39
+
40
+ inferred = aa_resource.graphql_config.field_capabilities.to_h do |name, capability|
41
+ [name.to_s, capability.to_sym]
42
+ end
43
+ inferred.merge(explicit)
44
+ end
45
+
46
+ def normalize_q(q)
47
+ return {} if q.nil?
48
+ return q.to_unsafe_h if q.respond_to?(:to_unsafe_h)
49
+
50
+ q.respond_to?(:to_h) ? q.to_h : {}
51
+ end
52
+
53
+ def field_from_predicate_key(key)
54
+ predicates = ransack_predicate_names
55
+ predicates.sort_by(&:length).reverse_each do |predicate|
56
+ suffix = "_#{predicate}"
57
+ return key[0...-suffix.length] if key.end_with?(suffix) && key.length > suffix.length
58
+ end
59
+ nil
60
+ end
61
+
62
+ def ransack_predicate_names
63
+ if defined?(Ransack) && Ransack.respond_to?(:predicates)
64
+ Ransack.predicates.keys.map(&:to_s)
65
+ else
66
+ %w[eq cont start end gt lt gteq lteq in not_in null present blank matches does_not_match]
67
+ end
68
+ end
69
+
70
+ def assert_order_filterable!(context, aa_resource, order, grants)
71
+ column = order.to_s.sub(/_(asc|desc|ascs|descs)\z/i, "")
72
+ return if column.blank?
73
+
74
+ assert_field_filterable!(context, aa_resource, column, grants)
75
+ end
76
+
77
+ def assert_field_filterable!(context, aa_resource, field_name, grants)
78
+ unless field_schema_visible?(context, aa_resource, field_name)
79
+ raise ::GraphQL::ExecutionError, "not authorized to filter on #{field_name}"
80
+ end
81
+
82
+ capability = grants[field_name.to_s]
83
+ return if capability.nil?
84
+ return if Capability.granted?(context, capability)
85
+
86
+ raise ::GraphQL::ExecutionError, "not authorized to filter on #{field_name}"
87
+ end
88
+
89
+ def field_schema_visible?(context, aa_resource, field_name)
90
+ type_name = graphql_type_name_for(aa_resource)
91
+ type = context.schema.get_type(type_name, context)
92
+ return false unless type
93
+
94
+ !context.schema.get_field(type, field_name.to_s, context).nil?
95
+ end
96
+
97
+ def graphql_type_name_for(aa_resource)
98
+ aa_resource.graphql_config.graphql_type_name.presence ||
99
+ aa_resource.resource_class.name.delete_prefix("::").gsub("::", "__")
100
+ end
101
+ end
102
+ end
103
+ end
@@ -26,6 +26,12 @@ module ActiveAdmin
26
26
 
27
27
  def register_namespace_settings!
28
28
  ns = ActiveAdmin::NamespaceSettings
29
+ register_surface_namespace_settings!(ns)
30
+ register_capability_namespace_settings!(ns)
31
+ register_limit_namespace_settings!(ns)
32
+ end
33
+
34
+ def register_surface_namespace_settings!(ns)
29
35
  ns.register :graphql, false
30
36
  # true preserves 0.3.x: every ActiveRecord resource is in the schema once the namespace endpoint is on.
31
37
  # Set false for opt-in per resource (empty graphql block or enable!).
@@ -45,8 +51,26 @@ module ActiveAdmin
45
51
  ns.register :graphql_visibility, nil
46
52
  ns.register :graphql_visibility_profile, nil
47
53
  ns.register :graphql_schema_visible, nil
54
+ end
55
+
56
+ def register_capability_namespace_settings!(ns)
48
57
  # Optional Proc +(context_hash) -> Enumerable+ of capability names (RFC 0008).
49
58
  ns.register :graphql_capabilities, nil
59
+ # RFC 0009: default field capability mode (:omit or :null).
60
+ ns.register :graphql_field_capability_mode_default, :omit
61
+ # RFC 0009: optional Proc +(context, meta) -> Boolean+ to omit object/root fields.
62
+ ns.register :graphql_field_visible, nil
63
+ # RFC 0010: optional Proc +(context, meta) -> void+ after run-action outcomes.
64
+ ns.register :graphql_run_action_audit, nil
65
+ # RFC 0011: optional Proc +(context, meta) -> void+ at collection resolve start.
66
+ ns.register :graphql_collection_query_policy, nil
67
+ # RFC 0012: optional default capabilities for run-action mutation fields.
68
+ ns.register :graphql_member_action_capability_default, nil
69
+ ns.register :graphql_batch_action_capability_default, nil
70
+ ns.register :graphql_collection_action_capability_default, nil
71
+ end
72
+
73
+ def register_limit_namespace_settings!(ns)
50
74
  ns.register :graphql_max_complexity, 300
51
75
  ns.register :graphql_max_depth, 18
52
76
  ns.register :graphql_batch_action_max_ids, 5000
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module GraphQL
5
+ # Compatibility wrapper around {RunActionCapability} for member actions (RFC 0012).
6
+ module MemberActionCapability
7
+ module_function
8
+
9
+ def resolve(namespace, aa_res, action_name = nil)
10
+ RunActionCapability.resolve(namespace, aa_res, :member, action_name)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module GraphQL
5
+ # Preview context and resolve helpers for +member_action_mutation dry_run:+ (RFC 0010).
6
+ class MemberActionDryRun
7
+ PreviewContext = RunActionPreview
8
+
9
+ def self.resolve(
10
+ aa_res,
11
+ proxy:,
12
+ context:,
13
+ action:,
14
+ id:,
15
+ params_h:,
16
+ extras:,
17
+ per:,
18
+ dry_run:
19
+ )
20
+ new(aa_res, proxy:, context:, action:, id:, params_h:, extras:, per:, dry_run:).call
21
+ end
22
+
23
+ def initialize(aa_res, proxy:, context:, action:, id:, params_h:, extras:, per:, dry_run:)
24
+ @aa_res = aa_res
25
+ @proxy = proxy
26
+ @context = context
27
+ @action = action
28
+ @id = id
29
+ @params_h = params_h
30
+ @extras = extras
31
+ @per = per
32
+ @dry_run = dry_run
33
+ end
34
+
35
+ def call
36
+ record = @proxy.find_member(@id)
37
+ preview_result = run_preview(record)
38
+
39
+ if @dry_run
40
+ result = preview_result || default_dry_run_result(record)
41
+ audit!(result, dry_run: true)
42
+ return result
43
+ end
44
+
45
+ if preview_result && !preview_result.ok
46
+ audit!(preview_result, dry_run: false)
47
+ return preview_result
48
+ end
49
+
50
+ result = run_action
51
+ audit!(result, dry_run: false)
52
+ result
53
+ end
54
+
55
+ private
56
+
57
+ def run_preview(record)
58
+ return nil unless @per.preview_proc
59
+
60
+ PreviewContext.new.instance_exec(
61
+ record,
62
+ proxy: @proxy,
63
+ context: @context,
64
+ aa_resource: @aa_res,
65
+ auth: @context[:auth],
66
+ action: @action,
67
+ id: @id,
68
+ params: @params_h,
69
+ **@extras,
70
+ &@per.preview_proc
71
+ )
72
+ end
73
+
74
+ def default_dry_run_result(record)
75
+ body = if record
76
+ "would_run:#{@action}:#{record.id}"
77
+ else
78
+ "would_run:#{@action}"
79
+ end
80
+ RunActionPayload::Result.new(ok: true, status: 200, location: nil, body: body)
81
+ end
82
+
83
+ def run_action
84
+ resolve = @per.resolve_proc || @aa_res.graphql_config.member_run_action.resolve_proc
85
+ if resolve
86
+ resolve.call(
87
+ proxy: @proxy,
88
+ context: @context,
89
+ aa_resource: @aa_res,
90
+ auth: @context[:auth],
91
+ action: @action,
92
+ id: @id,
93
+ params: @params_h,
94
+ **@extras
95
+ )
96
+ else
97
+ @proxy.run_member_action(@action, @id, extra_params: @params_h)
98
+ end
99
+ end
100
+
101
+ def audit!(result, dry_run:)
102
+ hook = @context[:namespace]&.graphql_run_action_audit
103
+ return if hook.nil?
104
+
105
+ hook.call(
106
+ @context,
107
+ {
108
+ action: @action,
109
+ id: @id,
110
+ status: result.respond_to?(:status) ? result.status : nil,
111
+ dry_run: dry_run,
112
+ ok: result.respond_to?(:ok) ? result.ok : nil,
113
+ reason: result.respond_to?(:body) ? result.body : nil
114
+ }
115
+ )
116
+ rescue
117
+ nil
118
+ end
119
+ end
120
+ end
121
+ end
@@ -4,7 +4,7 @@ require_relative "capability_definition_dsl"
4
4
 
5
5
  module ActiveAdmin
6
6
  module GraphQL
7
- # +capability+ entry on {ResourceDefinitionDSL} (RFC 0008).
7
+ # +capability+ entry on {ResourceDefinitionDSL} (RFC 0008, RFC 0009).
8
8
  module ResourceCapabilityDSL
9
9
  def capability(
10
10
  name,
@@ -14,10 +14,11 @@ module ActiveAdmin
14
14
  member_actions: nil,
15
15
  batch_actions: nil,
16
16
  collection_actions: nil,
17
+ mode: nil,
17
18
  &block
18
19
  )
19
- body = CapabilityDefinitionDSL.new(@config, name)
20
- body.fields(*fields) if fields
20
+ body = CapabilityDefinitionDSL.new(@config, name, mode: mode)
21
+ body.fields(*fields, mode: mode) if fields
21
22
  body.queries(*queries) if queries
22
23
  body.mutations(*mutations) if mutations
23
24
  body.member_actions(*member_actions) if member_actions
@@ -2,12 +2,16 @@
2
2
 
3
3
  module ActiveAdmin
4
4
  module GraphQL
5
- # Capability → member maps on {ResourceConfig} (RFC 0008).
5
+ # Capability → member maps on {ResourceConfig} (RFC 0008, RFC 0009).
6
6
  module ResourceCapabilityMaps
7
7
  def field_capabilities
8
8
  @field_capabilities ||= {}
9
9
  end
10
10
 
11
+ def field_capability_modes
12
+ @field_capability_modes ||= {}
13
+ end
14
+
11
15
  def query_capabilities
12
16
  @query_capabilities ||= {}
13
17
  end
@@ -20,6 +24,10 @@ module ActiveAdmin
20
24
  field_capabilities[name.to_sym]
21
25
  end
22
26
 
27
+ def capability_mode_for_field(name)
28
+ field_capability_modes[name.to_sym]
29
+ end
30
+
23
31
  def capability_for_query(kind)
24
32
  query_capabilities[kind.to_sym]
25
33
  end
@@ -28,8 +36,18 @@ module ActiveAdmin
28
36
  mutation_capabilities[kind.to_sym]
29
37
  end
30
38
 
31
- def assign_field_capabilities!(capability_name, names)
39
+ def assign_field_capabilities!(capability_name, names, mode: nil)
32
40
  assign_capabilities!(field_capabilities, capability_name, names, label: "field")
41
+ return if mode.nil?
42
+
43
+ resolved = mode.to_sym
44
+ unless %i[omit null].include?(resolved)
45
+ raise ArgumentError, "capability mode must be :omit or :null, got #{mode.inspect}"
46
+ end
47
+
48
+ names.flatten.each do |name|
49
+ field_capability_modes[name.to_sym] = resolved
50
+ end
33
51
  end
34
52
 
35
53
  def assign_query_capabilities!(capability_name, names)
@@ -103,21 +103,32 @@ module ActiveAdmin
103
103
  end
104
104
 
105
105
  # Pair +type+ and +resolve+ for +posts_batch_action+ (graphql-ruby-style block).
106
- def batch_action_mutation(&block)
107
- RunActionMutationDSL.new(@config.batch_run_action).instance_exec(&block)
106
+ # +capability:+ overrides the resource map / namespace default for the aggregate field (RFC 0012).
107
+ def batch_action_mutation(capability: nil, &block)
108
+ cfg = @config.batch_run_action
109
+ cfg.capability = capability.to_sym if capability
110
+ RunActionMutationDSL.new(cfg).instance_exec(&block) if block
111
+ cfg
108
112
  end
109
113
 
110
114
  # With no name: configures the aggregate +posts_member_action(action: …)+ field.
111
115
  # With a symbol/string: configures +posts_member_<action>+ (one field per +member_action+), so each
112
116
  # action can use its own +type+, +resolve+, +arguments+, and GraphQL inputs.
113
- def member_action_mutation(name = nil, &block)
117
+ # +dry_run:+ / +capability:+ apply to named per-action fields (RFC 0010, RFC 0012).
118
+ def member_action_mutation(name = nil, dry_run: false, capability: nil, &block)
114
119
  cfg = name.nil? ? @config.member_run_action : @config.member_action_mutation_for(name)
115
- RunActionMutationDSL.new(cfg).instance_exec(&block)
120
+ cfg.dry_run = true if dry_run
121
+ cfg.capability = capability.to_sym if capability
122
+ RunActionMutationDSL.new(cfg).instance_exec(&block) if block
123
+ cfg
116
124
  end
117
125
 
118
- def collection_action_mutation(name = nil, &block)
126
+ def collection_action_mutation(name = nil, dry_run: false, capability: nil, &block)
119
127
  cfg = name.nil? ? @config.collection_run_action : @config.collection_action_mutation_for(name)
120
- RunActionMutationDSL.new(cfg).instance_exec(&block)
128
+ cfg.dry_run = true if dry_run
129
+ cfg.capability = capability.to_sym if capability
130
+ RunActionMutationDSL.new(cfg).instance_exec(&block) if block
131
+ cfg
121
132
  end
122
133
 
123
134
  # Default return type for all run-action fields unless a kind-specific +type+ is set inside