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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/README.md +154 -7
  4. data/app/assets/javascripts/usernames/index.js +16 -0
  5. data/app/assets/javascripts/usernames/username_check_controller.js +191 -0
  6. data/app/controllers/usernames/availability_controller.rb +177 -0
  7. data/config/importmap.rb +8 -0
  8. data/config/locales/usernames.en.yml +6 -2
  9. data/config/locales/usernames.es.yml +2 -0
  10. data/config/locales/usernames.pt.yml +2 -0
  11. data/lib/generators/usernames/availability_generator.rb +85 -24
  12. data/lib/generators/usernames/install_generator.rb +24 -0
  13. data/lib/generators/usernames/install_messages.rb +41 -2
  14. data/lib/generators/usernames/javascript_generator.rb +38 -0
  15. data/lib/generators/usernames/javascript_setup.rb +124 -0
  16. data/lib/generators/usernames/templates/availability/_field.html.erb.tt +5 -15
  17. data/lib/generators/usernames/templates/usernames.rb +8 -2
  18. data/lib/usernames/configuration.rb +93 -3
  19. data/lib/usernames/engine.rb +74 -13
  20. data/lib/usernames/form_helpers.rb +215 -0
  21. data/lib/usernames/locale.rb +39 -0
  22. data/lib/usernames/model.rb +137 -26
  23. data/lib/usernames/reserved/listing.rb +1 -0
  24. data/lib/usernames/reserved.rb +18 -8
  25. data/lib/usernames/routes.rb +26 -0
  26. data/lib/usernames/rules/options.rb +9 -1
  27. data/lib/usernames/rules.rb +9 -1
  28. data/lib/usernames/targets.rb +65 -0
  29. data/lib/usernames/unique_violation.rb +0 -1
  30. data/lib/usernames/version.rb +1 -1
  31. data/lib/usernames.rb +3 -0
  32. data/sig/usernames.rbs +33 -6
  33. metadata +11 -3
  34. data/lib/generators/usernames/templates/availability/availability_controller.rb.tt +0 -107
  35. data/lib/generators/usernames/templates/availability/username_check_controller.js.tt +0 -115
@@ -25,7 +25,7 @@ module Usernames
25
25
  base.class_attribute :_usernames, instance_accessor: false, default: {}.freeze
26
26
  end
27
27
 
28
- # has_username(column = :username, required: false, generate: nil, **rules)
28
+ # has_username(column = :username, required: false, generate: nil, seed: nil, clearable: true, **rules)
29
29
  #
30
30
  # Installs, for that column:
31
31
  #
@@ -35,17 +35,30 @@ module Usernames
35
35
  # attributes;
36
36
  # 3. with `required:`, presence — on create, and on an update that
37
37
  # changes it (a legacy blank still saves unrelated changes);
38
- # 4. uniqueness, compared the way the unique index compares (Comparison);
39
- # 5. a generated name on create when none was given — by default, unless
40
- # a name is required for that record;
41
- # 6. a race wrapper, so two sign-ups racing for one name end in a redraw
38
+ # 4. with `clearable: false`, no clearing: a saved name cannot be saved
39
+ # blank again, required or not — other people link to it;
40
+ # 5. uniqueness, compared the way the unique index compares (Comparison);
41
+ # 6. a generated name for a new record that has none — by default,
42
+ # unless a name is required for it in the context it is validated in —
43
+ # drawn from `seed:` when that has a value;
44
+ # 7. a race wrapper, so two sign-ups racing for one name end in a redraw
42
45
  # (a generated name) or in "has already been taken" (a typed one) —
43
46
  # never a RecordNotUnique 500.
44
47
  #
45
48
  # `required:` and `generate:` take true, false, a method name or a proc,
46
49
  # evaluated on the record the way Rails' `if:` is: a sign-up flow that
47
50
  # asks for a name (`required: :handle_required`) gets presence, and every
