studio-engine 0.92.2 → 0.94.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.
@@ -1,17 +1,167 @@
1
+ # A human-readable `slug` that is written once and changed only on purpose.
2
+ #
3
+ # The slug is set at create from `name_slug` (each including model defines it)
4
+ # and never recomputed by a later save, because other tables point at it by
5
+ # value: a display-name edit must not rename the row and orphan its children.
6
+ # A persisted row whose slug is blank gets one on its next save, since filling a
7
+ # blank is still the first write. A `name_slug` that reads the id (`user-<id>`)
8
+ # is settled once more right after the insert, inside the create, because the
9
+ # id does not exist when before_save runs.
10
+ #
11
+ # The one way to change a persisted slug is `rename_slug!` (or `rename_slug`),
12
+ # which updates the row and every child column that references it in one
13
+ # transaction. The children are found two ways:
14
+ #
15
+ # * every `has_many` / `has_one` on the model with `primary_key: :slug`;
16
+ # * every pair declared with `has_slug_children`, for a table that has no
17
+ # association here:
18
+ #
19
+ # class Person < ApplicationRecord
20
+ # include Sluggable
21
+ # has_slug_children "athletes" => :person_slug,
22
+ # "news" => %i[primary_person_slug secondary_person_slug]
23
+ # end
24
+ #
25
+ # The declaration lives on the parent so the cascade never depends on whether a
26
+ # child class happens to be loaded.
27
+ #
28
+ # A refusal (blank, badly formed, or taken) raises Sluggable::SlugRefused, a
29
+ # subclass of ActiveRecord::RecordInvalid, with the reason on `errors[:slug]`.
30
+ # Studio::ErrorHandling answers it with 422 and the reason, never a 500, and
31
+ # `rename_slug` returns false instead of raising, for an inline form error.
1
32
  module Sluggable
2
33
  extend ActiveSupport::Concern
3
34
 
35
+ # Lowercase words of letters and digits joined by single hyphens: the shape
36
+ # `String#parameterize` produces. A model whose slugs legitimately hold other
37
+ # characters sets its own `self.slug_format`.
38
+ DEFAULT_SLUG_FORMAT = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/
39
+
40
+ # The refusal rename_slug! raises: a RecordInvalid, so a caller that already
41
+ # rescues RecordInvalid keeps working, with a class of its own so the
42
+ # controller layer can answer it with 422 without claiming every invalid save.
43
+ class SlugRefused < ActiveRecord::RecordInvalid; end
44
+
4
45
  included do
5
- before_save :set_slug
46
+ class_attribute :slug_format, instance_writer: false, default: DEFAULT_SLUG_FORMAT
47
+ class_attribute :declared_slug_children, instance_writer: false, default: [].freeze
48
+
49
+ before_save :set_slug, if: :sluggable_unwritten?
50
+ after_create :sluggable_settle_derived_slug
51
+ end
52
+
53
+ class_methods do
54
+ # Declares child columns that hold this model's slug: a hash of table name to
55
+ # one column or a list of columns. Repeatable; each call adds to the set.
56
+ def has_slug_children(pairs)
57
+ added = pairs.flat_map do |table, columns|
58
+ Array(columns).map { |column| [table.to_s, column.to_s].freeze }
59
+ end
60
+ self.declared_slug_children = (declared_slug_children + added).uniq.freeze
61
+ end
62
+
63
+ # Every [table, column] pair `rename_slug!` updates: the slug-keyed
64
+ # associations plus the declared pairs, without duplicates.
65
+ def slug_children
66
+ from_associations = reflect_on_all_associations.filter_map do |reflection|
67
+ next unless %i[has_many has_one].include?(reflection.macro)
68
+ next if reflection.through_reflection? || reflection.polymorphic? || reflection.options[:as]
69
+ next unless reflection.options[:primary_key].to_s == "slug"
70
+
71
+ [reflection.klass.table_name, reflection.foreign_key.to_s].freeze
72
+ end
73
+ (from_associations + declared_slug_children).uniq
74
+ end
6
75
  end
