alchemy_cms 8.1.9 → 8.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +12 -1
  3. data/app/assets/builds/alchemy/admin.css +1 -1
  4. data/app/assets/builds/alchemy/alchemy_admin.min.js +2 -0
  5. data/app/assets/builds/alchemy/alchemy_admin.min.js.map +1 -0
  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/theme.css +1 -1
  9. data/app/assets/builds/alchemy/welcome.css +1 -1
  10. data/app/assets/builds/tinymce/skins/content/alchemy/content.min.css +1 -1
  11. data/app/assets/builds/tinymce/skins/content/alchemy-dark/content.min.css +1 -1
  12. data/app/assets/images/alchemy/icons-sprite.svg +1 -1
  13. data/app/components/alchemy/admin/current_user_name.rb +34 -0
  14. data/app/components/alchemy/admin/locale_select.rb +12 -8
  15. data/app/components/alchemy/admin/page_node.html.erb +3 -2
  16. data/app/components/alchemy/admin/picture_thumbnail.rb +1 -1
  17. data/app/components/alchemy/admin/preview_time_select.rb +55 -0
  18. data/app/components/alchemy/admin/publish_element_button.html.erb +41 -0
  19. data/app/components/alchemy/admin/publish_element_button.rb +13 -0
  20. data/app/components/alchemy/admin/timezone_select.rb +47 -0
  21. data/app/components/alchemy/ingredients/select_editor.rb +6 -1
  22. data/app/controllers/alchemy/admin/base_controller.rb +1 -0
  23. data/app/controllers/alchemy/admin/elements_controller.rb +54 -34
  24. data/app/controllers/alchemy/admin/pages_controller.rb +1 -0
  25. data/app/controllers/alchemy/admin/resources_controller.rb +11 -6
  26. data/app/controllers/alchemy/pages_controller.rb +1 -2
  27. data/app/helpers/alchemy/admin/base_helper.rb +4 -7
  28. data/app/helpers/alchemy/url_helper.rb +2 -10
  29. data/app/javascript/alchemy_admin/components/element_editor/publish_element_button.js +28 -27
  30. data/app/javascript/alchemy_admin/components/element_editor.js +11 -2
  31. data/app/javascript/alchemy_admin/components/message.js +5 -1
  32. data/app/javascript/alchemy_admin/components/uploader/file_upload.js +5 -5
  33. data/app/javascript/alchemy_admin/image_cropper.js +10 -6
  34. data/app/javascript/alchemy_admin/initializer.js +6 -33
  35. data/app/javascript/alchemy_admin/shoelace_theme.js +6 -2
  36. data/app/javascript/alchemy_admin/templates/compiled.js +1 -1
  37. data/app/javascript/alchemy_admin.js +12 -2
  38. data/app/models/alchemy/attachment.rb +1 -1
  39. data/app/models/alchemy/current.rb +5 -1
  40. data/app/models/alchemy/element/element_ingredients.rb +11 -3
  41. data/app/models/alchemy/element.rb +10 -0
  42. data/app/models/alchemy/ingredient.rb +2 -0
  43. data/app/models/alchemy/ingredients/select.rb +1 -2
  44. data/app/models/alchemy/page/etag_generator.rb +21 -0
  45. data/app/models/alchemy/page/url_path.rb +11 -2
  46. data/app/models/alchemy/page.rb +12 -2
  47. data/app/models/alchemy/page_version.rb +5 -5
  48. data/app/models/alchemy/picture.rb +19 -2
  49. data/app/models/alchemy/storage_adapter/active_storage.rb +9 -0
  50. data/app/models/alchemy/storage_adapter/dragonfly.rb +9 -0
  51. data/app/models/alchemy/storage_adapter.rb +1 -0
  52. data/app/models/concerns/alchemy/publishable.rb +20 -12
  53. data/app/models/concerns/alchemy/relatable_resource.rb +16 -2
  54. data/app/models/concerns/alchemy/touch_elements.rb +3 -3
  55. data/app/services/alchemy/element_preloader.rb +107 -0
  56. data/app/stylesheets/alchemy/_custom-properties.scss +1 -0
  57. data/app/stylesheets/alchemy/_mixins.scss +1 -1
  58. data/app/stylesheets/alchemy/_themes.scss +2 -0
  59. data/app/stylesheets/alchemy/admin/base.scss +2 -1
  60. data/app/stylesheets/alchemy/admin/elements.scss +22 -19
  61. data/app/stylesheets/alchemy/admin/form_fields.scss +3 -0
  62. data/app/stylesheets/alchemy/admin/forms.scss +14 -1
  63. data/app/stylesheets/alchemy/admin/frame.scss +9 -8
  64. data/app/stylesheets/alchemy/admin/notices.scss +1 -1
  65. data/app/stylesheets/alchemy/admin/popover.scss +37 -0
  66. data/app/stylesheets/alchemy/admin/selects.scss +4 -0
  67. data/app/stylesheets/alchemy/admin/shoelace.scss +16 -4
  68. data/app/stylesheets/alchemy/admin/toolbar.scss +8 -0
  69. data/app/stylesheets/alchemy/admin.scss +1 -0
  70. data/app/views/alchemy/admin/_header.html.erb +4 -0
  71. data/app/views/alchemy/admin/_left_menu.html.erb +24 -0
  72. data/app/views/alchemy/admin/_main_navi.html.erb +6 -0
  73. data/app/views/alchemy/admin/_top_menu.html.erb +6 -0
  74. data/app/views/alchemy/admin/_user_info.html.erb +5 -0
  75. data/app/views/alchemy/admin/crop.html.erb +6 -11
  76. data/app/views/alchemy/admin/elements/_header.html.erb +16 -6
  77. data/app/views/alchemy/admin/elements/_schedule.html.erb +62 -0
  78. data/app/views/alchemy/admin/elements/_toolbar.html.erb +1 -15
  79. data/app/views/alchemy/admin/elements/publish.turbo_stream.erb +28 -0
  80. data/app/views/alchemy/admin/nodes/index.html.erb +1 -1
  81. data/app/views/alchemy/admin/pages/_locked_pages.html.erb +5 -0
  82. data/app/views/alchemy/admin/pages/_publication_fields.html.erb +4 -4
  83. data/app/views/alchemy/admin/pages/_table.html.erb +1 -1
  84. data/app/views/alchemy/admin/pages/edit.html.erb +6 -2
  85. data/app/views/alchemy/admin/partials/_language_tree_select.html.erb +10 -10
  86. data/app/views/alchemy/admin/partials/_site_select.html.erb +6 -3
  87. data/app/views/alchemy/admin/pictures/index.html.erb +2 -2
  88. data/app/views/alchemy/admin/tinymce/_setup.html.erb +9 -16
  89. data/app/views/alchemy/admin/uploader/_setup.html.erb +1 -6
  90. data/app/views/alchemy/language_links/_language.html.erb +1 -2
  91. data/app/views/layouts/alchemy/admin.html.erb +2 -45
  92. data/config/importmap.rb +7 -2
  93. data/config/locales/alchemy.en.yml +35 -5
  94. data/lib/alchemy/admin/preview_time.rb +23 -0
  95. data/lib/alchemy/admin/preview_url.rb +13 -2
  96. data/lib/alchemy/admin/timezone.rb +56 -0
  97. data/lib/alchemy/configuration.rb +2 -0
  98. data/lib/alchemy/configurations/main.rb +13 -1
  99. data/lib/alchemy/tasks/tidy.rb +6 -7
  100. data/lib/alchemy/test_support/factories/element_factory.rb +2 -2
  101. data/lib/alchemy/test_support/relatable_resource_examples.rb +2 -2
  102. data/lib/alchemy/test_support/shared_publishable_examples.rb +44 -2
  103. data/lib/alchemy/upgrader.rb +3 -1
  104. data/lib/alchemy/version.rb +1 -1
  105. data/lib/alchemy_cms.rb +2 -0
  106. data/lib/generators/alchemy/install/install_generator.rb +2 -1
  107. data/vendor/javascript/handlebars.min.js +4 -4
  108. data/vendor/javascript/shoelace.min.js +1419 -1323
  109. data/vendor/javascript/sortable.min.js +2 -2
  110. data/vendor/javascript/tinymce.min.js +1 -1
  111. metadata +35 -1
