trek 2.1.0 → 2.3.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/.slim-lint.yml +0 -3
- data/AGENTS.md +8 -1
- data/CHANGELOG.md +75 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +20 -3
- data/Makefile +11 -9
- data/README.md +1 -1
- data/app/assets/icons/trek/audit.svg +1 -0
- data/app/assets/icons/trek/chevron-down.svg +1 -0
- data/app/assets/icons/trek/chevron-up.svg +1 -0
- data/app/assets/icons/trek/external.svg +1 -0
- data/app/assets/icons/trek/print.svg +1 -0
- data/app/assets/icons/trek/theme-auto.svg +1 -0
- data/app/assets/icons/trek/theme-dark.svg +1 -0
- data/app/assets/icons/trek/theme-light.svg +1 -0
- data/app/assets/stylesheets/trek/base.css +13 -0
- data/app/components/trek/button_component/button_component.css +9 -0
- data/app/components/trek/button_component.rb +13 -1
- 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 +20 -2
- data/app/components/trek/dialog_component/dialog_component.html.slim +6 -2
- data/app/components/trek/dialog_component/dialog_component.js +62 -2
- data/app/components/trek/dropdown_component/dropdown_component.css +66 -0
- data/app/components/trek/dropdown_component/dropdown_component.html.slim +31 -0
- data/app/components/trek/dropdown_component/dropdown_component.js +114 -0
- data/app/components/trek/dropdown_component.rb +29 -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 +4 -0
- data/app/components/trek/form/group_component/group_component.css +34 -0
- data/app/components/trek/header_component/header_component.css +10 -4
- data/app/components/trek/header_component/header_component.html.slim +17 -10
- data/app/components/trek/header_component/header_component.js +29 -5
- data/app/components/trek/header_component.rb +20 -3
- 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/menu_component.css +6 -0
- data/app/components/trek/menu_component.rb +29 -4
- data/app/components/trek/toaster/toast_component.rb +1 -1
- data/app/controllers/concerns/trek/authorization.rb +12 -0
- data/app/formatters/prosemirror_to_html/global_id_to_links_formatter.rb +3 -1
- data/app/helpers/trek/page_path_helper.rb +4 -3
- data/app/helpers/trek/theme_helper.rb +16 -1
- data/app/models/concerns/trek/phonable.rb +15 -3
- data/app/models/concerns/trek/sectionable.rb +5 -0
- data/app/models/concerns/trek/users/validations.rb +1 -1
- data/app/models/prosemirror.rb +5 -2
- data/app/uploaders/audio_uploader.rb +11 -0
- data/app/uploaders/record_image_uploader.rb +3 -0
- 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/config/locales/trek.en.yml +3 -2
- data/config/locales/trek.fr.yml +1 -0
- data/docs/customize.md +18 -2
- data/docs/deploy.md +7 -2
- data/docs/getting-started.md +1 -1
- data/docs/index.md +1 -1
- data/docs/reference/components.md +15 -0
- data/docs/reference/concerns/phonable.md +27 -1
- data/docs/reference/environment-variables.md +2 -0
- data/docs/reference/forms.md +2 -0
- data/docs/reference/generators/install.md +4 -1
- data/docs/reference/icons.md +1 -1
- data/docs/reference/models/tag.md +1 -1
- data/docs/reference/models/user.md +4 -2
- data/docs/reference/uploaders.md +31 -6
- data/docs/what-is-trek.md +1 -1
- data/lib/generators/trek/install/admin/routes_generator.rb +7 -1
- data/lib/generators/trek/install/appsignal_generator.rb +102 -0
- data/lib/generators/trek/install/model_specs_generator.rb +64 -0
- data/lib/generators/trek/install/postmark_generator.rb +3 -0
- data/lib/generators/trek/install/ruby_generator.rb +1 -1
- data/lib/generators/trek/install/scalingo_generator.rb +1 -1
- data/lib/generators/trek/install/uploaders_generator.rb +2 -0
- data/lib/generators/trek/install_generator.rb +5 -0
- data/lib/generators/trek/taxonomies_generator.rb +7 -0
- data/lib/generators/trek/templates/.gitlab-ci.yml +1 -1
- data/lib/generators/trek/templates/config/appsignal.rb.tt +25 -0
- data/lib/generators/trek/templates/controllers/admin/uploads_controller.rb +4 -0
- data/lib/generators/trek/templates/controllers/admin/users_controller.rb +4 -1
- data/lib/generators/trek/templates/controllers/concerns/appsignal_context.rb +37 -0
- data/lib/generators/trek/templates/db/seeds/setup/fragments.rb +3 -0
- data/lib/generators/trek/templates/db/seeds/setup/users.rb +6 -0
- data/lib/generators/trek/templates/db/seeds/test/cases/page_tree.rb +5 -0
- data/lib/generators/trek/templates/initializers/appsignal_context.rb +5 -0
- data/lib/generators/trek/templates/locales/scaffold/model.en.yml.tt +1 -1
- data/lib/generators/trek/templates/migrations/fragments.rb.erb +1 -1
- data/lib/generators/trek/templates/migrations/menu_nodes.rb.erb +0 -1
- data/lib/generators/trek/templates/migrations/tag_categories.rb.erb +1 -1
- data/lib/generators/trek/templates/migrations/taggings.rb.erb +1 -1
- data/lib/generators/trek/templates/migrations/users.rb.erb +2 -1
- data/lib/generators/trek/templates/models/tag.rb +6 -1
- data/lib/generators/trek/templates/policies/admin/tag_policy.rb +1 -1
- data/lib/generators/trek/templates/policies/external_link_policy.rb +1 -1
- data/lib/generators/trek/templates/spec/fixtures/files/image.png +0 -0
- data/lib/generators/trek/templates/spec/fixtures/files/sound.mp3 +0 -0
- data/lib/generators/trek/templates/spec/models/page_spec.rb +24 -0
- data/lib/generators/trek/templates/spec/models/user_spec.rb +76 -0
- data/lib/generators/trek/templates/spec/policies/admin/external_link_policy_spec.rb +17 -0
- data/lib/generators/trek/templates/spec/policies/admin/fragment_policy_spec.rb +33 -0
- data/lib/generators/trek/templates/spec/policies/admin/page_policy_spec.rb +39 -0
- data/lib/generators/trek/templates/spec/policies/admin/preview_policy_spec.rb +9 -0
- data/lib/generators/trek/templates/spec/policies/admin/tag_category_policy_spec.rb +31 -0
- data/lib/generators/trek/templates/spec/policies/admin/tag_policy_spec.rb +31 -0
- data/lib/generators/trek/templates/spec/policies/admin/user_policy_spec.rb +58 -0
- data/lib/generators/trek/templates/spec/requests/admin/fragments_spec.rb +50 -0
- data/lib/generators/trek/templates/spec/requests/admin/pages_spec.rb +47 -0
- data/lib/generators/trek/templates/spec/requests/admin/uploads_spec.rb +22 -0
- data/lib/generators/trek/templates/spec/requests/admin/user_password_resets_spec.rb +71 -0
- data/lib/generators/trek/templates/spec/requests/admin/user_sessions_spec.rb +45 -0
- data/lib/generators/trek/templates/spec/requests/admin/users_spec.rb +99 -0
- data/lib/generators/trek/templates/views/admin/user_password_resets/edit.html.slim +1 -1
- data/lib/generators/trek/templates/views/admin/user_password_resets/new.html.slim +1 -1
- data/lib/generators/trek/templates/views/admin/user_sessions/new.html.slim +2 -2
- data/lib/generators/trek/templates/views/admin/users/theme.turbo_stream.slim +2 -0
- data/lib/generators/trek/upgrade/v2_generator.rb +3 -0
- data/lib/tasks/trek/attachments.rake +18 -0
- data/lib/trek/cached_attachments.rb +53 -0
- data/lib/trek/saved_attachments_reload.rb +44 -0
- data/lib/trek/version.rb +1 -1
- data/lib/trek.rb +3 -0
- data/package.json +1 -1
- data/postcss.config.js +36 -0
- data/trek.gemspec +5 -0
- metadata +76 -3
- data/app/assets/icons/trek/theme.svg +0 -11
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
require "rails_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe "Admin fragments", type: :request do
|
|
4
|
+
let!(:footer) { fragments.create(key: "footer", namespace: "layout", title: "Footer") }
|
|
5
|
+
|
|
6
|
+
before do
|
|
7
|
+
editor = users.create(role: :editor)
|
|
8
|
+
post admin_user_session_path, params: { email: editor.email, password: users.password }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it "lists the fragments" do
|
|
12
|
+
get admin_fragments_path
|
|
13
|
+
|
|
14
|
+
expect(response.body).to include("footer")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it "renders the new fragment form" do
|
|
18
|
+
get new_admin_fragment_path
|
|
19
|
+
|
|
20
|
+
expect(response).to have_http_status(:ok)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it "creates a fragment" do
|
|
24
|
+
params = { fragment: { key: "tagline", namespace: "home", title: "Tagline" } }
|
|
25
|
+
|
|
26
|
+
expect { post admin_fragments_path, params: }.to change(Fragment, :count).by(1)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it "refuses a key already used in the namespace" do
|
|
30
|
+
post admin_fragments_path, params: { fragment: { key: "footer", namespace: "layout", title: "Another footer" } }
|
|
31
|
+
|
|
32
|
+
expect(response).to have_http_status(:unprocessable_entity)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it "renders the edit form" do
|
|
36
|
+
get edit_admin_fragment_path(footer)
|
|
37
|
+
|
|
38
|
+
expect(response).to have_http_status(:ok)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "updates a fragment" do
|
|
42
|
+
patch admin_fragment_path(footer), params: { fragment: { title: "Site footer" } }
|
|
43
|
+
|
|
44
|
+
expect(footer.reload.title).to eq("Site footer")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it "deletes a fragment" do
|
|
48
|
+
expect { delete admin_fragment_path(footer) }.to change(Fragment, :count).by(-1)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
require "rails_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe "Admin pages", type: :request do
|
|
4
|
+
before do
|
|
5
|
+
seed "cases/page_tree"
|
|
6
|
+
editor = users.create(role: :editor)
|
|
7
|
+
post admin_user_session_path, params: { email: editor.email, password: users.password }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
it "lists the pages" do
|
|
11
|
+
get admin_pages_path
|
|
12
|
+
|
|
13
|
+
expect(response.body).to include("Company").and include("Our team")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it "renders the new page form" do
|
|
17
|
+
get new_admin_page_path
|
|
18
|
+
|
|
19
|
+
expect(response).to have_http_status(:ok)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it "creates a page" do
|
|
23
|
+
expect { post admin_pages_path, params: { page: { title: "Contact" } } }.to change(Page, :count).by(1)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it "refuses a page without a title" do
|
|
27
|
+
post admin_pages_path, params: { page: { title: "" } }
|
|
28
|
+
|
|
29
|
+
expect(response).to have_http_status(:unprocessable_entity)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it "renders the edit form" do
|
|
33
|
+
get edit_admin_page_path(pages.team)
|
|
34
|
+
|
|
35
|
+
expect(response).to have_http_status(:ok)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it "updates a page" do
|
|
39
|
+
patch admin_page_path(pages.team), params: { page: { title: "The team" } }
|
|
40
|
+
|
|
41
|
+
expect(pages.team.title).to eq("The team")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it "deletes a page" do
|
|
45
|
+
expect { delete admin_page_path(pages.team) }.to change(Page, :count).by(-1)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
require "rails_helper"
|
|
2
|
+
|
|
3
|
+
# The endpoints the admin's image and sound fields upload to (`Trek::Form::ImageFieldComponent`,
|
|
4
|
+
# `Trek::Form::SoundFieldComponent`).
|
|
5
|
+
RSpec.describe "Admin uploads", type: :request do
|
|
6
|
+
before do
|
|
7
|
+
editor = users.create(role: :editor)
|
|
8
|
+
post admin_user_session_path, params: { email: editor.email, password: users.password }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it "stores an uploaded image in the cache and returns its data" do
|
|
12
|
+
post "/admin/upload/image", params: { file: fixture_file_upload("image.png", "image/png") }
|
|
13
|
+
|
|
14
|
+
expect(response.parsed_body["data"]).to include("id", "storage" => "cache")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it "stores an uploaded sound in the cache and returns its data" do
|
|
18
|
+
post "/admin/upload/audio", params: { file: fixture_file_upload("sound.mp3", "audio/mpeg") }
|
|
19
|
+
|
|
20
|
+
expect(response.parsed_body["data"]).to include("id", "storage" => "cache")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
require "rails_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe "Admin password resets", type: :request do
|
|
4
|
+
let(:editor) { users.create(role: :editor) }
|
|
5
|
+
|
|
6
|
+
it "renders the reset request form" do
|
|
7
|
+
get new_admin_user_password_reset_path
|
|
8
|
+
|
|
9
|
+
expect(response).to have_http_status(:ok)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it "emails a reset link to a privileged user" do
|
|
13
|
+
expect { post admin_user_password_resets_path, params: { email: editor.email } }
|
|
14
|
+
.to have_enqueued_mail(Admin::PasswordsMailer, :reset)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it "sends nothing for an unknown email" do
|
|
18
|
+
expect { post admin_user_password_resets_path, params: { email: "nobody@example.com" } }
|
|
19
|
+
.not_to have_enqueued_mail
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it "answers an unknown email as a known one, so it can't be used to find accounts" do
|
|
23
|
+
post admin_user_password_resets_path, params: { email: "nobody@example.com" }
|
|
24
|
+
|
|
25
|
+
expect(response).to redirect_to(new_admin_user_session_path)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it "renders the new password form from a reset link" do
|
|
29
|
+
get edit_admin_user_password_reset_path(editor.password_reset_token)
|
|
30
|
+
|
|
31
|
+
expect(response).to have_http_status(:ok)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it "renders the new password form from an invitation link" do
|
|
35
|
+
get edit_admin_user_password_reset_path(editor.generate_token_for(:invite))
|
|
36
|
+
|
|
37
|
+
expect(response).to have_http_status(:ok)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it "sends an invalid link back to the reset request form" do
|
|
41
|
+
get edit_admin_user_password_reset_path("not-a-token")
|
|
42
|
+
|
|
43
|
+
expect(response).to redirect_to(new_admin_user_password_reset_path)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it "sets the new password" do
|
|
47
|
+
patch admin_user_password_reset_path(editor.password_reset_token), params: { password: "a brand new password" }
|
|
48
|
+
|
|
49
|
+
expect(editor.reload.authenticate("a brand new password")).to be_truthy
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "signs the user out everywhere" do
|
|
53
|
+
post admin_user_session_path, params: { email: editor.email, password: users.password }
|
|
54
|
+
patch admin_user_password_reset_path(editor.password_reset_token), params: { password: "a brand new password" }
|
|
55
|
+
|
|
56
|
+
expect(editor.user_sessions).to be_empty
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it "sends a password shorter than 8 characters back to the form" do
|
|
60
|
+
token = editor.password_reset_token
|
|
61
|
+
patch admin_user_password_reset_path(token), params: { password: "short" }
|
|
62
|
+
|
|
63
|
+
expect(response).to redirect_to(edit_admin_user_password_reset_path(token))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it "keeps the old password when the new one is too short" do
|
|
67
|
+
patch admin_user_password_reset_path(editor.password_reset_token), params: { password: "short" }
|
|
68
|
+
|
|
69
|
+
expect(editor.reload.authenticate(users.password)).to be_truthy
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
require "rails_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe "Admin sign-in", type: :request do
|
|
4
|
+
let(:editor) { users.create(role: :editor) }
|
|
5
|
+
|
|
6
|
+
it "renders the sign-in form" do
|
|
7
|
+
get new_admin_user_session_path
|
|
8
|
+
|
|
9
|
+
expect(response).to have_http_status(:ok)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it "signs a privileged user in and opens the dashboard" do
|
|
13
|
+
post admin_user_session_path, params: { email: editor.email, password: users.password }
|
|
14
|
+
follow_redirect!
|
|
15
|
+
|
|
16
|
+
expect(response).to have_http_status(:ok)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it "refuses a wrong password" do
|
|
20
|
+
post admin_user_session_path, params: { email: editor.email, password: "not the password" }
|
|
21
|
+
|
|
22
|
+
expect(response).to redirect_to(new_admin_user_session_path)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it "refuses a user who is not privileged" do
|
|
26
|
+
reader = users.create(role: :reader)
|
|
27
|
+
post admin_user_session_path, params: { email: reader.email, password: users.password }
|
|
28
|
+
|
|
29
|
+
expect(response).to redirect_to(new_admin_user_session_path)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it "sends a signed-out visitor to the sign-in form" do
|
|
33
|
+
get admin_root_path
|
|
34
|
+
|
|
35
|
+
expect(response).to redirect_to(new_admin_user_session_path)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it "signs out" do
|
|
39
|
+
post admin_user_session_path, params: { email: editor.email, password: users.password }
|
|
40
|
+
delete admin_user_session_path
|
|
41
|
+
get admin_root_path
|
|
42
|
+
|
|
43
|
+
expect(response).to redirect_to(new_admin_user_session_path)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
require "rails_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe "Admin users", type: :request do
|
|
4
|
+
let(:admin) { users.create(role: :admin, name: "Ada") }
|
|
5
|
+
let(:editor) { users.create(role: :editor, name: "Grace") }
|
|
6
|
+
|
|
7
|
+
def sign_in(user)
|
|
8
|
+
post admin_user_session_path, params: { email: user.email, password: users.password }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
context "when signed in as an admin" do
|
|
12
|
+
before { sign_in(admin) }
|
|
13
|
+
|
|
14
|
+
it "lists the privileged users" do
|
|
15
|
+
editor
|
|
16
|
+
get admin_users_path
|
|
17
|
+
|
|
18
|
+
expect(response.body).to include("Grace")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it "leaves the other users out" do
|
|
22
|
+
users.create(role: :reader, name: "Linus")
|
|
23
|
+
get admin_users_path
|
|
24
|
+
|
|
25
|
+
expect(response.body).not_to include("Linus")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it "renders the new user form" do
|
|
29
|
+
get new_admin_user_path
|
|
30
|
+
|
|
31
|
+
expect(response).to have_http_status(:ok)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it "creates a user and sends them an invitation" do
|
|
35
|
+
params = { user: { email: "new@example.com", role: "editor", send_invite: "1" } }
|
|
36
|
+
|
|
37
|
+
expect { post admin_users_path, params: }
|
|
38
|
+
.to change(User, :count).by(1).and have_enqueued_mail(AdminMailer, :invite)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "changes a user's role" do
|
|
42
|
+
patch admin_user_path(editor), params: { user: { role: "admin" } }
|
|
43
|
+
|
|
44
|
+
expect(editor.reload.role).to eq("admin")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it "deletes a user" do
|
|
48
|
+
editor
|
|
49
|
+
|
|
50
|
+
expect { delete admin_user_path(editor) }.to change(User, :count).by(-1)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
context "when signed in as an editor" do
|
|
55
|
+
before { sign_in(editor) }
|
|
56
|
+
|
|
57
|
+
it "is sent back to the dashboard from the users list" do
|
|
58
|
+
get admin_users_path
|
|
59
|
+
|
|
60
|
+
expect(response).to redirect_to(admin_root_path)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it "is sent back to the dashboard from another user's profile" do
|
|
64
|
+
get admin_user_path(admin)
|
|
65
|
+
|
|
66
|
+
expect(response).to redirect_to(admin_root_path)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "is told why they were sent back" do
|
|
70
|
+
get admin_users_path
|
|
71
|
+
|
|
72
|
+
expect(flash[:alert]).to eq(I18n.t!("notices.unauthorized"))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "gets a 403 for a JSON request they aren't allowed" do
|
|
76
|
+
get admin_users_path(format: :json)
|
|
77
|
+
|
|
78
|
+
expect(response).to have_http_status(:forbidden)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it "edits their own profile" do
|
|
82
|
+
patch admin_user_path(editor), params: { user: { name: "Grace H." } }
|
|
83
|
+
|
|
84
|
+
expect(editor.reload.name).to eq("Grace H.")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "cannot change their own role" do
|
|
88
|
+
patch admin_user_path(editor), params: { user: { role: "admin" } }
|
|
89
|
+
|
|
90
|
+
expect(editor.reload.role).to eq("editor")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "switches their admin theme" do
|
|
94
|
+
patch theme_admin_users_path(format: :turbo_stream), params: { theme: "dark" }
|
|
95
|
+
|
|
96
|
+
expect(editor.reload.theme).to eq("dark")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
= render Trek::GateComponent.new
|
|
2
2
|
= form_with url: admin_user_session_path do |f|
|
|
3
|
-
= f.group :email
|
|
3
|
+
= f.group :email, model_name: User.model_name
|
|
4
4
|
= f.email_field :email,
|
|
5
5
|
required: true,
|
|
6
6
|
autofocus: @email.nil?,
|
|
7
7
|
autocomplete: "username",
|
|
8
8
|
value: @email
|
|
9
9
|
|
|
10
|
-
= f.group :password
|
|
10
|
+
= f.group :password, model_name: User.model_name
|
|
11
11
|
= f.password_field :password,
|
|
12
12
|
required: true,
|
|
13
13
|
autofocus: @email.present?,
|
|
@@ -60,6 +60,9 @@ module Trek
|
|
|
60
60
|
content.sub!(/^#{key}:\n/, "") if YAML.safe_load(content)&.fetch(key, :absent).nil?
|
|
61
61
|
end
|
|
62
62
|
content.gsub!(/\n{3,}/, "\n\n")
|
|
63
|
+
# No blank line left at the end where a removed block was: `preapprove_npm_package` appends
|
|
64
|
+
# its own, and a second run would otherwise collapse the double blank line it makes.
|
|
65
|
+
content.sub!(/\n+\z/, "\n")
|
|
63
66
|
|
|
64
67
|
create_file ".yarnrc.yml", content, force: true
|
|
65
68
|
rescue Psych::Exception
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
namespace :trek do
|
|
2
|
+
namespace :attachments do
|
|
3
|
+
desc "Promote Shrine attachments left in the cache storage (DRY_RUN=1 only lists them)"
|
|
4
|
+
task promote_cached: :environment do
|
|
5
|
+
# Loads every model, so that all attachments are found
|
|
6
|
+
Rails.application.eager_load!
|
|
7
|
+
|
|
8
|
+
statuses = Hash.new(0)
|
|
9
|
+
|
|
10
|
+
Trek::CachedAttachments.new.promote(dry_run: ENV["DRY_RUN"].present?) do |entry|
|
|
11
|
+
statuses[entry.status] += 1
|
|
12
|
+
puts [entry.status, "#{entry.record.class.name}##{entry.record.id}.#{entry.name}", entry.file.id].join("\t")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
puts statuses.empty? ? "No attachment left in the cache storage." : statuses.map { "#{_2} #{_1}" }.join(", ")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module Trek
|
|
2
|
+
# Finds the records whose Shrine attachments still point to the temporary cache storage —
|
|
3
|
+
# files whose promotion was skipped — and promotes those whose cached file still exists.
|
|
4
|
+
class CachedAttachments
|
|
5
|
+
Entry = Struct.new(:record, :name, :file, :status, keyword_init: true)
|
|
6
|
+
|
|
7
|
+
def self.default_models
|
|
8
|
+
ActiveRecord::Base.descendants.select do |model|
|
|
9
|
+
!model.abstract_class? && model == model.base_class && model.table_exists?
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(models = self.class.default_models)
|
|
14
|
+
@models = models
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Yields an Entry per cached attachment, whose status is :promoted, :promotable (dry run),
|
|
18
|
+
# :missing (the cached file is gone) or :changed (the record changed during promotion).
|
|
19
|
+
def promote(dry_run: false)
|
|
20
|
+
return enum_for(:promote, dry_run:) unless block_given?
|
|
21
|
+
|
|
22
|
+
each_cached_attachment do |record, name|
|
|
23
|
+
attacher = record.public_send(:"#{name}_attacher")
|
|
24
|
+
|
|
25
|
+
yield Entry.new(record:, name:, file: attacher.file, status: promote_attacher(attacher, dry_run:))
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def promote_attacher(attacher, dry_run:)
|
|
32
|
+
return :missing unless attacher.file.exists?
|
|
33
|
+
return :promotable if dry_run
|
|
34
|
+
|
|
35
|
+
attacher.atomic_promote
|
|
36
|
+
:promoted
|
|
37
|
+
rescue Shrine::AttachmentChanged
|
|
38
|
+
:changed
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def each_cached_attachment(&)
|
|
42
|
+
@models.each do |model|
|
|
43
|
+
model.ancestors.grep(Shrine::Attachment).each do |attachment|
|
|
44
|
+
name = attachment.attachment_name
|
|
45
|
+
column = model.connection.quote_column_name(:"#{name}_data")
|
|
46
|
+
cache_key = attachment.options.fetch(:cache, :cache).to_s
|
|
47
|
+
|
|
48
|
+
model.where("#{column}::jsonb ->> 'storage' = ?", cache_key).find_each { |record| yield record, name }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trek
|
|
4
|
+
# Shrine plugin keeping, across a reload, the attachers whose file was saved with the record.
|
|
5
|
+
#
|
|
6
|
+
# Shrine's `activerecord` plugin resets every attacher when the record is reloaded, which is
|
|
7
|
+
# right for an explicit reload but loses a pending attachment when something reloads the record
|
|
8
|
+
# *during* the save: closure_tree does exactly that in its `after_save` callback when it
|
|
9
|
+
# reorders siblings (on create, or when the parent changes) on a numerically ordered tree.
|
|
10
|
+
# The attacher is then no longer `changed?` when Shrine's `after_commit` runs, so the file is
|
|
11
|
+
# never promoted from the temporary `cache` storage to `store` and breaks once the cache is
|
|
12
|
+
# cleaned up.
|
|
13
|
+
#
|
|
14
|
+
# An attachment that was *not* saved is still discarded, as on any reload.
|
|
15
|
+
module SavedAttachmentsReload
|
|
16
|
+
module ReloadGuard
|
|
17
|
+
def reload(...)
|
|
18
|
+
saved_attachers = instance_variables.filter_map do |ivar|
|
|
19
|
+
attacher = instance_variable_get(ivar)
|
|
20
|
+
[ivar, attacher.dup] if attacher.is_a?(::Shrine::Attacher) && attacher.changed?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
super.tap do
|
|
24
|
+
saved_attachers.each do |ivar, attacher|
|
|
25
|
+
# Only the attacher whose file is the one now in the column: anything else was
|
|
26
|
+
# assigned but not saved, and a reload drops it.
|
|
27
|
+
instance_variable_set(ivar, attacher) if attacher.file == instance_variable_get(ivar).file
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
module AttachmentMethods
|
|
34
|
+
def included(model)
|
|
35
|
+
super
|
|
36
|
+
|
|
37
|
+
# Prepended rather than included: Shrine's own `reload` is defined on this attachment
|
|
38
|
+
# module, which the model includes — a prepended module wraps it whatever the order the
|
|
39
|
+
# model includes its concerns in.
|
|
40
|
+
model.prepend(ReloadGuard)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
data/lib/trek/version.rb
CHANGED
data/lib/trek.rb
CHANGED
|
@@ -19,12 +19,15 @@ require "shrine"
|
|
|
19
19
|
require "shrine/plugins/blurhash"
|
|
20
20
|
require "slim-rails"
|
|
21
21
|
require "strip_attributes"
|
|
22
|
+
require "turbo-rails"
|
|
22
23
|
require "view_component"
|
|
23
24
|
require "view_component/form"
|
|
24
25
|
|
|
25
26
|
module Trek
|
|
27
|
+
require_relative "trek/cached_attachments"
|
|
26
28
|
require_relative "trek/engine"
|
|
27
29
|
require_relative "trek/i18n_keys_resolver"
|
|
30
|
+
require_relative "trek/saved_attachments_reload"
|
|
28
31
|
require_relative "trek/typography_formatter"
|
|
29
32
|
require_relative "trek/version"
|
|
30
33
|
|
data/package.json
CHANGED
data/postcss.config.js
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
|
+
// Radix Colors only ship light (`:root, .light-theme`) and dark (`.dark-theme`)
|
|
2
|
+
// variables, with no way to follow the system preference
|
|
3
|
+
// (https://github.com/radix-ui/colors/issues/26). Copy every rule targeting
|
|
4
|
+
// `.dark-theme` into a `prefers-color-scheme: dark` media query, with
|
|
5
|
+
// `.auto-theme` in place of `.dark-theme`. The media query is appended last so
|
|
6
|
+
// it wins over the light defaults, and each rule keeps its enclosing at-rules
|
|
7
|
+
// (e.g. the Display P3 `@supports`/`@media` blocks).
|
|
8
|
+
const DARK_THEME = /\.dark-theme(?![\w-])/;
|
|
9
|
+
const autoDarkTheme = {
|
|
10
|
+
postcssPlugin: "auto-dark-theme",
|
|
11
|
+
OnceExit(root, { AtRule }) {
|
|
12
|
+
const media = new AtRule({
|
|
13
|
+
name: "media",
|
|
14
|
+
params: "(prefers-color-scheme: dark)",
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
root.walkRules((rule) => {
|
|
18
|
+
const selectors = rule.selectors
|
|
19
|
+
.filter((selector) => DARK_THEME.test(selector))
|
|
20
|
+
.map((selector) => selector.replace(DARK_THEME, ".auto-theme"));
|
|
21
|
+
if (!selectors.length) return;
|
|
22
|
+
|
|
23
|
+
let node = rule.clone({ selectors });
|
|
24
|
+
let parent = rule.parent;
|
|
25
|
+
while (parent.type === "atrule") {
|
|
26
|
+
node = parent.clone({ nodes: [] }).append(node);
|
|
27
|
+
parent = parent.parent;
|
|
28
|
+
}
|
|
29
|
+
media.append(node);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (media.nodes?.length) root.append(media);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
1
36
|
module.exports = {
|
|
2
37
|
plugins: [
|
|
3
38
|
require("postcss-import"),
|
|
39
|
+
autoDarkTheme,
|
|
4
40
|
// call the `postcss-modules` plugin ourselves
|
|
5
41
|
require("postcss-modules")({
|
|
6
42
|
generateScopedName: (name, filename, _css) => {
|
data/trek.gemspec
CHANGED
|
@@ -39,6 +39,10 @@ Gem::Specification.new do |spec|
|
|
|
39
39
|
spec.add_dependency "http_accept_language", "~> 2.1"
|
|
40
40
|
spec.add_dependency "i18n-tasks", "~> 1.0"
|
|
41
41
|
spec.add_dependency "image_processing", "~> 1.8"
|
|
42
|
+
# json 3's `JSON.parse` takes keyword options only, but ActiveSupport (up to 8.1.3.1) passes them
|
|
43
|
+
# positionally, which breaks `db/schema.rb` for jsonb columns with a default. Lift once Rails
|
|
44
|
+
# supports json 3 (spec/dummy/generated_files_spec.rb checks the schema dump).
|
|
45
|
+
spec.add_dependency "json", "< 3"
|
|
42
46
|
spec.add_dependency "kaminari"
|
|
43
47
|
spec.add_dependency "listen"
|
|
44
48
|
spec.add_dependency "lookbook", ">= 2.3.13"
|
|
@@ -53,6 +57,7 @@ Gem::Specification.new do |spec|
|
|
|
53
57
|
spec.add_dependency "shrine-blurhash", "~> 0.2"
|
|
54
58
|
spec.add_dependency "slim-rails", "~> 4.0"
|
|
55
59
|
spec.add_dependency "strip_attributes", "~> 2.0"
|
|
60
|
+
spec.add_dependency "turbo-rails", ">= 1.0"
|
|
56
61
|
spec.add_dependency "view_component", "< 5.0"
|
|
57
62
|
spec.add_dependency "view_component-form", "~> 0.3"
|
|
58
63
|
spec.add_dependency "zeitwerk", "~> 2.6"
|