usernames 0.1.3 → 0.2.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 +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +154 -7
- data/app/assets/javascripts/usernames/index.js +16 -0
- data/app/assets/javascripts/usernames/username_check_controller.js +191 -0
- data/app/controllers/usernames/availability_controller.rb +177 -0
- data/config/importmap.rb +8 -0
- data/config/locales/usernames.en.yml +6 -2
- data/config/locales/usernames.es.yml +2 -0
- data/config/locales/usernames.pt.yml +2 -0
- data/lib/generators/usernames/availability_generator.rb +85 -24
- data/lib/generators/usernames/install_generator.rb +24 -0
- data/lib/generators/usernames/install_messages.rb +41 -2
- data/lib/generators/usernames/javascript_generator.rb +38 -0
- data/lib/generators/usernames/javascript_setup.rb +124 -0
- data/lib/generators/usernames/templates/availability/_field.html.erb.tt +5 -15
- data/lib/generators/usernames/templates/usernames.rb +8 -2
- data/lib/usernames/configuration.rb +93 -3
- data/lib/usernames/engine.rb +74 -13
- data/lib/usernames/form_helpers.rb +215 -0
- data/lib/usernames/locale.rb +39 -0
- data/lib/usernames/model.rb +137 -26
- data/lib/usernames/reserved/listing.rb +1 -0
- data/lib/usernames/reserved.rb +18 -8
- data/lib/usernames/routes.rb +26 -0
- data/lib/usernames/rules/options.rb +9 -1
- data/lib/usernames/rules.rb +9 -1
- data/lib/usernames/targets.rb +65 -0
- data/lib/usernames/unique_violation.rb +0 -1
- data/lib/usernames/version.rb +1 -1
- data/lib/usernames.rb +3 -0
- data/sig/usernames.rbs +33 -6
- metadata +11 -3
- data/lib/generators/usernames/templates/availability/availability_controller.rb.tt +0 -107
- data/lib/generators/usernames/templates/availability/username_check_controller.js.tt +0 -115
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<%%# locals: (form:, attribute: :<%= column %>, label: nil) -%>
|
|
2
2
|
<%%#
|
|
3
|
-
A username field with live hints and
|
|
4
|
-
Generated by `rails generate usernames:availability`. It's yours: edit it.
|
|
3
|
+
A username field with live hints and the availability check.
|
|
4
|
+
Generated by `rails generate usernames:availability --copy`. It's yours: edit it.
|
|
5
5
|
|
|
6
6
|
Render it inside a form, handing it the form builder:
|
|
7
7
|
|
|
@@ -10,16 +10,6 @@
|
|
|
10
10
|
Hints are advisory: nothing here stops the form from submitting, and the
|
|
11
11
|
model's validations have the last word.
|
|
12
12
|
-%>
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
data-username-check-rules-value="<%%= <%= model_name %>.<%= column %>_rules_for_browser.to_json %>"
|
|
17
|
-
data-username-check-lowercase-value="<%%= <%= model_name %>.<%= column %>_options[:case] != :preserve %>">
|
|
18
|
-
<%%= form.label attribute, label %>
|
|
19
|
-
<%%= form.text_field attribute,
|
|
20
|
-
autocomplete: "username", autocapitalize: "none", autocorrect: "off", spellcheck: false,
|
|
21
|
-
aria: { describedby: "#{form.field_id(attribute)}_hint #{form.field_id(attribute)}_status" },
|
|
22
|
-
data: { username_check_target: "input", action: "input->username-check#check" } %>
|
|
23
|
-
<p id="<%%= form.field_id(attribute) %>_hint" data-username-check-target="hint" aria-live="polite"></p>
|
|
24
|
-
<p id="<%%= form.field_id(attribute) %>_status" data-username-check-target="status" aria-live="polite"></p>
|
|
25
|
-
</div>
|
|
13
|
+
<%%= form.label attribute, label %>
|
|
14
|
+
<%%= form.username_field attribute %>
|
|
15
|
+
<%%= form.username_status attribute %>
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Every line below shows the default: uncomment one to change it.
|
|
3
|
+
# Every commented line below shows the default: uncomment one to change it.
|
|
4
4
|
# The README documents the less common options (first_character,
|
|
5
5
|
# reserve_routes, app_name, offensive.adapters, on_adapter_failure, generator,
|
|
6
|
-
# unique_index). Every
|
|
6
|
+
# unique_index, and the rest of config.availability). Every rule is also
|
|
7
|
+
# accepted per model:
|
|
7
8
|
# has_username :handle, length: 2..24, case: :preserve
|
|
8
9
|
Usernames.configure do |config|
|
|
10
|
+
# The username columns the live availability check answers for, by the
|
|
11
|
+
# alias the browser sends (`f.username_field` fills it in). Nothing else is
|
|
12
|
+
# ever looked up: a model with has_username is not public until it is here.
|
|
13
|
+
config.availability.targets = { <%= target_alias.inspect %> => <%= target.inspect %> }
|
|
14
|
+
|
|
9
15
|
# How long a username may be, in characters.
|
|
10
16
|
# config.length = 3..30
|
|
11
17
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "active_support/core_ext/integer/time"
|
|
4
|
+
|
|
3
5
|
module Usernames
|
|
4
6
|
# `Usernames.configure { |config| … }`. The generated initializer shows ten
|
|
5
7
|
# of these options; the README documents the rest.
|
|
@@ -88,6 +90,89 @@ module Usernames
|
|
|
88
90
|
end
|
|
89
91
|
end
|
|
90
92
|
|
|
93
|
+
# config.availability: the built-in "is this name free?" endpoint. None
|
|
94
|
+
# of it is a rule, so nothing here touches the compiled rules.
|
|
95
|
+
class Availability
|
|
96
|
+
# The route drawn for the endpoint; nil or false draws none.
|
|
97
|
+
attr_accessor :path
|
|
98
|
+
|
|
99
|
+
# Which username columns the endpoint answers for, by an alias the
|
|
100
|
+
# browser sends: { "user" => "User#handle" }. Nothing else is ever
|
|
101
|
+
# looked up, so a model with has_username is not published by
|
|
102
|
+
# declaring it. The install generator writes yours.
|
|
103
|
+
attr_accessor :targets
|
|
104
|
+
|
|
105
|
+
# The class the endpoint inherits from, by name. nil: your
|
|
106
|
+
# ApplicationController when there is one (its locale and
|
|
107
|
+
# authentication callbacks apply), else ActionController::Base.
|
|
108
|
+
# "ActionController::API" works too.
|
|
109
|
+
attr_accessor :parent_controller
|
|
110
|
+
|
|
111
|
+
# Rails' rate_limit, per client IP: { to: 30, within: 1.minute }.
|
|
112
|
+
attr_accessor :rate_limit
|
|
113
|
+
|
|
114
|
+
# Where the rate limit counts. nil: Rails.cache. Read once, when the
|
|
115
|
+
# endpoint's class loads, so a change needs a restart.
|
|
116
|
+
attr_accessor :rate_limit_store
|
|
117
|
+
|
|
118
|
+
ALIAS = /\A[a-z0-9][a-z0-9_-]*\z/
|
|
119
|
+
TARGET = /\A[A-Z]\w*(?:::[A-Z]\w*)*#[a-z_]\w*\z/
|
|
120
|
+
|
|
121
|
+
def initialize
|
|
122
|
+
@path = Usernames::Routes::DEFAULT_PATH
|
|
123
|
+
@targets = {}.freeze
|
|
124
|
+
@parent_controller = nil
|
|
125
|
+
@rate_limit = { to: 30, within: 1.minute }.freeze
|
|
126
|
+
@rate_limit_store = nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The endpoint's superclass (see parent_controller). :nodoc:
|
|
130
|
+
def parent_class
|
|
131
|
+
name = parent_controller
|
|
132
|
+
name ||= Object.const_defined?(:ApplicationController) ? "ApplicationController" : "ActionController::Base"
|
|
133
|
+
Object.const_get(name)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def validate!
|
|
137
|
+
valid_path!
|
|
138
|
+
valid_targets!
|
|
139
|
+
unless parent_controller.nil? || (parent_controller.is_a?(String) && !parent_controller.empty?)
|
|
140
|
+
invalid!(:parent_controller, parent_controller, "a class name as a String, like \"ApplicationController\"")
|
|
141
|
+
end
|
|
142
|
+
valid_rate_limit!
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def valid_path!
|
|
148
|
+
return if [nil, false].include?(path) || (path.is_a?(String) && path.match?(%r{\A/[^/?#\s]}))
|
|
149
|
+
|
|
150
|
+
invalid!(:path, path, "a path like \"/usernames/availability\", or nil to draw no route")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def valid_targets!
|
|
154
|
+
valid = targets.is_a?(Hash) && targets.all? do |name, target|
|
|
155
|
+
name.is_a?(String) && name.match?(ALIAS) && target.is_a?(String) && target.match?(TARGET)
|
|
156
|
+
end
|
|
157
|
+
return if valid
|
|
158
|
+
|
|
159
|
+
invalid!(:targets, targets, "a Hash of aliases to \"Model#column\" Strings, like { \"user\" => \"User#handle\" }")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def valid_rate_limit!
|
|
163
|
+
limit = rate_limit
|
|
164
|
+
valid = limit.is_a?(Hash) && limit.keys.sort == %i[to within] && limit[:to].is_a?(Integer) &&
|
|
165
|
+
limit[:to].positive? && limit[:within].is_a?(Numeric) && limit[:within].positive?
|
|
166
|
+
return if valid
|
|
167
|
+
|
|
168
|
+
invalid!(:rate_limit, limit, "{ to: 30, within: 1.minute }: a whole number of requests per period")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def invalid!(option, value, expected)
|
|
172
|
+
raise ConfigurationError, "config.availability.#{option} must be #{expected} (got #{value.inspect})"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
91
176
|
# Per-call word lists ADD to the configured ones: a model that passes
|
|
92
177
|
# `reserved: %w[recipe]` keeps every bundled reservation. Every other
|
|
93
178
|
# per-call option replaces the configured value. Adding is the choice
|
|
@@ -100,9 +185,9 @@ module Usernames
|
|
|
100
185
|
CACHE_LIMIT = 64
|
|
101
186
|
|
|
102
187
|
option :length, :separators, :edge_separators, :case, :first_character, :reserve_routes, :app_name,
|
|
103
|
-
:reserved, :forbidden, :allowed, :on_adapter_failure, :unique_index
|
|
188
|
+
:reserved, :forbidden, :allowed, :reserved_if, :on_adapter_failure, :unique_index
|
|
104
189
|
|
|
105
|
-
attr_reader :offensive, :generator
|
|
190
|
+
attr_reader :offensive, :generator, :availability
|
|
106
191
|
|
|
107
192
|
def initialize
|
|
108
193
|
@length = 3..30
|
|
@@ -115,10 +200,14 @@ module Usernames
|
|
|
115
200
|
@reserved = Data.reserved
|
|
116
201
|
@forbidden = Data.impersonation
|
|
117
202
|
@allowed = [].freeze
|
|
203
|
+
# ->(name) { Page.exists?(slug: name) }: names reserved by your data
|
|
204
|
+
# rather than a list. nil: none.
|
|
205
|
+
@reserved_if = nil
|
|
118
206
|
@on_adapter_failure = :allow
|
|
119
207
|
@unique_index = nil
|
|
120
208
|
@offensive = Offensive.new(self)
|
|
121
209
|
@generator = Generator.new(self)
|
|
210
|
+
@availability = Availability.new
|
|
122
211
|
end
|
|
123
212
|
|
|
124
213
|
# `config.generator = ->(attempt) { … }` replaces the default shape;
|
|
@@ -140,6 +229,7 @@ module Usernames
|
|
|
140
229
|
|
|
141
230
|
def validate!
|
|
142
231
|
rules_for
|
|
232
|
+
availability.validate!
|
|
143
233
|
self
|
|
144
234
|
end
|
|
145
235
|
|
|
@@ -147,7 +237,7 @@ module Usernames
|
|
|
147
237
|
{
|
|
148
238
|
length: length, separators: separators, edge_separators: edge_separators, case: @case,
|
|
149
239
|
first_character: first_character, reserve_routes: reserve_routes, app_name: app_name,
|
|
150
|
-
reserved: reserved, forbidden: forbidden, allowed: allowed, offensive: offensive.to_h,
|
|
240
|
+
reserved: reserved, forbidden: forbidden, allowed: allowed, reserved_if: reserved_if, offensive: offensive.to_h,
|
|
151
241
|
on_adapter_failure: on_adapter_failure, generator: generator.to_h, unique_index: unique_index
|
|
152
242
|
}
|
|
153
243
|
end
|
data/lib/usernames/engine.rb
CHANGED
|
@@ -1,32 +1,93 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "form_helpers"
|
|
4
|
+
require_relative "routes"
|
|
5
|
+
|
|
3
6
|
module Usernames
|
|
4
|
-
# Not isolated
|
|
5
|
-
# (the availability check is a generator that copies files into the app).
|
|
7
|
+
# Not isolated. What it wires into the app, and how to turn each piece off:
|
|
6
8
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
9
|
+
# * `has_username` on every Active Record model.
|
|
10
|
+
# * Its config/locales, loaded BEFORE the app's — Rails puts engines first
|
|
11
|
+
# and the application last — so any key in the app's own locale files
|
|
12
|
+
# wins.
|
|
13
|
+
# * The app's own routes reserved, re-read whenever they reload
|
|
14
|
+
# (config.reserve_routes = false turns it off).
|
|
15
|
+
# * The availability endpoint, Usernames::AvailabilityController, at
|
|
16
|
+
# config.availability.path: one route appended after the app's own, and
|
|
17
|
+
# left out when the app already has one named usernames_availability
|
|
18
|
+
# (path = nil draws none).
|
|
19
|
+
# * `f.username_field` / `f.username_status` on every form builder.
|
|
20
|
+
# * With importmap-rails, the pins `usernames` and `usernames/username-check`;
|
|
21
|
+
# with Propshaft or Sprockets, the JavaScript behind them. `import
|
|
22
|
+
# "usernames"` in app/javascript/application.js is what uses them.
|
|
23
|
+
# * A bad configuration value fails at boot, not at the first sign-up.
|
|
12
24
|
#
|
|
13
25
|
# The generators (`usernames:install`, `usernames:availability`,
|
|
14
|
-
# `usernames:locale`) need no wiring: Rails finds
|
|
15
|
-
# lib/generators/usernames/ on the load path.
|
|
26
|
+
# `usernames:javascript`, `usernames:locale`) need no wiring: Rails finds
|
|
27
|
+
# them in lib/generators/usernames/ on the load path.
|
|
16
28
|
class Engine < ::Rails::Engine
|
|
29
|
+
JAVASCRIPTS = %w[usernames/index.js usernames/username_check_controller.js].freeze
|
|
30
|
+
|
|
17
31
|
initializer "usernames.active_record" do
|
|
18
32
|
ActiveSupport.on_load(:active_record) { extend Usernames::Model }
|
|
19
33
|
end
|
|
20
34
|
|
|
35
|
+
initializer "usernames.form_helpers" do
|
|
36
|
+
ActiveSupport.on_load(:action_view) { ActionView::Helpers::FormBuilder.include(Usernames::FormBuilder) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Once, before the routes are first drawn. The block runs on every
|
|
40
|
+
# draw (and redraw in development), reading the configuration as it is
|
|
41
|
+
# by then.
|
|
42
|
+
initializer "usernames.routes" do |app|
|
|
43
|
+
ActionDispatch::Routing::Mapper.include(Usernames::Routes)
|
|
44
|
+
routes = app.routes
|
|
45
|
+
routes.append { Usernames::Engine.draw_availability_route(self, routes) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Before importmap-rails reads its paths; nothing at all without it.
|
|
49
|
+
initializer "usernames.importmap", before: "importmap" do |app|
|
|
50
|
+
next unless defined?(::Importmap) && app.config.respond_to?(:importmap)
|
|
51
|
+
|
|
52
|
+
app.config.importmap.paths << root.join("config/importmap.rb")
|
|
53
|
+
app.config.importmap.cache_sweepers << root.join("app/assets/javascripts")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Propshaft and Sprockets both find an engine's app/assets on their own;
|
|
57
|
+
# Sprockets also needs to be told to precompile them.
|
|
58
|
+
initializer "usernames.assets" do |app|
|
|
59
|
+
Usernames::Engine.add_assets(app.config.assets) if app.config.respond_to?(:assets) && app.config.assets
|
|
60
|
+
end
|
|
61
|
+
|
|
21
62
|
# After every initializer, including the app's config/initializers/usernames.rb.
|
|
22
63
|
initializer "usernames.validate_configuration" do |app|
|
|
23
64
|
app.config.after_initialize { Usernames.configuration.validate! }
|
|
24
65
|
end
|
|
25
66
|
|
|
26
|
-
# The app's static top-level paths are reserved
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
67
|
+
# The app's static top-level paths are reserved — the endpoint's own
|
|
68
|
+
# first segment (`usernames`) among them. They are collected lazily, on
|
|
69
|
+
# the first check after the routes load; in development the routes
|
|
70
|
+
# reload when config/routes.rb changes, and a page added there is
|
|
71
|
+
# reserved from the next check on.
|
|
30
72
|
config.after_routes_loaded { Usernames::Reserved.reset_routes! }
|
|
73
|
+
|
|
74
|
+
class << self
|
|
75
|
+
# The appended route: drawn unless the path is off or the app drew one
|
|
76
|
+
# of that name itself. :nodoc:
|
|
77
|
+
def draw_availability_route(mapper, routes)
|
|
78
|
+
return unless Usernames.configuration.availability.path
|
|
79
|
+
return if routes.named_routes.key?(:usernames_availability)
|
|
80
|
+
|
|
81
|
+
mapper.usernames_availability
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# :nodoc:
|
|
85
|
+
def add_assets(assets)
|
|
86
|
+
javascripts = root.join("app/assets/javascripts").to_s
|
|
87
|
+
paths = assets.paths
|
|
88
|
+
paths << javascripts if paths.is_a?(Array) && paths.none? { |path| path.to_s == javascripts }
|
|
89
|
+
assets.precompile |= JAVASCRIPTS if assets.precompile.is_a?(Array)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
31
92
|
end
|
|
32
93
|
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Usernames
|
|
6
|
+
# A username field with live hints and the availability check:
|
|
7
|
+
#
|
|
8
|
+
# <%= form_with model: @user do |f| %>
|
|
9
|
+
# <%= f.label :handle %>
|
|
10
|
+
# <%= f.username_field :handle %>
|
|
11
|
+
# <%= f.username_status :handle %>
|
|
12
|
+
# <% end %>
|
|
13
|
+
#
|
|
14
|
+
# The field is a text field the `username-check` Stimulus controller
|
|
15
|
+
# watches; the status is the live region it writes to. While someone
|
|
16
|
+
# types, the field shows the first format rule the name breaks, then
|
|
17
|
+
# whether it is free. Nothing blocks submission: the model decides.
|
|
18
|
+
#
|
|
19
|
+
# f.username_field :handle, availability: false # format hints only, no request
|
|
20
|
+
# f.username_field :handle, static: true # a plain text field
|
|
21
|
+
#
|
|
22
|
+
# Every other option is the text field's, `class:` included.
|
|
23
|
+
# `data: { controller:, action: }` and `aria: { describedby: }` are joined
|
|
24
|
+
# with the field's own as token lists; the rest of `data:` and `aria:` is
|
|
25
|
+
# merged in, and the field's own `username-check-*` values are the field's.
|
|
26
|
+
#
|
|
27
|
+
# The input's id is `field_id(:handle)` (`user_handle`), the status's
|
|
28
|
+
# `field_id(:handle, :status)`. Two forms for the same record on one page
|
|
29
|
+
# need different ones: pass `namespace:` to form_with, or the same `id:`
|
|
30
|
+
# to both helpers.
|
|
31
|
+
module FormBuilder
|
|
32
|
+
def username_field(column = :username, availability: true, static: false, **html)
|
|
33
|
+
column = column.to_sym
|
|
34
|
+
html = Field.attributes(html)
|
|
35
|
+
return text_field(column, Field::TYPING.merge(html)) if static
|
|
36
|
+
|
|
37
|
+
own, input = username_field_ids(column, html)
|
|
38
|
+
check = Field.check(@template, object, column, id: input, availability: availability)
|
|
39
|
+
return text_field(column, Field::TYPING.merge(html)) if check.nil?
|
|
40
|
+
|
|
41
|
+
text_field(column, Field.combine(Field::TYPING.merge(html), check).merge(id: own))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# <output id="user_handle_status" for="user_handle" aria-live="polite">,
|
|
45
|
+
# empty until someone types; the controller sets its data-state
|
|
46
|
+
# (checking, free, taken, invalid, unchanged, error) for styling.
|
|
47
|
+
# Pass the field's `id:`, `namespace:` or `index:` here too if you gave it them.
|
|
48
|
+
def username_status(column = :username, **html)
|
|
49
|
+
html = Field.attributes(html)
|
|
50
|
+
_own, input = username_field_ids(column, html)
|
|
51
|
+
own = { id: "#{input}_status", for: input, aria: { "live" => "polite" } }
|
|
52
|
+
@template.tag.output("", **Field.combine(html.except(:id, :namespace, :index), own))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# The id the field gives Rails, and the id Rails renders from it. Given
|
|
58
|
+
# explicitly, so the input has the id the status points at even where
|
|
59
|
+
# form_with generates none; Rails prefixes the namespace to it (the
|
|
60
|
+
# field's own `namespace:`, else the form's), so this does too, and a
|
|
61
|
+
# field's own `index:` is in its id as in its name.
|
|
62
|
+
def username_field_ids(column, html)
|
|
63
|
+
own = html.fetch(:id) { field_id(column, namespace: nil, index: html.fetch(:index) { options[:index] }) }
|
|
64
|
+
[own, username_input_id(own, html.fetch(:namespace) { options[:namespace] })]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# An input's id as Rails renders it: the namespace, then the id.
|
|
68
|
+
def username_input_id(id, namespace)
|
|
69
|
+
namespace ? "#{namespace}_#{id}" : id
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The attributes behind the helpers. :nodoc:
|
|
74
|
+
module Field
|
|
75
|
+
# What tells a browser this is a username, not prose. Overridable:
|
|
76
|
+
# `autocomplete: "nickname"` for a public handle that isn't a login.
|
|
77
|
+
TYPING = { autocomplete: "username", autocapitalize: "none", autocorrect: "off", spellcheck: false }.freeze
|
|
78
|
+
|
|
79
|
+
# Space-separated lists, where the caller's values join the field's own.
|
|
80
|
+
TOKEN_LISTS = %w[data-controller data-action aria-describedby].freeze
|
|
81
|
+
|
|
82
|
+
NESTED = %w[data aria].freeze
|
|
83
|
+
|
|
84
|
+
MISSING_TARGET_LOGGED = Concurrent::AtomicBoolean.new(false)
|
|
85
|
+
|
|
86
|
+
class << self
|
|
87
|
+
# The data and aria attributes for a checked field, or nil when the
|
|
88
|
+
# field can only be plain (a production form whose column is not a target).
|
|
89
|
+
def check(template, object, column, id:, availability:)
|
|
90
|
+
model = model_of!(object, column)
|
|
91
|
+
endpoint = availability ? endpoint(template, model, column) : {}
|
|
92
|
+
return if endpoint == :missing
|
|
93
|
+
|
|
94
|
+
data = hints(object, model, column).merge("username-check-status-id-value" => "#{id}_status", **endpoint)
|
|
95
|
+
{ data: data.compact, aria: { "describedby" => "#{id}_status" } }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The form's model, which must have declared has_username for `column`.
|
|
99
|
+
def model_of!(object, column)
|
|
100
|
+
model = object.class
|
|
101
|
+
return model if model.respond_to?(:_usernames) && model._usernames.key?(column)
|
|
102
|
+
|
|
103
|
+
raise ArgumentError, "username_field :#{column} needs a form for a model with has_username :#{column} " \
|
|
104
|
+
"(got #{object.inspect})"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# `data-…` and `aria-…` given as plain keys, moved into data: and
|
|
108
|
+
# aria:, so there is one place to merge them.
|
|
109
|
+
def attributes(html)
|
|
110
|
+
html.each_with_object({}) do |(key, value), attributes|
|
|
111
|
+
nested, name = key.to_s.split("-", 2)
|
|
112
|
+
if value.is_a?(Hash)
|
|
113
|
+
nested = key.to_s
|
|
114
|
+
value = value.transform_keys { |each| each.to_s.dasherize }
|
|
115
|
+
end
|
|
116
|
+
next attributes[key.to_sym] = value unless NESTED.include?(nested)
|
|
117
|
+
|
|
118
|
+
(attributes[nested.to_sym] ||= {}).merge!(name ? { name => value } : value)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The caller's attributes with the field's own over them: token lists
|
|
123
|
+
# joined, data and aria merged (deeply), the field's values winning for
|
|
124
|
+
# its own keys.
|
|
125
|
+
def combine(theirs, ours)
|
|
126
|
+
theirs.merge(ours) do |key, given, own|
|
|
127
|
+
next own unless given.is_a?(Hash) && own.is_a?(Hash)
|
|
128
|
+
|
|
129
|
+
given.deep_merge(own) { |name, mine, field| token_list?("#{key}-#{name}") ? tokens(mine, field) : field }
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
# What the browser needs for its format hints: the rules (in the model's
|
|
136
|
+
# words), the case mode and the saved name. Every value a String, and
|
|
137
|
+
# the rules encoded by the json gem itself, so rendering a field never
|
|
138
|
+
# depends on Active Support's JSON encoder.
|
|
139
|
+
def hints(object, model, column)
|
|
140
|
+
{
|
|
141
|
+
"controller" => "username-check",
|
|
142
|
+
"action" => "input->username-check#check",
|
|
143
|
+
"username-check-rules-value" => JSON.generate(model.public_send(:"#{column}_rules_for_browser")),
|
|
144
|
+
"username-check-lowercase-value" => (model._usernames.fetch(column).mode != :preserve).to_s,
|
|
145
|
+
"username-check-persisted-value" => persisted(object, column)
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The endpoint's URL and this column's alias in config.availability.targets.
|
|
150
|
+
def endpoint(template, model, column)
|
|
151
|
+
url = url(template)
|
|
152
|
+
return {} if url.nil?
|
|
153
|
+
|
|
154
|
+
target = Targets.alias_for(model, column)
|
|
155
|
+
return missing_target(model, column) if target.nil?
|
|
156
|
+
|
|
157
|
+
{ "username-check-url-value" => url, "username-check-target-value" => target,
|
|
158
|
+
"username-check-error-message-value" => error_message }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# In the page's language, else in English, never "Translation missing"
|
|
162
|
+
# (see Usernames::Locale).
|
|
163
|
+
def error_message
|
|
164
|
+
Locale.t(:usernames, :availability, :error)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Through the app's route helper (`main_app` from inside an engine's
|
|
168
|
+
# views), so a sub-URI deployment's SCRIPT_NAME is in it. None when
|
|
169
|
+
# the app has no such route: the field then only hints.
|
|
170
|
+
def url(template)
|
|
171
|
+
routes = template.respond_to?(:main_app) ? template.main_app : template
|
|
172
|
+
routes.usernames_availability_path if routes.respond_to?(:usernames_availability_path)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Publishing a column takes a line in config.availability.targets: in
|
|
176
|
+
# development and test, saying so loudly; in production, a plain
|
|
177
|
+
# field and one log line rather than a broken page.
|
|
178
|
+
def missing_target(model, column)
|
|
179
|
+
target = "#{model.name}##{column}"
|
|
180
|
+
why = "no entry in config.availability.targets names #{target}: add " \
|
|
181
|
+
"{ #{model.name.to_s.underscore.tr('/', '_').inspect} => #{target.inspect} } to it, or pass " \
|
|
182
|
+
"availability: false"
|
|
183
|
+
raise ConfigurationError, "username_field :#{column} can't check availability: #{why}" if Rails.env.local?
|
|
184
|
+
|
|
185
|
+
Rails.logger.error "[usernames] username_field :#{column} renders without its check: #{why}." if
|
|
186
|
+
MISSING_TARGET_LOGGED.make_true
|
|
187
|
+
:missing
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def persisted(object, column)
|
|
191
|
+
object.attribute_in_database(column).presence if object.respond_to?(:persisted?) && object.persisted?
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def token_list?(name)
|
|
195
|
+
TOKEN_LISTS.include?(name)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Joined as Rails joins a token list (token_list, class_names): Arrays
|
|
199
|
+
# flattened, a Hash keeping the keys whose values are truthy, nil and
|
|
200
|
+
# false left out; then split on whitespace and deduplicated.
|
|
201
|
+
def tokens(*lists)
|
|
202
|
+
lists.flat_map { |list| token_values(list) }.flat_map { |value| value.to_s.split }.uniq.join(" ")
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def token_values(value)
|
|
206
|
+
case value
|
|
207
|
+
when Hash then value.filter_map { |token, wanted| token if wanted }
|
|
208
|
+
when Array then value.flat_map { |each| token_values(each) }
|
|
209
|
+
when nil, false then []
|
|
210
|
+
else [value]
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Usernames
|
|
6
|
+
# The gem's own messages that are not a record's errors — what the
|
|
7
|
+
# availability check says — looked up so they never read
|
|
8
|
+
# "Translation missing":
|
|
9
|
+
#
|
|
10
|
+
# 1. in the current locale, so the app's own translation wins;
|
|
11
|
+
# 2. else in English as I18n has it (the app's English wording, if any);
|
|
12
|
+
# 3. else in the gem's English, read from config/locales/usernames.en.yml
|
|
13
|
+
# itself — for an app whose available locales leave English out, where
|
|
14
|
+
# I18n never loads it.
|
|
15
|
+
#
|
|
16
|
+
# The YAML file stays the one place the words are written. :nodoc:
|
|
17
|
+
module Locale
|
|
18
|
+
ENGLISH = File.expand_path("../../config/locales/usernames.en.yml", __dir__)
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# Locale.t(:usernames, :availability, :error) # => "couldn't be checked just now"
|
|
22
|
+
def t(*keys, key)
|
|
23
|
+
I18n.t(key, scope: keys, raise: true)
|
|
24
|
+
rescue I18n::MissingTranslationData
|
|
25
|
+
if I18n.backend.exists?(:en, key, scope: keys)
|
|
26
|
+
I18n.backend.translate(:en, key, scope: keys)
|
|
27
|
+
else
|
|
28
|
+
english(*keys, key)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The gem's own English for these keys, from the shipped file.
|
|
33
|
+
def english(*keys)
|
|
34
|
+
@english ||= YAML.safe_load(File.read(ENGLISH, encoding: "UTF-8")).fetch("en").freeze
|
|
35
|
+
@english.dig(*keys.map(&:to_s))
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|