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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +83 -0
- data/README.md +1 -1
- data/app/controllers/concerns/studio/error_handling.rb +15 -0
- data/app/controllers/studio/onboarding_controller.rb +4 -6
- data/app/javascript/studio/alpine_shims.js +45 -0
- data/app/javascript/studio/alpine_stores.js +82 -0
- data/app/javascript/studio/application.js +37 -0
- data/app/javascript/studio/controllers/nav_collapse_controller.js +28 -0
- data/app/javascript/studio/head_chrome.js +79 -0
- data/app/javascript/studio/nav_collapse.js +238 -0
- data/app/javascript/studio/pinned_stack.js +306 -0
- data/app/javascript/studio/vendor/stimulus.js +5 -0
- data/app/models/concerns/sluggable.rb +152 -2
- data/app/views/layouts/_navbar.html.erb +14 -6
- data/app/views/layouts/studio/_head.html.erb +59 -567
- data/config/importmap.rb +14 -3
- data/lib/studio/engine.rb +26 -0
- data/lib/studio/version.rb +1 -1
- metadata +9 -1
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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()"
|
|
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
|