rails_onboarding 0.8.11 → 0.8.13

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8e42f9771d8f9d357ebbf763900651576721b726c548e6f837853d6590ff2da5
4
- data.tar.gz: 5892846e24f4c86ab43217016e5713c8a80958e72aa861cdeed555d659e83179
3
+ metadata.gz: 593e5e47988a8b3f81d6ea034f186669a099d98b09707723ed723128bcdad01d
4
+ data.tar.gz: 790f85934b3f9573cc6421488b882fcf3ac80340e27a0b6b587cc2de93bcdcab
5
5
  SHA512:
6
- metadata.gz: ed6b1a9c9a64766bca536370562c962e04dfafb0478283834e335e7a80e0e1e11e5bd7619e468597af0c2fe24d1fa84a2c2bfd2eaf52b57a546a209053bca307
7
- data.tar.gz: 7dd60ecfec9f08115c6b25a40ed91c7c54790ca79e539085929891cb900895767bc4e17e2b64cfa50117132464dd655c709ae7bdc83fd05f1319142ca826acc7
6
+ metadata.gz: f9bc4b4bd26ace6d84e153b906a0b687035e6d0ccb1b81d13ed15f6219bcc1e7be6c02ae30aa85658fc3c79933c5a7c374243bdffc9bf0197b923172ce1c2a36
7
+ data.tar.gz: 1d1bed61cc8462db8e23fe1e92a114d7df9be93cc7254e6f0cecfe8d56a8fcae765ffe072769c768044f5315795510c0c9533339f9835ac57fc48213a6f83e77
@@ -216,8 +216,7 @@ export default class extends Controller {
216
216
  this.executeCallback(step.beforeShow, step)
217
217
  }
218
218
 
219
- // Find target element
220
- const targetElement = step.selector ? document.querySelector(step.selector) : null
219
+ const targetElement = this.resolveTarget(step)
221
220
 
222
221
  this.currentTargetElement = targetElement
223
222
  this.applyScrim(step, targetElement)
@@ -244,6 +243,37 @@ export default class extends Controller {
244
243
  this.dispatch('step-shown', { detail: { step, index } })
245
244
  }
246
245
 
246
+ /**
247
+ * Find the element a step should point at.
248
+ *
249
+ * `selector` may be a list, which is how a step survives a responsive layout:
250
+ * the same idea is often two elements, one of them display:none at the current
251
+ * breakpoint - a row of tabs on a wide screen and a select on a narrow one. The
252
+ * first candidate that is actually rendered wins.
253
+ *
254
+ * An element with no layout box is treated as absent rather than used anyway.
255
+ * getBoundingClientRect() on a display:none element is all zeroes, so the
256
+ * spotlight became a small square in the top-left corner and the popup was
257
+ * placed against the origin - pointing confidently at nothing, on top of
258
+ * whatever happened to be there.
259
+ */
260
+ resolveTarget(step) {
261
+ const selectors = Array.isArray(step.selector) ? step.selector : [step.selector]
262
+
263
+ for (const selector of selectors) {
264
+ if (!selector) continue
265
+
266
+ const element = document.querySelector(selector)
267
+ if (element && this.hasLayoutBox(element)) return element
268
+ }
269
+
270
+ return null
271
+ }
272
+
273
+ hasLayoutBox(element) {
274
+ return element.getClientRects().length > 0
275
+ }
276
+
247
277
  /**
248
278
  * Hide current step
249
279
  */
@@ -592,26 +622,26 @@ export default class extends Controller {
592
622
 
593
623
  const popupRect = this.popup.getBoundingClientRect()
594
624
  const margin = 20
595
- let top, left
596
-
597
- if (targetElement) {
598
- const targetRect = targetElement.getBoundingClientRect()
599
- const position = this.calculateBestPosition(step, targetRect, popupRect, margin)
625
+ const targetRect = targetElement ? targetElement.getBoundingClientRect() : null
600
626
 
601
- top = position.top
602
- left = position.left
603
- } else {
604
- // Center on screen if no target
605
- top = (window.innerHeight - popupRect.height) / 2
606
- left = (window.innerWidth - popupRect.width) / 2
607
- }
627
+ const coords = targetRect
628
+ ? this.calculateBestPosition(step, targetRect, popupRect, margin)
629
+ : {
630
+ top: (window.innerHeight - popupRect.height) / 2,
631
+ left: (window.innerWidth - popupRect.width) / 2
632
+ }
608
633
 
609
- // Ensure popup stays in viewport
610
- top = Math.max(margin, Math.min(top, window.innerHeight - popupRect.height - margin))
611
- left = Math.max(margin, Math.min(left, window.innerWidth - popupRect.width - margin))
634
+ // The clamp keeps the popup reachable, which matters more than anything else -
635
+ // its own buttons are the only way forward. calculateBestPosition is what keeps
636
+ // it off the highlight; this is the last word on staying on screen.
637
+ this.popup.style.top =
638
+ `${this.clamp(coords.top, margin, window.innerHeight - popupRect.height - margin)}px`
639
+ this.popup.style.left =
640
+ `${this.clamp(coords.left, margin, window.innerWidth - popupRect.width - margin)}px`
641
+ }
612
642
 