48
- # other record a generated name.
51
+ # other record a generated name. `required: { on: :profile }` asks for
52
+ # one only in that validation context (`save(context: :profile)`), on
53
+ # every save there.
54
+ #
55
+ # `seed:` is a method name or a proc (evaluated the same way) whose value
56
+ # a generated name starts from: `seed: :first_name` makes `ana` (or
57
+ # `ana_42` when that is taken) rather than `quiet_otter_42`. It is read
58
+ # once per save, and a redraw after a lost race reuses it. A blank seed
59
+ # draws the default shape. Whatever it returns becomes part of a public
60
+ # name: an email is used only if the seed returns one, and then as
61
+ # Usernames.generate reads it (the local part, or the domain for info@).
49
62
  #
50
63
  # And the class helpers, named after the column: `username_available?`,
51
64
  # `suggest_usernames`, `generate_username`, `find_by_username` (and `!`),
@@ -56,8 +69,9 @@ module Usernames
56
69
  # mode, so the model keeps comparing in it even if the configuration
57
70
  # changes later. Every other rule is read from the configuration on each
58
71
  # check.
59
- def has_username(column = :username, required: false, generate: nil, **rules)
60
- column = Column.new(self, column, required: required, generate: generate, rules: rules)
72
+ def has_username(column = :username, required: false, generate: nil, seed: nil, clearable: true, **rules)
73
+ column = Column.new(self, column, required: required, generate: generate, seed: seed, clearable: clearable,
74
+ rules: rules)
61
75
  if _usernames.key?(column.name)
62
76
  raise ConfigurationError, "has_username :#{column.name} is already declared on #{name || self}"
63
77
  end
@@ -73,36 +87,62 @@ module Usernames
73
87
  result =
74
88
  case condition
75
89
  when true, false then condition
76
- when Symbol then record.send(condition)
77
- else condition.arity.zero? ? record.instance_exec(&condition) : condition.call(record)
90
+ else value_of(condition, record)
78
91
  end
79
92
  result ? true : false
80
93
  end
81
94
 
95
+ # What a method name or a proc gives for `record`, evaluated as
96
+ # applies? evaluates a condition. :nodoc:
97
+ def self.value_of(source, record)
98
+ return record.send(source) if source.is_a?(Symbol)
99
+
100
+ source.arity.zero? ? record.instance_exec(&source) : source.call(record)
101
+ end
102
+
82
103
  # One has_username declaration, and everything that column needs: the
83
104
  # rules in force, the comparison expression, and the queries behind the
84
105
  # class helpers. :nodoc:
85
106
  class Column
86
- attr_reader :model, :name, :required, :generate, :options
107
+ attr_reader :model, :name, :required, :generate, :seed, :options
87
108
 
88
- def initialize(model, name, required:, generate:, rules:)
109
+ def initialize(model, name, required:, generate:, rules:, seed: nil, clearable: true)
89
110
  @model = model
90
111
  @name = name.to_sym
91
- @required = condition(:required, required)
112
+ @required = required.is_a?(Hash) ? contexts(required) : condition(:required, required)
92
113
  # By default a name is generated exactly where none is required: where
93
114
  # one is, the person chooses.
94
115
  @generate = generate.nil? ? nil : condition(:generate, generate)
116
+ @seed = seed.nil? ? nil : source(:seed, seed)
117
+ @clearable = switch(:clearable, clearable)
95
118
  # Resolving them now raises ConfigurationError for a bad option when
96
119
  # the model is defined, and pins the case mode (see has_username).
97
120
  @options = rules.merge(case: Usernames.configuration.rules_for(**rules).case).freeze
98
121
  end
99
122
 
100
- # Whether `record` must have a name.
123
+ # Whether `record` must have a name. With `required: { on: … }`, while
124
+ # it is being validated in one of those contexts.
101
125
  def required?(record)
102
- Model.applies?(required, record)
126
+ return Model.applies?(required, record) unless required.is_a?(Hash)
127
+
128
+ Array(record.validation_context).intersect?(required.fetch(:on))
129
+ end
130
+
131
+ # Whether a saved name may be saved blank again (`clearable: false` says no).
132
+ def clearable?
133
+ @clearable
134
+ end
135
+
136
+ # What a generated name for `record` starts from: the seed's value, or
137
+ # nil (the default shape) when there is no seed or it is blank.
138
+ def seed_for(record)
139
+ return if seed.nil?
140
+
141
+ value = Model.value_of(seed, record)
142
+ value.to_s unless value.blank?
103
143
  end