7
76
 
8
77
  def to_param
9
78
  slug
10
79
  end
11
80
 
81
+ # Changes this row's slug to `new_slug` and rewrites every child column that
82
+ # held the old one, all or nothing. Returns { "table.column" => rows updated }.
83
+ # Raises Sluggable::SlugRefused, with the reason on errors[:slug], when
84
+ # the slug is blank, badly formed, or already taken.
85
+ def rename_slug!(new_slug)
86
+ raise ActiveRecord::RecordNotSaved.new("a slug can be renamed only on a saved record", self) unless persisted?
87
+
88
+ new_slug = new_slug.to_s.strip
89
+ old_slug = slug_in_database
90
+ errors.delete(:slug)
91
+ return {} if new_slug == old_slug
92
+
93
+ sluggable_refuse!(:blank) if new_slug.empty?
94
+ sluggable_refuse!(:invalid) unless slug_format.match?(new_slug)
95
+ sluggable_refuse!(:taken, value: new_slug) if sluggable_taken?(new_slug)
96
+
97
+ renamed = { slug: new_slug }
98
+ renamed[:updated_at] = Time.current if has_attribute?(:updated_at)
99
+ counts = {}
100
+ self.class.transaction(requires_new: true) do
101
+ self.class.base_class.unscoped.where(self.class.primary_key => id).update_all(renamed)
102
+ self.class.slug_children.each do |table, column|
103
+ counts["#{table}.#{column}"] = sluggable_cascade(table, column, old_slug, new_slug)
104
+ end
105
+ end
106
+ renamed.each { |name, value| write_attribute(name, value) }
107
+ clear_attribute_changes(renamed.keys)
108
+ counts
109
+ rescue ActiveRecord::RecordNotUnique
110
+ # The pre-check passed and a concurrent write took the slug first; the
111
+ # unique index is the arbiter, and the answer is the same refusal.
112
+ sluggable_refuse!(:taken, value: new_slug)
113
+ rescue ActiveRecord::InvalidForeignKey
114
+ sluggable_refuse!(:invalid, message: "cannot change while a constraint without ON UPDATE CASCADE references it")
115
+ end
116
+
117
+ # rename_slug! for a form: false, with the reason on errors[:slug], instead of
118
+ # raising.
119
+ def rename_slug(new_slug)
120
+ rename_slug!(new_slug)
121
+ true
122
+ rescue SlugRefused
123
+ false
124
+ end
125
+
12
126
  private
13
127
 
128
+ def sluggable_unwritten?
129
+ new_record? || slug.blank?
130
+ end
131
+
132
+ # A model may override this; the override owns its slug, and the post-insert
133
+ # settle below leaves it alone.
14
134
  def set_slug
15
- self.slug = name_slug
135
+ self.slug = @sluggable_derived_slug = name_slug
136
+ end
137
+
138
+ def sluggable_settle_derived_slug
139
+ derived = @sluggable_derived_slug
140
+ @sluggable_derived_slug = nil
141
+ return if derived.nil? || slug != derived
142
+
143
+ settled = name_slug
144
+ update_column(:slug, settled) if settled.present? && settled != slug
145
+ end
146
+
147
+ def sluggable_taken?(candidate)
148
+ self.class.base_class.unscoped.where(slug: candidate).where.not(self.class.primary_key => id).exists?
149
+ end
150
+
151
+ # One UPDATE on the parent's connection: the cascade a foreign key with
152
+ # ON UPDATE CASCADE would run, and it needs no model for the child table.
153
+ def sluggable_cascade(table, column, old_slug, new_slug)
154
+ connection = self.class.connection
155
+ quoted_column = connection.quote_column_name(column)
156
+ connection.update(<<~SQL.squish, "Sluggable cascade")
157
+ UPDATE #{connection.quote_table_name(table)}
158
+ SET #{quoted_column} = #{connection.quote(new_slug)}
159
+ WHERE #{quoted_column} = #{connection.quote(old_slug)}
160
+ SQL
161
+ end
162
+
163
+ def sluggable_refuse!(reason, **options)
164
+ errors.add(:slug, reason, **options)
165
+ raise SlugRefused, self
16
166
  end
