alchemy_cms 8.2.7 → 8.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.
Files changed (189) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -1
  3. data/app/assets/builds/alchemy/admin.css +1 -1
  4. data/app/assets/builds/alchemy/alchemy_admin.min.js +1 -1
  5. data/app/assets/builds/alchemy/alchemy_admin.min.js.map +1 -1
  6. data/app/assets/builds/alchemy/dark-theme.css +1 -1
  7. data/app/assets/builds/alchemy/light-theme.css +1 -1
  8. data/app/assets/builds/alchemy/preview.min.js +1 -1
  9. data/app/assets/builds/alchemy/theme.css +1 -1
  10. data/app/assets/builds/alchemy/welcome.css +1 -1
  11. data/app/assets/builds/tinymce/skins/content/alchemy/content.min.css +1 -1
  12. data/app/assets/builds/tinymce/skins/content/alchemy-dark/content.min.css +1 -1
  13. data/app/assets/builds/tinymce/skins/ui/alchemy/skin.min.css +1 -1
  14. data/app/assets/builds/tinymce/skins/ui/alchemy-dark/skin.min.css +1 -1
  15. data/app/assets/images/alchemy/admin/logo.svg +27 -0
  16. data/app/assets/images/alchemy/icons-sprite.svg +1 -1
  17. data/app/components/alchemy/admin/dashboard/widget.rb +40 -0
  18. data/app/components/alchemy/admin/dashboard/widgets/attachment_counts.rb +17 -0
  19. data/app/components/alchemy/admin/dashboard/widgets/element_usage.rb +37 -0
  20. data/app/components/alchemy/admin/dashboard/widgets/greeting.html.erb +13 -0
  21. data/app/components/alchemy/admin/dashboard/widgets/greeting.rb +21 -0
  22. data/app/components/alchemy/admin/dashboard/widgets/locked_pages.html.erb +54 -0
  23. data/app/components/alchemy/admin/dashboard/widgets/locked_pages.rb +20 -0
  24. data/app/components/alchemy/admin/dashboard/widgets/online_users.html.erb +22 -0
  25. data/app/components/alchemy/admin/dashboard/widgets/online_users.rb +19 -0
  26. data/app/components/alchemy/admin/dashboard/widgets/page_counts.rb +23 -0
  27. data/app/components/alchemy/admin/dashboard/widgets/page_usage.rb +46 -0
  28. data/app/components/alchemy/admin/dashboard/widgets/picture_counts.rb +17 -0
  29. data/app/components/alchemy/admin/dashboard/widgets/recent_pages.html.erb +41 -0
  30. data/app/components/alchemy/admin/dashboard/widgets/recent_pages.rb +16 -0
  31. data/app/components/alchemy/admin/dashboard/widgets/sites.html.erb +29 -0
  32. data/app/components/alchemy/admin/dashboard/widgets/sites.rb +15 -0
  33. data/app/components/alchemy/admin/dashboard/widgets/stat_widget.html.erb +23 -0
  34. data/app/components/alchemy/admin/dashboard/widgets/stat_widget.rb +19 -0
  35. data/app/components/alchemy/admin/dashboard/widgets/system_info.html.erb +32 -0
  36. data/app/components/alchemy/admin/dashboard/widgets/system_info.rb +37 -0
  37. data/app/components/alchemy/admin/dashboard/widgets/usage_widget.html.erb +42 -0
  38. data/app/components/alchemy/admin/dashboard/widgets/usage_widget.rb +66 -0
  39. data/app/components/alchemy/admin/dashboard/widgets/user_counts.rb +25 -0
  40. data/app/components/alchemy/admin/element_editor.html.erb +27 -20
  41. data/app/components/alchemy/admin/element_schedule_timestamps.rb +33 -0
  42. data/app/components/alchemy/admin/element_select.rb +4 -3
  43. data/app/components/alchemy/admin/page_node.html.erb +1 -20
  44. data/app/components/alchemy/admin/page_publication_fields.html.erb +30 -0
  45. data/app/components/alchemy/admin/page_publication_fields.rb +18 -0
  46. data/app/components/alchemy/admin/page_status_indicators.html.erb +29 -0
  47. data/app/components/alchemy/admin/page_status_indicators.rb +9 -0
  48. data/app/components/alchemy/admin/publish_element_button.html.erb +12 -4
  49. data/app/components/alchemy/ingredients/headline_editor.rb +1 -1
  50. data/app/controllers/alchemy/admin/dashboard/widgets_controller.rb +21 -0
  51. data/app/controllers/alchemy/admin/dashboard_controller.rb +3 -12
  52. data/app/controllers/alchemy/pages_controller.rb +5 -4
  53. data/app/helpers/alchemy/elements_block_helper.rb +1 -0
  54. data/app/javascript/alchemy_admin/components/auto_submit.js +15 -9
  55. data/app/javascript/alchemy_admin/components/char_counter.js +17 -7
  56. data/app/javascript/alchemy_admin/components/clipboard_button.js +2 -6
  57. data/app/javascript/alchemy_admin/components/color_select.js +13 -4
  58. data/app/javascript/alchemy_admin/components/datepicker.js +11 -14
  59. data/app/javascript/alchemy_admin/components/dialog_link.js +5 -2
  60. data/app/javascript/alchemy_admin/components/element_editor/delete_element_button.js +6 -3
  61. data/app/javascript/alchemy_admin/components/element_editor.js +45 -28
  62. data/app/javascript/alchemy_admin/components/element_select.js +7 -4
  63. data/app/javascript/alchemy_admin/components/elements_window.js +38 -31
  64. data/app/javascript/alchemy_admin/components/elements_window_handle.js +7 -3
  65. data/app/javascript/alchemy_admin/components/file_editor.js +5 -2
  66. data/app/javascript/alchemy_admin/components/ingredient_group.js +6 -4
  67. data/app/javascript/alchemy_admin/components/link_buttons/link_button.js +1 -2
  68. data/app/javascript/alchemy_admin/components/link_buttons/unlink_button.js +1 -2
  69. data/app/javascript/alchemy_admin/components/link_buttons.js +6 -2
  70. data/app/javascript/alchemy_admin/components/list_filter.js +44 -29
  71. data/app/javascript/alchemy_admin/components/message.js +22 -15
  72. data/app/javascript/alchemy_admin/components/overlay.js +5 -7
  73. data/app/javascript/alchemy_admin/components/page_publication_fields.js +38 -25
  74. data/app/javascript/alchemy_admin/components/picture_description_select.js +5 -2
  75. data/app/javascript/alchemy_admin/components/picture_editor.js +5 -10
  76. data/app/javascript/alchemy_admin/components/picture_thumbnail.js +4 -5
  77. data/app/javascript/alchemy_admin/components/preview_window.js +5 -10
  78. data/app/javascript/alchemy_admin/components/publish_page_button.js +2 -5
  79. data/app/javascript/alchemy_admin/components/remote_select.js +53 -23
  80. data/app/javascript/alchemy_admin/components/select.js +169 -26
  81. data/app/javascript/alchemy_admin/components/sortable_elements.js +1 -1
  82. data/app/javascript/alchemy_admin/components/spinner.js +11 -11
  83. data/app/javascript/alchemy_admin/components/tags_autocomplete.js +9 -1
  84. data/app/javascript/alchemy_admin/components/tinymce.js +16 -22
  85. data/app/javascript/alchemy_admin/components/uploader/file_upload.js +48 -45
  86. data/app/javascript/alchemy_admin/components/uploader/progress.js +70 -84
  87. data/app/javascript/alchemy_admin/components/uploader.js +71 -46
  88. data/app/javascript/alchemy_admin/dialog.js +3 -0
  89. data/app/javascript/alchemy_admin/hotkeys.js +0 -18
  90. data/app/javascript/alchemy_admin/image_cropper.js +7 -9
  91. data/app/javascript/alchemy_admin/initializer.js +21 -0
  92. data/app/javascript/alchemy_admin/utils/dispatch_page_dirty_event.js +7 -0
  93. data/app/javascript/tinymce/plugins/alchemy_link/index.js +9 -0
  94. data/app/jobs/alchemy/base_job.rb +2 -2
  95. data/app/jobs/alchemy/invalidate_elements_cache_job.rb +33 -0
  96. data/app/models/alchemy/page/page_naming.rb +28 -5
  97. data/app/models/alchemy/page/page_natures.rb +7 -2
  98. data/app/models/alchemy/page/page_scopes.rb +2 -2
  99. data/app/models/alchemy/page/url_path.rb +7 -2
  100. data/app/models/alchemy/page.rb +2 -2
  101. data/app/models/alchemy/page_definition.rb +1 -0
  102. data/app/models/alchemy/permissions.rb +1 -1
  103. data/app/models/concerns/alchemy/relatable_resource.rb +8 -0
  104. data/app/services/alchemy/page_finder.rb +88 -0
  105. data/app/stylesheets/alchemy/_custom-properties.scss +6 -4
  106. data/app/stylesheets/alchemy/_mixins.scss +1 -7
  107. data/app/stylesheets/alchemy/_themes.scss +13 -1
  108. data/app/stylesheets/alchemy/admin/_tom-select.scss +240 -0
  109. data/app/stylesheets/alchemy/admin/archive.scss +0 -1
  110. data/app/stylesheets/alchemy/admin/base.scss +0 -19
  111. data/app/stylesheets/alchemy/admin/dashboard.scss +395 -28
  112. data/app/stylesheets/alchemy/admin/elements.scss +14 -17
  113. data/app/stylesheets/alchemy/admin/form_fields.scss +3 -3
  114. data/app/stylesheets/alchemy/admin/forms.scss +107 -93
  115. data/app/stylesheets/alchemy/admin/icons.scss +28 -0
  116. data/app/stylesheets/alchemy/admin/image_library.scss +20 -10
  117. data/app/stylesheets/alchemy/admin/navigation.scss +4 -1
  118. data/app/stylesheets/alchemy/admin/popover.scss +3 -5
  119. data/app/stylesheets/alchemy/admin/resource_info.scss +11 -17
  120. data/app/stylesheets/alchemy/admin/shoelace.scss +8 -0
  121. data/app/stylesheets/alchemy/admin/sitemap.scss +5 -0
  122. data/app/stylesheets/alchemy/admin/tables.scss +32 -3
  123. data/app/stylesheets/alchemy/admin/toolbar.scss +0 -1
  124. data/app/stylesheets/alchemy/admin.scss +1 -0
  125. data/app/stylesheets/tinymce/skins/ui/alchemy/skin.scss +0 -4
  126. data/app/stylesheets/tinymce/skins/ui/alchemy-dark/skin.scss +0 -4
  127. data/app/types/alchemy/wildcard_url_type.rb +48 -0
  128. data/app/views/alchemy/_menubar.html.erb +1 -5
  129. data/app/views/alchemy/admin/attachments/edit.html.erb +6 -3
  130. data/app/views/alchemy/admin/dashboard/_dashboard.html.erb +3 -2
  131. data/app/views/alchemy/admin/dashboard/_footer.html.erb +22 -0
  132. data/app/views/alchemy/admin/dashboard/_stats.html.erb +7 -0
  133. data/app/views/alchemy/admin/dashboard/_top.html.erb +4 -12
  134. data/app/views/alchemy/admin/dashboard/_widgets.html.erb +7 -0
  135. data/app/views/alchemy/admin/dashboard/index.html.erb +0 -17
  136. data/app/views/alchemy/admin/dashboard/info.html.erb +1 -62
  137. data/app/views/alchemy/admin/dashboard/widgets/show.html.erb +3 -0
  138. data/app/views/alchemy/admin/elements/_form.html.erb +2 -1
  139. data/app/views/alchemy/admin/elements/_schedule.html.erb +2 -15
  140. data/app/views/alchemy/admin/elements/_schedule_fields.html.erb +2 -0
  141. data/app/views/alchemy/admin/layoutpages/edit.html.erb +6 -3
  142. data/app/views/alchemy/admin/nodes/_page_nodes.html.erb +10 -8
  143. data/app/views/alchemy/admin/pages/_form.html.erb +25 -19
  144. data/app/views/alchemy/admin/pages/_publication_fields.html.erb +2 -32
  145. data/app/views/alchemy/admin/pages/_table.html.erb +1 -18
  146. data/app/views/alchemy/admin/pages/configure.html.erb +2 -2
  147. data/app/views/alchemy/admin/pages/info.html.erb +6 -0
  148. data/app/views/alchemy/admin/resources/_form.html.erb +7 -4
  149. data/app/views/alchemy/admin/resources/edit.html.erb +3 -1
  150. data/app/views/alchemy/admin/resources/new.html.erb +3 -1
  151. data/app/views/alchemy/admin/styleguide/index.html.erb +52 -30
  152. data/app/views/alchemy/admin/translations/_en.js +4 -0
  153. data/app/views/layouts/alchemy/admin.html.erb +3 -3
  154. data/config/importmap.rb +2 -0
  155. data/config/locales/alchemy.en.yml +15 -0
  156. data/config/routes.rb +1 -0
  157. data/lib/alchemy/configuration/class_option.rb +46 -3
  158. data/lib/alchemy/configuration/collection_option.rb +4 -0
  159. data/lib/alchemy/configurations/dashboard.rb +79 -0
  160. data/lib/alchemy/configurations/main.rb +15 -0
  161. data/lib/alchemy/engine.rb +9 -3
  162. data/lib/alchemy/sprockets/skip_builds_compression.rb +33 -0
  163. data/lib/alchemy/test_support/capybara_helpers.rb +17 -0
  164. data/lib/alchemy/test_support/relatable_resource_examples.rb +20 -0
  165. data/lib/alchemy/test_support/rspec_matchers.rb +8 -0
  166. data/lib/alchemy/test_support/shared_publishable_examples.rb +38 -31
  167. data/lib/alchemy/tinymce.rb +1 -1
  168. data/lib/alchemy/version.rb +17 -3
  169. data/vendor/javascript/cropperjs.min.js +1 -1
  170. data/vendor/javascript/flatpickr.min.js +1 -1
  171. data/vendor/javascript/floating-ui.min.js +1 -0
  172. data/vendor/javascript/keymaster.min.js +1 -1
  173. data/vendor/javascript/rails-ujs.min.js +1 -1
  174. data/vendor/javascript/shoelace.min.js +93 -93
  175. data/vendor/javascript/sortable.min.js +1 -1
  176. data/vendor/javascript/tinymce.min.js +5 -1
  177. data/vendor/javascript/tom-select.min.js +1 -0
  178. metadata +57 -18
  179. data/app/javascript/alchemy_admin/components/alchemy_html_element.js +0 -129
  180. data/app/views/alchemy/admin/dashboard/_left_column.html.erb +0 -4
  181. data/app/views/alchemy/admin/dashboard/_right_column.html.erb +0 -9
  182. data/app/views/alchemy/admin/dashboard/widgets/_locked_pages.html.erb +0 -52
  183. data/app/views/alchemy/admin/dashboard/widgets/_recent_pages.html.erb +0 -34
  184. data/app/views/alchemy/admin/dashboard/widgets/_sites.html.erb +0 -25
  185. data/app/views/alchemy/admin/dashboard/widgets/_users.html.erb +0 -21
  186. data/app/views/alchemy/admin/languages/edit.html.erb +0 -1
  187. data/app/views/alchemy/admin/languages/new.html.erb +0 -1
  188. data/app/views/alchemy/admin/sites/edit.html.erb +0 -1
  189. data/app/views/alchemy/admin/sites/new.html.erb +0 -1
