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
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module GraphQL
5
+ # Resolves the capability for batch / member / collection run-action fields (RFC 0012).
6
+ module RunActionCapability
7
+ KINDS = %i[batch member collection].freeze
8
+
9
+ module_function
10
+
11
+ # @param kind [Symbol] +:batch+, +:member+, or +:collection+
12
+ # @param action_name [String, Symbol, nil] per-action name; +nil+ for the aggregate field
13
+ def resolve(namespace, aa_res, kind, action_name = nil)
14
+ kind = kind.to_sym
15
+ unless KINDS.include?(kind)
16
+ raise ArgumentError, "run-action kind must be one of #{KINDS.inspect}, got #{kind.inspect}"
17
+ end
18
+
19
+ if action_name
20
+ per_capability = per_action_config_capability(aa_res, kind, action_name)
21
+ return per_capability if per_capability
22
+
23
+ mapped = aa_res.graphql_config.capability_for_mutation(
24
+ :"#{kind}_#{action_name.to_s.tr("-", "_")}"
25
+ )
26
+ else
27
+ aggregate_capability = aggregate_config_capability(aa_res, kind)
28
+ return aggregate_capability if aggregate_capability
29
+
30
+ mapped = aa_res.graphql_config.capability_for_mutation(:"#{kind}_action")
31
+ end
32
+ return mapped if mapped
33
+
34
+ namespace.public_send(:"graphql_#{kind}_action_capability_default")&.to_sym
35
+ end
36
+
37
+ def per_action_config_capability(aa_res, kind, action_name)
38
+ map =
39
+ case kind
40
+ when :member then aa_res.graphql_config.member_action_mutations
41
+ when :collection then aa_res.graphql_config.collection_action_mutations
42
+ when :batch then nil
43
+ end
44
+ return nil unless map
45
+
46
+ map[action_name.to_s]&.capability
47
+ end
48
+ module_function :per_action_config_capability
49
+
50
+ def aggregate_config_capability(aa_res, kind)
51
+ cfg =
52
+ case kind
53
+ when :member then aa_res.graphql_config.member_run_action
54
+ when :batch then aa_res.graphql_config.batch_run_action
55
+ when :collection then aa_res.graphql_config.collection_run_action
56
+ end
57
+ cfg&.capability
58
+ end
59
+ module_function :aggregate_config_capability
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module GraphQL
5
+ # Confirm gate and optional preview short-circuit for custom run-action fields (RFC 0013).
6
+ class RunActionControlExtension < ::GraphQL::Schema::FieldExtension
7
+ def resolve(arguments:, context:, object: nil, objects: nil)
8
+ if options[:confirm] && !arguments[:dry_run] && !arguments[:confirm]
9
+ result = RunActionPayload::Result.new(
10
+ ok: false, status: 422, location: nil, body: "confirm_required"
11
+ )
12
+ audit!(context, result, dry_run: false)
13
+ return result
14
+ end
15
+
16
+ preview_proc = options[:preview]
17
+ if preview_proc
18
+ preview_result = run_preview(preview_proc, arguments, context)
19
+
20
+ if arguments[:dry_run]
21
+ result = preview_result || default_dry_run_result
22
+ audit!(context, result, dry_run: true)
23
+ return result
24
+ end
25
+
26
+ if preview_result&.respond_to?(:ok) && !preview_result.ok
27
+ audit!(context, preview_result, dry_run: false)
28
+ return preview_result
29
+ end
30
+ end
31
+
32
+ yield(object.nil? ? objects : object, arguments, nil)
33
+ end
34
+
35
+ private
36
+
37
+ def run_preview(preview_proc, arguments, context)
38
+ kwargs = arguments.respond_to?(:to_h) ? arguments.to_h : arguments
39
+ kwargs = kwargs.merge(context: context)
40
+ RunActionPreview.call(preview_proc, **kwargs.transform_keys(&:to_sym))
41
+ end
42
+
43
+ def default_dry_run_result
44
+ RunActionPayload::Result.new(
45
+ ok: true,
46
+ status: 200,
47
+ location: nil,
48
+ body: "would_run:#{field.graphql_name}"
49
+ )
50
+ end
51
+
52
+ def audit!(context, result, dry_run:)
53
+ hook = context[:namespace]&.graphql_run_action_audit
54
+ return if hook.nil?
55
+
56
+ hook.call(
57
+ context,
58
+ {
59
+ action: field.graphql_name,
60
+ id: nil,
61
+ status: result.respond_to?(:status) ? result.status : nil,
62
+ dry_run: dry_run,
63
+ ok: result.respond_to?(:ok) ? result.ok : nil,
64
+ reason: result.respond_to?(:body) ? result.body : nil
65
+ }
66
+ )
67
+ rescue
68
+ nil
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module GraphQL
5
+ # DSL sugar for custom run-action mutation fields (RFC 0013).
6
+ #
7
+ # ActiveAdmin::GraphQL::RunActionField.define(schema.mutation, :create_batch,
8
+ # dry_run: true, confirm: true, capability: :support_act) do
9
+ # argument :q, GraphQL::Types::JSON, required: true, camelize: false
10
+ # preview { |**| ok("would_run:create_batch") }
11
+ # resolve { |q:, **| HostService.call(q: q) }
12
+ # end
13
+ module RunActionField
14
+ module_function
15
+
16
+ def define(
17
+ owner,
18
+ name,
19
+ type: RunActionPayload,
20
+ null: false,
21
+ camelize: false,
22
+ dry_run: false,
23
+ confirm: false,
24
+ capability: nil,
25
+ authorize: false,
26
+ visibility: nil,
27
+ **kwargs,
28
+ &block
29
+ )
30
+ definition = Definition.new
31
+ definition.instance_exec(&block) if block
32
+ unless definition.resolve_proc
33
+ raise ArgumentError, "run_action_field #{name.inspect} requires a resolve block"
34
+ end
35
+
36
+ owner.field(
37
+ name,
38
+ type,
39
+ null: null,
40
+ camelize: camelize,
41
+ dry_run: dry_run,
42
+ confirm: confirm,
43
+ preview: definition.preview_proc,
44
+ capability: capability,
45
+ authorize: authorize,
46
+ visibility: visibility,
47
+ **kwargs,
48
+ &definition.field_configuration_block
49
+ )
50
+
51
+ resolve_proc = definition.resolve_proc
52
+ owner.define_method(name) do |**args|
53
+ resolve_proc.call(**args)
54
+ end
55
+ end
56
+
57
+ # Block body for {RunActionField.define}.
58
+ class Definition
59
+ attr_reader :preview_proc, :resolve_proc
60
+
61
+ def initialize
62
+ @argument_calls = []
63
+ @arguments_proc = nil
64
+ end
65
+
66
+ def argument(*args, **kwargs)
67
+ @argument_calls << [args, kwargs]
68
+ end
69
+
70
+ def arguments(&block)
71
+ @arguments_proc = block
72
+ end
73
+
74
+ def preview(&block)
75
+ @preview_proc = block
76
+ end
77
+
78
+ def resolve(&block)
79
+ @resolve_proc = block
80
+ end
81
+
82
+ def ok(body, status: 200)
83
+ RunActionPreview.new.ok(body, status: status)
84
+ end
85
+
86
+ def refuse(body, status: 422)
87
+ RunActionPreview.new.refuse(body, status: status)
88
+ end
89
+
90
+ def field_configuration_block
91
+ argument_calls = @argument_calls
92
+ arguments_proc = @arguments_proc
93
+ proc do
94
+ instance_exec(&arguments_proc) if arguments_proc
95
+ argument_calls.each do |args, kwargs|
96
+ argument(*args, **kwargs)
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -14,6 +14,14 @@ module ActiveAdmin
14
14
  attr_accessor :arguments_proc