613
- this.popup.style.top = `${top}px`
614
- this.popup.style.left = `${left}px`
643
+ clamp(value, min, max) {
644
+ return Math.max(min, Math.min(value, max))
615
645
  }
616
646
 
617
647
  /**
@@ -641,22 +671,40 @@ export default class extends Controller {
641
671
  }
642
672
  }
643
673
 
644
- // If position is auto, find best fit
645
- if (step.position === 'auto') {
646
- const preferences = ['bottom', 'top', 'right', 'left']
674
+ // 'center' is a host saying "do not point at anything", so it is taken at its
675
+ // word. Every other position is a *preference*: it used to be handed back
676
+ // without being checked, and positionPopup then clamped it into the viewport -
677
+ // which on a phone slides the popup straight over the element it is describing.
678
+ // A tour that hides what it is explaining is worse than one placed on the wrong
679
+ // side, so the asked-for side is tried first and the others are tried after it.
680
+ if (step.position === 'center') return positions.center
647
681
 
648
- for (const pos of preferences) {
649
- const coords = positions[pos]
650
- if (this.isPositionValid(coords, popupRect, margin)) {
651
- return coords
652
- }
653
- }
682
+ const fallbacks = ['bottom', 'top', 'right', 'left']
683
+ const order = positions[step.position] && step.position !== 'auto'
684
+ ? [step.position, ...fallbacks.filter((pos) => pos !== step.position)]
685
+ : fallbacks
654
686
 
655
- // Fallback to center
656
- return positions.center
687
+ for (const pos of order) {
688
+ if (this.isPositionValid(positions[pos], popupRect, margin)) return positions[pos]
657
689
  }
658
690
 
659
- return positions[step.position] || positions.bottom
691
+ return this.positionBesideTarget(targetRect, popupRect, margin)
692
+ }
693
+
694
+ /**
695
+ * Nothing fits cleanly on any side, which on a small viewport is the ordinary
696
+ * case rather than the exceptional one. Put the popup in whichever band - above
697
+ * the target or below it - has more room. Centring instead, which is what this
698
+ * used to do, lands on the target more often than not.
699
+ */
700
+ positionBesideTarget(targetRect, popupRect, margin) {
701
+ const roomAbove = targetRect.top - margin
702
+ const roomBelow = window.innerHeight - targetRect.bottom - margin
703
+ const left = targetRect.left + (targetRect.width - popupRect.width) / 2
704
+
705
+ return roomBelow >= roomAbove
706
+ ? { top: targetRect.bottom + margin, left }
707
+ : { top: targetRect.top - popupRect.height - margin, left }
660
708
  }
661
709
 