@@ -7,14 +7,18 @@ module Alchemy
7
7
 
8
8
  before_action :load_page_and_version, only: [:index, :new]
9
9
  include Alchemy::Admin::Clipboard
10
+ include Alchemy::Admin::PreviewTime
10
11
 
11
12
  before_action :load_element, only: [:update, :destroy, :collapse, :expand, :publish]
12
13
  authorize_resource class: Alchemy::Element
13
14
 
14
15
  def index
15
- elements = @page_version.elements.order(:position).includes(*element_includes)
16
- @elements = elements.not_nested.unfixed
17
- @fixed_elements = elements.not_nested.fixed
16
+ preloaded = Alchemy::ElementPreloader
17
+ .new(page_version: @page_version)
18
+ .call
19
+
20
+ @elements = preloaded.reject(&:fixed?)
21
+ @fixed_elements = preloaded.select(&:fixed?)
18
22
  end
19
23
 
20
24
  def new
@@ -89,12 +93,23 @@ module Alchemy
89
93
  end
90
94
 
91
95
  def publish
92
- @element.public = !@element.public?
93
- @element.save(validate: false)
94
- render json: {
95
- public: @element.public?,
96
- label: @element.public? ? Alchemy.t(:hide_element) : Alchemy.t(:show_element)
97
- }.merge(pagePublicationData(@element.page))
96
+ if schedule_element_params.present?
97
+ @element.assign_attributes(schedule_element_params)
98
+ @element.skip_ingredient_validations = true
99
+ @element.save
100
+ status = @element.valid? ? 200 : 422
101
+ else
102
+ @element.public = !@element.public?
103
+ @element.save(validate: false)
104
+ status = 200
105
+ end
106
+
107
+ respond_to do |format|
108
+ format.turbo_stream do
109
+ @page = @element.page
110
+ render status:
111
+ end
112
+ end
98
113
  end
