settings_hub 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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +28 -0
  4. data/Rakefile +10 -0
  5. data/app/assets/stylesheets/settings_hub/application.css +15 -0
  6. data/app/controllers/settings_hub/api/sections_controller.rb +41 -0
  7. data/app/controllers/settings_hub/application_controller.rb +23 -0
  8. data/app/controllers/settings_hub/sections_controller.rb +78 -0
  9. data/app/controllers/settings_hub/settings_controller.rb +7 -0
  10. data/app/helpers/settings_hub/app_routes_helper.rb +12 -0
  11. data/app/helpers/settings_hub/application_helper.rb +4 -0
  12. data/app/jobs/settings_hub/application_job.rb +4 -0
  13. data/app/mailers/settings_hub/application_mailer.rb +6 -0
  14. data/app/models/settings_hub/application_record.rb +5 -0
  15. data/app/models/settings_hub/change_name.rb +15 -0
  16. data/app/views/settings_hub/sections/_profile.html.erb +6 -0
  17. data/app/views/settings_hub/settings/show.html.erb +26 -0
  18. data/config/routes.rb +11 -0
  19. data/lib/generators/settings_hub/section/section_generator.rb +43 -0
  20. data/lib/generators/settings_hub/section/templates/action.rb.tt +11 -0
  21. data/lib/generators/settings_hub/section/templates/action_test.rb.tt +9 -0
  22. data/lib/generators/settings_hub/section/templates/partial.html.erb.tt +6 -0
  23. data/lib/settings_hub/bad_registration.rb +4 -0
  24. data/lib/settings_hub/engine.rb +9 -0
  25. data/lib/settings_hub/reference/guide.md +216 -0
  26. data/lib/settings_hub/registry.rb +58 -0
  27. data/lib/settings_hub/result.rb +22 -0
  28. data/lib/settings_hub/section.rb +30 -0
  29. data/lib/settings_hub/settings_account.rb +10 -0
  30. data/lib/settings_hub/version.rb +3 -0
  31. data/lib/settings_hub.rb +36 -0
  32. data/lib/tasks/settings_hub_tasks.rake +4 -0
  33. data/the_local/agents/settings_hub-develop.md +281 -0
  34. data/the_local/agents/settings_hub-info.md +92 -0
  35. data/the_local/agents/settings_hub-install.md +145 -0
  36. data/the_local/interface.yml +57 -0
  37. metadata +110 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ad85780a6a73a02eb859164d7c912fb92229e357e63f0b52e90a7d1cf11c5929