662
710
  /**
@@ -7,6 +7,45 @@ module RailsOnboarding
7
7
  MAX_MILESTONES_ACHIEVED = 500
8
8
  MAX_JSON_SIZE_BYTES = 65_535 # ~64KB for TEXT columns
9
9
 
10
+ # Whether the host model's table can be inspected right now.
11
+ #
12
+ # `columns_hash` and `column_names` both query the database, and the
13
+ # `included do` block below runs the moment the host model is loaded --
14
+ # which can be long before that table exists. A host that references its
15
+ # user model from an initializer hits this immediately: the common OmniAuth
16
+ # `:identity` setup passes `model: User`, so the model loads during boot,
17
+ # the include queries `columns_hash`, and against an empty database the
18
+ # whole process dies with "Table 'users' doesn't exist".
19
+ #
20
+ # That is circular and unrecoverable: loading the schema requires booting
21
+ # the app, and booting the app requires the schema. It stops `db:prepare`
22
+ # bootstrapping a genuinely new database, a restore into an empty schema,
23
+ # and CI against a fresh database service.
24
+ #
25
+ # A missing table, a missing database and an absent connection all mean the
26
+ # same thing here -- the column types cannot be known yet -- so degrade
27
+ # instead of raising. Nothing is lost: a process that boots against an empty
28
+ # database is bootstrapping it, not serving from it, and the next process
29
+ # configures everything normally once the schema is in place.
30
+ #
31
+ # Only ever answers false when the table is positively known to be absent.
32
+ # This concern is deliberately includable into plain classes that are not
33
+ # ActiveRecord models -- hence the `respond_to?(:has_many)` and
34
+ # `respond_to?(:validate)` guards below -- and such a class has no
35
+ # `table_exists?` while still answering `columns_hash`. Saying "not
36
+ # available" for those would silently stop configuring them, so anything
37
+ # that cannot be asked is treated as queryable and left to the callers'
38
+ # own guards, exactly as before this check existed.
39
+ def self.columns_queryable?(model)
40
+ return true unless model.respond_to?(:table_exists?)
41
+
42
+ model.table_exists?
43
+ rescue ActiveRecord::NoDatabaseError,
44
+ ActiveRecord::ConnectionNotEstablished,
45
+ ActiveRecord::StatementInvalid
46
+ false
47
+ end
48
+
10
49
  included do
11
50
  # Add fields via migration or expect them in the host model
12
51
  # The host app should have these columns:
@@ -21,17 +60,21 @@ module RailsOnboarding
21
60
  # - onboarding_replay_started_at: datetime
22
61
  # - onboarding_replay_steps: text (serialized JSON array)
23
62
 
24
- # Fix for Rails 8: Use the new serialize syntax
25
- if columns_hash["feature_tooltips_shown"]&.type == :text
26
- serialize :feature_tooltips_shown, coder: JSON
27
- end
63
+ # Fix for Rails 8: Use the new serialize syntax.
64
+ # Guarded because columns_hash queries the database -- see
65
+ # Onboardable.columns_queryable? above.
66
+ if RailsOnboarding::Onboardable.columns_queryable?(self)
67
+ if columns_hash["feature_tooltips_shown"]&.type == :text
68
+ serialize :feature_tooltips_shown, coder: JSON
69
+ end
28
70
 
29
- if columns_hash["milestones_achieved"]&.type == :text
30
- serialize :milestones_achieved, coder: JSON
31
- end
71
+ if columns_hash["milestones_achieved"]&.type == :text
72
+ serialize :milestones_achieved, coder: JSON
73
+ end
32
74
 
33
- if columns_hash["onboarding_replay_steps"]&.type == :text
34
- serialize :onboarding_replay_steps, coder: JSON
75
+ if columns_hash["onboarding_replay_steps"]&.type == :text
76
+ serialize :onboarding_replay_steps, coder: JSON
77
+ end
35
78
  end
36
79
 
37
80
  # Association with analytics events (only if ActiveRecord is available)
@@ -56,6 +99,7 @@ module RailsOnboarding
56
99
  # than raising on a column that isn't there.
57
100
  def onboarding_replay_supported?
58
101
  return false unless respond_to?(:column_names)
102
+ return false unless RailsOnboarding::Onboardable.columns_queryable?(self)
59
103
 
60
104
  column_names.include?("onboarding_replay_started_at") &&
61
105
  column_names.include?("onboarding_replay_steps")
@@ -1,3 +1,3 @@
1
1
  module RailsOnboarding
2
- VERSION = "0.8.11"
2
+ VERSION = "0.8.13"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_onboarding
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.11
4
+ version: 0.8.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Lewis
@@ -282,7 +282,7 @@ licenses:
282
282
  metadata:
283
283
  allowed_push_host: https://rubygems.org
284
284
  homepage_uri: https://github.com/bunnahabhain/rails_onboarding
285
- source_code_uri: https://github.com/bunnahabhain/rails_onboarding/tree/v0.8.11
285
+ source_code_uri: https://github.com/bunnahabhain/rails_onboarding/tree/v0.8.13
286
286
  changelog_uri: https://github.com/bunnahabhain/rails_onboarding/blob/master/docs/CHANGELOG.md
287
287
  rdoc_options: []
288
288
  require_paths: