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
@@ -17,6 +17,9 @@ export default class ImageCropper {
17
17
  settings.crop_size_form_field_id
18
18
  )
19
19
  this.elementId = settings.element_id
20
+ this.elementEditor = document.querySelector(
21
+ `[data-element-id='${this.elementId}']`
22
+ )
20
23
  this.dialog = Alchemy.currentDialog()
21
24
  if (this.dialog) {
22
25
  this.dialog.options.closed = () => this.destroy()
@@ -32,11 +35,7 @@ export default class ImageCropper {
32
35
  zoomable: false,
33
36
  checkCrossOrigin: false, // Prevent CORS issues
34
37
  checkOrientation: false, // Prevent loading the image via AJAX which can cause CORS issues
35
- data: this.box,
36
- cropend: () => {
37
- const data = this.#cropper.getData(true)
38
- this.update(data)
39
- }
38
+ data: this.box
40
39
  }
41
40
  }
42
41
 
@@ -116,10 +115,9 @@ export default class ImageCropper {
116
115
 
117
116
  bind() {
118
117
  this.dialog.dialog_body.find('button[type="submit"]').on("click", () => {
119
- const elementEditor = document.querySelector(
120
- `[data-element-id='${this.elementId}']`
121
- )
122
- elementEditor.setDirty()
118
+ const data = this.#cropper.getData(true)
119
+ this.update(data)
120
+ this.elementEditor.setDirty()
123
121
  this.dialog.close()
124
122
  return false
125
123
  })
@@ -1,5 +1,22 @@
1
1
  import Hotkeys from "alchemy_admin/hotkeys"
2
2
  import pleaseWaitOverlay from "alchemy_admin/please_wait_overlay"
3
+ import { openDialog } from "alchemy_admin/dialog"
4
+
5
+ // Opens the help dialog when the user presses the "?" key outside of a field.
6
+ function showHelp(evt) {
7
+ if (
8
+ !$(evt.target).is("input, textarea") &&
9
+ String.fromCharCode(evt.which) === "?"
10
+ ) {
11
+ openDialog("/admin/help", {
12
+ title: Alchemy.t("help"),
13
+ size: "400x492"
14
+ })
15
+ return false
16
+ } else {
17
+ return true
18
+ }
19
+ }
3
20
 
4
21
  export default function Initializer() {
5
22
  // We obviously have javascript enabled.
@@ -8,6 +25,10 @@ export default function Initializer() {
8
25
  // Initialize hotkeys.
9
26
  Hotkeys()
10
27
 
28
+ // (Re)bind the help dialog hotkey.
29
+ document.removeEventListener("keypress", showHelp)
30
+ document.addEventListener("keypress", showHelp)
31
+
11
32
  // Add observer for please wait overlay.
12
33
  document.querySelectorAll(".please_wait").forEach((element) => {
13
34
  element.addEventListener("click", pleaseWaitOverlay)
@@ -0,0 +1,7 @@
1
+ export function dispatchPageDirtyEvent(data) {
2
+ document.dispatchEvent(
3
+ new CustomEvent("alchemy:page-dirty", {
4
+ detail: { tooltip: data.publishButtonTooltip }
5
+ })
6
+ )
7
+ }
@@ -48,4 +48,13 @@ tinymce.PluginManager.add("alchemy_link", function (editor) {
48
48
  // Replace the default link command with our own
49
49
  editor.addCommand("mceLink", openLinkDialog)
50
50
  editor.addShortcut("Meta+K", "", openLinkDialog)
51
+
52
+ // Override the default link menu item so the contextual menu
53
+ // (right-click) opens the Alchemy link dialog instead of TinyMCE's.
54
+ editor.ui.registry.addMenuItem("link", {
55
+ icon: "link",
56
+ text: "Link...",
57
+ shortcut: "Meta+K",
58
+ onAction: openLinkDialog
59
+ })
51
60
  })
@@ -3,9 +3,9 @@
3
3
  module Alchemy
4
4
  class BaseJob < ActiveJob::Base
5
5
  # Automatically retry jobs that encountered a deadlock
6
- # retry_on ActiveRecord::Deadlocked
6
+ retry_on ActiveRecord::Deadlocked
7
7
 
8
8
  # Most jobs are safe to ignore if the underlying records are no longer available
9
- # discard_on ActiveJob::DeserializationError
9
+ discard_on ActiveJob::DeserializationError
10
10
  end
11
11
  end
@@ -0,0 +1,33 @@
1
+ module Alchemy
2
+ class InvalidateElementsCacheJob < BaseJob
3
+ queue_as :default
4
+
5
+ def perform(related_object_type, related_object_id)
6
+ element_ids = Ingredient
7
+ .where(related_object_type:, related_object_id:)
8
+ .joins(:element)
9
+ .pluck(:element_id)
10
+ elements = Element.where(id: element_ids)
11
+
12
+ all_element_ids = get_all_element_ids(elements, element_ids)
13
+ Element.where(id: all_element_ids).touch_all
14
+
15
+ page_ids = elements.joins(page_version: :page).select("DISTINCT alchemy_pages.id")
16
+ Page.where(id: page_ids).touch_all
17
+ end
18
+
19
+ private
20
+
21
+ def get_all_element_ids(elements, element_ids)
22
+ parent_element_ids = elements.where.not(parent_element_id: nil).pluck(:parent_element_id)
23
+ parent_elements = Element.distinct.where(id: parent_element_ids)
24
+
25
+ if parent_elements.any?
26
+ element_ids += parent_element_ids
27
+ get_all_element_ids(parent_elements, element_ids)
28
+ else
29
+ element_ids
30
+ end
31
+ end
32
+ end
33
+ end
@@ -8,6 +8,8 @@ module Alchemy
8
8
 
9
9
  RESERVED_URLNAMES = %w[admin messages new]
10
10
 
11
+ delegate :wildcard_url, to: :definition
12
+
11
13
  included do
12
14
  before_validation :set_urlname,
13
15
  if: :renamed?,
@@ -18,6 +20,7 @@ module Alchemy
18
20
  validates :urlname,
19
21
  uniqueness: {scope: [:language_id, :layoutpage], if: -> { urlname.present? }, case_sensitive: false},
20
22
  exclusion: {in: RESERVED_URLNAMES}
23
+ validate :unique_wildcard_param_keys, if: :has_wildcard_url?
21
24
 
22
25
  after_update :update_descendants_urlnames,
23
26
  if: :saved_change_to_urlname?
@@ -42,13 +45,32 @@ module Alchemy
42
45
  end
43
46
  end
44
47
 
45
- # Returns always the last part of a urlname path
48
+ # Returns wildcard url param or the last part of an urlname path
46
49
  def slug
47
50
  urlname.to_s.split("/").last
48
51
  end
49
52
 
53
+ def has_wildcard_url?
54
+ wildcard_url.present?
55
+ end
56
+
50
57
  private
51
58
 
59
+ def unique_wildcard_param_keys
60
+ conflicting = PageDefinition.all.find do |other|
61
+ other.name != page_layout && other.wildcard_url == wildcard_url
62
+ end
63
+
64
+ if conflicting
65
+ errors.add(
66
+ :page_layout,
67
+ :conflicting_wildcard_param_key,
68
+ param: wildcard_url,
69
+ conflicting_layout: conflicting.name
70
+ )
71
+ end
72
+ end
73
+
52
74
  def update_descendants_urlnames
53
75
  reload
54
76
  descendants.each(&:update_urlname!)
@@ -67,13 +89,14 @@ module Alchemy
67
89
  end
68
90
 
69
91
  # Returns the full nested urlname.
70
- #
92
+ # Uses the wildcard_url from the page definition if present,
93
+ # otherwise converts the slug or name to a url-friendly string.
71
94
  def nested_url_name
72
- converted_url_name = convert_to_urlname(slug.blank? ? name : slug)
95
+ url_part = wildcard_url.presence || convert_to_urlname(slug.blank? ? name : slug)
73
96
  if parent&.language_root?
74
- converted_url_name
97
+ url_part
75
98
  else
76
- [parent&.urlname, converted_url_name].compact.join("/")
99
+ [parent&.urlname, url_part].compact.join("/")
77
100
  end
78
101
  end
79
102
  end
@@ -83,7 +83,8 @@ module Alchemy
83
83
  {
84
84
  public: public?,
85
85
  locked: locked?,
86
- restricted: restricted?
86
+ restricted: restricted?,
87
+ scheduled: scheduled?
87
88
  }
88
89
  end
89
90
 
@@ -92,7 +93,11 @@ module Alchemy
92
93
  # @param [Symbol] status_type
93
94
  #
94
95
  def status_message(status_type)
95
- Alchemy.t(status[status_type].to_s, scope: "page_states.#{status_type}")
96
+ if status_type == :scheduled && scheduled?
97
+ Alchemy.t(public_on&.future? ? :public_on : :public_until, scope: "page_states.scheduled", public_on: public_on && ::I18n.l(public_on, format: :"alchemy.default"), public_until: public_until && ::I18n.l(public_until, format: :"alchemy.default"))
98
+ else
99
+ Alchemy.t(status[status_type].to_s, scope: "page_states.#{status_type}")
100
+ end
96
101
  end
97
102
 
98
103
  # Returns the sort translated status title for given status type.
@@ -63,11 +63,11 @@ module Alchemy
63
63
  )
64
64
  }
65
65
 
66
- # Last 5 pages that where recently edited by given user
66
+ # Pages that where updated by given user sorted by update time
67
67
  #
68
68
  scope :all_last_edited_from,
69
69
  ->(user) {
70
- where(updater_id: user.id).order("updated_at DESC").limit(5)
70
+ where(updater_id: user.id).order("updated_at DESC")
71
71
  }
72
72
 
73
73
  # Returns all pages that have the given +language_id+
@@ -20,11 +20,12 @@ module Alchemy
20
20
  # link_to page.url
21
21
  #
22
22
  class UrlPath
23
- def initialize(page, optional_params = {})
23
+ def initialize(page, optional_params = {}, wildcard_params: {})
24
24
  @page = page
25
25
  @language = @page.language
26
26
  @site = @language.site
27
27
  @optional_params = optional_params
28
+ @wildcard_params = wildcard_params
28
29
  end
29
30
 
30
31
  def call
@@ -64,7 +65,11 @@ module Alchemy
64
65
  end
65
66
 
66
67
  def page_path
67
- "#{root_path}#{@page.urlname}"
68
+ urlname = @page.urlname
69
+ @wildcard_params.each do |key, val|
70
+ urlname = urlname.gsub(":#{key}", val.to_s)
71
+ end
72
+ "#{root_path}#{urlname}"
68
73
  end
69
74
 
70
75
  def root_path
@@ -305,8 +305,8 @@ module Alchemy
305
305
  # = The url_path for this page
306
306
  #
307
307
  # @see Alchemy::Page::UrlPath#call
308
- def url_path(optional_params = {})
309
- self.class.url_path_class.new(self, optional_params).call
308
+ def url_path(optional_params = {}, wildcard_params: {})
309
+ self.class.url_path_class.new(self, optional_params, wildcard_params: wildcard_params).call
310
310
  end
311
311
 
312
312
  # The page's view partial is dependent from its page layout
@@ -20,6 +20,7 @@ module Alchemy
20
20
  attribute :hide, :boolean, default: false
21
21
  attribute :editable_by
22
22
  attribute :hint
23
+ attribute :wildcard_url, Alchemy::WildcardUrlType.new
23
24
 
24
25
  # Needs to be down here in order to have the attribute reader
25
26
  # available after the attribute is defined.
@@ -94,7 +94,7 @@ module Alchemy
94
94
 
95
95
  # Controller actions
96
96
  can :leave, :alchemy_admin
97
- can [:info, :help], :alchemy_admin_dashboard
97
+ can [:info, :help, :show], :alchemy_admin_dashboard
98
98
  can :manage, :alchemy_admin_clipboard
99
99
  can :update, :alchemy_admin_layoutpages
100
100
  can :tree, :alchemy_admin_pages
@@ -41,6 +41,8 @@ module Alchemy
41
41
 
42
42
  has_many :related_elements, through: :related_ingredients, source: :element
43
43
  has_many :related_pages, through: :related_elements, source: :page
44
+
45
+ after_touch :touch_related_ingredients, if: -> { related_ingredients.exists? }
44
46
  end
45
47
 
46
48
  # Returns true if object is not assigned to any ingredient.
@@ -48,5 +50,11 @@ module Alchemy
48
50
  def deletable?
49
51
  related_ingredients.none?
50
52
  end
53
+
54
+ private
55
+
56
+ def touch_related_ingredients
57
+ InvalidateElementsCacheJob.perform_later(self.class.name, id)
58
+ end
51
59
  end
52
60
  end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alchemy
4
+ class PageFinder
5
+ attr_reader :urlname
6
+
7
+ Result = Data.define(:page, :extracted_params)
8
+
9
+ def initialize(urlname)
10
+ @urlname = urlname
11
+ end
12
+
13
+ # @return [PageFinder::Result, nil]
14
+ def call
15
+ return if urlname.blank?
16
+
17
+ find_by_urlname || find_by_wildcard_url
18
+ end
19
+
20
+ private
21
+
22
+ # Finds a page by exact urlname match within the current language.
23
+ def find_by_urlname
24
+ page = Current.language.pages.contentpages.find_by(urlname: urlname)
25
+ Result.new(page: page, extracted_params: ActionController::Parameters.new.permit!) if page
26
+ end
27
+
28
+ # Finds a page whose urlname pattern matches the given URL.
29
+ # Loads all pages whose urlname contains a `:param` segment in a
30
+ # single SQL query, then matches each in Ruby.
31
+ #
32
+ # A urlname may contain more than one `:param` segment when a wildcard
33
+ # page is nested under another wildcard page.
34
+ def find_by_wildcard_url
35
+ return unless any_wildcard_definitions?
36
+
37
+ # Tree depth of a contentpage = urlname slash count + 1 (skipping the language root)
38
+ page_depth = urlname.count("/") + 1
39
+
40
+ wildcard_pages = Current.language.pages.contentpages
41
+ .where("urlname LIKE ?", "%:%")
42
+ .where(depth: page_depth)
43
+ .order(:lft)
44
+
45
+ wildcard_pages.each do |wildcard_page|
46
+ matched_params = match_url_pattern(wildcard_page)
47
+ next unless matched_params
48
+
49
+ # return the first match
50
+ return Result.new(
51
+ page: wildcard_page,
52
+ extracted_params: ActionController::Parameters.new(matched_params).permit!
53
+ )
54
+ end
55
+
56
+ nil
57
+ end
58
+
59
+ # Matches the urlname against a page's urlname pattern.
60
+ # Static segments must match literally; each `:param` segment is captured
61
+ # as a single URL segment.
62
+ #
63
+ # @param wildcard_page [Alchemy::Page] a page whose urlname contains one or more `:param` segments
64
+ # @return [Hash<Symbol, String>, nil] matched params or nil
65
+ def match_url_pattern(wildcard_page)
66
+ regex_parts = wildcard_page.urlname.split("/").map do |segment|
67
+ if segment.start_with?(":")
68
+ # create a named capture group for the segment e.g. ":slug" => "(?<slug>.+)"
69
+ "(?<#{segment[1..]}>[\\w\\-]+)"
70
+ else
71
+ # only return the current segment and escape any special regex characters
72
+ Regexp.escape(segment)
73
+ end
74
+ end
75
+
76
+ # connect regex parts and match them against the urlname
77
+ match = Regexp.new("\\A#{regex_parts.join("/")}\\z").match(urlname)
78
+
79
+ # extract the named capture groups as parameters
80
+ match&.named_captures&.symbolize_keys!
81
+ end
82
+
83
+ # @return [Boolean] whether any page definition declares a wildcard_url
84
+ def any_wildcard_definitions?
85
+ PageDefinition.all.any?(&:wildcard_url)
86
+ end
87
+ end
88
+ end
@@ -65,6 +65,7 @@
65
65
  --font-size_small: 10px; /* 0.875rem */
66
66
  --font-size_medium: 12px; /* 1rem */
67
67
  --font-size_large: 16px; /* 1.25 rem */
68
+ --font-size_xlarge: 24px; /* 2 rem */
68
69
 
69
70
  --font-weight_normal: 500;
70
71
  --font-weight_bold: 700;
@@ -73,6 +74,7 @@
73
74
 
74
75
  /* Borders */
75
76
  --border-radius_medium: 3px;
77
+ --border-radius_large: 6px;
76
78
  --border-width_small: 1px;
77
79
  --border-style: solid;
78
80
  --border-default: var(--border-width_small) var(--border-style)
@@ -90,7 +92,7 @@
90
92
  var(--button-border-color);
91
93
  --button-font-size: var(--font-size_medium);
92
94
  --button-font-weight: var(--font-weight_bold);
93
- --button-height: 30px;
95
+ --button-height: 31px;
94
96
  --button-line-height: var(--form-field-line-height);
95
97
  --button-margin: var(--form-field-margin);
96
98
  --button-padding: var(--spacing-1) var(--spacing-5);
@@ -111,12 +113,12 @@
111
113
  --elements-window-transition-easing: ease-in-out;
112
114
 
113
115
  /* Form */
114
- --form-left-column-width: 35%;
115
- --form-right-column-width: 65%;
116
+ --form-left-column-width: 1fr;
117
+ --form-right-column-width: 2.25fr;
116
118
 
117
119
  /* Form Field */
118
120
  --form-field-margin: var(--spacing-1) 0;
119
- --form-field-height: 31px;
121
+ --form-field-height: var(--button-height);
120
122
  --form-field-addon-width: 30px;
121
123
  --form-field-border-width: var(--border-width_small);
122
124
  --form-field-border-style: var(--border-style);
@@ -84,13 +84,7 @@
84
84
  }
85
85
 
86
86
  @mixin form-label {
87
- width: var(--form-left-column-width);
88
- padding-right: var(--spacing-2);
89
- padding-top: 0.6em;
90
- margin-top: var(--spacing-1);
91
- vertical-align: top;
92
87
  word-break: normal;
93
- float: left;
94
88
  text-align: right;
95
89
  font-size: var(--font-size_medium);
96
90
  color: var(--form-field-label-color);
@@ -108,7 +102,7 @@
108
102
  border: 1px solid $border-color;
109
103
  color: $color;
110
104
  display: block;
111
- clear: both;
105
+ grid-column: 2;
112
106
  border-radius: var(--border-radius_medium);
113
107
  }
114
108
 
@@ -29,6 +29,12 @@
29
29
  --code-background-color: var(--a-darkest-grey);
30
30
  --code-border-color: var(--a-grey);
31
31
 
32
+ /* Dashboard */
33
+ --dashboard-widget-background-color: var(--a-darkest-grey);
34
+ --dashboard-widget-box-shadow: none;
35
+ --table-scroll-fade-color: rgba(0, 0, 0, 0);
36
+ --table-scroll-shadow-color: rgba(0, 0, 0, 0.6);
37
+
32
38
  /* Datepicker */
33
39
  --datepicker-bg-color: var(--a-dark-grey);
34
40
  --datepicker-border-color: var(--a-grey);
@@ -82,7 +88,7 @@
82
88
  --form-field-background-color: var(--a-darkest-grey);
83
89
  --form-field-border-color: var(--border-inset-color);
84
90
  --form-field-box-shadow: inset 0 0 1px var(--a-dark-grey);
85
- --form-field-label-color: var(--text-color-muted);
91
+ --form-field-label-color: var(--text-color);
86
92
  --form-field-text-color: var(--text-color);
87
93
  --form-field-disabled-text-color: var(--form-field-text-color);
88
94
  --form-field-disabled-bg-color: transparent;
@@ -315,6 +321,12 @@
315
321
  --code-background-color: var(--color-grey_light);
316
322
  --code-border-color: var(--border-color);
317
323
 
324
+ /* Dashboard */
325
+ --dashboard-widget-background-color: var(--color-white);
326
+ --dashboard-widget-box-shadow: 0px 1px 2px 0px rgba(10, 10, 10, 0.1);
327
+ --table-scroll-fade-color: rgba(255, 255, 255, 0);
328
+ --table-scroll-shadow-color: rgba(0, 0, 0, 0.15);
329
+
318
330
  /* Datepicker */
319
331
  --datepicker-bg-color: var(--color-white);
320
332
  --datepicker-border-color: var(--border-color);