15
15
  # Optional authorization toggle for this mutation configuration. +nil+ means namespace default.
16
16
  attr_accessor :authorize
17
+ # When true, inject +dry_run+ and honor +preview+ (RFC 0010).
18
+ attr_accessor :dry_run
19
+ # @return [Proc, nil] preview block for dry-run / refuse-before-run
20
+ attr_accessor :preview_proc
21
+ # @return [Array<String>, nil] allowlisted extra GraphQL kwargs forwarded into AA params
22
+ attr_accessor :extra_param_keys
23
+ # Optional capability override for this action field (RFC 0012).
24
+ attr_accessor :capability
17
25
 
18
26
  def self.ensure_graphql_object_subclass!(type)
19
27
  unless type.is_a?(Class) && type < ::GraphQL::Schema::Object
@@ -31,6 +31,24 @@ module ActiveAdmin
31
31
  def authorize(value = true)
32
32
  @mutation_config.authorize = !!value
33
33
  end
34
+
35
+ # Preview before run / dry-run short-circuit (RFC 0010). Block receives the member record.
36
+ def preview(&block)
37
+ @mutation_config.preview_proc = block
38
+ end
39
+
40
+ # Allowlisted GraphQL kwargs forwarded into the ActiveAdmin request (never +id+).
41
+ def extra_params(*names)
42
+ @mutation_config.extra_param_keys = names.flatten.map(&:to_s)
43
+ end
44
+
45
+ def ok(body, status: 200)
46
+ RunActionPreview.new.ok(body, status: status)
47
+ end
48
+
49
+ def refuse(body, status: 422)
50
+ RunActionPreview.new.refuse(body, status: status)
51
+ end
34
52
  end