104
144
 
105
- # Whether `record` gets a generated name on create when it has none.
145
+ # Whether `record`, while new and without a name, gets a generated one.
106
146
  def generate?(record)
107
147
  generate.nil? ? !required?(record) : Model.applies?(generate, record)
108
148
  end
@@ -174,28 +214,60 @@ module Usernames
174
214
 
175
215
  def install_normalization_and_rules
176
216
  column = name
177
- definition = self
178
217
  changed = :"will_save_change_to_#{column}?"
179
218
  model.normalizes column, with: Usernames.normalizer(**options)
180
219
  model.validates column, username: options, if: changed
220
+ install_presence
221
+ install_no_clearing
222
+ end
223
+
224
+ # On create, and when an update touches it: a pre-existing blank
225
+ # saves unrelated changes; clearing a name is :blank. Not when
226
+ # generation already said why the name is blank
227
+ # (:username_unavailable): one error per name. In a named context
228
+ # (`required: { on: :profile }`), on every save in it: that form is
229
+ # where the person types one.
230
+ def install_presence
181
231
  return if required == false
232
+ return install_presence_in_context if required.is_a?(Hash)
182
233
 
183
- # On create, and when an update touches it: a pre-existing blank
184
- # saves unrelated changes; clearing a name is :blank. Not when
185
- # generation already said why the name is blank
186
- # (:username_unavailable): one error per name.
234
+ column = name
235
+ definition = self
187
236
  model.validates column, presence: true, if: lambda {
188
237
  (new_record? || will_save_change_to_attribute?(column)) && definition.required?(self) &&
189
238
  !errors.of_kind?(column, :username_unavailable)
190
239
  }
191
240
  end
192
241
 
242
+ def install_presence_in_context
243
+ column = name
244
+ model.validates column, presence: true, on: required.fetch(:on),
245
+ if: -> { !errors.of_kind?(column, :username_unavailable) }
246
+ end
247
+
248
+ # `clearable: false`: a saved name other people link to stays.
249
+ # Saving it blank is :blank, required or not (once, if presence already
250
+ # said so). A stored blank (nil, "", " ") was never a name, so saving
251
+ # it blank again is not clearing; a new record has nothing saved to
252
+ # clear. It is a validation: update_column and update_all skip it.
253
+ def install_no_clearing
254
+ return if clearable?
255
+
256
+ column = name
257
+ model.validate(if: lambda {
258
+ persisted? && will_save_change_to_attribute?(column) && public_send(column).blank? &&
259
+ attribute_in_database(column).present? && !errors.of_kind?(column, :blank)
260
+ }) { errors.add(column, :blank) }
261
+ end
262
+
193
263
  # Left out altogether when no record can ever get a generated name.
264
+ # Gated on new_record?, not `on: :create`: a new record validated in a
265
+ # context of its own (`save(context: :import)`) is still new.
194
266
  def install_generation
195
267
  return if generate == false || (generate.nil? && required == true)
196
268
 
197
269
  definition = self
198
- model.before_validation(:"assign_#{name}", on: :create, if: -> { definition.generate?(self) })
270
+ model.before_validation(:"assign_#{name}", if: -> { new_record? && definition.generate?(self) })
199
271
  end
200
272
 
201
273
  # Lowercase mode stores the canonical value, so Rails' own exact
@@ -219,8 +291,33 @@ module Usernames
219
291
  def condition(option, value)
220
292
  return value if [true, false].include?(value) || value.is_a?(Symbol) || value.is_a?(Proc)
221
293
 