99
114
 
100
115
  def order
@@ -119,12 +134,12 @@ module Alchemy
119
134
  def collapse
120
135
  # We do not want to trigger the touch callback or any validations
121
136
  @element.update_columns(folded: true)
122
- # Collapse all nested elements
123
- nested_elements_ids = collapse_nested_elements_ids(@element)
124
- Alchemy::Element.where(id: nested_elements_ids).update_all(folded: true)
137
+ # Collapse all nested elements (except compact ones which stay as-is)
138
+ nested_element_ids = nested_element_ids_to_collapse(@element)
139
+ Alchemy::Element.where(id: nested_element_ids).update_all(folded: true)
125
140
 
126
141
  render json: {
127
- nestedElementIds: nested_elements_ids,
142
+ nestedElementIds: nested_element_ids,
128
143
  title: Alchemy.t(@element.folded? ? :show_element_content : :hide_element_content)
129
144
  }
130
145
  end
@@ -152,30 +167,31 @@ module Alchemy
152
167
  @page = @page_version.page
153
168
  end
154
169
 
155
- def collapse_nested_elements_ids(element)
170
+ # Collects IDs of nested elements that should be collapsed.
171
+ # Skips compact elements (they retain their fold state).
172
+ # Optimized to use a single query instead of N queries for N levels.
173
+ def nested_element_ids_to_collapse(element)
174
+ all_elements = Element
175
+ .where(page_version_id: element.page_version_id)
176
+ .select(:id, :parent_element_id, :folded, :name)
177
+ .to_a
178
+
179
+ children_by_parent = all_elements.group_by(&:parent_element_id)
180
+
156
181
  ids = []
157
- element.all_nested_elements.includes(:all_nested_elements).reject(&:compact?).each do |nested_element|
158
- ids.push nested_element.id if nested_element.expanded?
159
- ids.concat collapse_nested_elements_ids(nested_element) if nested_element.all_nested_elements.reject(&:compact?).any?
182
+ stack = [element.id]
183
+
184
+ while stack.any?
185
+ current_id = stack.pop
186
+ children = children_by_parent[current_id] || []
187
+
188
+ children.reject(&:compact?).each do |child|
189
+ ids << child.id if child.expanded?
190
+ stack << child.id
191
+ end
160
192
  end
161
- ids
162
- end
163
193
 
164
- def element_includes
165
- [
166
- {
167
- ingredients: :related_object
168
- },
169
- :tags,
170
- {
171
- all_nested_elements: [
172
- {
173
- ingredients: :related_object
174
- },
175
- :tags
176
- ]
177
- }
178
- ]
194
+ ids
179
195
  end
180
196
 
181
197
  def load_element
@@ -216,6 +232,10 @@ module Alchemy
216
232
  def create_element_params
217
233
  params.require(:element).permit(:name, :page_version_id, :parent_element_id)
218
234
  end
235
+
236
+ def schedule_element_params
237
+ params[:element]&.permit(:public_on, :public_until)
238
+ end
219
239
  end
220
240
  end
221
241
  end
@@ -5,6 +5,7 @@ module Alchemy
5
5
  class PagesController < ResourcesController
