rails_ai_admin 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +458 -0
- data/LICENSE +21 -0
- data/README.md +292 -0
- data/SECURITY.md +14 -0
- data/THIRD_PARTY_NOTICES.md +19 -0
- data/app/assets/builds/rails_ai_admin.LICENSE.txt +55 -0
- data/app/assets/builds/rails_ai_admin.css +345 -0
- data/app/assets/builds/rails_ai_admin.js +33 -0
- data/app/controllers/rails_ai_admin/application_controller.rb +255 -0
- data/app/controllers/rails_ai_admin/assistant_controller.rb +164 -0
- data/app/controllers/rails_ai_admin/audit_controller.rb +16 -0
- data/app/controllers/rails_ai_admin/collection_jobs_controller.rb +33 -0
- data/app/controllers/rails_ai_admin/collection_operations_controller.rb +79 -0
- data/app/controllers/rails_ai_admin/custom_controller.rb +26 -0
- data/app/controllers/rails_ai_admin/dashboard_controller.rb +17 -0
- data/app/controllers/rails_ai_admin/exports_controller.rb +31 -0
- data/app/controllers/rails_ai_admin/files_controller.rb +37 -0
- data/app/controllers/rails_ai_admin/imports_controller.rb +41 -0
- data/app/controllers/rails_ai_admin/mcp_controller.rb +81 -0
- data/app/controllers/rails_ai_admin/mcp_tokens_controller.rb +45 -0
- data/app/controllers/rails_ai_admin/operations_controller.rb +105 -0
- data/app/controllers/rails_ai_admin/references_controller.rb +16 -0
- data/app/controllers/rails_ai_admin/resources_controller.rb +290 -0
- data/app/controllers/rails_ai_admin/search_controller.rb +39 -0
- data/app/controllers/rails_ai_admin/selections_controller.rb +95 -0
- data/app/controllers/rails_ai_admin/surfaces_controller.rb +122 -0
- data/app/helpers/rails_ai_admin/dashboard_helper.rb +28 -0
- data/app/helpers/rails_ai_admin/fields_helper.rb +242 -0
- data/app/helpers/rails_ai_admin/filters_helper.rb +40 -0
- data/app/helpers/rails_ai_admin/history_helper.rb +36 -0
- data/app/helpers/rails_ai_admin/navigation_helper.rb +54 -0
- data/app/helpers/rails_ai_admin/tool_views_helper.rb +48 -0
- data/app/jobs/rails_ai_admin/assistant_turn_job.rb +15 -0
- data/app/jobs/rails_ai_admin/collections/execution_job.rb +12 -0
- data/app/jobs/rails_ai_admin/outbox/delivery_job.rb +13 -0
- data/app/models/rails_ai_admin/application_record.rb +8 -0
- data/app/models/rails_ai_admin/audit_event.rb +12 -0
- data/app/models/rails_ai_admin/batch_item.rb +8 -0
- data/app/models/rails_ai_admin/operation_run.rb +20 -0
- data/app/models/rails_ai_admin/selection.rb +9 -0
- data/app/models/rails_ai_admin/selection_item.rb +9 -0
- data/app/models/rails_ai_admin/work_item.rb +18 -0
- data/app/views/layouts/rails_ai_admin/application.html.erb +49 -0
- data/app/views/layouts/rails_ai_admin/frame.html.erb +5 -0
- data/app/views/layouts/rails_ai_admin/status.html.erb +13 -0
- data/app/views/rails_ai_admin/assistant/_ask.html.erb +16 -0
- data/app/views/rails_ai_admin/assistant/_transcript.html.erb +45 -0
- data/app/views/rails_ai_admin/assistant/frame.html.erb +1 -0
- data/app/views/rails_ai_admin/assistant/index.html.erb +14 -0
- data/app/views/rails_ai_admin/assistant/show.html.erb +44 -0
- data/app/views/rails_ai_admin/audit/index.html.erb +40 -0
- data/app/views/rails_ai_admin/collection_jobs/index.html.erb +5 -0
- data/app/views/rails_ai_admin/collection_jobs/show.html.erb +24 -0
- data/app/views/rails_ai_admin/collection_operations/confirmation.html.erb +25 -0
- data/app/views/rails_ai_admin/collection_operations/failure.html.erb +10 -0
- data/app/views/rails_ai_admin/collection_operations/new.html.erb +11 -0
- data/app/views/rails_ai_admin/dashboard/index.html.erb +33 -0
- data/app/views/rails_ai_admin/fields/_index.html.erb +1 -0
- data/app/views/rails_ai_admin/fields/_input.html.erb +21 -0
- data/app/views/rails_ai_admin/fields/_reference.html.erb +1 -0
- data/app/views/rails_ai_admin/fields/_reference_picker.html.erb +14 -0
- data/app/views/rails_ai_admin/fields/_row.html.erb +27 -0
- data/app/views/rails_ai_admin/fields/_show.html.erb +1 -0
- data/app/views/rails_ai_admin/imports/new.html.erb +34 -0
- data/app/views/rails_ai_admin/mcp_tokens/index.html.erb +33 -0
- data/app/views/rails_ai_admin/operations/confirmation.html.erb +32 -0
- data/app/views/rails_ai_admin/operations/failure.html.erb +5 -0
- data/app/views/rails_ai_admin/operations/form.html.erb +13 -0
- data/app/views/rails_ai_admin/resources/_associations.html.erb +12 -0
- data/app/views/rails_ai_admin/resources/_errors.html.erb +14 -0
- data/app/views/rails_ai_admin/resources/_recovery.html.erb +5 -0
- data/app/views/rails_ai_admin/resources/confirm_destroy.html.erb +11 -0
- data/app/views/rails_ai_admin/resources/edit.html.erb +1 -0
- data/app/views/rails_ai_admin/resources/form.html.erb +16 -0
- data/app/views/rails_ai_admin/resources/history.html.erb +27 -0
- data/app/views/rails_ai_admin/resources/index.html.erb +88 -0
- data/app/views/rails_ai_admin/resources/new.html.erb +1 -0
- data/app/views/rails_ai_admin/resources/show.html.erb +12 -0
- data/app/views/rails_ai_admin/search/index.html.erb +20 -0
- data/app/views/rails_ai_admin/selections/confirm_destroy.html.erb +13 -0
- data/app/views/rails_ai_admin/selections/destroyed.html.erb +15 -0
- data/app/views/rails_ai_admin/selections/show.html.erb +19 -0
- data/app/views/rails_ai_admin/shared/_input_fields.html.erb +23 -0
- data/app/views/rails_ai_admin/shared/_navigation.html.erb +8 -0
- data/app/views/rails_ai_admin/shared/_session.html.erb +5 -0
- data/app/views/rails_ai_admin/statuses/show.html.erb +4 -0
- data/app/views/rails_ai_admin/surfaces/_operation_review.html.erb +43 -0
- data/app/views/rails_ai_admin/surfaces/show.html.erb +4 -0
- data/config/locales/en.yml +549 -0
- data/config/routes.rb +64 -0
- data/db/assistant_migrate/20261103000001_create_rails_ai_admin_assistant.rb +138 -0
- data/db/assistant_migrate/20261112000001_upgrade_rails_ai_admin_assistant_to_ruby_llm_2_2.rb +102 -0
- data/db/assistant_migrate/20261114000001_add_streaming_and_access_tokens_to_rails_ai_admin_assistant.rb +29 -0
- data/db/migrate/20261029000001_create_rails_ai_admin_execution_tables.rb +35 -0
- data/db/migrate/20261029000002_add_callback_warning_to_rails_ai_admin_runs.rb +7 -0
- data/db/migrate/20261030000003_create_rails_ai_admin_work_items.rb +26 -0
- data/db/migrate/20261030000004_create_rails_ai_admin_selections.rb +28 -0
- data/db/migrate/20261030000005_create_rails_ai_admin_collection_runs.rb +41 -0
- data/db/migrate/20261113000001_add_record_index_to_rails_ai_admin_audit_events.rb +8 -0
- data/lib/generators/rails_ai_admin/assistant/assistant_generator.rb +25 -0
- data/lib/generators/rails_ai_admin/assistant/templates/admin_assistant.rb +18 -0
- data/lib/generators/rails_ai_admin/engine_migrations.rb +42 -0
- data/lib/generators/rails_ai_admin/install/engine_mounts.rb +19 -0
- data/lib/generators/rails_ai_admin/install/install_generator.rb +53 -0
- data/lib/generators/rails_ai_admin/install/templates/access_test.rb +17 -0
- data/lib/generators/rails_ai_admin/install/templates/admin_context.rb +25 -0
- data/lib/generators/rails_ai_admin/install/templates/admin_policy_adapter.rb +28 -0
- data/lib/generators/rails_ai_admin/install/templates/example_admin.rb +10 -0
- data/lib/generators/rails_ai_admin/install/templates/initializer.rb +7 -0
- data/lib/generators/rails_ai_admin/override/override_generator.rb +46 -0
- data/lib/generators/rails_ai_admin/resource/resource_generator.rb +88 -0
- data/lib/rails_ai_admin/assistant/continuation.rb +33 -0
- data/lib/rails_ai_admin/assistant/coordinator.rb +306 -0
- data/lib/rails_ai_admin/assistant/records.rb +71 -0
- data/lib/rails_ai_admin/assistant/runtime.rb +49 -0
- data/lib/rails_ai_admin/assistant/tools.rb +477 -0
- data/lib/rails_ai_admin/assistant/transcript.rb +140 -0
- data/lib/rails_ai_admin/assistant.rb +50 -0
- data/lib/rails_ai_admin/authorization/access.rb +296 -0
- data/lib/rails_ai_admin/authorization/adapter.rb +92 -0
- data/lib/rails_ai_admin/authorization/pundit_adapter.rb +195 -0
- data/lib/rails_ai_admin/collections/claim.rb +91 -0
- data/lib/rails_ai_admin/collections/contract.rb +113 -0
- data/lib/rails_ai_admin/collections/dispatcher.rb +126 -0
- data/lib/rails_ai_admin/collections/queries.rb +79 -0
- data/lib/rails_ai_admin/collections/result.rb +15 -0
- data/lib/rails_ai_admin/collections/runner.rb +208 -0
- data/lib/rails_ai_admin/configuration.rb +178 -0
- data/lib/rails_ai_admin/context.rb +29 -0
- data/lib/rails_ai_admin/doctor.rb +301 -0
- data/lib/rails_ai_admin/engine.rb +24 -0
- data/lib/rails_ai_admin/error.rb +16 -0
- data/lib/rails_ai_admin/field.rb +625 -0
- data/lib/rails_ai_admin/fields/association.rb +188 -0
- data/lib/rails_ai_admin/fields/computation.rb +43 -0
- data/lib/rails_ai_admin/fields/dependency_graph.rb +45 -0
- data/lib/rails_ai_admin/fields/file_value.rb +23 -0
- data/lib/rails_ai_admin/fields/hook_signatures.rb +18 -0
- data/lib/rails_ai_admin/fields/projection.rb +78 -0
- data/lib/rails_ai_admin/fields/registry.rb +48 -0
- data/lib/rails_ai_admin/fields/scalar_schema.rb +49 -0
- data/lib/rails_ai_admin/fields/type.rb +50 -0
- data/lib/rails_ai_admin/forms/plan.rb +90 -0
- data/lib/rails_ai_admin/input_schema.rb +76 -0
- data/lib/rails_ai_admin/integrations/propose_operation.rb +150 -0
- data/lib/rails_ai_admin/integrations/tool_views.rb +367 -0
- data/lib/rails_ai_admin/mcp.rb +130 -0
- data/lib/rails_ai_admin/navigation/list_state.rb +194 -0
- data/lib/rails_ai_admin/navigation/menu.rb +31 -0
- data/lib/rails_ai_admin/navigation/settings.rb +28 -0
- data/lib/rails_ai_admin/operation.rb +120 -0
- data/lib/rails_ai_admin/operations/default_handlers.rb +17 -0
- data/lib/rails_ai_admin/operations/execution_context.rb +38 -0
- data/lib/rails_ai_admin/operations/form_intent.rb +69 -0
- data/lib/rails_ai_admin/operations/registry.rb +80 -0
- data/lib/rails_ai_admin/operations/result.rb +21 -0
- data/lib/rails_ai_admin/operations/runner.rb +633 -0
- data/lib/rails_ai_admin/operations/runner_fingerprint.rb +12 -0
- data/lib/rails_ai_admin/outbox/delivery_context.rb +51 -0
- data/lib/rails_ai_admin/outbox/dispatcher.rb +189 -0
- data/lib/rails_ai_admin/outbox/registry.rb +36 -0
- data/lib/rails_ai_admin/outbox/request_digest.rb +42 -0
- data/lib/rails_ai_admin/outbox/writer.rb +43 -0
- data/lib/rails_ai_admin/presentation/association_panel.rb +90 -0
- data/lib/rails_ai_admin/presentation/audit_log.rb +63 -0
- data/lib/rails_ai_admin/presentation/csv_export.rb +85 -0
- data/lib/rails_ai_admin/presentation/csv_import.rb +142 -0
- data/lib/rails_ai_admin/presentation/dashboard.rb +122 -0
- data/lib/rails_ai_admin/presentation/history.rb +46 -0
- data/lib/rails_ai_admin/presentation/index_settings.rb +77 -0
- data/lib/rails_ai_admin/presentation/outcomes.rb +44 -0
- data/lib/rails_ai_admin/presentation/status.rb +40 -0
- data/lib/rails_ai_admin/prune.rb +61 -0
- data/lib/rails_ai_admin/queries/list.rb +162 -0
- data/lib/rails_ai_admin/queries/named_scope.rb +65 -0
- data/lib/rails_ai_admin/record_version.rb +40 -0
- data/lib/rails_ai_admin/registry.rb +45 -0
- data/lib/rails_ai_admin/request_log_filter.rb +24 -0
- data/lib/rails_ai_admin/resource.rb +249 -0
- data/lib/rails_ai_admin/resource_identity.rb +15 -0
- data/lib/rails_ai_admin/schema_suggestions.rb +105 -0
- data/lib/rails_ai_admin/selections/builder.rb +162 -0
- data/lib/rails_ai_admin/templates/catalog.rb +84 -0
- data/lib/rails_ai_admin/templates/inspector.rb +94 -0
- data/lib/rails_ai_admin/templates/metadata.rb +48 -0
- data/lib/rails_ai_admin/templates/safe_paths.rb +30 -0
- data/lib/rails_ai_admin/test_kit.rb +103 -0
- data/lib/rails_ai_admin/version.rb +5 -0
- data/lib/rails_ai_admin/work.rb +48 -0
- data/lib/rails_ai_admin.rb +112 -0
- data/lib/tasks/rails_ai_admin.rake +58 -0
- metadata +328 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
# Snapshot capture/view is deliberately separate from business confirmation.
|
|
5
|
+
class SelectionsController < ApplicationController
|
|
6
|
+
def create
|
|
7
|
+
@resource = RailsAiAdmin.registry.fetch(params[:resource])
|
|
8
|
+
resolve_list_navigation
|
|
9
|
+
# Nothing ticked is an ordinary mistake: return to the same list (RAA-013).
|
|
10
|
+
if Array(params[:target_ids]).compact_blank.empty?
|
|
11
|
+
Authorization::Access.new(@context, @resource).authorize_selection!
|
|
12
|
+
redirect_to list_return_path, status: :see_other, notice: t("rails_ai_admin.selections.empty")
|
|
13
|
+
return
|
|
14
|
+
end
|
|
15
|
+
selection = Selections::Builder.new.capture(context: @context, resource: @resource, target_ids: params[:target_ids])
|
|
16
|
+
redirect_to selection_path(selection_ref: selection.selection_ref, **navigation_params), status: :see_other
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def show
|
|
20
|
+
records = []
|
|
21
|
+
@selection = Selections::Builder.new.revalidate(context: @context, selection_ref: params[:selection_ref]) { |record| records << record }
|
|
22
|
+
@resource = RailsAiAdmin.registry.fetch(@selection.resource_key)
|
|
23
|
+
resolve_list_navigation
|
|
24
|
+
access = Authorization::Access.new(@context, @resource)
|
|
25
|
+
# Offered when the actor may delete at least one selected record now.
|
|
26
|
+
@deletable = @resource.action_enabled?(:destroy) && records.any? do |record|
|
|
27
|
+
access.authorize!(:destroy, record)
|
|
28
|
+
rescue AccessDenied
|
|
29
|
+
false
|
|
30
|
+
end
|
|
31
|
+
@operations = RailsAiAdmin.operation_registry.custom(@resource).select do |definition|
|
|
32
|
+
next false unless definition.scope == :collection
|
|
33
|
+
access.authorize!(definition.key)
|
|
34
|
+
definition.fields(access, nil)
|
|
35
|
+
true
|
|
36
|
+
rescue AccessDenied
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Bulk delete reviews the fixed selection: records the actor may delete
|
|
42
|
+
# now, and how many will be skipped.
|
|
43
|
+
def confirm_destroy
|
|
44
|
+
load_deletable
|
|
45
|
+
raise AccessDenied if @deletable.empty?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Each record is deleted through the native destroy operation, with its own
|
|
49
|
+
# policy check, captured version, transaction and audit event. One record
|
|
50
|
+
# that cannot be deleted does not stop the others.
|
|
51
|
+
def destroy
|
|
52
|
+
digest = params[:manifest_digest]
|
|
53
|
+
raise AccessDenied, "INVALID_QUERY" unless digest.is_a?(String) && digest.match?(/\A[0-9a-f]{64}\z/)
|
|
54
|
+
load_deletable(manifest_digest: digest)
|
|
55
|
+
raise AccessDenied if @deletable.empty?
|
|
56
|
+
runner = Operations::Runner.new
|
|
57
|
+
@outcomes = @deletable.map do |target|
|
|
58
|
+
code = begin
|
|
59
|
+
intent = runner.prepare(context: @context, resource: @resource, operation: :destroy, target_id: target.id, expected_version: target.expected_version)
|
|
60
|
+
runner.execute(context: @context, intent_ref: intent.id, input: {}, human_confirmed: true, origin: :ui).code
|
|
61
|
+
rescue AccessDenied => error
|
|
62
|
+
error.code
|
|
63
|
+
end
|
|
64
|
+
[ target, code ]
|
|
65
|
+
end
|
|
66
|
+
@deleted = @outcomes.count { |_, code| code == "OK" }
|
|
67
|
+
failed = @outcomes.reject { |_, code| code == "OK" }
|
|
68
|
+
if failed.empty? && @skipped.zero?
|
|
69
|
+
redirect_to list_return_path, status: :see_other, notice: t("rails_ai_admin.selections.destroy.done", count: @deleted)
|
|
70
|
+
else
|
|
71
|
+
@failed = failed
|
|
72
|
+
render :destroyed
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def load_deletable(manifest_digest: nil)
|
|
79
|
+
records = {}
|
|
80
|
+
@selection = Selections::Builder.new.revalidate(context: @context, selection_ref: params[:selection_ref], manifest_digest: manifest_digest) do |record|
|
|
81
|
+
records[record.id.to_s] = record
|
|
82
|
+
end
|
|
83
|
+
@resource = RailsAiAdmin.registry.fetch(@selection.resource_key)
|
|
84
|
+
resolve_list_navigation
|
|
85
|
+
raise AccessDenied unless @resource.action_enabled?(:destroy)
|
|
86
|
+
access = Authorization::Access.new(@context, @resource)
|
|
87
|
+
@deletable = @selection.targets.select do |target|
|
|
88
|
+
access.authorize!(:destroy, records.fetch(target.id))
|
|
89
|
+
rescue AccessDenied
|
|
90
|
+
false
|
|
91
|
+
end
|
|
92
|
+
@skipped = @selection.targets.size - @deletable.size
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
# Admin-owned rails_tool_views endpoints (plan §6.3.1 shared mode). They use
|
|
5
|
+
# the public present/restore/dispatch/render API with an explicit admin Scope
|
|
6
|
+
# and never the host's global resolver, parent controller or agent adapter.
|
|
7
|
+
# rails_tool_views is referenced only inside actions, so conventional hosts
|
|
8
|
+
# can eager-load this file without loading the integration.
|
|
9
|
+
class SurfacesController < ApplicationController
|
|
10
|
+
ACT_FIELDS = %w[action_name base_revision idempotency_key source_component arguments].freeze
|
|
11
|
+
FORM_FURNITURE = %w[authenticity_token commit button].freeze
|
|
12
|
+
|
|
13
|
+
before_action :require_surfaces
|
|
14
|
+
around_action :bind_admin_context
|
|
15
|
+
|
|
16
|
+
# Native confirmation page -> persistent review Surface for the same intent.
|
|
17
|
+
def create
|
|
18
|
+
resource = RailsAiAdmin.registry.fetch(params[:resource])
|
|
19
|
+
definition = RailsAiAdmin.operation_registry.fetch(resource, params[:operation])
|
|
20
|
+
access = Authorization::Access.new(@context, resource)
|
|
21
|
+
record = access.find!(params[:id], action: definition.key)
|
|
22
|
+
surface = tool_views.present(context: @context, resource: resource, definition: definition, record: record,
|
|
23
|
+
intent_ref: params[:intent_ref], input: confirmed_input)
|
|
24
|
+
redirect_to admin_surface_path(surface_id: surface.id), status: :see_other
|
|
25
|
+
rescue AccessDenied => error
|
|
26
|
+
# An authorized actor with an expired, invalidated or input-mismatched
|
|
27
|
+
# intent gets the native conflict status, not anti-enumeration 404.
|
|
28
|
+
raise unless %w[APPROVAL_EXPIRED APPROVAL_INVALIDATED].include?(error.code)
|
|
29
|
+
admin_status(:conflict)
|
|
30
|
+
rescue ::RailsToolViews::Error => error
|
|
31
|
+
# The same intent was already presented with different input.
|
|
32
|
+
error.code == "IDEMPOTENCY_CONFLICT" ? admin_status(:conflict) : reject(error)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def show
|
|
36
|
+
@surface = ::RailsToolViews.restore(params[:surface_id], scope: admin_scope)
|
|
37
|
+
@surface_scope = admin_scope
|
|
38
|
+
rescue ::RailsToolViews::Error => error
|
|
39
|
+
reject(error)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def act
|
|
43
|
+
input = request.request_parameters.except(*FORM_FURNITURE)
|
|
44
|
+
raise AccessDenied, "INVALID_QUERY" unless (input.keys - ACT_FIELDS).empty?
|
|
45
|
+
revision = input["base_revision"]
|
|
46
|
+
raise AccessDenied, "INVALID_QUERY" unless revision.is_a?(String) && revision.match?(/\A[1-9][0-9]{0,9}\z/)
|
|
47
|
+
arguments = input.fetch("arguments", {})
|
|
48
|
+
raise AccessDenied, "INVALID_QUERY" unless arguments.is_a?(Hash)
|
|
49
|
+
result = ::RailsToolViews.dispatch(params[:surface_id], scope: admin_scope, action: input["action_name"],
|
|
50
|
+
arguments: arguments, base_revision: revision.to_i, idempotency_key: input["idempotency_key"],
|
|
51
|
+
source_component: input["source_component"])
|
|
52
|
+
respond_to do |format|
|
|
53
|
+
format.turbo_stream do
|
|
54
|
+
@surface = ::RailsToolViews.restore(result[:surface_id], scope: admin_scope)
|
|
55
|
+
@surface_scope = admin_scope
|
|
56
|
+
render turbo_stream: turbo_stream.replace(@surface.dom_id, helpers.admin_surface(@surface, scope: admin_scope))
|
|
57
|
+
end
|
|
58
|
+
format.html { redirect_to surface_return_path(result[:surface_id]), status: :see_other }
|
|
59
|
+
end
|
|
60
|
+
rescue ::RailsToolViews::Error => error
|
|
61
|
+
reject(error)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def tool_views
|
|
67
|
+
require "rails_ai_admin/integrations/tool_views"
|
|
68
|
+
Integrations::ToolViews
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def require_surfaces
|
|
72
|
+
raise AccessDenied unless RailsAiAdmin.configuration.surfaces
|
|
73
|
+
tool_views.ensure_registered!
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def bind_admin_context(&block)
|
|
77
|
+
tool_views.with_context(@context, &block)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def admin_scope = @admin_scope ||= tool_views.scope_for(@context)
|
|
81
|
+
|
|
82
|
+
# A review shown in a conversation returns there, so confirming or
|
|
83
|
+
# dismissing keeps the person in the chat (RAA-019).
|
|
84
|
+
def surface_return_path(surface_id)
|
|
85
|
+
reference = ::RailsToolViews::Surface.where(id: surface_id).pick(:context_ref)
|
|
86
|
+
chat_ref = reference.is_a?(String) && reference[/\Araa:chat:([0-9a-f-]{36})\z/, 1]
|
|
87
|
+
if chat_ref && RailsAiAdmin.configuration.assistant
|
|
88
|
+
require "rails_ai_admin/assistant"
|
|
89
|
+
chat = Assistant::Chat.owned(@context, chat_ref)
|
|
90
|
+
return assistant_conversation_path(chat_ref: chat.ref) if chat
|
|
91
|
+
end
|
|
92
|
+
admin_surface_path(surface_id: surface_id)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def confirmed_input
|
|
96
|
+
value = params[:confirmed_input]
|
|
97
|
+
raise AccessDenied, "INVALID_QUERY" unless value.is_a?(String) && value.bytesize <= Integrations::ToolViews::MAX_INPUT_BYTES
|
|
98
|
+
parsed = JSON.parse(value)
|
|
99
|
+
raise AccessDenied, "INVALID_QUERY" unless parsed.is_a?(Hash)
|
|
100
|
+
parsed
|
|
101
|
+
rescue JSON::ParserError
|
|
102
|
+
raise AccessDenied, "INVALID_QUERY"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Unknown, foreign or unauthorized Surfaces all look missing. A stale
|
|
106
|
+
# revision or business-level rejection re-renders the saved Surface.
|
|
107
|
+
def reject(error)
|
|
108
|
+
case error.code
|
|
109
|
+
when "NOT_FOUND", "NOT_ALLOWED", "INVALID_SCOPE", "SURFACE_PURGED" then admin_status(:not_found)
|
|
110
|
+
when "CONFLICT", "IDEMPOTENCY_CONFLICT", "SURFACE_CLOSED", "OPERATION_FAILED", "RATE_LIMITED"
|
|
111
|
+
@surface = ::RailsToolViews.restore(params[:surface_id], scope: admin_scope)
|
|
112
|
+
@surface_scope = admin_scope
|
|
113
|
+
@surface_error = error
|
|
114
|
+
render :show, status: error.status
|
|
115
|
+
when "INCOMPATIBLE_COMPONENT" then admin_status(:unavailable)
|
|
116
|
+
else admin_status(:invalid_request)
|
|
117
|
+
end
|
|
118
|
+
rescue ::RailsToolViews::Error
|
|
119
|
+
admin_status(:not_found)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
# Inline SVG bars: no script and no inline style, so the admin CSP holds.
|
|
5
|
+
# The chart is decorative for assistive technology; the caption and the data
|
|
6
|
+
# table beside it carry the numbers.
|
|
7
|
+
module DashboardHelper
|
|
8
|
+
CHART_WIDTH = 600
|
|
9
|
+
CHART_HEIGHT = 120
|
|
10
|
+
|
|
11
|
+
def admin_trend_chart(trend)
|
|
12
|
+
count = trend.days.size
|
|
13
|
+
gap = 2.0
|
|
14
|
+
bar = (CHART_WIDTH - gap * (count - 1)) / count
|
|
15
|
+
bars = trend.days.each_with_index.map do |(_day, value), index|
|
|
16
|
+
height = value.zero? ? 0 : [ (value.fdiv(trend.max) * (CHART_HEIGHT - 4)).round(1), 2 ].max
|
|
17
|
+
tag.rect(x: (index * (bar + gap)).round(2), y: (CHART_HEIGHT - height).round(2), width: bar.round(2), height: height, rx: 1, class: "raa-bar")
|
|
18
|
+
end
|
|
19
|
+
baseline = tag.line(x1: 0, y1: CHART_HEIGHT - 0.5, x2: CHART_WIDTH, y2: CHART_HEIGHT - 0.5, class: "raa-axis")
|
|
20
|
+
content_tag(:svg, safe_join(bars + [ baseline ]), viewBox: "0 0 #{CHART_WIDTH} #{CHART_HEIGHT}", class: "raa-chart",
|
|
21
|
+
preserveAspectRatio: "none", focusable: "false", aria: { hidden: true })
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def admin_number(value, capped)
|
|
25
|
+
capped ? t("rails_ai_admin.dashboard.more_than", number: number_with_delimiter(value)) : number_with_delimiter(value)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module FieldsHelper
|
|
5
|
+
# Only receives the already-authorized scalar projection. Host partials are
|
|
6
|
+
# trusted Rails code, not a sandbox or an authorization mechanism.
|
|
7
|
+
# record_id names the record a file field links to; pages that show one
|
|
8
|
+
# record use @record.
|
|
9
|
+
def admin_field_value(field, value, view:, record_id: @record&.id)
|
|
10
|
+
value = String.new(value) if value.is_a?(String)
|
|
11
|
+
return admin_file_value(field, value, view: view, record_id: record_id) if field.scalar_type == :file && !field.plugin?
|
|
12
|
+
return admin_files_value(field, value, view: view, record_id: record_id) if field.scalar_type == :files
|
|
13
|
+
return admin_references_value(field, value) if field.scalar_type == :references
|
|
14
|
+
if field.scalar_type == :belongs_to
|
|
15
|
+
reference = value.nil? ? nil : admin_associations.reference(field, value)
|
|
16
|
+
return render(partial: "rails_ai_admin/fields/reference", locals: { field: field, value: value, reference: reference })
|
|
17
|
+
end
|
|
18
|
+
text = field.format(value, time_zone: @context.time_zone)
|
|
19
|
+
unless field.plugin? || value.nil?
|
|
20
|
+
# The readable text stays visible; the exact value is machine-readable,
|
|
21
|
+
# and long text keeps its line breaks (RAA-015).
|
|
22
|
+
if field.scalar_type == :datetime
|
|
23
|
+
text = tag.time(text, datetime: value.in_time_zone(@context.time_zone).iso8601)
|
|
24
|
+
elsif field.scalar_type == :date
|
|
25
|
+
text = tag.time(text, datetime: value.iso8601)
|
|
26
|
+
elsif field.scalar_type == :textarea && view != :index
|
|
27
|
+
text = tag.span(text, class: "raa-multiline")
|
|
28
|
+
elsif field.scalar_type == :email
|
|
29
|
+
text = mail_to(value.to_s, text)
|
|
30
|
+
elsif field.scalar_type == :url && field.web_address?(value.to_s)
|
|
31
|
+
# Only an absolute http(s) address becomes a link (never javascript:).
|
|
32
|
+
text = link_to(text, value.to_s, rel: "noopener noreferrer nofollow", target: "_blank")
|
|
33
|
+
elsif field.scalar_type == :json
|
|
34
|
+
text = view == :index ? text.squish.truncate(80) : tag.pre(text, class: "raa-json")
|
|
35
|
+
elsif field.scalar_type == :rich_text
|
|
36
|
+
# The stored HTML through Rails' safe list; lists show plain text.
|
|
37
|
+
text = view == :index ? text.squish.truncate(80) : tag.div(sanitize(value.to_s), class: "raa-rich-text")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
partial = field.display_partial(view) || "rails_ai_admin/fields/#{view == :index ? 'index' : 'show'}"
|
|
41
|
+
render(partial: partial, locals: { field: field, value: value, text: text })
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Labels through each target's own grants; unavailable targets stay keys.
|
|
45
|
+
REFERENCES_SHOWN = 50
|
|
46
|
+
def admin_references_value(field, value)
|
|
47
|
+
ids = Array(value)
|
|
48
|
+
return "—" if ids.empty?
|
|
49
|
+
admin_associations.prime(field, ids.first(REFERENCES_SHOWN))
|
|
50
|
+
items = ids.first(REFERENCES_SHOWN).map do |id|
|
|
51
|
+
reference = admin_associations.reference(field, id)
|
|
52
|
+
reference.visible ? link_to(reference.label, resource_path(resource: reference.resource_key, id: reference.id)) : reference.label
|
|
53
|
+
end
|
|
54
|
+
items << t("rails_ai_admin.references.more", count: ids.size - REFERENCES_SHOWN) if ids.size > REFERENCES_SHOWN
|
|
55
|
+
safe_join(items, ", ")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Checkboxes for the visible targets (up to 100). With more targets, the
|
|
59
|
+
# current ones stay as checkboxes and one key input, with the search
|
|
60
|
+
# picker, adds another. The empty hidden value lets "none" be submitted.
|
|
61
|
+
def admin_references_input(form, field, value, input_options)
|
|
62
|
+
selected = Array(value)
|
|
63
|
+
id = form.field_id(field.name)
|
|
64
|
+
name = "#{form.field_name(field.name)}[]"
|
|
65
|
+
choices = admin_associations.choices(field)
|
|
66
|
+
admin_associations.prime(field, selected)
|
|
67
|
+
options = if choices.complete
|
|
68
|
+
choices.options
|
|
69
|
+
else
|
|
70
|
+
selected.filter_map { |key| (reference = admin_associations.reference(field, key)).visible ? [ reference.label, key ] : nil }
|
|
71
|
+
end
|
|
72
|
+
boxes = options.map do |label, key|
|
|
73
|
+
box_id = "#{id}--#{key}"
|
|
74
|
+
tag.div(safe_join([ check_box_tag(name, key, selected.include?(key), id: box_id), " ", label_tag(box_id, label) ]), class: "raa-check")
|
|
75
|
+
end
|
|
76
|
+
parts = [ hidden_field_tag(name, "", id: nil), *boxes ]
|
|
77
|
+
parts << tag.p(t("rails_ai_admin.fields.none"), class: "raa-field-help") if options.empty? && choices.complete
|
|
78
|
+
unless choices.complete
|
|
79
|
+
target = Fields::Association.target(field)
|
|
80
|
+
key_input = text_field_tag(name, "", id: "#{id}--add", maxlength: 100, autocomplete: "off", spellcheck: false,
|
|
81
|
+
placeholder: t("rails_ai_admin.references.key_placeholder", target: target.label.singularize))
|
|
82
|
+
parts << admin_reference_picker(target, key_input, "#{id}--add", field, nil)
|
|
83
|
+
end
|
|
84
|
+
tag.div(safe_join(parts), id: id, class: "raa-references", role: "group", aria: { labelledby: "#{id}--label", describedby: input_options.dig(:aria, :describedby), invalid: input_options.dig(:aria, :invalid) })
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# A file links to the admin's own download route, which checks the record
|
|
88
|
+
# and field grants again. Raster images also show a small inline preview.
|
|
89
|
+
def admin_file_value(field, value, view:, record_id:)
|
|
90
|
+
return "—" unless value.is_a?(Fields::FileValue)
|
|
91
|
+
text = field.format(value, time_zone: @context.time_zone)
|
|
92
|
+
return text unless record_id && @resource
|
|
93
|
+
path = resource_file_path(resource: @resource.key, id: record_id, field: field.name)
|
|
94
|
+
link = link_to(text, path)
|
|
95
|
+
return link unless view != :index && value.image?
|
|
96
|
+
safe_join([ tag.img(src: resource_file_path(resource: @resource.key, id: record_id, field: field.name, disposition: "inline"),
|
|
97
|
+
alt: value.filename, class: "raa-file-preview", loading: "lazy"), link ])
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Trix, from the host's Action Text assets, edits a hidden input. The
|
|
101
|
+
# label and descriptions point at the editor; file attachments are refused.
|
|
102
|
+
def admin_rich_text_input(form, field, value, input_options)
|
|
103
|
+
id = form.field_id(field.name)
|
|
104
|
+
editor = content_tag("trix-editor", "", id: id, input: "#{id}--value", class: "raa-rich-text-editor",
|
|
105
|
+
aria: input_options[:aria].merge(required: input_options[:required], labelledby: "#{id}--label"), data: { controller: "raa-rich-text",
|
|
106
|
+
action: "trix-file-accept->raa-rich-text#refuse trix-change->raa-rich-text#change" })
|
|
107
|
+
safe_join([ stylesheet_link_tag("trix"), javascript_include_tag("trix", defer: true),
|
|
108
|
+
hidden_field_tag(form.field_name(field.name), value, id: "#{id}--value"), editor ])
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Several files: a count in lists, a gallery of previews and links on
|
|
112
|
+
# show pages. Each link names its attachment.
|
|
113
|
+
def admin_files_value(field, value, view:, record_id:)
|
|
114
|
+
files = Array(value).grep(Fields::FileValue)
|
|
115
|
+
return field.format(files, time_zone: @context.time_zone) if view == :index || files.empty? || !(record_id && @resource)
|
|
116
|
+
items = files.map do |file|
|
|
117
|
+
path = resource_file_path(resource: @resource.key, id: record_id, field: field.name, attachment: file.id)
|
|
118
|
+
text = "#{file.filename} (#{number_to_human_size(file.byte_size)})"
|
|
119
|
+
preview = file.image? ? tag.img(src: resource_file_path(resource: @resource.key, id: record_id, field: field.name, attachment: file.id, disposition: "inline"), alt: file.filename, class: "raa-file-preview", loading: "lazy") : nil
|
|
120
|
+
tag.li(safe_join([ preview, link_to(text, path) ].compact))
|
|
121
|
+
end
|
|
122
|
+
tag.ul(safe_join(items), class: "raa-file-list")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Current files with remove boxes, then a multiple file chooser.
|
|
126
|
+
def admin_files_input(form, field, value, input_options)
|
|
127
|
+
id = form.field_id(field.name)
|
|
128
|
+
current = Array(value).grep(Fields::FileValue)
|
|
129
|
+
boxes = current.map do |file|
|
|
130
|
+
box_id = "#{id}--remove-#{file.id}"
|
|
131
|
+
tag.li(safe_join([ check_box_tag("remove_attachments[#{field.name}][]", file.id, false, id: box_id), " ",
|
|
132
|
+
label_tag(box_id, t("rails_ai_admin.files.remove_named", file: file.filename)) ]), class: "raa-check")
|
|
133
|
+
end
|
|
134
|
+
options = input_options.merge(multiple: true, required: nil)
|
|
135
|
+
options[:accept] = field.options[:accept].join(",") if field.options[:accept]
|
|
136
|
+
safe_join([ (tag.ul(safe_join(boxes), class: "raa-file-list") if boxes.any?), form.file_field(field.name, **options),
|
|
137
|
+
tag.p(t("rails_ai_admin.files.list_limit", size: number_to_human_size(field.options[:max_size]), count: field.options[:max_files]), class: "raa-field-help") ].compact)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Current file, a file chooser and (when the field may be empty) a remove box.
|
|
141
|
+
def admin_file_input(form, field, value, input_options)
|
|
142
|
+
current = value.is_a?(Fields::FileValue) ? value : nil
|
|
143
|
+
parts = []
|
|
144
|
+
if current
|
|
145
|
+
parts << tag.p(t("rails_ai_admin.files.current", file: field.format(current, time_zone: @context.time_zone)), class: "raa-file-current")
|
|
146
|
+
end
|
|
147
|
+
options = input_options.merge(required: current ? nil : input_options[:required])
|
|
148
|
+
options[:accept] = field.options[:accept].join(",") if field.options[:accept]
|
|
149
|
+
parts << form.file_field(field.name, **options)
|
|
150
|
+
parts << tag.p(t("rails_ai_admin.files.limit", size: number_to_human_size(field.options[:max_size])), class: "raa-field-help")
|
|
151
|
+
if current && !admin_field_required?(field)
|
|
152
|
+
id = "#{form.field_id(field.name)}--remove"
|
|
153
|
+
parts << tag.p(safe_join([ check_box_tag("remove_files[]", field.name.to_s, false, id: id), " ", label_tag(id, t("rails_ai_admin.files.remove")) ]), class: "raa-file-remove")
|
|
154
|
+
end
|
|
155
|
+
safe_join(parts)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# A bounded picker of currently visible targets. A stored key the actor
|
|
159
|
+
# cannot see stays selected so saving other fields never clears it; past
|
|
160
|
+
# MAX_CHOICES (or without a target list grant) the form takes a typed key.
|
|
161
|
+
def admin_reference_input(form, field, value, input_options)
|
|
162
|
+
value = value.nil? ? nil : String.new(value.to_s)
|
|
163
|
+
choices = admin_associations.choices(field)
|
|
164
|
+
unless choices.complete
|
|
165
|
+
target = Fields::Association.target(field)
|
|
166
|
+
key_input = form.text_field(field.name, value: value, maxlength: 100, autocomplete: "off", spellcheck: false,
|
|
167
|
+
placeholder: t("rails_ai_admin.references.key_placeholder", target: target.label.singularize), **input_options)
|
|
168
|
+
return admin_reference_picker(target, key_input, form.field_id(field.name), field, value)
|
|
169
|
+
end
|
|
170
|
+
options = choices.options.map(&:dup)
|
|
171
|
+
if value.present? && options.none? { |_, key| key == value }
|
|
172
|
+
options.unshift([ t("rails_ai_admin.references.not_available", label: admin_associations.reference(field, value).label), value ])
|
|
173
|
+
end
|
|
174
|
+
form.select(field.name, options, { selected: value, include_blank: field.options[:nullable] ? t("rails_ai_admin.fields.none") : t("rails_ai_admin.fields.choose_record") }, input_options)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The typed key input, plus a search box when the actor can list the
|
|
178
|
+
# target (the search endpoint applies the same grants again).
|
|
179
|
+
def admin_reference_picker(target, key_input, key_id, field, value)
|
|
180
|
+
return key_input unless allowed_action?(target, :index)
|
|
181
|
+
current = value.present? ? admin_associations.reference(field, value) : nil
|
|
182
|
+
render(partial: "rails_ai_admin/fields/reference_picker",
|
|
183
|
+
locals: { target: target, key_input: key_input, key_id: key_id, current: current&.visible ? current : nil })
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Untranslated templates for the picker script; it fills %{count} and %{label}.
|
|
187
|
+
def admin_reference_messages
|
|
188
|
+
results = t("rails_ai_admin.references.results")
|
|
189
|
+
{ searching: t("rails_ai_admin.references.searching"), none: t("rails_ai_admin.references.none"),
|
|
190
|
+
failed: t("rails_ai_admin.references.failed"), one: results[:one], other: results[:other],
|
|
191
|
+
chosen: t("rails_ai_admin.references.chosen") }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Says how the box matches, so "Love" not finding "Ada Lovelace" under
|
|
195
|
+
# prefix search is not a surprise (RAA-021).
|
|
196
|
+
def admin_search_placeholder(fields)
|
|
197
|
+
modes = fields.map { |field| field.options[:searchable] == true ? :prefix : field.options[:searchable] }.uniq
|
|
198
|
+
case modes
|
|
199
|
+
when [ :prefix ] then t("rails_ai_admin.search.prefix")
|
|
200
|
+
when [ :contains ] then t("rails_ai_admin.search.contains")
|
|
201
|
+
when [ :exact ] then t("rails_ai_admin.search.exact")
|
|
202
|
+
else t("rails_ai_admin.search.any")
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# The same rule as blank-input coercion: required or not nullable (RAA-023).
|
|
207
|
+
# An edit form's empty password box keeps the current password.
|
|
208
|
+
def admin_field_required?(field)
|
|
209
|
+
return field.options[:required] == true && @operation != :update if field.scalar_type == :password
|
|
210
|
+
field.options[:required] == true || field.options[:nullable] == false
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def admin_time_input_value(field, value)
|
|
214
|
+
return value if value.nil? || value.is_a?(String) && !value.match?(Field::TIME_OF_DAY)
|
|
215
|
+
field.format_time_of_day(value)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Submitted text comes back as typed; a stored value is indented.
|
|
219
|
+
def admin_json_input_value(value)
|
|
220
|
+
return value if value.nil? || value.is_a?(String)
|
|
221
|
+
JSON.pretty_generate(value)
|
|
222
|
+
rescue JSON::GeneratorError
|
|
223
|
+
nil
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# datetime-local value in the context zone. Seconds are kept when set so
|
|
227
|
+
# an unchanged form submits the stored second back.
|
|
228
|
+
def admin_datetime_input_value(value)
|
|
229
|
+
return value unless value.respond_to?(:in_time_zone)
|
|
230
|
+
local = value.in_time_zone(@context.time_zone)
|
|
231
|
+
local.strftime(local.sec.zero? ? "%Y-%m-%dT%H:%M" : "%Y-%m-%dT%H:%M:%S")
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def admin_field_choices(field, value)
|
|
235
|
+
choices = field.choice_options.map(&:dup)
|
|
236
|
+
if value.present? && !field.choice_values.include?(value)
|
|
237
|
+
choices.unshift([ t("rails_ai_admin.fields.unavailable_value"), String.new(value.to_s), { disabled: true } ])
|
|
238
|
+
end
|
|
239
|
+
choices
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
# Native inputs for already-granted typed filters. Values are the normalized
|
|
5
|
+
# list hint strings; the server coerces them again before querying.
|
|
6
|
+
module FiltersHelper
|
|
7
|
+
def admin_filter_label(field, operator)
|
|
8
|
+
case operator.to_sym
|
|
9
|
+
when :gte then t("rails_ai_admin.filters.from", label: field.label)
|
|
10
|
+
when :lte then t("rails_ai_admin.filters.to", label: field.label)
|
|
11
|
+
else Field::RANGE_TYPES.include?(field.scalar_type) ? t("rails_ai_admin.filters.equals", label: field.label) : field.label
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def admin_filter_input(field, operator, value, id:)
|
|
16
|
+
name = "filter[#{field.name}][#{operator}]"
|
|
17
|
+
value = value && String.new(value)
|
|
18
|
+
case field.scalar_type
|
|
19
|
+
when :belongs_to
|
|
20
|
+
choices = admin_associations.choices(field)
|
|
21
|
+
unless choices.complete
|
|
22
|
+
key_input = text_field_tag(name, value, id: id, maxlength: 100, autocomplete: "off", spellcheck: false)
|
|
23
|
+
return admin_reference_picker(Fields::Association.target(field), key_input, id, field, value)
|
|
24
|
+
end
|
|
25
|
+
options = [ [ t("rails_ai_admin.filters.any"), "" ] ] + choices.options.map(&:dup)
|
|
26
|
+
options << [ admin_associations.reference(field, value).label, value ] if value.present? && options.none? { |_, key| key == value }
|
|
27
|
+
select_tag(name, options_for_select(options, value), id: id)
|
|
28
|
+
when :enum, :select
|
|
29
|
+
select_tag(name, options_for_select([ [ t("rails_ai_admin.filters.any"), "" ] ] + field.choice_options.map(&:dup), value), id: id)
|
|
30
|
+
when :boolean
|
|
31
|
+
select_tag(name, options_for_select([ [ t("rails_ai_admin.filters.any"), "" ], [ t("rails_ai_admin.fields.yes"), "true" ], [ t("rails_ai_admin.fields.no"), "false" ] ], value), id: id)
|
|
32
|
+
when :integer then number_field_tag(name, value, id: id, step: 1)
|
|
33
|
+
when :decimal then text_field_tag(name, value, id: id, inputmode: "decimal", maxlength: 200)
|
|
34
|
+
when :date then date_field_tag(name, value, id: id)
|
|
35
|
+
when :datetime then datetime_local_field_tag(name, value, id: id)
|
|
36
|
+
else text_field_tag(name, value, id: id, maxlength: 200)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module HistoryHelper
|
|
5
|
+
def admin_history_allowed?(resource, record)
|
|
6
|
+
Authorization::Access.new(@context, resource).authorize_history!(record)
|
|
7
|
+
rescue AccessDenied
|
|
8
|
+
false
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def admin_history_time(time)
|
|
12
|
+
local = time.in_time_zone(@context.time_zone)
|
|
13
|
+
tag.time(local.strftime("%-d %b %Y %H:%M:%S %Z"), datetime: local.iso8601)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# "Updated", "Created", "Deleted" or a custom operation's description,
|
|
17
|
+
# plus the outcome when the change was not saved.
|
|
18
|
+
def admin_history_change(resource, entry)
|
|
19
|
+
name = if %w[create update destroy export].include?(entry.operation)
|
|
20
|
+
t(entry.operation, scope: %i[rails_ai_admin history operations])
|
|
21
|
+
else
|
|
22
|
+
RailsAiAdmin.operation_registry.custom(resource).find { |definition| definition.key.to_s == entry.operation }&.description ||
|
|
23
|
+
entry.operation.to_s.humanize
|
|
24
|
+
end
|
|
25
|
+
return name if entry.status == "succeeded"
|
|
26
|
+
safe_join([ name, tag.br, tag.span(t("rails_ai_admin.history.not_saved"), class: "raa-danger"), " ", admin_outcome(entry.code) ])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def admin_history_fields(entry)
|
|
30
|
+
return "—" if entry.fields.empty? && entry.hidden_fields.zero?
|
|
31
|
+
parts = [ entry.fields.join(", ") ]
|
|
32
|
+
parts << t("rails_ai_admin.history.other_fields", count: entry.hidden_fields) if entry.hidden_fields.positive?
|
|
33
|
+
parts.reject(&:blank?).join("; ")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
# Original project-owned outline glyphs, with fixed SVG attributes and paths.
|
|
5
|
+
module NavigationHelper
|
|
6
|
+
# Page title parts, most specific first (RAA-024). The first call wins, so
|
|
7
|
+
# a page rendered inside another keeps the outer page's title.
|
|
8
|
+
def admin_title(*parts)
|
|
9
|
+
@raa_title_parts ||= parts.compact.map { |part| part.to_s.squish.truncate(80) }.reject(&:empty?)
|
|
10
|
+
nil
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# A result code as a sentence, with the code kept as secondary text for
|
|
14
|
+
# support (RAA-027).
|
|
15
|
+
def admin_outcome(code)
|
|
16
|
+
return "" if code.blank?
|
|
17
|
+
safe_join([ Presentation::Outcomes.sentence(code), " ", tag.span("(#{code})", class: "raa-code") ])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# A stored run, item or work status ("succeeded") as display text.
|
|
21
|
+
def admin_state_name(status)
|
|
22
|
+
status = status.to_s
|
|
23
|
+
return status.humanize unless status.match?(/\A[a-z][a-z_]{0,63}\z/)
|
|
24
|
+
t(status, scope: %i[rails_ai_admin states], default: status.humanize)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# { "succeeded" => 2, "failed" => 1 } as "Succeeded: 2 · Failed: 1".
|
|
28
|
+
def admin_state_counts(counts)
|
|
29
|
+
counts.map { |status, count| t("rails_ai_admin.states.count", state: admin_state_name(status), count: count) }.join(" · ")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# "Edit ORD-0001 · Orders · Administration".
|
|
33
|
+
def admin_page_title
|
|
34
|
+
[ *Array(@raa_title_parts), RailsAiAdmin.configuration.title ].join(" · ")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
ICON_PATHS = {
|
|
38
|
+
"users" => [ "M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z", "M2 21v-3a6 6 0 0 1 12 0v3M17 4a4 4 0 0 1 0 8M18 15a5 5 0 0 1 4 5v1" ],
|
|
39
|
+
"shopping-bag" => [ "M4 7h16l1 14H3L4 7ZM8 8V6a4 4 0 0 1 8 0v2" ],
|
|
40
|
+
"life-buoy" => [ "M22 12a10 10 0 1 1-20 0 10 10 0 0 1 20 0ZM16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z", "m5 5 4 4m6 6 4 4M5 19l4-4m6-6 4-4" ],
|
|
41
|
+
"document" => [ "M5 2h9l5 5v15H5V2ZM14 2v6h5M8 12h8M8 16h8" ],
|
|
42
|
+
"grid" => [ "M3 3h7v7H3V3Zm11 0h7v7h-7V3ZM3 14h7v7H3v-7Zm11 0h7v7h-7v-7Z" ]
|
|
43
|
+
}.transform_values { |paths| paths.each(&:freeze).freeze }.freeze
|
|
44
|
+
|
|
45
|
+
def navigation_icon(name)
|
|
46
|
+
return unless name
|
|
47
|
+
paths = ICON_PATHS.fetch(name) { raise ConfigurationError, "Unknown navigation icon" }
|
|
48
|
+
tag.svg(class: "raa-nav-icon", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor",
|
|
49
|
+
"stroke-width": "1.7", "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true", focusable: "false") do
|
|
50
|
+
safe_join(paths.map { |path| tag.path(d: path) })
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
# View helpers for admin-owned review Surfaces. rails_tool_views is touched
|
|
5
|
+
# only when a Surface is actually rendered, never when this file loads.
|
|
6
|
+
module ToolViewsHelper
|
|
7
|
+
# Renders through RailsToolViews.render with fresh authorization. The
|
|
8
|
+
# rails_tool_views view helpers are added to this view instance only.
|
|
9
|
+
def admin_surface(surface, scope:, error: nil)
|
|
10
|
+
extend ::RailsToolViews::SurfacesHelper unless is_a?(::RailsToolViews::SurfacesHelper)
|
|
11
|
+
::RailsToolViews.render(surface, view_context: self, scope: scope, error: error)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# A Surface referenced from a conversation, restored for the current admin.
|
|
15
|
+
def admin_assistant_surface(surface_id, scope:)
|
|
16
|
+
surface = ::RailsToolViews.restore(surface_id, scope: scope)
|
|
17
|
+
admin_surface(surface, scope: scope)
|
|
18
|
+
rescue ::RailsToolViews::Error
|
|
19
|
+
tag.p(t("rails_ai_admin.surfaces.no_longer_available"), class: "raa-chat-activity")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Like rails_tool_views_action_form, but posts to the admin endpoint so the
|
|
23
|
+
# host's global resolver never authenticates admin actions.
|
|
24
|
+
def admin_surface_action_form(surface, action, **options, &block)
|
|
25
|
+
fields = rails_tool_views_action_fields(surface, action)
|
|
26
|
+
form_with(url: admin_surface_actions_path(surface_id: surface.id), method: :post, scope: :arguments, namespace: surface.dom_id, **options) do |form|
|
|
27
|
+
safe_join([ fields, (capture(form, &block) if block) ])
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The current authorized review behind a Surface, or nil.
|
|
32
|
+
def admin_surface_review(props)
|
|
33
|
+
Integrations::ToolViews.review(Integrations::ToolViews.current_context, props)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def admin_surface_changes(review, wire)
|
|
37
|
+
Integrations::ToolViews.proposed_changes(review, wire)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def admin_surface_display_values(review, wire)
|
|
41
|
+
Integrations::ToolViews.display_values(review, wire)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def admin_surface_review_available?(resource, definition)
|
|
45
|
+
RailsAiAdmin.configuration.surfaces && resource.surface_operation?(definition.key)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|