35
53
  end
36
54
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAdmin
4
+ module GraphQL
5
+ # Shared +ok+ / +refuse+ helpers for run-action preview blocks (RFC 0010, RFC 0013).
6
+ class RunActionPreview
7
+ def ok(body, status: 200)
8
+ RunActionPayload::Result.new(ok: true, status: status, location: nil, body: body.to_s)
9
+ end
10
+
11
+ def refuse(body, status: 422)
12
+ RunActionPayload::Result.new(ok: false, status: status, location: nil, body: body.to_s)
13
+ end
14
+
15
+ def self.call(preview_proc, **kwargs)
16
+ new.instance_exec(**kwargs, &preview_proc)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -15,7 +15,7 @@ module ActiveAdmin
15
15
  proc do
16
16
  field fname.to_sym, batch_payload_t, null: false, camelize: false,
17
17
  visibility: {kind: :mutation_batch_action, graphql_type_name: sb.send(:graphql_type_name_for, aa_res), resource: aa_res, field_name: fname},
18
- capability: aa_res.graphql_config.capability_for_mutation(:batch_action),
18
+ capability: RunActionCapability.resolve(ns, aa_res, :batch),
19
19
  description: "Run a batch action registered on #{aa_res.resource_name} (+batch_action+ DSL), " \
20
20
  "same as the index batch UI (+collection_selection+ / +batch_action_inputs+)." do
21
21
  argument :batch_action, ::GraphQL::Types::String, required: true, camelize: false
@@ -42,7 +42,7 @@ module ActiveAdmin
42
42
  end
43
43
  unless Capability.granted?(
44
44
  context,
45
- aa_res.graphql_config.capability_for_mutation(:"batch_#{ba.tr("-", "_")}")
45
+ RunActionCapability.resolve(ns, aa_res, :batch, ba)
46
46
  )
47
47
  raise ::GraphQL::ExecutionError, "not authorized for batch action #{ba}"
48
48
  end
@@ -21,7 +21,7 @@ module ActiveAdmin
21
21
  proc do
22
22
  field fname.to_sym, collection_payload_t, null: false, camelize: false,
23
23
  visibility: {kind: :mutation_collection_action, graphql_type_name: sb.send(:graphql_type_name_for, aa_res), resource: aa_res, field_name: fname, aggregate: true},
24
- capability: aa_res.graphql_config.capability_for_mutation(:collection_action),
24
+ capability: RunActionCapability.resolve(ns, aa_res, :collection),
25
25
  description: "Invoke a +collection_action+ on #{aa_res.resource_name} by name (same scoping as REST +index+). Prefer per-action fields +#{plural}_collection_<action>+ for distinct inputs or return types." do
26
26
  argument :action, ::GraphQL::Types::String, required: true, camelize: false
27
27
  argument :params, [KeyValuePairInput], required: false, camelize: false,
