trek 2.0.1 → 2.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/.prettierignore +2 -0
- data/.rubocop.yml +11 -0
- data/AGENTS.md +5 -2
- data/CHANGELOG.md +47 -1
- data/Gemfile.lock +1 -1
- data/Makefile +13 -0
- data/app/assets/icons/trek/audit.svg +1 -0
- data/app/assets/icons/trek/print.svg +1 -0
- data/app/components/trek/dashboard/analytics_component/analytics_component.html.slim +2 -0
- data/app/components/trek/dashboard/analytics_component.rb +1 -0
- data/app/components/trek/dashboard/chart_component/chart_component.css +192 -0
- data/app/components/trek/dashboard/chart_component/chart_component.html.slim +6 -0
- data/app/components/trek/dashboard/chart_component/chart_component.js +1 -0
- data/app/components/trek/dashboard/chart_component.rb +27 -0
- data/app/components/trek/dashboard/tile_component/tile_component.css +40 -0
- data/app/components/trek/dashboard/tile_component/tile_component.html.slim +14 -4
- data/app/components/trek/dashboard/tile_component.rb +6 -2
- data/app/components/trek/entries/item_component/item_component.css +1 -0
- data/app/components/trek/form/actions_component/actions_component.css +6 -7
- data/app/components/trek/form/content_editor_component/content_editor_component.html.slim +10 -12
- data/app/components/trek/form/content_editor_component/content_editor_component.js +6 -0
- data/app/components/trek/form/content_editor_component.rb +4 -2
- data/app/components/trek/form/fieldset_component/fieldset_component.css +4 -3
- data/app/components/trek/form/group_component/group_component.css +5 -2
- data/app/components/trek/layout_component/layout_component.css +20 -0
- data/app/components/trek/layout_component/layout_component.html.slim +16 -0
- data/app/components/trek/layout_component/layout_component.js +35 -6
- data/app/components/trek/menu_component.rb +15 -2
- data/app/controllers/concerns/trek/model.rb +13 -6
- data/app/controllers/trek/resource_controller.rb +9 -1
- data/app/formatters/prosemirror_to_html/global_id_to_links_formatter.rb +3 -1
- data/app/helpers/trek/i18n_helper.rb +1 -1
- data/app/models/prosemirror.rb +5 -2
- data/app/views/trek/panels/images/_form.html.slim +1 -1
- data/app/views/trek/panels/images/create.turbo_stream.slim +3 -3
- data/app/views/trek/panels/images/new.html.slim +1 -1
- data/app/views/trek/panels/links/_form.html.slim +1 -1
- data/app/views/trek/panels/links/create.turbo_stream.slim +3 -3
- data/app/views/trek/panels/links/new.html.slim +1 -1
- data/app/views/trek/panels/prompts/_form.html.slim +1 -1
- data/app/views/trek/panels/prompts/create.turbo_stream.slim +3 -3
- data/app/views/trek/panels/prompts/new.html.slim +1 -1
- data/docs/.vitepress/config.mjs +2 -1
- data/docs/deploy.md +57 -0
- data/docs/getting-started.md +1 -0
- data/docs/reference/controllers.md +23 -0
- data/docs/reference/generators/install.md +2 -0
- data/docs/reference/generators/scaffold.md +12 -0
- data/lib/generators/trek/install/config_generator.rb +9 -1
- data/lib/generators/trek/install/postmark_generator.rb +2 -0
- data/lib/generators/trek/install/prosopite_generator.rb +56 -0
- data/lib/generators/trek/install/scalingo_generator.rb +19 -7
- data/lib/generators/trek/install/yarn_generator.rb +1 -1
- data/lib/generators/trek/install_generator.rb +1 -0
- data/lib/generators/trek/scaffold_generator.rb +37 -5
- data/lib/generators/trek/templates/controllers/admin/scaffold_controller.rb.tt +2 -2
- data/lib/generators/trek/templates/locales/scaffold/model.en.yml.tt +4 -3
- data/lib/generators/trek/templates/spec/requests/admin/n_plus_one_spec.rb +28 -0
- data/lib/generators/trek/templates/views/admin/scaffold/show.html.slim.tt +2 -2
- data/lib/generators/trek/upgrade/v2_generator.rb +31 -0
- data/lib/trek/version.rb +1 -1
- data/package.json +1 -1
- data/vendor/tom_select/css/tom-select.custom.css +0 -1
- metadata +11 -1
|
@@ -23,6 +23,7 @@ export class Controller extends BaseController {
|
|
|
23
23
|
];
|
|
24
24
|
static values = {
|
|
25
25
|
placeholder: String,
|
|
26
|
+
autofocus: Boolean,
|
|
26
27
|
};
|
|
27
28
|
|
|
28
29
|
connect() {
|
|
@@ -40,6 +41,10 @@ export class Controller extends BaseController {
|
|
|
40
41
|
// }),
|
|
41
42
|
Link.configure({
|
|
42
43
|
openOnClick: false,
|
|
44
|
+
// Internal links are stored as GlobalIDs (resolved to URLs at render
|
|
45
|
+
// time); without this the gid:// scheme fails Link's URI validation
|
|
46
|
+
// and setLink silently does nothing.
|
|
47
|
+
protocols: ["gid"],
|
|
43
48
|
HTMLAttributes: {
|
|
44
49
|
"data-action": "trek--form--content-editor#selectLink",
|
|
45
50
|
target: null,
|
|
@@ -55,6 +60,7 @@ export class Controller extends BaseController {
|
|
|
55
60
|
PromptsBlock,
|
|
56
61
|
],
|
|
57
62
|
content: this.contentJson,
|
|
63
|
+
autofocus: this.autofocusValue ? "end" : false,
|
|
58
64
|
});
|
|
59
65
|
|
|
60
66
|
this.editor.on("update", () => this.updateContent());
|
|
@@ -8,7 +8,7 @@ module Trek
|
|
|
8
8
|
include Turbo::FramesHelper
|
|
9
9
|
include ViewComponent::Translatable
|
|
10
10
|
|
|
11
|
-
attr_reader :nodes, :blocks, :floating
|
|
11
|
+
attr_reader :nodes, :blocks, :floating, :autofocus
|
|
12
12
|
|
|
13
13
|
def initialize(form, object_name, method_name, options = {})
|
|
14
14
|
@nodes = options.delete(:nodes)
|
|
@@ -17,6 +17,7 @@ module Trek
|
|
|
17
17
|
@blocks = true if @blocks.nil?
|
|
18
18
|
@floating = options.delete(:floating)
|
|
19
19
|
@floating = true if @floating.nil?
|
|
20
|
+
@autofocus = options.delete(:autofocus) || false
|
|
20
21
|
|
|
21
22
|
super
|
|
22
23
|
end
|
|
@@ -29,7 +30,8 @@ module Trek
|
|
|
29
30
|
}.merge(
|
|
30
31
|
stimulus_class_hash("active", class_for("is-active")),
|
|
31
32
|
stimulus_class_hash("empty", class_for("is-empty")),
|
|
32
|
-
stimulus_value_hash("placeholder", t(".placeholder"))
|
|
33
|
+
stimulus_value_hash("placeholder", t(".placeholder")),
|
|
34
|
+
stimulus_value_hash("autofocus", autofocus)
|
|
33
35
|
)
|
|
34
36
|
end
|
|
35
37
|
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
.title {
|
|
13
|
+
color: var(--slate-12);
|
|
14
|
+
display: block;
|
|
15
|
+
font-weight: var(--semibold);
|
|
16
|
+
margin-bottom: 1.2rem;
|
|
13
17
|
margin-left: -1rem;
|
|
14
18
|
padding: 0 1rem;
|
|
15
19
|
}
|
|
@@ -35,8 +39,5 @@
|
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
.root.section .title {
|
|
38
|
-
color: var(--slate-12);
|
|
39
42
|
font-size: 1.2em;
|
|
40
|
-
font-weight: var(--semibold);
|
|
41
|
-
margin-bottom: 1.2rem;
|
|
42
43
|
}
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
.root input[type="text"],
|
|
33
33
|
.root input[type="url"],
|
|
34
34
|
.root input[type="date"],
|
|
35
|
+
.root input[type="time"],
|
|
35
36
|
.root select,
|
|
36
37
|
.root textarea {
|
|
37
38
|
background-color: var(--slate-2);
|
|
@@ -55,7 +56,8 @@
|
|
|
55
56
|
|
|
56
57
|
.root input[type="number"],
|
|
57
58
|
.root input[name*="color"],
|
|
58
|
-
.root input[type="date"]
|
|
59
|
+
.root input[type="date"],
|
|
60
|
+
.root input[type="time"] {
|
|
59
61
|
max-width: 16rem;
|
|
60
62
|
}
|
|
61
63
|
|
|
@@ -63,7 +65,8 @@
|
|
|
63
65
|
max-width: 19rem;
|
|
64
66
|
}
|
|
65
67
|
|
|
66
|
-
.root input[type="date"]
|
|
68
|
+
.root input[type="date"],
|
|
69
|
+
.root input[type="time"] {
|
|
67
70
|
color: var(--slate-11);
|
|
68
71
|
font-weight: var(--light);
|
|
69
72
|
}
|
|
@@ -34,6 +34,18 @@
|
|
|
34
34
|
transform: translateX(0);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/* Stacked panel: overlays the primary panel. */
|
|
38
|
+
.panel-stacked {
|
|
39
|
+
box-shadow: 0 0 16px var(--slate-a8);
|
|
40
|
+
z-index: 3;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* Only when it actually stacks on an open primary panel, make it 1rem
|
|
44
|
+
narrower so a sliver of the panel underneath stays visible. */
|
|
45
|
+
.panel.open + .panel-stacked {
|
|
46
|
+
width: 41rem;
|
|
47
|
+
}
|
|
48
|
+
|
|
37
49
|
.panel-close {
|
|
38
50
|
--icon-size: 1.6rem;
|
|
39
51
|
|
|
@@ -65,4 +77,12 @@
|
|
|
65
77
|
.content {
|
|
66
78
|
margin: 6rem 3rem;
|
|
67
79
|
}
|
|
80
|
+
|
|
81
|
+
.panel {
|
|
82
|
+
width: 100vw;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.panel.open + .panel-stacked {
|
|
86
|
+
width: calc(100vw - 1rem);
|
|
87
|
+
}
|
|
68
88
|
}
|
|
@@ -18,3 +18,19 @@ div(
|
|
|
18
18
|
data-action=stimulus_action("closePanel")
|
|
19
19
|
)
|
|
20
20
|
= render(Trek::IconComponent.new("trek/close"))
|
|
21
|
+
/ Stacked panel: opens on top of the primary panel, so a panel-hosted form
|
|
22
|
+
(e.g. a content editor in a section panel) survives sub-panels like the
|
|
23
|
+
link/image/prompt inserters.
|
|
24
|
+
aside(
|
|
25
|
+
class=[class_for("panel"), class_for("panel-stacked")]
|
|
26
|
+
data=stimulus_target_hash("stackedPanel")
|
|
27
|
+
)
|
|
28
|
+
= turbo_frame_tag( \
|
|
29
|
+
"#{identifier}-stacked-panel",
|
|
30
|
+
data: stimulus_target_hash("stackedPanelFrame"),
|
|
31
|
+
)
|
|
32
|
+
button(
|
|
33
|
+
class=class_for("panel-close")
|
|
34
|
+
data-action=stimulus_action("closeStackedPanel")
|
|
35
|
+
)
|
|
36
|
+
= render(Trek::IconComponent.new("trek/close"))
|
|
@@ -5,7 +5,14 @@ import { useClickOutside } from "stimulus-use";
|
|
|
5
5
|
|
|
6
6
|
export class Controller extends BaseController {
|
|
7
7
|
static classes = ["open"];
|
|
8
|
-
static targets = [
|
|
8
|
+
static targets = [
|
|
9
|
+
"panel",
|
|
10
|
+
"panelFrame",
|
|
11
|
+
"panelCloser",
|
|
12
|
+
"stackedPanel",
|
|
13
|
+
"stackedPanelFrame",
|
|
14
|
+
"stackedPanelCloser",
|
|
15
|
+
];
|
|
9
16
|
|
|
10
17
|
connect() {
|
|
11
18
|
useClickOutside(this, { element: this.panelTarget });
|
|
@@ -17,31 +24,53 @@ export class Controller extends BaseController {
|
|
|
17
24
|
this.closePanel();
|
|
18
25
|
}
|
|
19
26
|
|
|
27
|
+
stackedPanelCloserTargetConnected() {
|
|
28
|
+
this.closeStackedPanel();
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
openPanelWhenLoaded() {
|
|
21
32
|
this.panelFrameTarget.loaded.then(() => this.openPanel());
|
|
22
33
|
}
|
|
23
34
|
|
|
35
|
+
openStackedPanelWhenLoaded() {
|
|
36
|
+
this.stackedPanelFrameTarget.loaded.then(() => this.openStackedPanel());
|
|
37
|
+
}
|
|
38
|
+
|
|
24
39
|
openPanel() {
|
|
25
40
|
this.panelTarget.classList.add(this.openClass);
|
|
26
41
|
}
|
|
27
42
|
|
|
43
|
+
openStackedPanel() {
|
|
44
|
+
this.stackedPanelTarget.classList.add(this.openClass);
|
|
45
|
+
}
|
|
46
|
+
|
|
28
47
|
closePanel() {
|
|
48
|
+
this.closeStackedPanel();
|
|
29
49
|
this.panelTarget.classList.remove(this.openClass);
|
|
30
|
-
this.emptyFrame();
|
|
50
|
+
this.emptyFrame(this.panelFrameTarget);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
closeStackedPanel() {
|
|
54
|
+
this.stackedPanelTarget.classList.remove(this.openClass);
|
|
55
|
+
this.emptyFrame(this.stackedPanelFrameTarget);
|
|
31
56
|
}
|
|
32
57
|
|
|
33
58
|
clickOutside(e) {
|
|
34
59
|
if (
|
|
35
60
|
e.target.dataset.action &&
|
|
36
|
-
e.target.dataset.action.includes("trek--layout#openPanel")
|
|
61
|
+
(e.target.dataset.action.includes("trek--layout#openPanel") ||
|
|
62
|
+
e.target.dataset.action.includes("trek--layout#openStackedPanel"))
|
|
37
63
|
)
|
|
38
64
|
return;
|
|
65
|
+
// Clicks inside the stacked panel are outside the primary panel element,
|
|
66
|
+
// but must not close anything.
|
|
67
|
+
if (this.stackedPanelTarget.contains(e.target)) return;
|
|
39
68
|
this.closePanel();
|
|
40
69
|
}
|
|
41
70
|
|
|
42
|
-
emptyFrame() {
|
|
43
|
-
while (
|
|
44
|
-
|
|
71
|
+
emptyFrame(frame) {
|
|
72
|
+
while (frame.firstChild) {
|
|
73
|
+
frame.removeChild(frame.firstChild);
|
|
45
74
|
}
|
|
46
75
|
}
|
|
47
76
|
}
|
|
@@ -57,6 +57,11 @@ module Trek
|
|
|
57
57
|
option :href
|
|
58
58
|
option :text
|
|
59
59
|
option :allowed_to, default: -> { true }
|
|
60
|
+
# Overrides the active state for a section whose sub-resources live under other
|
|
61
|
+
# controllers: a String is a controller_path prefix ("admin/shop"), a Regexp is matched
|
|
62
|
+
# against it, a boolean forces the state. By default the item is active when its href
|
|
63
|
+
# matches the current controller.
|
|
64
|
+
option :active, optional: true
|
|
60
65
|
|
|
61
66
|
def call
|
|
62
67
|
tag.li(
|
|
@@ -79,10 +84,18 @@ module Trek
|
|
|
79
84
|
private
|
|
80
85
|
|
|
81
86
|
def active?
|
|
82
|
-
|
|
87
|
+
case active
|
|
88
|
+
when nil then href_path == controller_path
|
|
89
|
+
when String then controller_path.start_with?(active)
|
|
90
|
+
when Regexp then active.match?(controller_path)
|
|
91
|
+
else active
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def href_path
|
|
96
|
+
Array.wrap(href).map do |part|
|
|
83
97
|
part.respond_to?(:model_name) ? part.model_name.collection : part
|
|
84
98
|
end.join("/").sub("root", "dashboards")
|
|
85
|
-
href_path == controller_path
|
|
86
99
|
end
|
|
87
100
|
end
|
|
88
101
|
end
|
|
@@ -21,7 +21,7 @@ module Trek
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
# Returns model collection as an array of symbols for route building.
|
|
24
|
-
# For namespaced models like
|
|
24
|
+
# For namespaced models like Shop::Product, returns [:shop, :products].
|
|
25
25
|
# For non-namespaced models like Page, returns [:pages].
|
|
26
26
|
def model_collection_route
|
|
27
27
|
model_name.collection.split("/").map(&:to_sym)
|
|
@@ -32,13 +32,20 @@ module Trek
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
# Returns model element as an array of symbols for route building.
|
|
35
|
-
# For namespaced models like Missive::List, returns [:missive, :list].
|
|
36
35
|
# For non-namespaced models like Page, returns [:page].
|
|
37
|
-
#
|
|
38
|
-
#
|
|
36
|
+
# For namespaced models, it depends on the route key: callers build member
|
|
37
|
+
# routes as `[:admin, *model_element_route[0...-1], record]`, and the
|
|
38
|
+
# record's route key must not carry the namespace twice.
|
|
39
|
+
# - Shop::Product (plain app namespace, route key `shop_product`)
|
|
40
|
+
# returns [:shop_product] — a [:shop, :product] prefix would yield
|
|
41
|
+
# `admin_shop_shop_product_path`.
|
|
42
|
+
# - Blog::Post from an isolated engine (`isolate_namespace` → relative
|
|
43
|
+
# model naming, route key `post`) returns [:blog, :post], since the
|
|
44
|
+
# namespace segment is needed for `admin_blog_post_path`.
|
|
39
45
|
def model_element_route
|
|
40
|
-
|
|
41
|
-
(
|
|
46
|
+
element = model_name.singular_route_key
|
|
47
|
+
namespace = element == model_name.element ? model_name.collection.split("/")[0...-1] : []
|
|
48
|
+
(namespace + [element]).map(&:to_sym)
|
|
42
49
|
end
|
|
43
50
|
|
|
44
51
|
def model_intro(action: nil)
|
|
@@ -79,8 +79,16 @@ module Trek
|
|
|
79
79
|
authorize! @object
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
+
# The form posts under model_name.param_key, which keeps the namespace
|
|
83
|
+
# (shop_product for Shop::Product) unlike model_element (product).
|
|
82
84
|
def object_params
|
|
83
|
-
authorized(params.require(
|
|
85
|
+
authorized(params.require(model_name.param_key))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# action_policy infers the target from controller_name.classify, which
|
|
89
|
+
# doesn't exist for a namespaced model (Product for Shop::Product).
|
|
90
|
+
def implicit_authorization_target
|
|
91
|
+
@object || model
|
|
84
92
|
end
|
|
85
93
|
end
|
|
86
94
|
end
|
|
@@ -2,7 +2,9 @@ module ProsemirrorToHtml
|
|
|
2
2
|
class GlobalIdToLinksFormatter
|
|
3
3
|
include Rails.application.routes.url_helpers
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
# The app segment allows hyphens and dots: GlobalID derives it from the
|
|
6
|
+
# Rails application name (e.g. "au-poste").
|
|
7
|
+
GLOBAL_ID_REGEX = %r{(gid://[\w.-]+/[\w:]+/[\w-]+)}
|
|
6
8
|
|
|
7
9
|
def initialize(str)
|
|
8
10
|
@str = str
|
|
@@ -11,7 +11,7 @@ module Trek
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def i18n_model_gender(model_name)
|
|
14
|
-
t("activerecord.models.#{model_name.
|
|
14
|
+
t("activerecord.models.#{model_name.i18n_key}.gender", default: "n")
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def boolean_collection(key: "boolean_labels")
|
data/app/models/prosemirror.rb
CHANGED
|
@@ -10,9 +10,12 @@ class Prosemirror
|
|
|
10
10
|
def format_content(parsed_content)
|
|
11
11
|
return if parsed_content.blank?
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
# GlobalIDs must be resolved to URLs before rendering: the renderer's
|
|
14
|
+
# sanitizer strips href attributes with unknown protocols, so a gid://
|
|
15
|
+
# link would lose its href entirely.
|
|
16
|
+
content = ProsemirrorToHtml::GlobalIdToLinksFormatter.new(parsed_content.to_json).to_s
|
|
17
|
+
content = renderer.render(JSON.parse(content))
|
|
14
18
|
content = ProsemirrorToHtml::RemoveEmptyParagraphsFormatter.new(content).to_s
|
|
15
|
-
content = ProsemirrorToHtml::GlobalIdToLinksFormatter.new(content).to_s
|
|
16
19
|
# rubocop:disable Rails/OutputSafety
|
|
17
20
|
content.html_safe
|
|
18
21
|
# rubocop:enable Rails/OutputSafety
|
|
@@ -24,7 +24,7 @@ h1.c-trek--layout-panel-title = t(".title")
|
|
|
24
24
|
= render Trek::Form::ActionsComponent.new do |c|
|
|
25
25
|
ruby:
|
|
26
26
|
c.with_button(
|
|
27
|
-
data: { action: "trek--layout#
|
|
27
|
+
data: { action: "trek--layout#closeStackedPanel" },
|
|
28
28
|
text: t("admin.actions.cancel"),
|
|
29
29
|
)
|
|
30
30
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
- if @object.valid?
|
|
2
|
-
= turbo_stream.append("trek--layout-panel")
|
|
3
|
-
div(data-trek--layout-target="
|
|
2
|
+
= turbo_stream.append("trek--layout-stacked-panel")
|
|
3
|
+
div(data-trek--layout-target="stackedPanelCloser")
|
|
4
4
|
|
|
5
5
|
= turbo_stream.append(@object.turbo_frame_id)
|
|
6
6
|
div(
|
|
@@ -15,5 +15,5 @@
|
|
|
15
15
|
text=@object.text
|
|
16
16
|
)
|
|
17
17
|
- else
|
|
18
|
-
= turbo_stream.update("trek--layout-panel")
|
|
18
|
+
= turbo_stream.update("trek--layout-stacked-panel")
|
|
19
19
|
= render "form"
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
= turbo_frame_tag("trek--layout-panel")
|
|
1
|
+
= turbo_frame_tag("trek--layout-stacked-panel")
|
|
2
2
|
= render "form"
|
|
@@ -28,7 +28,7 @@ h1.c-trek--layout-panel-title = t(".title")
|
|
|
28
28
|
= render Trek::Form::ActionsComponent.new do |c|
|
|
29
29
|
ruby:
|
|
30
30
|
c.with_button(
|
|
31
|
-
data: { action: "trek--layout#
|
|
31
|
+
data: { action: "trek--layout#closeStackedPanel" },
|
|
32
32
|
text: t("admin.actions.cancel"),
|
|
33
33
|
)
|
|
34
34
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
- if @object.valid?
|
|
2
|
-
= turbo_stream.append("trek--layout-panel")
|
|
3
|
-
div(data-trek--layout-target="
|
|
2
|
+
= turbo_stream.append("trek--layout-stacked-panel")
|
|
3
|
+
div(data-trek--layout-target="stackedPanelCloser")
|
|
4
4
|
|
|
5
5
|
= turbo_stream.append(@object.turbo_frame_id)
|
|
6
6
|
div(
|
|
@@ -8,5 +8,5 @@
|
|
|
8
8
|
data-params=@object.to_json
|
|
9
9
|
)
|
|
10
10
|
- else
|
|
11
|
-
= turbo_stream.update("trek--layout-panel")
|
|
11
|
+
= turbo_stream.update("trek--layout-stacked-panel")
|
|
12
12
|
= render "form"
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
= turbo_frame_tag("trek--layout-panel")
|
|
1
|
+
= turbo_frame_tag("trek--layout-stacked-panel")
|
|
2
2
|
= render "form"
|
|
@@ -26,7 +26,7 @@ h1.c-trek--layout-panel-title = t(".title")
|
|
|
26
26
|
= render Trek::Form::ActionsComponent.new do |c|
|
|
27
27
|
ruby:
|
|
28
28
|
c.with_button(
|
|
29
|
-
data: { action: "trek--layout#
|
|
29
|
+
data: { action: "trek--layout#closeStackedPanel" },
|
|
30
30
|
text: t("admin.actions.cancel"),
|
|
31
31
|
)
|
|
32
32
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
- if @object.valid?
|
|
2
|
-
= turbo_stream.append("trek--layout-panel")
|
|
3
|
-
div(data-trek--layout-target="
|
|
2
|
+
= turbo_stream.append("trek--layout-stacked-panel")
|
|
3
|
+
div(data-trek--layout-target="stackedPanelCloser")
|
|
4
4
|
|
|
5
5
|
= turbo_stream.append(@object.turbo_frame_id)
|
|
6
6
|
div(
|
|
@@ -14,5 +14,5 @@
|
|
|
14
14
|
title=@object.title
|
|
15
15
|
)
|
|
16
16
|
- else
|
|
17
|
-
= turbo_stream.update("trek--layout-panel")
|
|
17
|
+
= turbo_stream.update("trek--layout-stacked-panel")
|
|
18
18
|
= render "form"
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
= turbo_frame_tag("trek--layout-panel")
|
|
1
|
+
= turbo_frame_tag("trek--layout-stacked-panel")
|
|
2
2
|
= render "form"
|
data/docs/.vitepress/config.mjs
CHANGED
data/docs/deploy.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Deploying to Scalingo
|
|
2
|
+
|
|
3
|
+
Trek apps deploy to [Scalingo](https://scalingo.com) with the dedicated [trek-buildpack](https://github.com/etaminstudio/trek-buildpack), a single buildpack that replaces the usual `apt` + `ruby` buildpack pair and knows how a Trek app is built.
|
|
4
|
+
|
|
5
|
+
## What the buildpack does
|
|
6
|
+
|
|
7
|
+
- installs the APT packages listed in `Aptfile` (image processing libraries, jemalloc), skipping `apt-get update` and package resolution entirely when the `Aptfile` is unchanged
|
|
8
|
+
- installs Ruby with [rv](https://rv.dev) — precompiled, in seconds — using the version pinned in `.ruby-version`
|
|
9
|
+
- installs Node.js pinned by `.node-version` (generated by `rails new`), and the exact Yarn release pinned by `package.json`'s `packageManager` field — no unpinned defaults
|
|
10
|
+
- runs `yarn install` exactly once, with a persistent cache, before `assets:precompile`
|
|
11
|
+
- slims the shipped image: `node_modules`, Yarn caches and Node.js itself are dropped after the build, roughly halving the image size of a typical Trek app — faster deploys, restarts and scaling
|
|
12
|
+
|
|
13
|
+
Compared with the stock buildpacks, deploys typically go from around 3 minutes to about 1 minute 30 with a warm cache.
|
|
14
|
+
|
|
15
|
+
## Set up the app
|
|
16
|
+
|
|
17
|
+
Generate the Scalingo files (an `Aptfile` and a `Procfile` with the web process and post-deploy migrations):
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
rails g trek:install:scalingo
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Then configure the Scalingo app's environment, pinning the buildpack to a release tag:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
scalingo --app <app> env-set BUILDPACK_URL=https://github.com/etaminstudio/trek-buildpack#v0.1.0
|
|
27
|
+
scalingo --app <app> env-set 'DATABASE_URL=$SCALINGO_POSTGRESQL_URL'
|
|
28
|
+
scalingo --app <app> env-set RAILS_MASTER_KEY=$(cat config/master.key)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Make sure `config/database.yml` reads `DATABASE_URL` in production — the section `rails new` generates targets a local socket with a password variable instead:
|
|
32
|
+
|
|
33
|
+
```yml
|
|
34
|
+
production:
|
|
35
|
+
primary: &primary_production
|
|
36
|
+
<<: *default
|
|
37
|
+
url: <%= ENV["DATABASE_URL"] %>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Provision a PostgreSQL addon (`scalingo --app <app> addons-add postgresql <plan>`), push, and the app boots with `RAILS_ENV`, `RAILS_LOG_TO_STDOUT` and `RAILS_SERVE_STATIC_FILES` already set by the buildpack.
|
|
41
|
+
|
|
42
|
+
## Options
|
|
43
|
+
|
|
44
|
+
| Variable | Effect |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| `TREK_BUILDPACK_RUNTIME_NODE=1` | Ship Node.js in the image (only if the app runs JavaScript at runtime, e.g. execjs) |
|
|
47
|
+
| `TREK_BUILDPACK_PRUNE_SOURCEMAPS=1` | Also remove `.map` files from the compiled assets |
|
|
48
|
+
|
|
49
|
+
## Migrating an existing app
|
|
50
|
+
|
|
51
|
+
Apps deployed with the stock `.buildpacks` (apt + ruby) migrate in three steps:
|
|
52
|
+
|
|
53
|
+
1. delete `.buildpacks` (re-running `rails g trek:install:scalingo` does it) and check that `.node-version` exists — apps generated with `rails new -j esbuild` have it
|
|
54
|
+
2. set `BUILDPACK_URL` as above, and map `DATABASE_URL` if the app doesn't read it yet
|
|
55
|
+
3. deploy — the first build fills the caches, the next ones benefit from them
|
|
56
|
+
|
|
57
|
+
The buildpack's [README](https://github.com/etaminstudio/trek-buildpack#readme) details the cache layout and the `Aptfile` syntax.
|
data/docs/getting-started.md
CHANGED
|
@@ -132,3 +132,4 @@ Next steps:
|
|
|
132
132
|
|
|
133
133
|
- [Customize Trek](/customize) — branding, favicon, components
|
|
134
134
|
- [Reference manual](/reference/) — generators, models, components and more
|
|
135
|
+
- [Deploying to Scalingo](/deploy) — `trek:install:scalingo`, a few environment variables, and the dedicated trek-buildpack handles the rest
|
|
@@ -93,3 +93,26 @@ end
|
|
|
93
93
|
```
|
|
94
94
|
|
|
95
95
|
Like `Trek::Filters`, forgetting to call `apply_scopes!` raises `Trek::Scopes::NotAppliedError` outside production.
|
|
96
|
+
|
|
97
|
+
## N+1 detection
|
|
98
|
+
|
|
99
|
+
`trek:install:prosopite` (part of `trek:install`) sets up [Prosopite](https://github.com/charkost/prosopite)
|
|
100
|
+
in development and test: `config/initializers/prosopite.rb` mounts its Rack middleware, so **every request** —
|
|
101
|
+
including Trek's own engine controllers — is scanned. A detected N+1 raises `Prosopite::NPlusOneQueriesError`
|
|
102
|
+
in test (so request specs fail) and is logged in development.
|
|
103
|
+
|
|
104
|
+
The generator also adds `spec/requests/admin/n_plus_one_spec.rb`, a smoke request spec that renders the
|
|
105
|
+
dashboard, the pages index and the fragments index as an admin — extend its route table with the app's other
|
|
106
|
+
admin pages (scaffolded indexes and edit pages in particular), with a few records created beforehand, since an
|
|
107
|
+
empty list can't reveal an N+1.
|
|
108
|
+
|
|
109
|
+
Legitimate repeated queries (e.g. one `COUNT` per row) can be exempted with the escape hatch:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
Prosopite.pause do
|
|
113
|
+
scopes.index_with { |scope| relation.public_send(scope).count }
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Trek's own concerns already do this where needed (`Trek::Scopes#scope_counts`). Guard with
|
|
118
|
+
`if defined?(Prosopite)` in code that must also run in production, where the gem isn't loaded.
|
|
@@ -32,7 +32,9 @@ The installer orchestrates dedicated sub-generators, all under the `trek:install
|
|
|
32
32
|
| Tooling | esbuild, PostCSS, EditorConfig, Procfiles, Makefile, health check endpoint |
|
|
33
33
|
| Linting | Standard, ESLint, Stylelint, Prettier, Slim Lint, Lefthook git hooks |
|
|
34
34
|
| Security | Brakeman, bundler-audit, ruby_audit |
|
|
35
|
+
| N+1 detection | Prosopite (development/test), scanning every request — see [Controllers › N+1 detection](/reference/controllers#n-1-detection) |
|
|
35
36
|
| CI | GitLab CI configuration |
|
|
37
|
+
| Deployment | Scalingo files (`trek:install:scalingo`, run on demand) — see [Deploying to Scalingo](/deploy) |
|
|
36
38
|
|
|
37
39
|
## ENV variables read by the installer
|
|
38
40
|
|
|
@@ -18,6 +18,18 @@ Akin to Rails', Trek's scaffold generates, with a single command:
|
|
|
18
18
|
|
|
19
19
|
Standard Rails attribute types are supported (`string`, `text`, `integer`, `float`, `decimal`, `boolean`, `date`, `datetime`, `references`…) and mapped to the matching [form helpers](/reference/forms): `text` renders the content editor, `boolean` a switch box, `references` a collection select, and so on.
|
|
20
20
|
|
|
21
|
+
## Namespaced models
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
rails g trek:scaffold Shop::Product name price:decimal
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The controller and policy are nested accordingly (`Admin::Shop::ProductsController`, `Admin::Shop::ProductPolicy`), the routes go under `namespace :shop`, and everything else follows the namespace.
|
|
28
|
+
|
|
29
|
+
The generated code references the model as `::Shop::Product`, top-level qualified, and the dashboard menu entry is `::Shop::Product` too. This is deliberate: once `Admin::Shop` exists, a bare `Shop` written anywhere under `module Admin` resolves to that namespace instead of the `Shop` model. Existing admin code that references such a model — an `Admin::ShopsController#model` returning `Shop`, or a `Shop` entry in `Admin::Dashboard::MODELS_IN_MENU` — must be updated to `::Shop` once a scaffold introduces a namespace with the same name (the menu would otherwise silently list the module).
|
|
30
|
+
|
|
31
|
+
Also note that `rails g model Shop::Product` (which the scaffold runs) writes `app/models/shop.rb` as a `module Shop` namespace file; if a `Shop` model already lives there, the generator warns and Rails asks before overwriting it — move the model or answer `n`.
|
|
32
|
+
|
|
21
33
|
## Opt-in concerns
|
|
22
34
|
|
|
23
35
|
The generated model ships with commented-out hooks for Trek's most useful [concerns](/reference/), ready to be enabled when needed:
|
|
@@ -25,7 +25,15 @@ module Trek
|
|
|
25
25
|
|
|
26
26
|
# Mailers run outside a request, so in production they need the same host for
|
|
27
27
|
# link helpers. Development and test keep the host from their environment files.
|
|
28
|
-
ActionMailer::Base
|
|
28
|
+
# Deferred with on_load: referencing ActionMailer::Base here would load it
|
|
29
|
+
# before the app's autoloader can resolve app classes, and its load hook
|
|
30
|
+
# constantizes config.action_mailer.delivery_job — NameError at boot when
|
|
31
|
+
# the app configures a custom delivery job.
|
|
32
|
+
if Rails.env.production?
|
|
33
|
+
ActiveSupport.on_load(:action_mailer) do
|
|
34
|
+
self.default_url_options = Rails.application.routes.default_url_options
|
|
35
|
+
end
|
|
36
|
+
end
|
|
29
37
|
RUBY
|
|
30
38
|
end
|
|
31
39
|
end
|
|
@@ -28,6 +28,8 @@ module Trek
|
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def inject_postmark_to_config
|
|
31
|
+
return if File.read("config/environments/production.rb").include?("delivery_method = :postmark")
|
|
32
|
+
|
|
31
33
|
inject_into_file "config/environments/production.rb",
|
|
32
34
|
config_injection,
|
|
33
35
|
after: %r{# Specify outgoing SMTP server\. Remember to add smtp/\* credentials via (?:bin/)?rails credentials:edit\.\n}
|