4
+ data.tar.gz: c6e248ee131cd3b0a95b41907af314a106e999f117f6f7c8290d3d18780dfb4d
5
+ SHA512:
6
+ metadata.gz: 38d3f21a99d9dd6b5b03c2f4be5a4d509b9d596923ca1f23abdcf626c36f58324194ec9e058ee559e0cc3df0b8d37362bd6ca60cf74a18658d92ce54584b9840
7
+ data.tar.gz: 23c5e8a36399efa81853564f9480cc780855b3452c3c3e43b6dd2b130037d960ac19160aec0fc4125e2e6b0fce83a40af02018c2561ce3bf25aab31263245f8c
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright tylercschneider
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # SettingsHub
2
+ Short description and motivation.
3
+
4
+ ## Usage
5
+ How to use my plugin.
6
+
7
+ ## Installation
8
+ Add this line to your application's Gemfile:
9
+
10
+ ```ruby
11
+ gem "settings_hub"
12
+ ```
13
+
14
+ And then execute:
15
+ ```bash
16
+ $ bundle
17
+ ```
18
+
19
+ Or install it yourself as:
20
+ ```bash
21
+ $ gem install settings_hub
22
+ ```
23
+
24
+ ## Contributing
25
+ Contribution directions go here.
26
+
27
+ ## License
28
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
7
+ require "the_local/rake"
8
+
9
+ task test: "app:test"
10
+ task default: :test
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,41 @@
1
+ module SettingsHub
2
+ module Api
3
+ class SectionsController < SettingsHub::ApplicationController
4
+ before_action :refuse_without_capability, only: :update
5
+
6
+ def index
7
+ render json: available_sections.map { |section| { key: section.key, actions: section.actions.keys } }
8
+ end
9
+
10
+ def update
11
+ return head :unprocessable_content unless requested_action
12
+
13
+ result = requested_action.new(person: current_person, account: account_settings_act_on, values: submitted_values).call
14
+
15
+ render json: { ok: result.ok?, message: result.message }
16
+ end
17
+
18
+ private
19
+
20
+ def refuse_without_capability
21
+ head :forbidden unless section && allowed?(section)
22
+ end
23
+
24
+ def requested_action
25
+ section.actions[params[:action_name].to_sym]
26
+ end
27
+
28
+ def section
29
+ SettingsHub.registry.find(params[:key])
30
+ end
31
+
32
+ def submitted_values
33
+ params.except(:controller, :action, :key, :action_name).permit!.to_h.symbolize_keys
34
+ end
35
+
36
+ def available_sections
37
+ visible_areas.values.flatten
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,23 @@
1
+ module SettingsHub
2
+ class ApplicationController < ::ApplicationController
3
+ helper KeystoneUiHelper, SettingsHub::Engine.routes.url_helpers, SettingsHub::AppRoutesHelper
4
+
5
+ before_action { SettingsHub::AppRoutesHelper.define_app_route_helpers }
6
+
7
+ private
8
+
9
+ def account_settings_act_on
10
+ SettingsAccount.of(self)
11
+ end
12
+
13
+ def visible_areas
14
+ SettingsHub.registry.areas
15
+ .transform_values { |sections| sections.select { |section| allowed?(section) } }
16
+ .reject { |_area, sections| sections.empty? }
17
+ end
18
+
19
+ def allowed?(section)
20
+ section.capability.nil? || can?(section.capability)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,78 @@
1
+ module SettingsHub
2
+ class SectionsController < ApplicationController
3
+ before_action :set_section
4
+ before_action :refuse_without_capability
5
+
6
+ def show
7
+ return redirect_to @section.at if @section.at
8
+
9
+ @person = current_person
10
+ @account = account_settings_act_on
11
+ @areas = visible_areas
12
+ @section_addresses = section_addresses
13
+ @selection = selection
14
+
15
+ render template: "settings_hub/settings/show"
16
+ end
17
+
18
+ def update
19
+ return head :unprocessable_content unless requested_action
20
+
21
+ result = requested_action.new(person: current_person, account: account_settings_act_on, values: submitted_values).call
22
+ return show_refusal(result.message) unless result.ok?
23
+
24
+ tell_the_app_it_ran
25
+
26
+ redirect_to section_path(params[:key])
27
+ end
28
+
29
+ private
30
+
31
+ def requested_action
32
+ return @section.actions[params[:action_name].to_sym] if params[:action_name]
33
+
34
+ @section.action
35
+ end
36
+
37
+ def selection
38
+ params.except(:controller, :action, :key, :action_name).permit!.to_h.symbolize_keys
39
+ end
40
+
41
+ def section_addresses
42
+ return { submit_url: section_path(@section.key) } unless @section.named_actions?
43
+
44
+ { submit_urls: @section.actions.keys.to_h { |name| [ name, section_action_path(@section.key, name) ] } }
45
+ end
46
+
47
+ def show_refusal(message)
48
+ @person = current_person
49
+ @account = account_settings_act_on
50
+ @areas = visible_areas
51
+ @section_addresses = section_addresses
52
+ @selection = selection
53
+ @refusal = message
54
+
55
+ render template: "settings_hub/settings/show", status: :unprocessable_content
56
+ end
57
+
58
+ def tell_the_app_it_ran
59
+ return unless respond_to?(:after_settings_change, true)
60
+
61
+ after_settings_change(section: @section, person: current_person)
62
+ end
63
+
64
+ def submitted_values
65
+ params.except(:controller, :action, :key, :signed_in_as).permit!.to_h.symbolize_keys
66
+ end
67
+
68
+ def set_section
69
+ @section = SettingsHub.registry.find(params[:key])
70
+
71
+ raise ActionController::RoutingError, "No settings section named #{params[:key]}" unless @section
72
+ end
73
+
74
+ def refuse_without_capability
75
+ head :forbidden unless allowed?(@section)
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,7 @@
1
+ module SettingsHub
2
+ class SettingsController < ApplicationController
3
+ def show
4
+ @areas = visible_areas
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,12 @@
1
+ module SettingsHub
2
+ module AppRoutesHelper
3
+ def self.define_app_route_helpers
4
+ @app_route_helpers_defined ||= begin
5
+ (Rails.application.routes.named_routes.helper_names - SettingsHub::Engine.routes.named_routes.helper_names).each do |name|
6
+ define_method(name) { |*args, **options| main_app.public_send(name, *args, **options) }
7
+ end
8
+ true
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,4 @@
1
+ module SettingsHub
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module SettingsHub
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module SettingsHub
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module SettingsHub
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,15 @@
1
+ module SettingsHub
2
+ class ChangeName
3
+ def initialize(person:, account:, values:)
4
+ @person = person
5
+ @account = account
6
+ @values = values
7
+ end
8
+
9
+ def call
10
+ return Result.refused(@person.errors.full_messages.to_sentence) unless @person.update(name: @values[:name])
11
+
12
+ Result.ok
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,6 @@
1
+ <%= ui_panel do %>
2
+ <%= ui_form(action: submit_url, method: :patch) do %>
3
+ <%= ui_form_field(attribute: "name", label: "Name", value: person.name, required: true) %>
4
+ <%= ui_button(label: "Save") %>
5
+ <% end %>
6
+ <% end %>
@@ -0,0 +1,26 @@
1
+ <%= ui_page(max_width: :xl) do %>
2
+ <%= ui_page_header(title: "Settings") %>
3
+
4
+ <%= ui_grid(cols: { default: 1, lg: 2 }, gap: :lg) do %>
5
+ <%= ui_section do %>
6
+ <% @areas.each do |area, sections| %>
7
+ <section aria-label="<%= area.to_s.titleize %>">
8
+ <%= ui_section(title: area.to_s.titleize) do %>
9
+ <%= ui_panel do %>
10
+ <nav aria-label="Settings">
11
+ <% sections.each do |section| %>
12
+ <%= link_to section.title, section.at || section_path(section.key), "aria-current": ("page" if section == @section) %>
13
+ <% end %>
14
+ </nav>
15
+ <% end %>
16
+ <% end %>
17
+ </section>
18
+ <% end %>
19
+ <% end %>
20
+
21
+ <%= ui_section do %>
22
+ <%= ui_alert(message: @refusal, type: :error) if @refusal %>
23
+ <%= render partial: @section.renders, locals: {person: @person, account: @account, selection: @selection || {}, **(@section_addresses || {})} if @section %>
24
+ <% end %>
25
+ <% end %>
26
+ <% end %>
data/config/routes.rb ADDED
@@ -0,0 +1,11 @@
1
+ SettingsHub::Engine.routes.draw do
2
+ get "/", to: "settings#show", as: :settings
3
+ namespace :api do
4
+ resources :sections, only: :index
5
+ patch "sections/:key/:action_name", to: "sections#update"
6
+ end
7
+
8
+ get "/:key", to: "sections#show", as: :section
9
+ patch "/:key", to: "sections#update"
10
+ patch "/:key/:action_name", to: "sections#update", as: :section_action
11
+ end
@@ -0,0 +1,43 @@
1
+ require "rails/generators"
2
+
3
+ module SettingsHub
4
+ module Generators
5
+ class SectionGenerator < Rails::Generators::NamedBase
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ INITIALIZER = "config/initializers/settings_hub.rb".freeze
9
+ OPENING = "Rails.application.config.to_prepare do\n".freeze
10
+
11
+ def register_the_section
12
+ create_file INITIALIZER, "#{OPENING}end\n" unless initializer_exists?
13
+ inject_into_file INITIALIZER, registration, after: OPENING
14
+ end
15
+
16
+ def write_the_object
17
+ template "action.rb.tt", "app/models/save_#{file_name}.rb"
18
+ end
19
+
20
+ def write_the_partial
21
+ template "partial.html.erb.tt", "app/views/settings/_#{file_name}.html.erb"
22
+ end
23
+
24
+ def write_the_test
25
+ template "action_test.rb.tt", "test/models/save_#{file_name}_test.rb"
26
+ end
27
+
28
+ private
29
+
30
+ def registration
31
+ %( SettingsHub.section :#{file_name}, area: :user, title: "#{human_name}", renders: "settings/#{file_name}", runs: "#{save_object}"\n)
32
+ end
33
+
34
+ def save_object
35
+ "Save#{class_name}"
36
+ end
37
+
38
+ def initializer_exists?
39
+ File.exist?(File.join(destination_root, INITIALIZER))
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,11 @@
1
+ class <%= save_object %>
2
+ def initialize(person:, account:, values:)
3
+ @person = person
4
+ @account = account
5
+ @values = values
6
+ end
7
+
8
+ def call
9
+ SettingsHub::Result.ok
10
+ end
11
+ end
@@ -0,0 +1,9 @@
1
+ require "test_helper"
2
+
3
+ class <%= save_object %>Test < ActiveSupport::TestCase
4
+ test "it accepts a submitted change" do
5
+ result = <%= save_object %>.new(person: nil, account: nil, values: {}).call
6
+
7
+ assert result.ok?
8
+ end
9
+ end
@@ -0,0 +1,6 @@
1
+ <%%= ui_panel do %>
2
+ <%%= ui_form(action: submit_url, method: :patch) do %>
3
+ <%%= ui_form_field(attribute: "<%= file_name %>", label: "<%= human_name %>") %>
4
+ <%%= ui_button(label: "Save") %>
5
+ <%% end %>
6
+ <%% end %>
@@ -0,0 +1,4 @@
1
+ module SettingsHub
2
+ class BadRegistration < StandardError
3
+ end
4
+ end
@@ -0,0 +1,9 @@
1
+ module SettingsHub
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace SettingsHub
4
+
5
+ config.to_prepare do
6
+ SettingsHub.prepare!
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,216 @@
1
+ ## SettingsHub
2
+
3
+ > **DO NOT** explore the settings_hub gem source code. This reference is the complete
4
+ > user-facing API, and settings_hub's locals are authored from it. Keep it the single
5
+ > source of truth: change it in the same pass that changes the interface, then
6
+ > author the locals again.
7
+
8
+ SettingsHub is the settings page, shared across apps: **a section is a registration,
9
+ not a page.** SettingsHub owns the page, the list of sections on it, the address each
10
+ section is served at, and the check deciding who may see one. A section owns
11
+ only its fields and the things a person can do with them. Adding a section
12
+ changes no routing, no navigation and no controller.
13
+
14
+ SettingsHub owns no database table. It is a mountable Rails engine and it draws every
15
+ page with keystone_ui.
16
+
17
+ ### Installing it
18
+
19
+ ```ruby
20
+ gem "settings_hub"
21
+ ```
22
+
23
+ ```ruby
24
+ mount SettingsHub::Engine => "/settings"
25
+ ```
26
+
27
+ The host's `ApplicationController` supplies four things. Only the first is
28
+ required:
29
+
30
+ ```ruby
31
+ def current_person # required — who is signed in
32
+ def settings_account # the account settings act on
33
+ def current_account # used when settings_account is not defined
34
+ def can?(capability) # required once any section names a capability
35
+ def after_settings_change(section:, person:) # optional, runs after a change
36
+ ```
37
+
38
+ `settings_account` is asked for first and `current_account` is the fallback, so
39
+ an app whose settings act on something other than the account a person last
40
+ switched to says which by defining `settings_account`. An app that defines
41
+ neither gets `nil`, which is correct for a product whose settings are all
42
+ personal.
43
+
44
+ ### Generating a section
45
+
46
+ ```
47
+ bin/rails generate settings_hub:section reminders
48
+ ```
49
+
50
+ It registers the section in the app's settings_hub initializer, creating that file if
51
+ it is missing, and writes the object the section runs, the partial it draws and
52
+ a test for the object. The generated test passes as written, and the section
53
+ appears on the settings page with nothing further to wire. Everything it writes
54
+ is a starting point to edit.
55
+
56
+ ### Registering a section
57
+
58
+ ```ruby
59
+ Rails.application.config.to_prepare do
60
+ SettingsHub.section :password, area: :user, title: "Password",
61
+ renders: "settings/password", runs: "ChangePassword"
62
+ end
63
+ ```
64
+
65
+ | Detail | Required | What it is |
66
+ |---|---|---|
67
+ | first argument | yes | the section's key, used in its address |
68
+ | `area:` | yes | which list it appears in — any symbol, commonly `:user`, `:team`, `:account` |
69
+ | `title:` | yes | what the list calls it |
70
+ | `renders:` | no | the partial drawn inside the settings page |
71
+ | `runs:` | no | the object or objects a submitted change is handed to |
72
+ | `capability:` | no | what a person must hold to see it |
73
+ | `at:` | no | an address to send a person to instead of drawing a partial |
74
+
75
+ A section with `at:` draws nothing and sends the person to that address. Use it
76
+ for a page settings_hub cannot serve, such as one a vendored engine owns.
77
+
78
+ Registrations go in `config.to_prepare`, which runs again on every code reload.
79
+ SettingsHub clears its registry each time, so registrations are made fresh rather
80
+ than added to what is already there.
81
+
82
+ ### Replacing a registration
83
+
84
+ `SettingsHub.section` refuses a key already taken in that area. Code that means to
85
+ override a registered section says so:
86
+
87
+ ```ruby
88
+ SettingsHub.replace_section :profile, area: :user, title: "Profile",
89
+ renders: "my_app/profile", runs: "MyApp::ChangeName"
90
+ ```
91
+
92
+ ### What settings_hub refuses when the app starts
93
+
94
+ Three mistakes raise `SettingsHub::BadRegistration` at registration rather than in
95
+ front of a person:
96
+
97
+ - a key already taken in that area, named in the message
98
+ - an object named in `runs:` that the app cannot find, named in the message
99
+ - a capability the app does not recognise, named in the message
100
+
101
+ The third runs only once the app declares what it recognises:
102
+
103
+ ```ruby
104
+ SettingsHub.capabilities = -> { Citizen.capabilities }
105
+ ```
106
+
107
+ An app that declares nothing gets no capability check, which is what lets settings_hub
108
+ be installed without citizen.
109
+
110
+ ### The object a section runs
111
+
112
+ `runs:` names a class as a string. The object takes three keywords and answers
113
+ `call`:
114
+
115
+ ```ruby
116
+ class ChangeName
117
+ def initialize(person:, account:, values:)
118
+ @person = person
119
+ @account = account
120
+ @values = values
121
+ end
122
+
123
+ def call
124
+ return SettingsHub::Result.refused(@person.errors.full_messages.to_sentence) unless @person.update(name: @values[:name])
125
+
126
+ SettingsHub::Result.ok
127
+ end
128
+ end
129
+ ```
130
+
131
+ `values` is what the person submitted, as a hash with symbol keys. The object
132
+ reads no request, no session and no params object, which is what lets a caller
133
+ other than the page run it.
134
+
135
+ ```ruby
136
+ SettingsHub::Result.ok # it worked
137
+ SettingsHub::Result.refused("why not") # it did not, and this is shown to the person
138
+ ```
139
+
140
+ A refusal re-draws the section with the message above it and saves nothing, and
141
+ the app is not told a change was made.
142
+
143
+ ### The partial a section renders
144
+
145
+ The partial is handed locals and nothing else. It draws no page heading, no
146
+ frame and no layout, because it is rendered inside the settings page:
147
+
148
+ ```erb
149
+ <%= ui_panel do %>
150
+ <%= ui_form(action: submit_url, method: :patch) do %>
151
+ <%= ui_form_field(attribute: "name", label: "Name", value: person.name, required: true) %>
152
+ <%= ui_button(label: "Save") %>
153
+ <% end %>
154
+ <% end %>
155
+ ```
156
+
157
+ | Local | What it is |
158
+ |---|---|
159
+ | `person` | who is signed in |
160
+ | `account` | the account settings act on |
161
+ | `selection` | the query string, as a hash with symbol keys |
162
+ | `submit_url` | where to submit, for a section naming one object |
163
+ | `submit_urls` | where to submit each named object, for a section naming several |
164
+
165
+ `selection` is how a section holds a choice across a request without a
166
+ controller of its own — a section listing people links each one to
167
+ `?member_id=1` and reads `selection[:member_id]` to draw that person.
168
+
169
+ ### Calling it from JSON
170
+
171
+ The same registrations and the same objects answer a caller that speaks JSON,
172
+ signed in as a person the same way the page is:
173
+
174
+ ```
175
+ GET /settings/api/sections
176
+ PATCH /settings/api/sections/:key/:action_name
177
+ ```
178
+
179
+ The listing gives back only the sections that person holds the capability for,
180
+ each naming the actions it offers. A section running one object names that
181
+ action after the section itself, so `profile` offers the action `profile`.
182
+
183
+ ```json
184
+ [{"key": "profile", "actions": ["profile"]},
185
+ {"key": "team", "actions": ["invite", "remove"]}]
186
+ ```
187
+
188
+ A change answers whether it worked and why not:
189
+
190
+ ```json
191
+ {"ok": false, "message": "That name is spoken for"}
192
+ ```
193
+
194
+ A caller naming a section the person may not see is refused, and a refused
195
+ change saves nothing.
196
+
197
+ ### A section that does several things
198
+
199
+ `runs:` takes a hash when a section offers more than one thing. Each gets its
200
+ own address, handed to the partial in `submit_urls`:
201
+
202
+ ```ruby
203
+ SettingsHub.section :team, area: :team, title: "Team", capability: :manage_team,
204
+ renders: "citizen/members/team",
205
+ runs: {
206
+ invite: "Citizen::Invite",
207
+ remove: "Citizen::RemoveMember"
208
+ }
209
+ ```
210
+
211
+ ```erb
212
+ <%= ui_form(action: submit_urls[:invite], method: :patch) do %>
213
+ ```
214
+
215
+ Every object in the hash takes the same three keywords and returns the same
216
+ result as the single-object case.