@@ -46,7 +46,7 @@ module ActiveAdmin
46
46
  end
47
47
  unless Capability.granted?(
48
48
  context,
49
- aa_res.graphql_config.capability_for_mutation(:"collection_#{act.tr("-", "_")}")
49
+ RunActionCapability.resolve(ns, aa_res, :collection, act)
50
50
  )
51
51
  raise ::GraphQL::ExecutionError, "not authorized for collection action #{act}"
52
52
  end
@@ -76,6 +76,7 @@ module ActiveAdmin
76
76
  fname = field_name.to_sym
77
77
  per_cfg = aa_res.graphql_config.collection_action_mutations[action_name]
78
78
  collection_payload_t = collection_action_return_type(aa_res, action_name)
79
+ capability = RunActionCapability.resolve(ns, aa_res, :collection, action_name)
79
80
 
80
81
  proc do
81
82
  field fname, collection_payload_t, null: false, camelize: false,
@@ -86,7 +87,7 @@ module ActiveAdmin
86
87
  field_name: field_name,
87
88
  collection_action: action_name
88
89
  },
89
- capability: aa_res.graphql_config.capability_for_mutation(:"collection_#{action_name.tr("-", "_")}"),
90
+ capability: capability,
90
91
  description: "Invoke +collection_action+ #{action_name} on #{aa_res.resource_name} (same scoping as REST +index+)." do
91
92
  argument :params, [KeyValuePairInput], required: false, camelize: false,
92
93
  description: "Extra request params as flat key/value pairs."
@@ -21,7 +21,7 @@ module ActiveAdmin
21
21
  proc do
22
22
  field fname.to_sym, member_payload_t, null: false, camelize: false,
23
23
  visibility: {kind: :mutation_member_action, graphql_type_name: sb.send(:graphql_type_name_for, aa_res), resource: aa_res, field_name: fname, aggregate: true},
24
- capability: aa_res.graphql_config.capability_for_mutation(:member_action),
24
+ capability: MemberActionCapability.resolve(ns, aa_res),
25
25
  description: "Invoke a +member_action+ on #{aa_res.resource_name} by name (same scoping as REST +show+). Prefer per-action fields +#{plural}_member_<action>+ for distinct inputs or return types." do
26
26
  argument :action, ::GraphQL::Types::String, required: true, camelize: false
27
27
  argument :id, ::GraphQL::Types::ID, required: true, camelize: false
@@ -47,7 +47,7 @@ module ActiveAdmin
47
47
  end
48
48
  unless Capability.granted?(
49
49
  context,
50
- aa_res.graphql_config.capability_for_mutation(:"member_#{act.tr("-", "_")}")
50
+ MemberActionCapability.resolve(ns, aa_res, act)
51
51
  )
52
52
  raise ::GraphQL::ExecutionError, "not authorized for member action #{act}"
53
53
  end
@@ -78,6 +78,7 @@ module ActiveAdmin
78
78
  fname = field_name.to_sym
79
79
  per_cfg = aa_res.graphql_config.member_action_mutations[action_name]
80
80
  member_payload_t = member_action_return_type(aa_res, action_name)
81
+ capability = MemberActionCapability.resolve(ns, aa_res, action_name)
81
82
 
82
83
  proc do
83
84
  field fname, member_payload_t, null: false, camelize: false,
@@ -88,11 +89,15 @@ module ActiveAdmin
88
89
  field_name: field_name,
89
90
  member_action: action_name
90
91
  },
91
- capability: aa_res.graphql_config.capability_for_mutation(:"member_#{action_name.tr("-", "_")}"),
92
+ capability: capability,
92
93
  description: "Invoke +member_action+ #{action_name} on #{aa_res.resource_name} (same scoping as REST +show+)." do
93
94
  argument :id, ::GraphQL::Types::ID, required: true, camelize: false
94
95
  argument :params, [KeyValuePairInput], required: false, camelize: false,
95
96
  description: "Extra request params as flat key/value pairs."