@@ -1,4 +1,5 @@
1
1
  Alchemy.translations = {
2
+ Add: "Add",
2
3
  allowed_chars: "of %{count} chars",
3
4
  cancel: "Cancel",
4
5
  cancelled: "Cancelled",
@@ -23,6 +24,9 @@ Alchemy.translations = {
23
24
  "Uploaded bytes exceed file size": "Uploaded bytes exceed file size",
24
25
  "Abort upload": "Abort upload",
25
26
  "Cancel all uploads": "Cancel all uploads",
27
+ "Clear selection": "Clear selection",
28
+ Remove: "Remove",
29
+ "No results found": "No results found",
26
30
  None: "None",
27
31
  "No anchors found": "No anchors found",
28
32
  "Select a page first": "Select a page first",
@@ -45,13 +45,13 @@
45
45
  </noscript>
46
46
  <alchemy-overlay text="<%= Alchemy.t(:please_wait) %>"></alchemy-overlay>
47
47
  <%= render "alchemy/admin/left_menu" %>
48
- <% if current_alchemy_user %>
49
- <%= render "alchemy/admin/top_menu", locked_pages: @locked_pages %>
50
- <% end %>
51
48
  <%= render 'alchemy/admin/partials/flash_notices' %>
52
49
  <div id="main_content">
53
50
  <%= yield %>
54
51
  </div>
52
+ <% if current_alchemy_user %>
53
+ <%= render "alchemy/admin/top_menu", locked_pages: @locked_pages %>
54
+ <% end %>
55
55
  <%= render 'alchemy/admin/uploader/setup' %>
56
56
  <%= yield(:javascripts) %>
57
57
  <% end %>
data/config/importmap.rb CHANGED
@@ -7,10 +7,12 @@ pin "jquery", to: "jquery.min.js", preload: true
7
7
  pin "keymaster", to: "keymaster.min.js", preload: true
8
8
  pin "select2", to: "select2.min.js", preload: true
9
9
  pin "sortablejs", to: "sortable.min.js", preload: true # @1.15.1
10
+ pin "@floating-ui/dom", to: "floating-ui.min.js", preload: true
10
11
  pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
11
12
  pin "shoelace", to: "shoelace.min.js", preload: true
12
13
  pin "@rails/ujs", to: "rails-ujs.min.js", preload: true # @7.1.2
13
14
  pin "tinymce", to: "tinymce.min.js", preload: true
15
+ pin "tom-select", to: "tom-select.min.js", preload: true
14
16
 
15
17
  pin "alchemy_admin", to: "alchemy/alchemy_admin.min.js", preload: true
16
18
  pin "alchemy_admin/components/remote_select", to: "alchemy/alchemy_admin.min.js"
@@ -230,6 +230,12 @@ en:
230
230
  label: "Sorting"
231
231
  by_latest: "Latest"
232
232
  alphabetical: "A-Z"
233
+ dashboard:
234
+ widgets:
235
+ page_counts:
236
+ published: "published"
237
+ user_counts:
238
+ online: "online"
233
239
  elements:
234
240
  toolbar:
235
241
  hide: Hide
@@ -571,6 +577,10 @@ en:
571
577
  restricted:
572
578
  "true": "Page is only accessible by members."
573
579
  "false": "Page is accessible by all visitors."
580
+ scheduled:
581
+ public_on: "Page is visible from %{public_on}"
582
+ public_until: "Page is visible until %{public_until}"
583
+ "false": ""
574
584
  page_status_titles:
575
585
  public:
576
586
  "true": "online"
@@ -581,6 +591,9 @@ en:
581
591
  restricted:
582
592
  "true": "restricted"
583
593
  "false": "accessible"
594
+ scheduled:
595
+ "true": "scheduled"
596
+ "false": ""
584
597
  page_status: "Status"
585
598
  page_title: "Title"
586
599
  page_type: "Type"
@@ -834,6 +847,8 @@ en:
834
847
  base:
835
848
  restrict_dependent_destroy:
836
849
  has_many: "There are still %{record} attached to this page. Please remove them first."
850
+ page_layout:
851
+ conflicting_wildcard_param_key: 'has a conflicting wildcard param "%{param}" already used by the "%{conflicting_layout}" page layout'
837
852
  descendants:
838
853
  still_attached_to_nodes: "The following descendant pages are still attached to menu nodes: %{page_names}. Please remove them first."
839
854
  alchemy/element:
data/config/routes.rb CHANGED
@@ -11,6 +11,7 @@ Alchemy::Engine.routes.draw do
11
11
  get "/", to: redirect("#{Alchemy.admin_path}/dashboard"), as: :admin
12
12
  get "/dashboard", to: "admin/dashboard#index", as: :admin_dashboard
13
13
  get "/dashboard/info", to: "admin/dashboard#info", as: :dashboard_info
14
+ get "/dashboard/widgets/:id", to: "admin/dashboard/widgets#show", as: :admin_dashboard_widget
14
15
  get "/help", to: "admin/dashboard#help", as: :help
15
16
  get "/update_check" => "admin/update_checks#show", :as => :update_check
16
17
  get "/leave", to: "admin/base#leave", as: :leave_admin
@@ -5,11 +5,54 @@ require "alchemy/configuration/base_option"
5
5
  module Alchemy
6
6
  class Configuration
7
7
  class ClassOption < BaseOption
8
- def self.value_class
9
- String
8
+ def allowed_classes
9
+ [String, Array]
10
10
  end
11
11
 
12
- def value = @value&.constantize
12
+ def validate(value)
13
+ super
14
+
15
+ if value.is_a?(Array)
16
+ validate_array!(value)
17
+ end
18
+ end
19
+
20
+ def value
21
+ @_cached_value ||= case @value
22
+ when Array
23
+ @value[0] = @value[0]&.constantize
24
+ @value
25
+ when String
26
+ @value&.constantize
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def validate_array!(value)
33
+ @array = value
34
+ has_length_two! && first_value_is_string! && second_value_is_hash!
35
+ end
36
+
37
+ def has_length_two!
38
+ return true if @array.length == 2
39
+
40
+ raise(ConfigurationError.new(name, @array, [
41
+ Class.new(Array) { def self.name = "an Array of length two" }
42
+ ]))
43
+ end
44
+
45
+ def first_value_is_string!
46
+ return true if @array[0].is_a?(String)
47
+
48
+ raise(ConfigurationError.new(name, @array[0], [String]))
49
+ end
50
+
51
+ def second_value_is_hash!
52
+ return true if @array[1].is_a?(Hash)
53
+
54
+ raise(ConfigurationError.new(name, @array[1], [Hash]))
55
+ end
13
56
  end
14
57
  end
15
58
  end
@@ -43,6 +43,10 @@ module Alchemy
43
43
  @value.delete to_item(value)
44
44
  end
45
45
 
46
+ def insert(index, value)
47
+ @value.insert(index, to_item(value))
48
+ end
49
+
46
50
  delegate :join, :[], to: :to_a
47
51
 
48
52
  delegate :clear, :empty?, to: :@value
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alchemy
4
+ module Configurations
5
+ class Dashboard < Alchemy::Configuration
6
+ option :stats, :collection, item_type: :class, default: [
7
+ [
8
+ "Alchemy::Admin::Dashboard::Widget", {
9
+ id: "PageCounts",
10
+ style: "stat"
11
+ }
12
+ ],
13
+ [
14
+ "Alchemy::Admin::Dashboard::Widget", {
15
+ id: "UserCounts",
16
+ style: "stat"
17
+ }
18
+ ],
19
+ [
20
+ "Alchemy::Admin::Dashboard::Widget", {
21
+ id: "PictureCounts",
22
+ style: "stat"
23
+ }
24
+ ],
25
+ [
26
+ "Alchemy::Admin::Dashboard::Widget", {
27
+ id: "AttachmentCounts",
28
+ style: "stat"
29
+ }
30
+ ]
31
+ ]
32
+
33
+ option :widgets, :collection, item_type: :class, default: [
34
+ [
35
+ "Alchemy::Admin::Dashboard::Widget", {
36
+ id: "LockedPages",
37
+ style: "wide"
38
+ }
39
+ ],
40
+ [
41
+ "Alchemy::Admin::Dashboard::Widget", {
42
+ id: "RecentPages",
43
+ style: "wide"
44
+ }
45
+ ],
46
+ [
47
+ "Alchemy::Admin::Dashboard::Widget", {
48
+ id: "ElementUsage",
49
+ style: "usage",
50
+ loading: "lazy"
51
+ }
52
+ ],
53
+ [
54
+ "Alchemy::Admin::Dashboard::Widget", {
55
+ id: "PageUsage",
56
+ style: "usage",
57
+ loading: "lazy"
58
+ }
59
+ ],
60
+ [
61
+ "Alchemy::Admin::Dashboard::Widget", {
62
+ id: "Sites",
63
+ loading: "lazy",
64
+ condition: -> { helpers.multi_site? }
65
+ }
66
+ ],
67
+ [
68
+ "Alchemy::Admin::Dashboard::Widget", {
69
+ id: "OnlineUsers",
70
+ loading: "lazy",
71
+ condition: -> {
72
+ Alchemy.config.user_class.respond_to?(:logged_in)
73
+ }
74
+ }
75
+ ]
76
+ ]
77
+ end
78
+ end
79
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "alchemy/configuration"
4
+ require "alchemy/configurations/dashboard"
4
5
  require "alchemy/configurations/default_language"
5
6
  require "alchemy/configurations/default_site"
6
7
  require "alchemy/configurations/importmap"
@@ -434,6 +435,16 @@ module Alchemy
434
435
  # The path to the page showing the user they're unauthorized
435
436
  option :unauthorized_path, :string, default: "/"
436
437
 
438
+ # === Admin Users Path
439
+ #
440
+ # The path to the admin users list.
441
+ #
442
+ # == Example
443
+ #
444
+ # "/admin/users"
445
+ #
446
+ option :admin_users_path, :string
447
+
437
448
  # === Edit User Path
438
449
  #
439
450
  # The path to the edit user form.
@@ -453,6 +464,10 @@ module Alchemy
453
464
  # == Example
454
465
  # Alchemy.config.abilities.add("MyCustom::Ability")
455
466
  option :abilities, :collection, item_type: :class
467
+
468
+ # === Dashboard configuration
469
+ #
470
+ configuration :dashboard, Dashboard
456
471
  end
457
472
  end
458
473
  end
@@ -17,7 +17,8 @@ module Alchemy
17
17
  end
18
18
 
19
19
  initializer "alchemy.assets" do |app|
20
- if defined?(Sprockets)
20
+ if defined?(::Sprockets)
21
+ require_relative "sprockets/skip_builds_compression"
21
22
  require_relative "../non_stupid_digest_assets"
22
23
  NonStupidDigestAssets.whitelist += [/^tinymce\//]
23
24
  app.config.assets.precompile << "alchemy_manifest.js"
@@ -25,7 +26,7 @@ module Alchemy
25
26
  end
26
27
 
27
28
  initializer "alchemy.admin_stylesheets" do |app|
28
- if defined?(Sprockets)
29
+ if defined?(::Sprockets)
29
30
  Alchemy.config.admin_stylesheets.each do |stylesheet|
30
31
  app.config.assets.precompile << stylesheet
31
32
  end
@@ -33,7 +34,7 @@ module Alchemy
33
34
  end
34
35
 
35
36
  initializer "alchemy.propshaft" do |app|
36
- if defined?(Propshaft)
37
+ if defined?(::Propshaft)
37
38
  if app.config.assets.server
38
39
  # Monkey-patch Propshaft::Asset to enable access
39
40
  # of TinyMCE assets without a hash digest.
@@ -67,6 +68,11 @@ module Alchemy
67
68
  end
68
69
 
69
70
  if app.config.importmap.sweep_cache
71
+ # The importmap pins resolve to the bundled files in app/assets/builds,
72
+ # so the cache must be swept when those are rebuilt. Without this the
73
+ # cached importmap keeps emitting the previous digest after a rebuild
74
+ # and the asset 404s until the server is restarted.
75
+ watch_paths << Alchemy::Engine.root.join("app/assets/builds")
70
76
  Alchemy.importmap.cache_sweeper(watches: watch_paths)
71
77
  ActiveSupport.on_load(:action_controller_base) do
72
78
  before_action { Alchemy.importmap.cache_sweeper.execute_if_updated }
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "sprockets/sass_compressor"
5
+ rescue LoadError
6
+ # Sprockets::SassCompressor is only defined if sassc-rails is present,
7
+ # which is not the case in all environments (for example, when using Propshaft).
8
+ # In that case, we can skip prepending our patch module.
9
+ end
10
+
11
+ module Alchemy
12
+ module Sprockets
13
+ # Alchemy ships pre-built, already-minified admin CSS in +app/assets/builds+
14
+ # that uses modern CSS syntax (relative colors, +oklch()+ and friends). The
15
+ # legacy SassC/libSass +css_compressor+ — the Sprockets default whenever
16
+ # +sassc-rails+ is present (for example through Solidus) — re-parses every
17
+ # +text/css+ asset as SCSS and raises on that syntax. These files are already
18
+ # minified, so leave them untouched; all other stylesheets still get
19
+ # compressed as before.
20
+ module SkipBuildsCompression
21
+ def call(input)
22
+ builds_path = Alchemy::Engine.root.join("app/assets/builds").to_s
23
+ if input[:filename].to_s.start_with?(builds_path)
24
+ {data: input[:data]}
25
+ else
26
+ super
27
+ end
28
+ end
29
+
30
+ ::Sprockets::SassCompressor.singleton_class.prepend(self)
31
+ end
32
+ end
33
+ end
@@ -25,6 +25,23 @@ module Alchemy
25
25
  end
26
26
  end
27
27
 
28
+ # Tom Select capybara helper
29
+ def tom_select(value, options)
30
+ label = find_label_by_text(options[:from])
31
+
32
+ within label.find(:xpath, "..") do
33
+ find(".ts-control").click
34
+ end
35
+
36
+ # The dropdown is appended to the body, so search the whole page for it.
37
+ within_entire_page do
38
+ find(
39
+ ".ts-dropdown .option",
40
+ text: /#{Regexp.escape(value)}/i, match: :prefer_exact
41
+ ).click
42
+ end
43
+ end
44
+
28
45
  def select2_search(value, options)
29
46
  if options[:from]
30
47
  label = find_label_by_text(options[:from])
@@ -55,4 +55,24 @@ RSpec.shared_examples_for "a relatable resource" do |args|
55
55
  it { is_expected.to be(true) }
56
56
  end
57
57
  end
58
+
59
+ describe "after_touch" do
60
+ let(:related_object) { create(:"alchemy_#{args[:resource_name]}") }
61
+
62
+ context "when related ingredients exist" do
63
+ let!(:ingredient) { create(:"alchemy_ingredient_#{args[:ingredient_type]}", related_object:) }
64
+
65
+ it "enqueues InvalidateElementsCacheJob" do
66
+ expect {
67
+ related_object.touch
68
+ }.to have_enqueued_job(Alchemy::InvalidateElementsCacheJob).with(described_class.name, related_object.id)
69
+ end
70
+ end
71
+
72
+ context "when no related ingredients exist" do
73
+ it "does not enqueue InvalidateElementsCacheJob" do
74
+ expect { related_object.touch }.to_not have_enqueued_job(Alchemy::InvalidateElementsCacheJob)
75
+ end
76
+ end
77
+ end
58
78
  end
@@ -12,3 +12,11 @@ RSpec::Matchers.define :include_language_information_for do |expected|
12
12
  actual[:alchemy_language_id] == expected.id
13
13
  end
14
14
  end
15
+
16
+ # This matcher checks for the presence of an alchemy-select component with a given label.
17
+ RSpec::Matchers.define :have_alchemy_select do |expected|
18
+ match do |session|
19
+ label = session.find(:css, "label", exact_text: expected)
20
+ label.has_sibling?(%(select[is="alchemy-select"]), visible: :all)
21
+ end
22
+ end
@@ -3,7 +3,7 @@
3
3
  RSpec.shared_examples_for "being publishable" do |factory_name|
4
4
  describe "validations" do
5
5
  context "when public_until is older than public_on" do
6
- let(:record) do
6
+ let(:publishable) do
7
7
  build(
8
8
  factory_name,
9
9
  public_on: Time.current,
@@ -12,8 +12,8 @@ RSpec.shared_examples_for "being publishable" do |factory_name|
12
12
  end
13
13
 
14
14
  it "is not valid" do
15
- expect(record).not_to be_valid
16
- expect(record.errors[:public_until]).to include(
15
+ expect(publishable).not_to be_valid
16
+ expect(publishable.errors[:public_until]).to include(
17
17
  I18n.t("errors.attributes.public_until.must_be_after_public_on")
18
18
  )
19
19
  end
@@ -84,9 +84,9 @@ RSpec.shared_examples_for "being publishable" do |factory_name|
84
84
  end
85
85
 
86
86
  describe "#scheduled?" do
87
- subject { record.scheduled? }
87
+ subject { publishable.scheduled? }
88
88
 
89
- let(:record) { build(factory_name, public_on:, public_until:) }
89
+ let(:publishable) { build(factory_name, public_on:, public_until:) }
90
90
 
91
91
  context "when public_on is nil" do
92
92
  let(:public_on) { nil }
@@ -143,17 +143,17 @@ RSpec.shared_examples_for "being publishable" do |factory_name|
143
143
  end
144
144
 
145
145
  describe "#public?" do
146
- subject { page_version.public? }
146
+ subject { publishable.public? }
147
147
 
148
148
  context "when public_on is not set" do
149
- let(:page_version) { build(factory_name, public_on: nil) }
149
+ let(:publishable) { build(factory_name, public_on: nil) }
150
150
 
151
151
  it { is_expected.to be(false) }
152
152
  end
153
153
 
154
154
  context "when public_on is set to past date" do
155
155
  context "and public_until is set to nil" do
156
- let(:page_version) do
156
+ let(:publishable) do
157
157
  build(factory_name,
158
158
  public_on: Time.current - 2.days,
159
159
  public_until: nil)
@@ -163,7 +163,7 @@ RSpec.shared_examples_for "being publishable" do |factory_name|
163
163
  end
164
164
 
165
165
  context "and public_until is set to future date" do
166
- let(:page_version) do
166
+ let(:publishable) do
167
167
  build(factory_name,
168
168
  public_on: Time.current - 2.days,
169
169
  public_until: Time.current + 2.days)
@@ -173,7 +173,7 @@ RSpec.shared_examples_for "being publishable" do |factory_name|
173
173
  end
174
174
 
175
175
  context "and public_until is set to past date" do
176
- let(:page_version) do
176
+ let(:publishable) do
177
177
  build(factory_name,
178
178
  public_on: Time.current - 2.days,
179
179
  public_until: Time.current - 1.days)
@@ -184,13 +184,13 @@ RSpec.shared_examples_for "being publishable" do |factory_name|
184
184
  end
185
185
 
186
186
  context "when public_on is set to future date" do
187
- let(:page_version) { build(factory_name, public_on: Time.current + 2.days) }
187
+ let(:publishable) { build(factory_name, public_on: Time.current + 2.days) }
188
188
 
189
189
  it { is_expected.to be(false) }
190
190
  end
191
191
 
192
192
  context "when Current.preview_time is set" do
193
- let(:page_version) do
193
+ let(:publishable) do
194
194
  build(factory_name,
195
195
  public_on: Time.zone.parse("2025-06-01 00:00:00"),
196
196
  public_until: Time.zone.parse("2025-06-30 23:59:59"))
@@ -198,54 +198,61 @@ RSpec.shared_examples_for "being publishable" do |factory_name|
198
198
 
199
199
  it "uses preview_time to determine visibility" do
200
200
  Alchemy::Current.preview_time = Time.zone.parse("2025-06-15 12:00:00")
201
- expect(page_version.public?).to be(true)
201
+ expect(publishable.public?).to be(true)
202
202
  end
203
203
 
204
204
  it "returns false when preview_time is outside the public range" do
205
205
  Alchemy::Current.preview_time = Time.zone.parse("2025-07-15 12:00:00")
206
- expect(page_version.public?).to be(false)
206
+ expect(publishable.public?).to be(false)
207
207
  end
208
208
 
209
209
  it "returns false when preview_time is before public_on" do
210
210
  Alchemy::Current.preview_time = Time.zone.parse("2025-05-15 12:00:00")
211
- expect(page_version.public?).to be(false)
211
+ expect(publishable.public?).to be(false)
212
212
  end
213
213
  end
214
214
  end
215
215
 
216
216
  describe "#publishable?" do
217
+ let(:publishable) { build(factory_name, public_on:, public_until:) }
218
+ let(:public_on) { nil }
219
+ let(:public_until) { nil }
220
+
221
+ subject { publishable.publishable? }
222
+
217
223
  context "when public_on is nil" do
218
- let(:page_version) { build(factory_name, public_on: nil) }
224
+ let(:public_on) { nil }
219
225
 
220
- it { expect(page_version.publishable?).to be(false) }
226
+ it { is_expected.to be(false) }
221
227
  end
222
228
 
223
229
  context "when public_on is set and public_until is nil" do
224
- let(:page_version) { build(factory_name, public_on: Time.current) }
230
+ let(:public_on) { Time.current }
231
+
232
+ it { is_expected.to be(true) }
233
+ end
225
234
 
226
- it { expect(page_version.publishable?).to be(true) }
235
+ context "when public_on is set and public_until is in the future" do
236
+ let(:public_on) { Time.current }
237
+ let(:public_until) { Time.current + 1.day }
238
+
239
+ it { is_expected.to be(true) }
227
240
  end
228
241
 
229
242
  context "when public_on is set and public_until is in the past" do
230
- let(:page_version) do
231
- build(factory_name,
232
- public_on: Time.current - 2.days,
233
- public_until: Time.current - 1.day)
234
- end
243
+ let(:public_on) { Time.current - 2.days }
244
+ let(:public_until) { Time.current - 1.day }
235
245
 
236
- it { expect(page_version.publishable?).to be(false) }
246
+ it { is_expected.to be(false) }
237
247
  end
238
248
 
239
249
  context "when Current.preview_time is set to a future time" do
240
- let(:page_version) do
241
- build(factory_name,
242
- public_on: Time.current - 1.day,
243
- public_until: Time.current + 1.day)
244
- end
250
+ let(:public_on) { Time.current - 1.day }
251
+ let(:public_until) { Time.current + 1.day }
245
252
 
246
253
  it "uses Time.current instead of the preview_time" do
247
254
  Alchemy::Current.preview_time = Time.current + 1.week
248
- expect(page_version.publishable?).to be(true)
255
+ is_expected.to be(true)
249
256
  end
250
257
  end
251
258
  end
@@ -5,7 +5,6 @@ module Alchemy
5
5
  mattr_accessor :languages, :plugins
6
6
 
7
7
  DEFAULT_PLUGINS = %w[
8
- alchemy_link
9
8
  anchor
10
9
  charmap
11
10
  code
@@ -13,6 +12,7 @@ module Alchemy
13
12
  fullscreen
14
13
  link
15
14
  lists
15
+ alchemy_link
16
16
  ]
17
17
 
18
18
  @@plugins = DEFAULT_PLUGINS
@@ -1,13 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Alchemy
4
- VERSION = "8.2.7"
4
+ extend self
5
5
 
6
- def self.version
6
+ VERSION = "8.3.0"
7
+
8
+ def version
7
9
  VERSION
8
10
  end
9
11
 
10
- def self.gem_version
12
+ def gem_version
11
13
  Gem::Version.new(VERSION)
12
14
  end
15
+
16
+ def git_revision_info
17
+ source = Bundler.locked_gems.sources.find { _1.name == "alchemy_cms" }
18
+ return unless source.respond_to?(:revision)
19
+
20
+ {
21
+ revision: source.revision,
22
+ branch: source.branch
23
+ }
24
+ rescue
25
+ nil
26
+ end
13
27
  end