222
- raise ConfigurationError, "has_username #{option}: must be true, false, a method name or a proc " \
223
- "(got #{value.inspect})"
294
+ expected = if option == :required
295
+ "true, false, a method name, a proc or { on: :context }"
296
+ else
297
+ "true, false, a method name or a proc"
298
+ end
299
+ raise ConfigurationError, "has_username #{option}: must be #{expected} (got #{value.inspect})"
300
+ end
301
+
302
+ # `{ on: :profile }` or `{ on: %i[profile settings] }`, as Rails' own `on:`.
303
+ def contexts(value)
304
+ on = Array(value[:on])
305
+ return { on: on.freeze }.freeze if value.keys == [:on] && on.any? && on.all?(Symbol)
306
+
307
+ raise ConfigurationError, "has_username required: takes { on: :context } (one or more validation " \
308
+ "contexts) as a Hash (got #{value.inspect})"
309
+ end
310
+
311
+ def source(option, value)
312
+ return value if value.is_a?(Symbol) || value.is_a?(Proc)
313
+
314
+ raise ConfigurationError, "has_username #{option}: must be a method name or a proc (got #{value.inspect})"
315
+ end
316
+
317
+ def switch(option, value)
318
+ return value if [true, false].include?(value)
319
+
320
+ raise ConfigurationError, "has_username #{option}: must be true or false (got #{value.inspect})"
224
321
  end
225
322
  end
226
323
 
@@ -327,12 +424,26 @@ module Usernames
327
424
  drawn = instance_variable_get(generated)
328
425
  !drawn.nil? && drawn == public_send(column)
329
426
  end
427
+ define_drawing(methods, column, generated)
428
+ end
429
+
430
+ # Assigning a name to a blank new record reads the seed once (it is the
431
+ # app's code: whatever it raises is the app's error); a redraw after a
432
+ # lost race reuses it.
433
+ def self.define_drawing(methods, column, generated)
434
+ seed = :"@#{column}_seed"
330
435
  methods.define_method(:"draw_#{column}") do
331
- public_send(:"#{column}=", instance_variable_set(generated, self.class.public_send(:"generate_#{column}")))
436
+ drawn = self.class.public_send(:"generate_#{column}", from: instance_variable_get(seed))
437
+ public_send(:"#{column}=", instance_variable_set(generated, drawn))
332
438
  rescue NoFreeNameError
333
439
  errors.add(column, :username_unavailable)
334
440
  end
335
- methods.define_method(:"assign_#{column}") { send(:"draw_#{column}") if public_send(column).blank? }
441
+ methods.define_method(:"assign_#{column}") do
442
+ return unless public_send(column).blank?
443
+
444
+ instance_variable_set(seed, self.class._usernames.fetch(column).seed_for(self))
445
+ send(:"draw_#{column}")
446
+ end
336
447
  methods.send(:private, :"draw_#{column}", :"assign_#{column}")
337
448
  end
338
449
 
@@ -37,6 +37,7 @@ module Usernames
37
37
  words.each { |word, source| io.puts " #{word.ljust(width)} #{source}" }
38
38
  lifted = allowed(rules)
39
39
  io.puts "Lifted by config.allowed: #{lifted.join(' ')}" if lifted.any?
40
+ io.puts "And whatever config.reserved_if reserves, asked after these lists." if rules.reserved_if
40
41
  words.size
41
42
  end
42
43
 
@@ -32,8 +32,8 @@ module Usernames
32
32
  Sets = ::Data.define(:exact, :custom, :impersonation, :allowed)
33
33
 
34
34
  class << self
35
- # The kind of reservation that matched — :impersonation, :exact, :route
36
- # or :custom — or nil when the name is free to use. `key` is
35
+ # The kind of reservation that matched — :impersonation, :exact, :route,
36
+ # :custom or :reserved_if — or nil when the name is free to use. `key` is
37
37
  # Usernames.comparison_key of a name that already passed every format
38
38
  # rule; `rules` is the Usernames::Rules in force.
39
39
  #
@@ -44,15 +44,16 @@ module Usernames
44
44
  # word and reported as :impersonation; :custom names a config.reserved
45
45
  # word that is not bundled. A reserved prefix (`.well-known`, `xn--`)
46
46
  # is reported as :exact.