97
+ if per_cfg&.dry_run
98
+ argument :dry_run, ::GraphQL::Types::Boolean, required: false, default_value: false, camelize: false,
99
+ description: "When true, preview without performing the member action."
100
+ end
96
101
  if (btc = aa_res.belongs_to_config)
97
102
  argument btc.to_param.to_sym, ::GraphQL::Types::ID, required: btc.required?, camelize: false
98
103
  end
@@ -4,22 +4,29 @@ module ActiveAdmin
4
4
  module GraphQL
5
5
  class SchemaBuilder
6
6
  module Resolvers
7
- def mutation_extra_keyword_params(aa_res, kw)
7
+ def mutation_extra_keyword_params(aa_res, kw, allowlist: nil)
8
8
  skip =
9
9
  if (btc = aa_res.belongs_to_config)
10
10
  [btc.to_param.to_s]
11
11
  else
12
12
  []
13
13
  end
14
+ skip << "id"
15
+ skip << "dry_run"
16
+ allow = allowlist&.map(&:to_s)
17
+
14
18
  kw.each_with_object({}) do |(key, val), h|
15
19
  ks = key.to_s
16
20
  next if skip.include?(ks)
21
+ next if allow && !allow.include?(ks)
17
22
 
18
23
  h[ks] = val
19
24
  end
20
25
  end
21
26
 
22
27
  def graphql_resolve_index(aa_res, proxy:, context:, graph_params:, **field_kwargs)
28
+ invoke_collection_query_policy!(aa_res, context, field_kwargs)
29
+
23
30
  proc_ = aa_res.graphql_config.resolve_index_proc
24
31
  base_kwargs = {graph_params: graph_params}.merge(field_kwargs)
25
32
  if proc_
@@ -57,9 +64,26 @@ module ActiveAdmin
57
64
  end
58
65
 
59
66
  def graphql_resolve_member_action(aa_res, proxy:, context:, action:, id:, params: nil, **kw)
60
- extras = mutation_extra_keyword_params(aa_res, kw)
61
- params_h = coerce_action_param_map(params).merge(extras.transform_keys(&:to_s))
62
67
  per = aa_res.graphql_config.member_action_mutations[action.to_s]
68
+ dry_run = !!kw[:dry_run]
69
+ extras = mutation_extra_keyword_params(aa_res, kw, allowlist: per&.extra_param_keys)
70
+ params_h = coerce_action_param_map(params).merge(extras.transform_keys(&:to_s))
71
+ params_h.delete("id")
72
+
73
+ if per&.dry_run
74
+ return MemberActionDryRun.resolve(
75
+ aa_res,
76
+ proxy: proxy,
77
+ context: context,
78
+ action: action,
79
+ id: id,
80
+ params_h: params_h,
81
+ extras: extras.transform_keys(&:to_sym),
82
+ per: per,
83
+ dry_run: dry_run
84
+ )
85
+ end
86
+
63
87
  resolve = per&.resolve_proc || aa_res.graphql_config.member_run_action.resolve_proc
64
88
  if resolve
65
89
  resolve.call(
@@ -70,7 +94,7 @@ module ActiveAdmin
70
94
  action: action,
71
95
  id: id,
72
96
  params: params_h,
73
- **extras
97
+ **extras.transform_keys(&:to_sym)
74
98
  )
75
99
  else
76
100
  proxy.run_member_action(action, id, extra_params: params_h)
@@ -78,9 +102,10 @@ module ActiveAdmin
78
102
  end
79
103
 
80
104
  def graphql_resolve_collection_action(aa_res, proxy:, context:, action:, params: nil, **kw)
81
- extras = mutation_extra_keyword_params(aa_res, kw)
82
- params_h = coerce_action_param_map(params).merge(extras.transform_keys(&:to_s))
83
105
  per = aa_res.graphql_config.collection_action_mutations[action.to_s]
106
+ extras = mutation_extra_keyword_params(aa_res, kw, allowlist: per&.extra_param_keys)
107
+ params_h = coerce_action_param_map(params).merge(extras.transform_keys(&:to_s))
108
+ params_h.delete("id")
84
109
  resolve = per&.resolve_proc || aa_res.graphql_config.collection_run_action.resolve_proc
85
110
  if resolve