17
167
  end
@@ -40,20 +40,27 @@
40
40
  logo_path = Studio.logo_for("Navbar Logo")
41
41
  %>
42
42
 
43
- <%# nav-shell is the --nav-p scope: navCollapse() (layouts/studio/_head) writes
43
+ <%# nav-shell is the --nav-p scope: the engine's nav-collapse controller (studio/nav_collapse) writes
44
44
  the collapse progress, 0 expanded .. 1 collapsed, on THIS element once per
45
45
  animation frame, and the band table below derives the row padding, logo size
46
46
  and title sizes from it with calc(). Nothing on the collapse path carries a
47
47
  time-based transition — the head's comment carries the measurements that
48
48
  bought that. transition-shadow stays: box-shadow paints, it never reflows,
49
49
  so it cannot move content under the reader.
50
- Preview keeps nav-shell (the calc()s have to resolve) but no x-data — the
51
- /navbar review page drives --nav-p from its own Scrolled toggle. %>
50
+ The controller toggles the scrolled classes (the shadow) when the scroll
51
+ passes its hysteresis.
52
+ Preview keeps nav-shell (the calc()s have to resolve) but no controller — the
53
+ /navbar review page drives --nav-p from its own Scrolled toggle, and its
54
+ :class reads that toggle's `scrolled`. %>
52
55
  <%# data-pin names this header as a layer of the pinned stack, so
53
56
  layouts/studio/_head publishes --pin-nav-h and --pin-nav-bottom for it and
54
57
  anything below can position off it in pure CSS. The legacy --nav-h /
55
58
  --nav-bottom keep publishing unchanged; this is additive. %>
56
- <header data-pin="nav" <%= 'x-data="navCollapse()"'.html_safe unless is_preview %>
59
+ <%# The bare x-data is the Alpine scope the header's descendants still bind
60
+ through ($store.devMode on the user nav, the sidebar trigger's @click):
61
+ Alpine initializes directives only inside a component, and the header was
62
+ that component while navCollapse() lived on it. %>
63
+ <header data-pin="nav"<% unless is_preview %> x-data data-studio-controller="nav-collapse" data-nav-collapse-scrolled-class="shadow-lg border-b border-subtle is-scrolled"<% end %>
57
64
  <%# top-0, a STATIC value, and never a custom property. Any bars render as
58
65
  this header's sibling in normal flow (studio/banners/_stack), so they
59
66
  already occupy their own height above it and there is nothing to
@@ -63,7 +70,7 @@
63
70
  during a view transition composited at two different tops. Only CSS
64
71
  can set this top now, so none of that is reachable. %>
65
72
  class="nav-shell <%= is_preview ? 'bg-page' : "#{'vt-pinned-header ' if pin_header}sticky top-0 z-50 bg-page transition-shadow duration-300" %>"
66
- :class="scrolled && 'shadow-lg border-b border-subtle is-scrolled'">
73
+ <%= %(:class="scrolled && 'shadow-lg border-b border-subtle is-scrolled'").html_safe if is_preview %>>
67
74
  <style>
68
75
  /* THE PHONE STEP IS A SHARE OF THE VIEWPORT, NOT A CONSTANT, and that is the
69
76
  whole width fix. 14rem is 224px — 57% of a 390px screen — handed to a
@@ -109,7 +116,8 @@
109
116
  was left to hand-write its own — the way four independent copies happened
110
117
  in the first place. It ships from engine.css now (every consuming app
111
118
  imports it), so a forking app OPTS IN with `nav-shell` +
112
- x-data="navCollapse()" and overrides only the endpoints that differ.
119
+ data-studio-controller="nav-collapse" (or x-data="navCollapse()",
120
+ the Alpine shim) and overrides only the endpoints that differ.
113
121
 
114
122
  THE OPT-IN IS NOT THE WHOLE JOB — this is the partial a forker actually
115
123
  reads, so the caveat belongs here too rather than only beside the band