47
- def verdict(key, rules)
47
+ #
48
+ # Last, after every list, config.reserved_if is asked about `name` —
49
+ # the name as normalised, case kept in preserve mode — and a truthy
50
+ # answer is :reserved_if. It runs on the checks the lists let
51
+ # through, and whatever it raises is the app's.
52
+ def verdict(key, rules, name = key)
48
53
  sets = sets_for(rules)
49
54
  return if sets.allowed.include?(key)
50
55
 
51
- if impersonation?(key, sets.impersonation) then :impersonation
52
- elsif sets.custom.include?(key) then :custom
53
- elsif sets.exact.include?(key) || prefix?(key) then :exact
54
- elsif rules.reserve_routes && routes.include?(key) then :route
55
- end
56
+ listed(key, sets, rules) || (:reserved_if if rules.reserved_if&.call(name))
56
57
  end
57
58
 
58
59
  # Whether any spelling of the name is an impersonation entry: the name
@@ -115,6 +116,15 @@ module Usernames
115
116
 
116
117
  private
117
118
 
119
+ # The list that reserves `key`, in the order they are asked.
120
+ def listed(key, sets, rules)
121
+ if impersonation?(key, sets.impersonation) then :impersonation
122
+ elsif sets.custom.include?(key) then :custom
123
+ elsif sets.exact.include?(key) || prefix?(key) then :exact
124
+ elsif rules.reserve_routes && routes.include?(key) then :route
125
+ end
126
+ end
127
+
118
128
  def sets_for(rules)
119
129
  rules.compiled(:reserved) do
120
130
  bundled = Data.reserved.to_set
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usernames
4
+ # The engine draws the availability route itself, after your routes, so
5
+ # anything you declare wins. A catch-all such as `get "*path"` (or a
6
+ # dynamic `get ":section/:page"`) would win too, and swallow it: declare
7
+ # it yourself, above the catch-all, and the engine draws nothing.
8
+ #
9
+ # Rails.application.routes.draw do
10
+ # usernames_availability
11
+ # get "*path", to: "pages#show"
12
+ # end
13
+ #
14
+ # Call it at the top level of the routes, outside any namespace or scope.
15
+ # It answers at config.availability.path (or the path you pass), is named
16
+ # `usernames_availability`, and takes no format: `/usernames/availability.json`
17
+ # is not a route.
18
+ module Routes
19
+ DEFAULT_PATH = "/usernames/availability"
20
+
21
+ def usernames_availability(path = nil)
22
+ path ||= Usernames.configuration.availability.path || DEFAULT_PATH
23
+ get path, to: "usernames/availability#show", as: :usernames_availability, format: false
24
+ end
25
+ end
26
+ end
@@ -9,7 +9,7 @@ module Usernames
9
9
  module Options
10
10
  NAMES = %i[
11
11
  length separators edge_separators case first_character reserve_routes app_name
12
- reserved forbidden allowed offensive on_adapter_failure generator unique_index
12
+ reserved forbidden allowed reserved_if offensive on_adapter_failure generator unique_index
13
13
  ].freeze
14
14
 
15
15
  OFFENSIVE_NAMES = %i[severity languages allowed adapters].freeze
@@ -53,6 +53,7 @@ module Usernames
53
53
  @reserved = valid_words(:reserved, options.fetch(:reserved))
54
54
  @forbidden = valid_words(:forbidden, options.fetch(:forbidden))
55
55
  @allowed = valid_words(:allowed, options.fetch(:allowed))
56
+ @reserved_if = valid_callable(:reserved_if, options.fetch(:reserved_if), "->(name) { … }")
56
57
  @on_adapter_failure = one_of(:on_adapter_failure, options.fetch(:on_adapter_failure), ADAPTER_FAILURE_MODES)
57
58
  end
58
59
 
@@ -145,6 +146,13 @@ module Usernames
145
146
 
146
147
  # nil detects the name from the Rails application; false opts out; a
147
148
  # String names it explicitly (an app whose brand is not its module name).
149
+ # nil, or anything that answers `call`.
150
+ def valid_callable(name, value, example)
151
+ invalid!(name, value, "nil or a callable such as #{example}") unless value.nil? || value.respond_to?(:call)
152
+
153
+ value
154
+ end
155
+
148
156
  def valid_app_name(value)