86
111
  resolve.call(
@@ -90,7 +115,7 @@ module ActiveAdmin
90
115
  auth: context[:auth],
91
116
  action: action,
92
117
  params: params_h,
93
- **extras
118
+ **extras.transform_keys(&:to_sym)
94
119
  )
95
120
  else
96
121
  proxy.run_collection_action(action, extra_params: params_h)
@@ -105,6 +130,20 @@ module ActiveAdmin
105
130
  h.stringify_keys
106
131
  end
107
132
 
133
+ def invoke_collection_query_policy!(aa_res, context, field_kwargs)
134
+ hook = context[:namespace]&.graphql_collection_query_policy
135
+ return if hook.nil?
136
+
137
+ meta = field_kwargs.merge(
138
+ aa_resource: aa_res,
139
+ q: field_kwargs[:q],
140
+ scope: field_kwargs[:scope],
141
+ order: field_kwargs[:order],
142
+ filter: field_kwargs[:filter]
143
+ )
144
+ hook.call(context, meta)
145
+ end
146
+
108
147
  public :graphql_resolve_index,
109
148
  :graphql_resolve_show,
110
149
  :graphql_resolve_batch_action,
@@ -119,7 +119,8 @@ module ActiveAdmin
119
119
  null: true,
120
120
  camelize: false,
121
121
  authorize: false,
122
- capability: aa_res.graphql_config.capability_for_field(name)
122
+ capability: aa_res.graphql_config.capability_for_field(name),
123
+ capability_mode: aa_res.graphql_config.capability_mode_for_field(name)
123
124
  )
124
125
 
125
126
  type_class.define_method(name.to_sym) { object.public_send(name) }
@@ -22,7 +22,8 @@ module ActiveAdmin
22
22
 
23
23
  type_class.field field_name, @object_types[target], null: true, camelize: false,
24
24
  authorize: false,
25
- capability: aa_res.graphql_config.capability_for_field(field_name)
25
+ capability: aa_res.graphql_config.capability_for_field(field_name),
26
+ capability_mode: aa_res.graphql_config.capability_mode_for_field(field_name)
26
27
 
27
28
  type_class.define_method(field_name) do
28
29
  fk_val = object.public_send(fk)
@@ -2,17 +2,36 @@
2
2
 
3
3
  module ActiveAdmin
4
4
  module GraphQL
5
- # Subclass of {::GraphQL::Schema::Field} for namespace +Query+ / +Mutation+ fields.
5
+ # Subclass of {::GraphQL::Schema::Field} for namespace +Query+ / +Mutation+ fields
6
+ # and resource object fields.
6
7
  #
7
8
  # The +visibility:+ keyword accepts an optional Hash of metadata forwarded to
8
9
  # +graphql_schema_visible+ (second argument) when {GraphQL::Schema::Visibility} is enabled.
9
- # That hook runs from {#visible?}; it does not replace graphql-ruby's visibility system—it
10
- # composes with +super+ like any custom +Field+ class.
11
10
  #
12
- # +capability:+ (RFC 0008) gates +visible?+ and +authorized?+ via +context[:capabilities]+.
11
+ # +capability:+ (RFC 0008) and +capability_mode:+ (RFC 0009) gate +visible?+ /
12
+ # +authorized?+ via +context[:capabilities]+. +graphql_field_visible+ may omit fields.
13
+ # +dry_run:+ / +confirm:+ / +preview:+ (RFC 0013) control custom run-action fields.
13
14
  class SchemaField < ::GraphQL::Schema::Field
14
- def initialize(visibility: nil, authorize: nil, authorize_action: nil, capability: nil, **kwargs, &block)
15
+ CAPABILITY_MODES = %i[omit null].freeze
16
+
17
+ def initialize(
18
+ visibility: nil,
19
+ authorize: nil,
20
+ authorize_action: nil,
21
+ capability: nil,
22
+ capability_mode: nil,
23
+ dry_run: false,
24
+ confirm: false,
25
+ preview: nil,
26
+ extensions: ::GraphQL::EmptyObjects::EMPTY_ARRAY,
27
+ **kwargs,
28
+ &block
29
+ )
15
30
  @capability = capability&.to_sym