6
6
  include OnPageLayout::CallbacksRunner
7
7
  include Alchemy::Admin::Clipboard
8
+ include Alchemy::Admin::PreviewTime
8
9
 
9
10
  helper "alchemy/pages"
10
11
 
@@ -18,12 +18,7 @@ module Alchemy
18
18
  before_action :authorize_resource
19
19
 
20
20
  def index
21
- @query = resource_handler.model.ransack(search_filter_params[:q])
22
- @query.sorts = default_sort_order if @query.sorts.empty?
23
- items = @query.result
24
-
25
- items = items.includes(*resource_relations_names) if contains_relations?
26
- items = items.tagged_with(search_filter_params[:tagged_with]) if search_filter_params[:tagged_with].present?
21
+ items = collection
27
22
 
28
23
  respond_to do |format|
29
24
  format.html do
@@ -121,6 +116,16 @@ module Alchemy
121
116
  authorize!(action_name.to_sym, resource_instance_variable || resource_handler.model)
122
117
  end
123
118
 
119
+ def collection
120
+ @query = resource_handler.model.ransack(search_filter_params[:q])
121
+ @query.sorts = default_sort_order if @query.sorts.empty?
122
+ items = @query.result(distinct: true)
123
+
124
+ items = items.includes(*resource_relations_names) if contains_relations?
125
+ items = items.tagged_with(search_filter_params[:tagged_with]) if search_filter_params[:tagged_with].present?
126
+ items
127
+ end
128
+
124
129
  # Permits all editable resource attributes as default.
125
130
  #
126
131
  # Define this method in your inheriting controller if you want to permit additional attributes.
@@ -230,8 +230,7 @@ module Alchemy
230
230
  # Otherwise all users will see the same cached page, regardless of user's state.
231
231
  #
232
232
  def page_etag
233
- elements_cache_key = @page.public_version&.elements&.published&.order(:id)&.pluck(:id)
234
- [@page, elements_cache_key, current_alchemy_user]
233
+ Alchemy::Page::EtagGenerator.new(@page).call(current_alchemy_user)
235
234
  end
236
235
 
237
236
  # We only render the page if either the cache is disabled for this page
@@ -17,15 +17,12 @@ module Alchemy
17
17
  #
18
18
  # In order to represent your own +User+'s class instance,
19
19
  # you should add a +alchemy_display_name+ method to your +User+ class
20
- #
20
+ # @deprecated Use the +CurrentAlchemyUserName+ component instead.
21
21
  def current_alchemy_user_name
22
- name = current_alchemy_user.try(:alchemy_display_name)
23
- if name.present?
24
- content_tag :span, class: "current-user-name" do
25
- "#{render_icon(:user, size: "1x")} #{name}".html_safe
26
- end
27
- end
22
+ render Alchemy::Admin::CurrentUserName.new(user: current_alchemy_user)
28
23
  end
24
+ deprecate current_alchemy_user_name: "Use the Alchemy::Admin::CurrentUserName component instead.",
25
+ deprecator: Alchemy::Deprecation
29
26
 
30
27
  # This helper renders the link to an dialog.
31
28
  #
@@ -8,20 +8,12 @@ module Alchemy
8
8
  module UrlHelper
9
9
  # Returns the path for rendering an alchemy page
10
10
  def show_alchemy_page_path(page, optional_params = {})
11
- alchemy.show_page_path(show_page_path_params(page, optional_params))
11
+ page.url_path(optional_params)
12
12
  end
13
13
 
14
14
  # Returns the url for rendering an alchemy page
15
15
  def show_alchemy_page_url(page, optional_params = {})
16
- alchemy.show_page_url(show_page_path_params(page, optional_params))
17
- end
18
-
19
- # Returns the correct params-hash for passing to show_page_path
20
- def show_page_path_params(page, optional_params = {})
21
- raise ArgumentError, "Page is nil" if page.nil?
22
-
23
- url_params = {urlname: page.urlname}.update(optional_params)
24
- prefix_locale?(page.language_code) ? url_params.update(locale: page.language_code) : url_params
16
+ "#{request.base_url}#{page.url_path(optional_params)}"
25
17
  end
26
18
 
27
19
  # Returns the path for downloading an alchemy attachment