149
157
  valid = value.nil? || value == false || (value.is_a?(String) && !value.strip.empty?)
150
158
  invalid!(:app_name, value, "a String, false to opt out, or nil to detect it") unless valid
@@ -51,6 +51,14 @@ module Usernames
51
51
  defined?(Mime::EXTENSION_LOOKUP) ? Mime::EXTENSION_LOOKUP.keys.map(&:to_s) : []
52
52
  end
53
53
 
54
+ # Every reason a format rule can give, in the order the rules check:
55
+ # those of rules with every optional format rule switched on (all three
56
+ # separators, a letter first), so a new rule is in it the day it exists.
57
+ def self.format_reasons
58
+ @format_reasons ||= Configuration.new.rules_for(separators: Options::SEPARATORS, first_character: :letter)
59
+ .format_checks.map(&:first).freeze
60
+ end
61
+
54
62
  attr_reader :format_checks
55
63
 
56
64
  def initialize(**options)
@@ -143,7 +151,7 @@ module Usernames
143
151
  # Reserved words are matched on the comparison key alone; the offensive
144
152
  # matcher also reads the case the name was typed in (Offensive.verdict).
145
153
  def content_failure(string)
146
- if (rule = Reserved.verdict(comparison_key(string), self))
154
+ if (rule = Reserved.verdict(comparison_key(string), self, string))
147
155
  Failure.new(reason: :username_reserved, rule: rule)
148
156
  elsif (rule = Offensive.verdict(string, self))
149
157
  Failure.new(reason: :username_not_allowed, rule: rule)
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usernames
4
+ # The username columns the availability endpoint answers for, by alias:
5
+ # config.availability.targets, `{ "user" => "User#handle" }`.
6
+ #
7
+ # A request names an alias, and the alias is only ever a key looked up in
8
+ # that Hash: the class name resolved is the one the app configured, never
9
+ # anything a client sent. It is resolved on every lookup, so in
10
+ # development a reloaded model is the new class, and a model that no
11
+ # longer declares has_username fails loudly instead of answering for a
12
+ # column that is gone. :nodoc:
13
+ module Targets
14
+ class << self
15
+ # [model, column] for a configured alias; nil for anything else (the
16
+ # endpoint's 404). Raises ConfigurationError when the alias is
17
+ # configured but does not name a model with that has_username.
18
+ def find(name)
19
+ target = configured[name] if name.is_a?(String)
20
+ resolve(name, target) if target
21
+ end
22
+
23
+ # The alias the form of a `model` record sends for `column`: the target
24
+ # naming its class or, for a single-table subclass, the nearest parent
25
+ # that one names. Compared by name; nothing is loaded.
26
+ def alias_for(model, column)
27
+ wanted = column.to_s
28
+ model.ancestors.each do |ancestor|
29
+ next unless ancestor.is_a?(Class) && ancestor.name
30
+
31
+ found = configured.find { |_name, target| target == "#{ancestor.name}##{wanted}" }
32
+ return found.first if found
33
+ end
34
+ nil
35
+ end
36
+
37
+ private
38
+
39
+ def configured
40
+ Usernames.configuration.availability.targets
41
+ end
42
+
43
+ def resolve(name, target)
44
+ model_name, column = target.split("#", 2)
45
+ model = constant(name, target, model_name)
46
+ declared = model.is_a?(Class) && model.respond_to?(:_usernames) && model._usernames.key?(column.to_sym)
47
+ unless declared && !model.abstract_class?
48
+ broken!(name, target, "#{model_name} isn't a model that declares has_username :#{column}")
49
+ end
50
+
51
+ [model, column.to_sym]
52
+ end
53
+
54
+ def constant(name, target, model_name)
55
+ Object.const_get(model_name)
56
+ rescue NameError => e
57
+ broken!(name, target, "#{model_name} can't be loaded (#{e.message})")
58
+ end
59
+
60
+ def broken!(name, target, why)
61
+ raise ConfigurationError, "config.availability.targets[#{name.inspect}] is #{target.inspect}, but #{why}"
62
+ end
63
+ end
64
+ end
65
+ end
@@ -49,7 +49,6 @@ module Usernames
49
49
  # connection is used. `conflict` answers whether another row has the