31
+ @capability_mode = normalize_capability_mode(capability_mode)
32
+ @inject_dry_run = dry_run
33
+ @inject_confirm = confirm
34
+ @preview_proc = preview
16
35
  @visibility =
17
36
  if @capability && visibility.is_a?(Hash) && !visibility.key?(:capability)
18
37
  visibility.merge(capability: @capability)
@@ -21,16 +40,30 @@ module ActiveAdmin
21
40
  end
22
41
  @authorize = authorize
23
42
  @authorize_action = authorize_action
24
- super(**kwargs, &block)
43
+ field_extensions = Array(extensions)
44
+ if @inject_confirm || @preview_proc
45
+ field_extensions += [{
46
+ RunActionControlExtension => {confirm: @inject_confirm, preview: @preview_proc}
47
+ }]
48
+ end
49
+ if @inject_dry_run || @inject_confirm || @preview_proc || block
50
+ super(extensions: field_extensions, **kwargs) do
51
+ inject_run_action_control_arguments!
52
+ instance_exec(&block) if block
53
+ end
54
+ else
55
+ super(extensions: field_extensions, **kwargs)
56
+ end
25
57
  end
26
58
 
27
59
  def visible?(ctx)
28
60
  return false unless super
61
+ return false unless field_visible_by_hook?(ctx)
29
62
 
30
- # Gate introspection only when Schema::Visibility is enabled; otherwise
31
- # capability is enforced in +authorized?+ so hosts without the plugin
32
- # do not trip graphql-ruby's legacy Warden +visible?+ path.
33
- if ctx[:namespace]&.graphql_visibility && !Capability.granted?(ctx, @capability)
63
+ if ctx[:namespace]&.graphql_visibility &&
64
+ @capability &&
65
+ !Capability.granted?(ctx, @capability) &&
66
+ capability_mode_for(ctx) == :omit
34
67
  return false
35
68
  end
36
69
 
@@ -60,6 +93,48 @@ module ActiveAdmin
60
93
  action = @authorize_action || ActiveAdmin::Authorization::READ
61
94
  !!ctx[:auth]&.authorized?(aa_resource, action, object)
62
95
  end
96
+
97
+ private
98
+
99
+ def inject_run_action_control_arguments!
100
+ if @inject_dry_run
101
+ argument :dry_run, ::GraphQL::Types::Boolean, required: false, default_value: false, camelize: false,
102
+ description: "When true, preview without performing the side-effecting action."
103
+ end
104
+ if @inject_confirm
105
+ argument :confirm, ::GraphQL::Types::Boolean, required: false, default_value: false, camelize: false,
106
+ description: "When false and dry_run is false, refuse with body confirm_required."
107
+ end
108
+ end
109
+
110
+ def normalize_capability_mode(mode)
111
+ return nil if mode.nil?
112
+
113
+ sym = mode.to_sym
114
+ unless CAPABILITY_MODES.include?(sym)
115
+ raise ArgumentError, "capability_mode must be :omit or :null, got #{mode.inspect}"
116
+ end
117
+ sym
118
+ end
119
+
120
+ def capability_mode_for(ctx)
121
+ mode = @capability_mode || ctx[:namespace]&.graphql_field_capability_mode_default || :omit
122
+ mode.to_sym
123
+ end
124
+
125
+ def field_visible_by_hook?(ctx)
126
+ hook = ctx[:namespace]&.graphql_field_visible
127
+ return true if hook.nil?
128
+
129
+ owner = self.owner
130
+ meta = {
131
+ graphql_type_name: owner.respond_to?(:graphql_name) ? owner.graphql_name : nil,
132
+ field_name: graphql_name,
133
+ resource: owner.respond_to?(:activeadmin_graphql_resource) ? owner.activeadmin_graphql_resource : nil
134
+ }
135
+ meta = meta.merge(@visibility) if @visibility.is_a?(Hash)
136
+ !!hook.call(ctx, meta)
137
+ end
63
138
  end
64
139
  end
65
140
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveAdmin
4
4
  module GraphQL
5
- VERSION = "0.3.3"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end