@@ -1,42 +1,43 @@
1
- import { patch } from "alchemy_admin/utils/ajax"
2
- import { reloadPreview } from "alchemy_admin/components/preview_window"
3
- import { growl } from "alchemy_admin/growler"
4
- import { dispatchPageDirtyEvent } from "alchemy_admin/components/element_editor"
5
-
6
1
  export class PublishElementButton extends HTMLElement {
7
- constructor() {
8
- super()
2
+ #scheduleButtonVariant
3
+
4
+ connectedCallback() {
5
+ this.#scheduleButtonVariant = this.scheduleButton.getAttribute("variant")
6
+ this.publishButton.addEventListener("click", this)
7
+ this.dropdown.addEventListener("sl-show", this)
8
+ this.dropdown.addEventListener("sl-hide", this)
9
+ }
9
10
 
10
- this.addEventListener("sl-change", this)
11
+ disconnectedCallback() {
12
+ this.publishButton.removeEventListener("click", this)
13
+ this.dropdown.removeEventListener("sl-show", this)
14
+ this.dropdown.removeEventListener("sl-hide", this)
11
15
  }
12
16
 
13
17
  handleEvent(event) {
14
- const elementEditor = event.target.closest("alchemy-element-editor")
15
- if (elementEditor === this.elementEditor) {
16
- patch(Alchemy.routes.publish_admin_element_path(this.elementId))
17
- .then((response) => {
18
- const data = response.data
19
- this.elementEditor.published = data.public
20
- this.tooltip.setAttribute("content", data.label)
21
- reloadPreview()
22
- if (data.pageHasUnpublishedChanges) {
23
- dispatchPageDirtyEvent(data)
24
- }
25
- })
26
- .catch((error) => growl(error.message, "error"))
18
+ switch (event.type) {
19
+ case "click":
20
+ this.publishButton.loading = true
21
+ break
22
+ case "sl-show":
23
+ this.scheduleButton.setAttribute("variant", "primary")
24
+ break
25
+ case "sl-hide":
26
+ this.scheduleButton.setAttribute("variant", this.#scheduleButtonVariant)
27
+ break
27
28
  }
28
29
  }
29
30
 
30
- get elementEditor() {
31
- return this.closest("alchemy-element-editor")
31
+ get publishButton() {
32
+ return this.querySelector("sl-button[type='submit']")
32
33
  }
33
34
 
34
- get tooltip() {
35
- return this.closest("sl-tooltip")
35
+ get dropdown() {
36
+ return this.querySelector("sl-dropdown")
36
37
  }
37
38
 
38
- get elementId() {
39
- return this.elementEditor.elementId
39
+ get scheduleButton() {
40
+ return this.querySelector("sl-button[slot='trigger']")
40
41
  }
41
42
  }
42
43
 
@@ -27,7 +27,7 @@ export class ElementEditor extends HTMLElement {
27
27
 
28
28
  // Dirty observer still needs to be jQuery
29
29
  // in order to support select2.
30
- $(this).on("change", this.onChange)
30
+ $(this.form).on("change", this.onChange)
31
31
 
32
32
  this.header?.addEventListener("dblclick", () => {
33
33
  this.toggle()
@@ -87,7 +87,7 @@ export class ElementEditor extends HTMLElement {
87
87
  if (target.classList.contains("nested-elements")) {
88
88
  return
89
89
  }
90
- this.setDirty(target)
90
+ this.closest("alchemy-element-editor").setDirty(target)
91
91
  event.stopPropagation()
92
92
  return false
93
93
  }
@@ -555,6 +555,15 @@ export class ElementEditor extends HTMLElement {
555
555
  return this.querySelector("alchemy-element-editor")
556
556
  }
557
557
 
558
+ /**
559
+ * The form element if present
560
+ *
561
+ * @returns {HTMLFormElement|undefined}
562
+ */
563
+ get form() {
564
+ return this.querySelector("form.element-body")
565
+ }
566
+
558
567
  /**
559
568
  * The parent element editor if present
560
569
  *
@@ -39,6 +39,10 @@ class Message extends HTMLElement {
39
39
  return this.hasAttribute("dismissable")
40
40
  }
41
41
 
42
+ get icon() {
43
+ return this.getAttribute("icon")
44
+ }
45
+
42
46
  get type() {
43
47
  return this.getAttribute("type") || "notice"
44
48
  }
@@ -50,7 +54,7 @@ class Message extends HTMLElement {
50
54
  }
51
55
 
52
56
  get iconName() {
53
- switch (this.type) {
57
+ switch (this.icon || this.type) {
54
58
  case "warning":
55
59
  case "warn":
56
60
  case "alert":
@@ -85,13 +85,13 @@ export class FileUpload extends AlchemyHTMLElement {
85
85
  errorMessage = translate("Uploaded bytes exceed file size")
86
86
  }
87
87
 
88
- const fileConfiguration = this.file?.type.includes("image")
89
- ? "allowed_filetype_pictures"
90
- : "allowed_filetype_attachments"
88
+ const allowedFiletypes = this.file?.type.includes("image")
89
+ ? config.allowed_filetypes.alchemy_pictures
90
+ : config.allowed_filetypes.alchemy_attachments
91
91
 
92
92
  const isFileFormatSupported =
93
- config[fileConfiguration] === "*" ||
94
- config[fileConfiguration].includes(
93
+ allowedFiletypes.includes("*") ||
94
+ allowedFiletypes.includes(
95
95
  this.file?.type.replace(/^\w+\/(\w+)(\+\w+)?/i, "$1")
96
96
  )
97
97
 
@@ -6,13 +6,17 @@ export default class ImageCropper {
6
6
  #cropFromField = null
7
7
  #cropSizeField = null
8
8
 
9
- constructor(image, defaultBox, aspectRatio, formFieldIds, elementId) {
9
+ constructor(image, settings) {
10
10
  this.image = image
11
- this.defaultBox = defaultBox
12
- this.aspectRatio = aspectRatio
13
- this.#cropFromField = document.getElementById(formFieldIds[0])
14
- this.#cropSizeField = document.getElementById(formFieldIds[1])
15
- this.elementId = elementId
11
+ this.defaultBox = settings.default_box
12
+ this.aspectRatio = settings.ratio
13
+ this.#cropFromField = document.getElementById(
14
+ settings.crop_from_form_field_id
15
+ )
16
+ this.#cropSizeField = document.getElementById(
17
+ settings.crop_size_form_field_id
18
+ )
19
+ this.elementId = settings.element_id
16
20
  this.dialog = Alchemy.currentDialog()
17
21
  if (this.dialog) {
18
22
  this.dialog.options.closed = () => this.destroy()
@@ -1,48 +1,21 @@
1
1
  import Hotkeys from "alchemy_admin/hotkeys"
2
2
  import pleaseWaitOverlay from "alchemy_admin/please_wait_overlay"
3
3
 
4
- /**
5
- * add change listener to select to redirect the user after selecting another locale or site
6
- * @param {string} selectId
7
- * @param {string} parameterName
8
- * @param {boolean} forcedReload
9
- */
10
- function selectHandler(selectId, parameterName, forcedReload = false) {
11
- $(`select#${selectId}`).on("change", function (e) {
12
- let url = window.location.pathname
13
- let delimiter = url.match(/\?/) ? "&" : "?"
14
- const location = `${url}${delimiter}${parameterName}=${$(this).val()}`
15
-
16
- if (forcedReload) {
17
- window.location.href = location
18
- } else {
19
- Turbo.visit(location, {})
20
- }
21
- })
22
- }
23
-
24
4
  export default function Initializer() {
25
5
  // We obviously have javascript enabled.
26
- $("html").removeClass("no-js")
6
+ document.documentElement.classList.remove("no-js")
27
7
 
28
8
  // Initialize hotkeys.
29
9
  Hotkeys()
30
10
 
31
11
  // Add observer for please wait overlay.
32
- $(".please_wait").on("click", pleaseWaitOverlay)
12
+ document.querySelectorAll(".please_wait").forEach((element) => {
13
+ element.addEventListener("click", pleaseWaitOverlay)
14
+ })
33
15
 
34
16
  // Hack for enabling tab focus for <a>'s styled as button.
35
- $("a.button").attr({ tabindex: 0 })
36
-
37
- // Locale select handler
38
- selectHandler("change_locale", "admin_locale", true)
39
-
40
- // Site select handler
41
- selectHandler("change_site", "site_id")
42
-
43
- // Submit forms of selects with `data-autosubmit="true"`
44
- $('select[data-auto-submit="true"]').on("change", function () {
45
- $(this.form).submit()
17
+ document.querySelectorAll("a.button").forEach((button) => {
18
+ button.setAttribute("tabindex", 0)
46
19
  })
47
20
 
48
21
  // Override the filter of keymaster.js so we can blur the fields on esc key.
@@ -47,12 +47,16 @@ const spriteUrl = document
47
47
  .getAttribute("href")
48
48
 
49
49
  const iconMap = {
50
- "x-lg": "close"
50
+ "x-lg": "close",
51
+ caret: "arrow-down-s"
51
52
  }
52
53
 
53
54
  const options = {
54
55
  resolver: (name) => `${spriteUrl}#ri-${iconMap[name] || name}-line`,
55
- mutator: (svg) => svg.setAttribute("fill", "currentColor"),
56
+ mutator: (svg) => {
57
+ svg.setAttribute("fill", "currentColor")
58
+ svg.setAttribute("viewBox", "0 0 24 24")
59
+ },
56
60
  spriteSheet: true
57
61
  }
58
62
 
@@ -1 +1 @@
1
- (()=>{var n=Handlebars.template;(Handlebars.templates=Handlebars.templates||{})["node_folder.hbs"]=n({1:function(n,e,l,a,r){return"right"},3:function(n,e,l,a,r){return"down"},compiler:[8,">= 4.3.0"],main:function(n,e,l,a,r){var o,t=n.lambda,d=n.escapeExpression,u=n.lookupProperty||function(n,e){if(Object.prototype.hasOwnProperty.call(n,e))return n[e]};return'<a class="node_folder" data-record-id="'+d(t(null!=(o=null!=e?u(e,"node"):e)?u(o,"id"):o,e))+'" data-record-type="'+d(t(null!=(o=null!=e?u(e,"node"):e)?u(o,"type"):o,e))+'">\n <alchemy-icon name="arrow-'+(null!=(o=u(l,"if").call(null!=e?e:n.nullContext||{},null!=(o=null!=e?u(e,"node"):e)?u(o,"folded"):o,{name:"if",hash:{},fn:n.program(1,r,0),inverse:n.program(3,r,0),data:r,loc:{start:{line:2,column:28},end:{line:2,column:72}}}))?o:"")+'-s"></alchemy-icon>\n</a>\n'},useData:!0})})();
1
+ (()=>{var n=Handlebars.template;(Handlebars.templates=Handlebars.templates||{})["node_folder.hbs"]=n({0:function(n,e,l,a,r){return"right"},1:function(n,e,l,a,r){return"down"},compiler:[8,">= 4.3.0"],main:function(n,e,l,a,r){var o,t=n.lambda,d=n.escapeExpression,u=n.lookupProperty||function(n,e){if(Object.prototype.hasOwnProperty.call(n,e))return n[e]};return'<a class="node_folder" data-record-id="'+d(t(null!=(o=null!=e?u(e,"node"):e)?u(o,"id"):o,e))+'" data-record-type="'+d(t(null!=(o=null!=e?u(e,"node"):e)?u(o,"type"):o,e))+'">\n <alchemy-icon name="arrow-'+(null!=(o=u(l,"if").call(null!=e?e:n.nullContext||{},null!=(o=null!=e?u(e,"node"):e)?u(o,"folded"):o,{name:"if",hash:{},fn:n.program(0,r,0),inverse:n.program(1,r,0),data:r,loc:{start:{line:2,column:28},end:{line:2,column:72}}}))?o:"")+'-s"></alchemy-icon>\n</a>\n'},useData:!0})})();
@@ -2,7 +2,7 @@
2
2
  import "handlebars"
3
3
  import "jquery"
4
4
  import "@ungap/custom-elements"
5
- import "@hotwired/turbo-rails"
5
+ import { Turbo } from "@hotwired/turbo-rails"
6
6
  import "select2"
7
7
 
8
8
  import Rails from "@rails/ujs"
@@ -49,4 +49,14 @@ Object.assign(Alchemy, {
49
49
 
50
50
  Rails.start()
51
51
  Turbo.config.forms.confirm = openConfirmDialog
52
- $(document).on("turbo:load", Initializer)
52
+ document.addEventListener("turbo:load", Initializer)
53
+
54
+ // Public API for extensions
55
+ export { RemoteSelect } from "alchemy_admin/components/remote_select"
56
+ export { on } from "alchemy_admin/utils/events"
57
+
58
+ // Page-specific modules - bundled to avoid dual-loading
59
+ export { default as ImageCropper } from "alchemy_admin/image_cropper"
60
+ export { default as ImageOverlay } from "alchemy_admin/image_overlay"
61
+ export { default as pictureSelector } from "alchemy_admin/picture_selector"
62
+ export { default as NodeTree } from "alchemy_admin/node_tree"
@@ -114,7 +114,7 @@ module Alchemy
114
114
 
115
115
  # Checks if the attachment is restricted, because it is attached on restricted pages only
116
116
  def restricted?
117
- pages.any? && pages.not_restricted.blank?
117
+ related_pages.any? && related_pages.not_restricted.blank?
118
118
  end
119
119
 
120
120
  # File name
@@ -1,6 +1,6 @@
1
1
  module Alchemy
2
2
  class Current < ActiveSupport::CurrentAttributes
3
- attribute :preview_page, :page, :language, :site
3
+ attribute :preview_page, :preview_time, :page, :language, :site
4
4
 
5
5
  def language
6
6
  super || Language.default
@@ -10,6 +10,10 @@ module Alchemy
10
10
  super || Site.first
11
11
  end
12
12
 
13
+ def preview_time
14
+ super || Time.current
15
+ end
16
+
13
17
  def preview_page=(page)
14
18
  super
15
19
 
@@ -75,11 +75,19 @@ module Alchemy
75
75
  # This is used to re-initialize the TinyMCE editor in the element editor.
76
76
  #
77
77
  def richtext_ingredients_ids
78
- ids = ingredients.select(&:has_tinymce?).collect(&:id)
79
- expanded_nested_elements = nested_elements.expanded
78
+ ids = ingredients.filter_map { |i| i.id if i.has_tinymce? }
79
+
80
+ # Use preloaded association if available, otherwise query
81
+ expanded_nested_elements = if association(:all_nested_elements).loaded?
82
+ all_nested_elements.select(&:expanded?)
83
+ else
84
+ nested_elements.expanded
85
+ end
86
+
80
87
  if expanded_nested_elements.present?
81
- ids += expanded_nested_elements.collect(&:richtext_ingredients_ids)
88
+ ids += expanded_nested_elements.map(&:richtext_ingredients_ids)
82
89
  end
90
+
83
91
  ids.flatten
84
92
  end
85
93
 
@@ -37,6 +37,8 @@ module Alchemy
37
37
  include Alchemy::Taggable
38
38
  include Publishable
39
39
 
40
+ attr_accessor :skip_ingredient_validations
41
+
40
42
  FORBIDDEN_DEFINITION_ATTRIBUTES = [
41
43
  "amount",
42
44
  "autogenerate",
@@ -96,6 +98,7 @@ module Alchemy
96
98
 
97
99
  validates_presence_of :name, on: :create
98
100
  validates_format_of :name, on: :create, with: NAME_REGEXP
101
+ validate :validate_same_page_version_as_parent
99
102
 
100
103
  after_initialize :set_default_public_on, if: :new_record?
101
104
 
@@ -326,6 +329,13 @@ module Alchemy
326
329
  self.public_on ||= Time.current
327
330
  end
328
331
 
332
+ def validate_same_page_version_as_parent
333
+ return unless parent_element
334
+ return if page_version_id == parent_element.page_version_id
335
+
336
+ errors.add(:page_version_id, :must_match_parent)
337
+ end
338
+
329
339
  def generate_nested_elements
330
340
  definition.autogenerate.each do |nestable_element|
331
341
  if nestable_elements.include?(nestable_element)
@@ -147,6 +147,8 @@ module Alchemy
147
147
 
148
148
  # @return [Boolean]
149
149
  def has_validations?
150
+ return false if element.skip_ingredient_validations
151
+
150
152
  definition.validate.any?
151
153
  end
152
154
 
@@ -5,8 +5,7 @@ module Alchemy
5
5
  # A text value from a select box
6
6
  #
7
7
  class Select < Alchemy::Ingredient
8
- allow_settings %i[display_inline select_values]
9
- allow_settings %i[display_inline select_values multiple]
8
+ allow_settings %i[allow_clear display_inline select_values multiple]
10
9
 
11
10
  serialize :value, coder: JSON
12
11
 
@@ -0,0 +1,21 @@
1
+ module Alchemy
2
+ # Generates an ETag for a page.
3
+ #
4
+ # By default, it uses the page's id and the ids of its published elements.
5
+ # You can customize this by providing your own generator in the configuration.
6
+ class Page::EtagGenerator
7
+ attr_reader :page
8
+
9
+ # @param page [Alchemy::Page] The page for which to generate the ETag.
10
+ def initialize(page)
11
+ @page = page
12
+ end
13
+
14
+ # @return [Array<Object>]
15
+ # @param args [Array<Object>] Additional arguments that can be used in the ETag generation.
16
+ def call(*args)
17
+ elements_cache_key = page.public_version&.elements&.published&.order(:id)&.pluck(:id)
18
+ [page, elements_cache_key, *args]
19
+ end
20
+ end
21
+ end