50
50
  # record's name under the gem's comparison; without one, an ambiguous
51
51
  # SQLite message is not ours.
52
- # rubocop:disable-next Metrics/ParameterLists -- each is one fact about the save, all by keyword
53
52
  def of?(error, table:, column:, rules: Usernames.configuration.rules_for, connection: nil, conflict: nil)
54
53
  target = Target.new(table: table.to_s, bare_table: table.to_s.split(".").last, column: column.to_s,
55
54
  index: rules.unique_index&.to_s, mode: rules.case, connection: connection,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Usernames
4
- VERSION = "0.1.3"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/usernames.rb CHANGED
@@ -20,11 +20,14 @@ require_relative "usernames/words"
20
20
  require_relative "usernames/generator"
21
21
  require_relative "usernames/candidates"
22
22
  require_relative "usernames/rules"
23
+ require_relative "usernames/routes"
23
24
  require_relative "usernames/configuration"
24
25
  require_relative "usernames/validator"
25
26
  require_relative "usernames/comparison"
26
27
  require_relative "usernames/unique_violation"
27
28
  require_relative "usernames/model"
29
+ require_relative "usernames/targets"
30
+ require_relative "usernames/locale"
28
31
  require_relative "usernames/engine" if defined?(Rails::Engine)
29
32
 
30
33
  module Usernames
data/sig/usernames.rbs CHANGED
@@ -1,5 +1,6 @@
1
1
  # The public surface of the usernames gem: the module functions, the result
2
- # of a check, the errors, and the has_username macro.
2
+ # of a check, the errors, the has_username macro, the form helpers, the
3
+ # availability routing method and its configuration.
3
4
  module Usernames
4
5
  VERSION: String
5
6
  ROUTE: Regexp
@@ -16,7 +17,7 @@ module Usernames
16
17
  class DataError < Error
17
18
  end
18
19
 
19
- type rule = :impersonation | :exact | :route | :custom | :severe | :strong | :hate_number | :adapter
20
+ type rule = :impersonation | :exact | :route | :custom | :reserved_if | :severe | :strong | :hate_number | :adapter
20
21
 
21
22
  # Usernames.check("Ad_min") # => #<data Usernames::Result ok?=false, reason=:username_reserved, …>
22
23
  class Result
@@ -52,24 +53,50 @@ module Usernames
52
53
  attr_accessor reserved: Array[String]
53
54
  attr_accessor forbidden: Array[String]
54
55
  attr_accessor allowed: Array[String]
56
+ attr_accessor reserved_if: (^(String) -> boolish)?
55
57
  attr_accessor on_adapter_failure: :allow | :reject
56
58
  attr_accessor unique_index: (String | Symbol)?
57
59
  def offensive: () -> untyped
58
60
  def generator: () -> untyped
61
+ def availability: () -> Availability
59
62
  def generator=: (untyped callable) -> untyped
60
63
  def validate!: () -> self
64
+
65
+ # config.availability: the built-in "is this name free?" endpoint.
66
+ class Availability
67
+ attr_accessor path: (String | false)?
68
+ # { "user" => "User#handle" }: alias => "Model#column"
69
+ attr_accessor targets: Hash[String, String]
70
+ attr_accessor parent_controller: String?
71
+ attr_accessor rate_limit: { to: Integer, within: Numeric }
72
+ attr_accessor rate_limit_store: untyped
73
+ end
74
+ end
75
+
76
+ # f.username_field / f.username_status, on every ActionView form builder.
77
+ module FormBuilder
78
+ def username_field: (?(Symbol | String) column, ?availability: bool, ?static: bool, **untyped html) -> String
79
+ def username_status: (?(Symbol | String) column, **untyped html) -> String
80
+ end
81
+
82
+ # `usernames_availability` in config/routes.rb, above a catch-all.
83
+ module Routes
84
+ def usernames_availability: (?String? path) -> void
61
85
  end
62
86
 
63
- # has_username(column = :username, required: false, generate: nil, **rules)
87
+ # has_username(column = :username, required: false, generate: nil, seed: nil, clearable: true, **rules)
64
88
  #
65
89
  # required: and generate: take true, false, a method name or a proc,
66
- # evaluated on the record like Rails' if:. generate: nil (the default)
67
- # means: wherever a name is not required.
90
+ # evaluated on the record like Rails' if:; required: also takes
91
+ # { on: context } (or an Array of contexts). generate: nil (the default)
92
+ # means: wherever a name is not required. seed: is a method name or a
93
+ # proc whose value a generated name starts from; clearable: false
94
+ # refuses saving a saved name blank.
68
95
  #
69
96
  # Also defines, named after the column: <column>_available?, suggest_<column>s,
70
97
  # generate_<column>, find_by_<column>, find_by_<column>!, <column>_options, check_<column>,
71
98
  # <column>_rules_for_browser, and <column>_generated? on instances.
72
99
  module Model
73
- def has_username: (?(Symbol | String) column, ?required: (bool | Symbol | Proc), ?generate: (bool | Symbol | Proc)?, **untyped rules) -> void
100
+ def has_username: (?(Symbol | String) column, ?required: (bool | Symbol | Proc | { on: Symbol | Array[Symbol] }), ?generate: (bool | Symbol | Proc)?, ?seed: (Symbol | Proc)?, ?clearable: bool, **untyped rules) -> void
74
101
  end
75
102
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: usernames
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
@@ -106,6 +106,10 @@ files:
106
106
  - LICENSE.txt
107
107
  - README.md
108
108
  - SECURITY.md
109
+ - app/assets/javascripts/usernames/index.js
110
+ - app/assets/javascripts/usernames/username_check_controller.js
111
+ - app/controllers/usernames/availability_controller.rb
112
+ - config/importmap.rb
109
113
  - config/locales/usernames.en.yml
110
114
  - config/locales/usernames.es.yml
111
115
  - config/locales/usernames.pt.yml
@@ -146,11 +150,11 @@ files:
146
150
  - lib/generators/usernames/existing_schema.rb
147
151
  - lib/generators/usernames/install_generator.rb
148
152
  - lib/generators/usernames/install_messages.rb
153
+ - lib/generators/usernames/javascript_generator.rb
154
+ - lib/generators/usernames/javascript_setup.rb
149
155
  - lib/generators/usernames/locale_generator.rb
150
156
  - lib/generators/usernames/templates/add_username.rb.erb
151
157
  - lib/generators/usernames/templates/availability/_field.html.erb.tt
152
- - lib/generators/usernames/templates/availability/availability_controller.rb.tt
153
- - lib/generators/usernames/templates/availability/username_check_controller.js.tt
154
158
  - lib/generators/usernames/templates/usernames.rb
155
159
  - lib/tasks/usernames.rake
156
160
  - lib/usernames.rb
@@ -166,7 +170,9 @@ files:
166
170
  - lib/usernames/data.rb
167
171
  - lib/usernames/engine.rb
168
172
  - lib/usernames/errors.rb
173
+ - lib/usernames/form_helpers.rb
169
174
  - lib/usernames/generator.rb
175
+ - lib/usernames/locale.rb
170
176
  - lib/usernames/model.rb
171
177
  - lib/usernames/normalizer.rb
172
178
  - lib/usernames/offensive.rb
@@ -177,8 +183,10 @@ files:
177
183
  - lib/usernames/reserved.rb
178
184
  - lib/usernames/reserved/listing.rb
179
185
  - lib/usernames/result.rb
186
+ - lib/usernames/routes.rb
180
187
  - lib/usernames/rules.rb
181
188
  - lib/usernames/rules/options.rb
189
+ - lib/usernames/targets.rb
182
190
  - lib/usernames/unique_violation.rb
183
191
  - lib/usernames/validator.rb
184
192
  - lib/usernames/version.rb