alchemy_cms 8.2.10 → 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 (200) 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/admin/resource/action.rb +6 -4
  50. data/app/components/alchemy/admin/resource/cell.rb +6 -4
  51. data/app/components/alchemy/admin/resource/table.rb +2 -14
  52. data/app/components/alchemy/ingredients/base_view.rb +1 -1
  53. data/app/components/alchemy/ingredients/headline_editor.rb +1 -1
  54. data/app/components/alchemy/ingredients/picture_view.rb +1 -1
  55. data/app/components/alchemy/ingredients/text_view.rb +2 -2
  56. data/app/controllers/alchemy/admin/dashboard/widgets_controller.rb +21 -0
  57. data/app/controllers/alchemy/admin/dashboard_controller.rb +3 -12
  58. data/app/controllers/alchemy/pages_controller.rb +5 -4
  59. data/app/helpers/alchemy/elements_block_helper.rb +1 -0
  60. data/app/javascript/alchemy_admin/components/attachment_select.js +1 -1
  61. data/app/javascript/alchemy_admin/components/auto_submit.js +15 -9
  62. data/app/javascript/alchemy_admin/components/char_counter.js +17 -7
  63. data/app/javascript/alchemy_admin/components/clipboard_button.js +2 -6
  64. data/app/javascript/alchemy_admin/components/color_select.js +13 -4
  65. data/app/javascript/alchemy_admin/components/datepicker.js +11 -14
  66. data/app/javascript/alchemy_admin/components/dialog_link.js +5 -2
  67. data/app/javascript/alchemy_admin/components/element_editor/delete_element_button.js +6 -3
  68. data/app/javascript/alchemy_admin/components/element_editor.js +45 -28
  69. data/app/javascript/alchemy_admin/components/element_select.js +8 -5
  70. data/app/javascript/alchemy_admin/components/elements_window.js +38 -31
  71. data/app/javascript/alchemy_admin/components/elements_window_handle.js +7 -3
  72. data/app/javascript/alchemy_admin/components/file_editor.js +5 -2
  73. data/app/javascript/alchemy_admin/components/ingredient_group.js +6 -4
  74. data/app/javascript/alchemy_admin/components/link_buttons/link_button.js +1 -2
  75. data/app/javascript/alchemy_admin/components/link_buttons/unlink_button.js +1 -2
  76. data/app/javascript/alchemy_admin/components/link_buttons.js +6 -2
  77. data/app/javascript/alchemy_admin/components/list_filter.js +44 -29
  78. data/app/javascript/alchemy_admin/components/message.js +22 -15
  79. data/app/javascript/alchemy_admin/components/overlay.js +5 -7
  80. data/app/javascript/alchemy_admin/components/page_publication_fields.js +38 -25
  81. data/app/javascript/alchemy_admin/components/page_select.js +1 -1
  82. data/app/javascript/alchemy_admin/components/picture_description_select.js +5 -2
  83. data/app/javascript/alchemy_admin/components/picture_editor.js +5 -10
  84. data/app/javascript/alchemy_admin/components/picture_thumbnail.js +4 -5
  85. data/app/javascript/alchemy_admin/components/preview_window.js +5 -10
  86. data/app/javascript/alchemy_admin/components/publish_page_button.js +2 -5
  87. data/app/javascript/alchemy_admin/components/remote_select.js +53 -23
  88. data/app/javascript/alchemy_admin/components/select.js +169 -26
  89. data/app/javascript/alchemy_admin/components/sortable_elements.js +1 -1
  90. data/app/javascript/alchemy_admin/components/spinner.js +11 -11
  91. data/app/javascript/alchemy_admin/components/tags_autocomplete.js +10 -2
  92. data/app/javascript/alchemy_admin/components/tinymce.js +16 -22
  93. data/app/javascript/alchemy_admin/components/uploader/file_upload.js +48 -45
  94. data/app/javascript/alchemy_admin/components/uploader/progress.js +70 -84
  95. data/app/javascript/alchemy_admin/components/uploader.js +71 -46
  96. data/app/javascript/alchemy_admin/dialog.js +3 -0
  97. data/app/javascript/alchemy_admin/hotkeys.js +0 -18
  98. data/app/javascript/alchemy_admin/image_cropper.js +7 -9
  99. data/app/javascript/alchemy_admin/initializer.js +21 -0
  100. data/app/javascript/alchemy_admin/utils/dispatch_page_dirty_event.js +7 -0
  101. data/app/javascript/alchemy_admin.js +0 -5
  102. data/app/javascript/tinymce/plugins/alchemy_link/index.js +9 -0
  103. data/app/jobs/alchemy/base_job.rb +2 -2
  104. data/app/jobs/alchemy/invalidate_elements_cache_job.rb +33 -0
  105. data/app/models/alchemy/page/page_naming.rb +28 -5
  106. data/app/models/alchemy/page/page_natures.rb +7 -2
  107. data/app/models/alchemy/page/page_scopes.rb +2 -2
  108. data/app/models/alchemy/page/url_path.rb +7 -2
  109. data/app/models/alchemy/page.rb +2 -2
  110. data/app/models/alchemy/page_definition.rb +1 -0
  111. data/app/models/alchemy/permissions.rb +1 -1
  112. data/app/models/concerns/alchemy/publishable.rb +3 -1
  113. data/app/models/concerns/alchemy/relatable_resource.rb +8 -0
  114. data/app/services/alchemy/page_finder.rb +88 -0
  115. data/app/stylesheets/alchemy/_custom-properties.scss +6 -4
  116. data/app/stylesheets/alchemy/_mixins.scss +1 -7
  117. data/app/stylesheets/alchemy/_themes.scss +13 -1
  118. data/app/stylesheets/alchemy/admin/_tom-select.scss +240 -0
  119. data/app/stylesheets/alchemy/admin/archive.scss +0 -1
  120. data/app/stylesheets/alchemy/admin/base.scss +0 -19
  121. data/app/stylesheets/alchemy/admin/dashboard.scss +395 -28
  122. data/app/stylesheets/alchemy/admin/elements.scss +14 -17
  123. data/app/stylesheets/alchemy/admin/form_fields.scss +3 -3
  124. data/app/stylesheets/alchemy/admin/forms.scss +107 -93
  125. data/app/stylesheets/alchemy/admin/icons.scss +28 -0
  126. data/app/stylesheets/alchemy/admin/image_library.scss +20 -10
  127. data/app/stylesheets/alchemy/admin/navigation.scss +4 -1
  128. data/app/stylesheets/alchemy/admin/popover.scss +3 -5
  129. data/app/stylesheets/alchemy/admin/resource_info.scss +11 -17
  130. data/app/stylesheets/alchemy/admin/shoelace.scss +8 -0
  131. data/app/stylesheets/alchemy/admin/sitemap.scss +5 -0
  132. data/app/stylesheets/alchemy/admin/tables.scss +32 -3
  133. data/app/stylesheets/alchemy/admin/toolbar.scss +0 -1
  134. data/app/stylesheets/alchemy/admin.scss +1 -0
  135. data/app/stylesheets/tinymce/skins/ui/alchemy/skin.scss +0 -4
  136. data/app/stylesheets/tinymce/skins/ui/alchemy-dark/skin.scss +0 -4
  137. data/app/types/alchemy/wildcard_url_type.rb +48 -0
  138. data/app/views/alchemy/_menubar.html.erb +1 -5
  139. data/app/views/alchemy/admin/attachments/edit.html.erb +6 -3
  140. data/app/views/alchemy/admin/dashboard/_dashboard.html.erb +3 -2
  141. data/app/views/alchemy/admin/dashboard/_footer.html.erb +22 -0
  142. data/app/views/alchemy/admin/dashboard/_stats.html.erb +7 -0
  143. data/app/views/alchemy/admin/dashboard/_top.html.erb +4 -12
  144. data/app/views/alchemy/admin/dashboard/_widgets.html.erb +7 -0
  145. data/app/views/alchemy/admin/dashboard/index.html.erb +0 -17
  146. data/app/views/alchemy/admin/dashboard/info.html.erb +1 -62
  147. data/app/views/alchemy/admin/dashboard/widgets/show.html.erb +3 -0
  148. data/app/views/alchemy/admin/elements/_form.html.erb +2 -1
  149. data/app/views/alchemy/admin/elements/_schedule.html.erb +2 -15
  150. data/app/views/alchemy/admin/elements/_schedule_fields.html.erb +2 -0
  151. data/app/views/alchemy/admin/layoutpages/edit.html.erb +6 -3
  152. data/app/views/alchemy/admin/nodes/_page_nodes.html.erb +10 -8
  153. data/app/views/alchemy/admin/pages/_form.html.erb +25 -19
  154. data/app/views/alchemy/admin/pages/_publication_fields.html.erb +2 -32
  155. data/app/views/alchemy/admin/pages/_table.html.erb +1 -18
  156. data/app/views/alchemy/admin/pages/configure.html.erb +2 -2
  157. data/app/views/alchemy/admin/pages/info.html.erb +6 -0
  158. data/app/views/alchemy/admin/resources/_form.html.erb +7 -4
  159. data/app/views/alchemy/admin/resources/edit.html.erb +3 -1
  160. data/app/views/alchemy/admin/resources/new.html.erb +3 -1
  161. data/app/views/alchemy/admin/styleguide/index.html.erb +52 -30
  162. data/app/views/alchemy/admin/translations/_en.js +4 -0
  163. data/app/views/layouts/alchemy/admin.html.erb +3 -3
  164. data/config/importmap.rb +3 -12
  165. data/config/locales/alchemy.en.yml +15 -0
  166. data/config/routes.rb +1 -0
  167. data/lib/alchemy/configuration/class_option.rb +46 -3
  168. data/lib/alchemy/configuration/collection_option.rb +4 -0
  169. data/lib/alchemy/configurations/dashboard.rb +79 -0
  170. data/lib/alchemy/configurations/main.rb +15 -0
  171. data/lib/alchemy/engine.rb +9 -3
  172. data/lib/alchemy/sprockets/skip_builds_compression.rb +33 -0
  173. data/lib/alchemy/test_support/capybara_helpers.rb +17 -0
  174. data/lib/alchemy/test_support/relatable_resource_examples.rb +20 -0
  175. data/lib/alchemy/test_support/rspec_matchers.rb +8 -0
  176. data/lib/alchemy/test_support/shared_publishable_examples.rb +38 -41
  177. data/lib/alchemy/tinymce.rb +1 -1
  178. data/lib/alchemy/version.rb +17 -3
  179. data/vendor/javascript/cropperjs.min.js +1 -1
  180. data/vendor/javascript/flatpickr.min.js +1 -1
  181. data/vendor/javascript/floating-ui.min.js +1 -0
  182. data/vendor/javascript/keymaster.min.js +1 -1
  183. data/vendor/javascript/rails-ujs.min.js +1 -1
  184. data/vendor/javascript/shoelace.min.js +93 -93
  185. data/vendor/javascript/sortable.min.js +1 -1
  186. data/vendor/javascript/tinymce.min.js +5 -1
  187. data/vendor/javascript/tom-select.min.js +1 -0
  188. metadata +58 -20
  189. data/app/javascript/alchemy_admin/components/alchemy_html_element.js +0 -129
  190. data/app/views/alchemy/admin/dashboard/_left_column.html.erb +0 -4
  191. data/app/views/alchemy/admin/dashboard/_right_column.html.erb +0 -9
  192. data/app/views/alchemy/admin/dashboard/widgets/_locked_pages.html.erb +0 -52
  193. data/app/views/alchemy/admin/dashboard/widgets/_recent_pages.html.erb +0 -34
  194. data/app/views/alchemy/admin/dashboard/widgets/_sites.html.erb +0 -25
  195. data/app/views/alchemy/admin/dashboard/widgets/_users.html.erb +0 -21
  196. data/app/views/alchemy/admin/languages/edit.html.erb +0 -1
  197. data/app/views/alchemy/admin/languages/new.html.erb +0 -1
  198. data/app/views/alchemy/admin/sites/edit.html.erb +0 -1
  199. data/app/views/alchemy/admin/sites/new.html.erb +0 -1
  200. data/db/migrate/20260702090038_add_index_to_elements_pages_join_table.rb +0 -14
@@ -14,12 +14,12 @@ const formatItem = (object) => {
14
14
  }
15
15
 
16
16
  class ColorSelect extends HTMLElement {
17
+ #select2 = null
18
+
17
19
  connectedCallback() {
18
20
  if (this.select) {
19
21
  this.#initializeSelect2()
20
- $(this.select).on("change", (event) =>
21
- this.#toggleColorPicker(event.val === "custom_color")
22
- )
22
+ this.#select2.on("change", this.#onSelectChange)
23
23
  } else {
24
24
  this.colorInput?.addEventListener("input", this)
25
25
  this.textInput?.addEventListener("input", this)
@@ -41,6 +41,15 @@ class ColorSelect extends HTMLElement {
41
41
  disconnectedCallback() {
42
42
  this.colorInput?.removeEventListener("input", this)
43
43
  this.textInput?.removeEventListener("input", this)
44
+ if (this.#select2) {
45
+ this.#select2.off("change", this.#onSelectChange)
46
+ this.#select2.select2("destroy")
47
+ this.#select2 = null
48
+ }
49
+ }
50
+
51
+ #onSelectChange = (event) => {
52
+ this.#toggleColorPicker(event.val === "custom_color")
44
53
  }
45
54
 
46
55
  #initializeSelect2() {
@@ -50,7 +59,7 @@ class ColorSelect extends HTMLElement {
50
59
  formatResult: formatItem,
51
60
  formatSelection: formatItem
52
61
  }
53
- $(this.select).select2(options)
62
+ this.#select2 = $(this.select).select2(options)
54
63
  }
55
64
 
56
65
  #toggleColorPicker(enabled = true) {
@@ -1,31 +1,24 @@
1
- import { AlchemyHTMLElement } from "alchemy_admin/components/alchemy_html_element"
2
1
  import { translate, currentLocale } from "alchemy_admin/i18n"
3
2
  import flatpickr from "flatpickr"
4
3
 
5
4
  const locale = currentLocale()
6
5
 
7
- class Datepicker extends AlchemyHTMLElement {
8
- static properties = {
9
- inputType: { default: "date" }
10
- }
11
-
12
- constructor() {
13
- super()
14
- this.flatpickr = undefined
15
- }
16
-
6
+ class Datepicker extends HTMLElement {
17
7
  // Load the locales for flatpickr before setting it up.
18
- async connected() {
8
+ async connectedCallback() {
19
9
  // English is the default locale for flatpickr, so we don't need to load it
20
10
  if (locale !== "en") {
21
11
  await import(`flatpickr/${locale}.js`)
22
12
  }
13
+ // Bail out if the element was disconnected while the locale was loading.
14
+ // Otherwise flatpickr would leak a calendar onto a detached input.
15
+ if (!this.isConnected) return
23
16
 
24
17
  this.flatpickr = flatpickr(this.inputField, this.flatpickrOptions)
25
18
  }
26
19
 
27
- disconnected() {
28
- this.flatpickr.destroy()
20
+ disconnectedCallback() {
21
+ this.flatpickr?.destroy()
29
22
  }
30
23
 
31
24
  get flatpickrOptions() {
@@ -56,6 +49,10 @@ class Datepicker extends AlchemyHTMLElement {
56
49
  get inputField() {
57
50
  return this.querySelector("input")
58
51
  }
52
+
53
+ get inputType() {
54
+ return this.getAttribute("input-type") || "date"
55
+ }
59
56
  }
60
57
 
61
58
  customElements.define("alchemy-datepicker", Datepicker)
@@ -1,11 +1,14 @@
1
1
  import { Dialog } from "alchemy_admin/dialog"
2
2
 
3
3
  export class DialogLink extends HTMLAnchorElement {
4
- constructor() {
5
- super()
4
+ connectedCallback() {
6
5
  this.addEventListener("click", this)
7
6
  }
8
7
 
8
+ disconnectedCallback() {
9
+ this.removeEventListener("click", this)
10
+ }
11
+
9
12
  handleEvent(evt) {
10
13
  if (!this.disabled) {
11
14
  this.openDialog()
@@ -3,14 +3,17 @@ import { removeTab } from "alchemy_admin/fixed_elements"
3
3
  import { growl } from "alchemy_admin/growler"
4
4
  import { reloadPreview } from "alchemy_admin/components/preview_window"
5
5
  import { openConfirmDialog } from "alchemy_admin/confirm_dialog"
6
- import { dispatchPageDirtyEvent } from "alchemy_admin/components/element_editor"
6
+ import { dispatchPageDirtyEvent } from "alchemy_admin/utils/dispatch_page_dirty_event"
7
7
 
8
8
  export class DeleteElementButton extends HTMLElement {
9
- constructor() {
10
- super()
9
+ connectedCallback() {
11
10
  this.button?.addEventListener("click", this)
12
11
  }
13
12
 
13
+ disconnectedCallback() {
14
+ this.button?.removeEventListener("click", this)
15
+ }
16
+
14
17
  async handleEvent() {
15
18
  const confirmed = await openConfirmDialog(this.message)
16
19
  if (confirmed) {
@@ -1,22 +1,22 @@
1
1
  import IngredientAnchorLink from "alchemy_admin/ingredient_anchor_link"
2
2
  import { post } from "alchemy_admin/utils/ajax"
3
3
  import { createHtmlElement } from "alchemy_admin/utils/dom_helpers"
4
+ import { dispatchPageDirtyEvent } from "alchemy_admin/utils/dispatch_page_dirty_event"
4
5
  import { growl } from "alchemy_admin/growler"
5
6
 
6
7
  import "alchemy_admin/components/element_editor/publish_element_button"
7
8
  import "alchemy_admin/components/element_editor/delete_element_button"
8
9
 
9
- export function dispatchPageDirtyEvent(data) {
10
- document.dispatchEvent(
11
- new CustomEvent("alchemy:page-dirty", {
12
- detail: { tooltip: data.publishButtonTooltip }
13
- })
14
- )
15
- }
16
-
17
10
  export class ElementEditor extends HTMLElement {
18
- constructor() {
19
- super()
11
+ #form = null
12
+ #header = null
13
+ #toggleButton = null
14
+
15
+ connectedCallback() {
16
+ // The placeholder while be being dragged is empty.
17
+ if (this.classList.contains("ui-sortable-placeholder")) {
18
+ return
19
+ }
20
20
 
21
21
  // Add event listeners
22
22
  this.addEventListener("click", this)
@@ -27,24 +27,16 @@ 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.form).on("change", this.onChange)
30
+ this.#form = this.form
31
+ if (this.#form) {
32
+ $(this.#form).on("change", this.onChange)
33
+ }
31
34
 
32
- this.header?.addEventListener("dblclick", () => {
33
- this.toggle()
34
- })
35
- this.toggleButton?.addEventListener("click", (evt) => {
36
- const elementEditor = evt.target.closest("alchemy-element-editor")
37
- if (elementEditor === this) {
38
- this.toggle()
39
- }
40
- })
41
- }
35
+ this.#header = this.header
36
+ this.#header?.addEventListener("dblclick", this.#onHeaderDblclick)
42
37
 
43
- connectedCallback() {
44
- // The placeholder while be being dragged is empty.
45
- if (this.classList.contains("ui-sortable-placeholder")) {
46
- return
47
- }
38
+ this.#toggleButton = this.toggleButton
39
+ this.#toggleButton?.addEventListener("click", this.#onToggleClick)
48
40
 
49
41
  // When newly created, focus the element and refresh the preview
50
42
  if (this.hasAttribute("created")) {
@@ -56,6 +48,20 @@ export class ElementEditor extends HTMLElement {
56
48
  }
57
49
  }
58
50
 
51
+ disconnectedCallback() {
52
+ this.removeEventListener("click", this)
53
+ this.removeEventListener("alchemy:element-update-title", this)
54
+ this.removeEventListener("ajax:complete", this)
55
+ if (this.#form) {
56
+ $(this.#form).off("change", this.onChange)
57
+ this.#form = null
58
+ }
59
+ this.#header?.removeEventListener("dblclick", this.#onHeaderDblclick)
60
+ this.#header = null
61
+ this.#toggleButton?.removeEventListener("click", this.#onToggleClick)
62
+ this.#toggleButton = null
63
+ }
64
+
59
65
  handleEvent(event) {
60
66
  switch (event.type) {
61
67
  case "click":
@@ -79,7 +85,7 @@ export class ElementEditor extends HTMLElement {
79
85
  }
80
86
  }
81
87
 
82
- onChange(event) {
88
+ onChange = (event) => {
83
89
  const target = event.target
84
90
  // SortableJS fires a native change event :/
85
91
  // and we do not want to set the element editor dirty
@@ -87,7 +93,7 @@ export class ElementEditor extends HTMLElement {
87
93
  if (target.classList.contains("nested-elements")) {
88
94
  return
89
95
  }
90
- this.closest("alchemy-element-editor").setDirty(target)
96
+ this.setDirty(target)
91
97
  event.stopPropagation()
92
98
  return false
93
99
  }
@@ -579,6 +585,17 @@ export class ElementEditor extends HTMLElement {
579
585
  get previewWindow() {
580
586
  return document.getElementById("alchemy_preview_window")
581
587
  }
588
+
589
+ #onHeaderDblclick = () => {
590
+ this.toggle()
591
+ }
592
+
593
+ #onToggleClick = (evt) => {
594
+ const elementEditor = evt.target.closest("alchemy-element-editor")
595
+ if (elementEditor === this) {
596
+ this.toggle()
597
+ }
598
+ }
582
599
  }
583
600
 
584
601
  customElements.define("alchemy-element-editor", ElementEditor)
@@ -20,10 +20,8 @@ const formatItem = (icon, text, hint) => {
20
20
  `
21
21
  }
22
22
 
23
- export class ElementSelect extends HTMLElement {
24
- constructor() {
25
- super()
26
- }
23
+ class ElementSelect extends HTMLElement {
24
+ #select2 = null
27
25
 
28
26
  connectedCallback() {
29
27
  const results = this.options
@@ -48,7 +46,12 @@ export class ElementSelect extends HTMLElement {
48
46
  formatSelection,
49
47
  placeholder: this.placeholder
50
48
  }
51
- $(this.inputField).select2(options)
49
+ this.#select2 = $(this.inputField).select2(options)
50
+ }
51
+
52
+ disconnectedCallback() {
53
+ this.#select2?.select2("destroy")
54
+ this.#select2 = null
52
55
  }
53
56
 
54
57
  get options() {
@@ -4,22 +4,51 @@ class ElementsWindow extends HTMLElement {
4
4
  #visible = true
5
5
  #turboFrame = null
6
6
 
7
- constructor() {
8
- super()
9
- this.#attachEvents()
10
- }
11
-
12
7
  connectedCallback() {
13
- this.toggleButton?.addEventListener("click", (evt) => {
14
- evt.preventDefault()
15
- this.toggle()
16
- })
8
+ this.toggleButton?.addEventListener("click", this.#onToggleClick)
9
+ this.collapseButton?.addEventListener("click", this.#onCollapseClick)
10
+ window.addEventListener("message", this.#onWindowMessage)
11
+ document.body.addEventListener("click", this.#onBodyClick)
17
12
  if (window.location.hash) {
18
13
  this.focusElementEditor(window.location.hash)
19
14
  }
20
15
  this.resize()
21
16
  }
22
17
 
18
+ disconnectedCallback() {
19
+ this.toggleButton?.removeEventListener("click", this.#onToggleClick)
20
+ this.collapseButton?.removeEventListener("click", this.#onCollapseClick)
21
+ window.removeEventListener("message", this.#onWindowMessage)
22
+ document.body.removeEventListener("click", this.#onBodyClick)
23
+ }
24
+
25
+ #onToggleClick = (evt) => {
26
+ evt.preventDefault()
27
+ this.toggle()
28
+ }
29
+
30
+ #onCollapseClick = () => {
31
+ this.collapseAllElements()
32
+ }
33
+
34
+ #onWindowMessage = (event) => {
35
+ const data = event.data
36
+ if (data?.message == "Alchemy.focusElementEditor") {
37
+ const element = document.getElementById(`element_${data.element_id}`)
38
+ this.show()
39
+ element?.focusElement()
40
+ }
41
+ }
42
+
43
+ #onBodyClick = (evt) => {
44
+ if (!evt.target.closest("alchemy-element-editor")) {
45
+ this.querySelectorAll("alchemy-element-editor").forEach((editor) => {
46
+ editor.classList.remove("selected")
47
+ })
48
+ this.previewWindow?.postMessage({ message: "Alchemy.blurElements" })
49
+ }
50
+ }
51
+
23
52
  collapseAllElements() {
24
53
  this.querySelectorAll(
25
54
  "alchemy-element-editor:not([compact]):not([fixed])"
@@ -101,28 +130,6 @@ class ElementsWindow extends HTMLElement {
101
130
  this.turboFrame.style.transitionProperty = dragged ? "none" : null
102
131
  this.turboFrame.style.pointerEvents = dragged ? "none" : null
103
132
  }
104
-
105
- #attachEvents() {
106
- this.collapseButton?.addEventListener("click", () => {
107
- this.collapseAllElements()
108
- })
109
- window.addEventListener("message", (event) => {
110
- const data = event.data
111
- if (data?.message == "Alchemy.focusElementEditor") {
112
- const element = document.getElementById(`element_${data.element_id}`)
113
- this.show()
114
- element?.focusElement()
115
- }
116
- })
117
- document.body.addEventListener("click", (evt) => {
118
- if (!evt.target.closest("alchemy-element-editor")) {
119
- this.querySelectorAll("alchemy-element-editor").forEach((editor) => {
120
- editor.classList.remove("selected")
121
- })
122
- this.previewWindow?.postMessage({ message: "Alchemy.blurElements" })
123
- }
124
- })
125
- }
126
133
  }
127
134
 
128
135
  customElements.define("alchemy-elements-window", ElementsWindow)
@@ -7,14 +7,18 @@ class ElementsWindowHandle extends HTMLElement {
7
7
  #minWidth = MIN_WIDTH
8
8
  #maxWidth = MAX_WIDTH
9
9
 
10
- constructor() {
11
- super()
12
-
10
+ connectedCallback() {
13
11
  this.addEventListener("mousedown", this)
14
12
  window.addEventListener("mousemove", this)
15
13
  window.addEventListener("mouseup", this)
16
14
  }
17
15
 
16
+ disconnectedCallback() {
17
+ this.removeEventListener("mousedown", this)
18
+ window.removeEventListener("mousemove", this)
19
+ window.removeEventListener("mouseup", this)
20
+ }
21
+
18
22
  handleEvent(event) {
19
23
  switch (event.type) {
20
24
  case "mousedown":
@@ -1,6 +1,5 @@
1
1
  class FileEditor extends HTMLElement {
2
- constructor() {
3
- super()
2
+ connectedCallback() {
4
3
  this.deleteLink = this.querySelector(".remove_file_link")
5
4
  this.fileIcon = this.querySelector(".file_icon")
6
5
  this.fileName = this.querySelector(".file_name")
@@ -9,6 +8,10 @@ class FileEditor extends HTMLElement {
9
8
  this.deleteLink?.addEventListener("click", this)
10
9
  }
11
10
 
11
+ disconnectedCallback() {
12
+ this.deleteLink?.removeEventListener("click", this)
13
+ }
14
+
12
15
  handleEvent(event) {
13
16
  if (event.type === "click") this.removeFile()
14
17
  event.stopPropagation()
@@ -1,9 +1,7 @@
1
1
  export class IngredientGroup extends HTMLDetailsElement {
2
2
  #localStorageKey = "Alchemy.expanded_ingredient_groups"
3
3
 
4
- constructor() {
5
- super()
6
-
4
+ connectedCallback() {
7
5
  this.addEventListener("toggle", this)
8
6
 
9
7
  if (this.isInLocalStorage) {
@@ -11,6 +9,10 @@ export class IngredientGroup extends HTMLDetailsElement {
11
9
  }
12
10
  }
13
11
 
12
+ disconnectedCallback() {
13
+ this.removeEventListener("toggle", this)
14
+ }
15
+
14
16
  /**
15
17
  * Toggle visibility of the ingredient fields in this group
16
18
  */
@@ -21,7 +23,7 @@ export class IngredientGroup extends HTMLDetailsElement {
21
23
  this.toggleIcon.name = "arrow-down-s"
22
24
  if (!this.isInLocalStorage) expanded_ingredient_groups.push(this.id)
23
25
  } else {
24
- this.toggleIcon.name = "arrow-left-s"
26
+ this.toggleIcon.name = "arrow-right-s"
25
27
  expanded_ingredient_groups = expanded_ingredient_groups.filter(
26
28
  (value) => value !== this.id
27
29
  )
@@ -1,6 +1,5 @@
1
1
  class LinkButton extends HTMLButtonElement {
2
- constructor() {
3
- super()
2
+ connectedCallback() {
4
3
  this.addEventListener("click", this)
5
4
  this.classList.add("icon_button")
6
5
  // Prevent accidental form submits if this component is wrapped inside a form
@@ -1,6 +1,5 @@
1
1
  class UnlinkButton extends HTMLButtonElement {
2
- constructor() {
3
- super()
2
+ connectedCallback() {
4
3
  this.addEventListener("click", this)
5
4
  this.classList.add("icon_button")
6
5
  // Prevent accidental form submits if this component is wrapped inside a form
@@ -2,12 +2,16 @@ import "alchemy_admin/components/link_buttons/link_button"
2
2
  import "alchemy_admin/components/link_buttons/unlink_button"
3
3
 
4
4
  class LinkButtons extends HTMLElement {
5
- constructor() {
6
- super()
5
+ connectedCallback() {
7
6
  this.addEventListener("alchemy:link", this)
8
7
  this.addEventListener("alchemy:unlink", this)
9
8
  }
10
9
 
10
+ disconnectedCallback() {
11
+ this.removeEventListener("alchemy:link", this)
12
+ this.removeEventListener("alchemy:unlink", this)
13
+ }
14
+
11
15
  handleEvent(event) {
12
16
  switch (event.type) {
13
17
  case "alchemy:link":
@@ -2,41 +2,30 @@ const DEFAULT_DEBOUNCE_TIME = 150
2
2
 
3
3
  class ListFilter extends HTMLElement {
4
4
  #debounceTimer
5
+ #filterField = null
6
+ #clearButton = null
5
7
 
6
- constructor() {
7
- super()
8
- this.#attachEvents()
9
- }
10
-
11
- #attachEvents() {
8
+ connectedCallback() {
12
9
  if (this.hotkey) {
13
- key(this.hotkey, () => {
14
- this.filterField.focus()
15
- return false
16
- })
10
+ key(this.hotkey, this.#onHotkey)
17
11
  }
18
- this.filterField.addEventListener("keyup", () => {
19
- clearTimeout(this.#debounceTimer)
20
- this.#debounceTimer = setTimeout(() => {
21
- const term = this.filterField.value
22
- this.clearButton.style.visibility = term ? "visible" : "hidden"
23
- this.filter(term)
24
- }, this.debounceTime)
25
- })
26
- this.clearButton.addEventListener("click", (e) => {
27
- e.preventDefault()
28
- this.clear()
29
- })
30
- this.filterField.addEventListener("focus", () =>
31
- key.setScope("list_filter")
32
- )
33
- key("esc", "list_filter", () => {
34
- this.clear()
35
- this.filterField.blur()
36
- })
12
+ this.#filterField = this.filterField
13
+ this.#filterField.addEventListener("keyup", this.#onKeyup)
14
+ this.#filterField.addEventListener("focus", this.#onFocus)
15
+
16
+ this.#clearButton = this.clearButton
17
+ this.#clearButton.addEventListener("click", this.#onClearClick)
18
+
19
+ key("esc", "list_filter", this.#onEscape)
37
20
  }
38
21
 
39
22
  disconnectedCallback() {
23
+ clearTimeout(this.#debounceTimer)
24
+ this.#filterField?.removeEventListener("keyup", this.#onKeyup)
25
+ this.#filterField?.removeEventListener("focus", this.#onFocus)
26
+ this.#filterField = null
27
+ this.#clearButton?.removeEventListener("click", this.#onClearClick)
28
+ this.#clearButton = null
40
29
  if (this.hotkey) {
41
30
  key.unbind(this.hotkey)
42
31
  }
@@ -112,6 +101,32 @@ class ListFilter extends HTMLElement {
112
101
  get hotkey() {
113
102
  return this.getAttribute("hotkey")
114
103
  }
104
+
105
+ #onHotkey = () => {
106
+ this.filterField.focus()
107
+ return false
108
+ }
109
+
110
+ #onKeyup = () => {
111
+ clearTimeout(this.#debounceTimer)
112
+ this.#debounceTimer = setTimeout(() => {
113
+ const term = this.filterField.value
114
+ this.clearButton.style.visibility = term ? "visible" : "hidden"
115
+ this.filter(term)
116
+ }, this.debounceTime)
117
+ }
118
+
119
+ #onFocus = () => key.setScope("list_filter")
120
+
121
+ #onClearClick = (e) => {
122
+ e.preventDefault()
123
+ this.clear()
124
+ }
125
+
126
+ #onEscape = () => {
127
+ this.clear()
128
+ this.filterField.blur()
129
+ }
115
130
  }
116
131
 
117
132
  customElements.define("alchemy-list-filter", ListFilter)
@@ -1,15 +1,7 @@
1
1
  const DISMISS_DELAY = 5000
2
2
 
3
3
  class Message extends HTMLElement {
4
- #message
5
-
6
- constructor() {
7
- super()
8
- this.#message = this.innerHTML
9
- if (this.dismissable || this.type === "error") {
10
- this.addEventListener("click", this)
11
- }
12
- }
4
+ #dismissTimeoutId = null
13
5
 
14
6
  handleEvent(event) {
15
7
  if (event.type === "click") {
@@ -18,18 +10,33 @@ class Message extends HTMLElement {
18
10
  }
19
11
 
20
12
  connectedCallback() {
21
- this.innerHTML = `
22
- <alchemy-icon name="${this.iconName}"></alchemy-icon>
23
- ${this.dismissable && this.type === "error" ? '<alchemy-icon name="close"></alchemy-icon>' : ""}
24
- ${this.#message}
25
- `
13
+ if (!this.querySelector(":scope > alchemy-icon")) {
14
+ const closeIcon =
15
+ this.dismissable && this.type === "error"
16
+ ? '<alchemy-icon name="close"></alchemy-icon>'
17
+ : ""
18
+ this.insertAdjacentHTML(
19
+ "afterbegin",
20
+ `<alchemy-icon name="${this.iconName}"></alchemy-icon>${closeIcon}`
21
+ )
22
+ }
23
+ if (this.dismissable || this.type === "error") {
24
+ this.addEventListener("click", this)
25
+ }
26
26
  if (this.dismissable && this.type !== "error") {
27
- setTimeout(() => {
27
+ this.#dismissTimeoutId = setTimeout(() => {
28
28
  this.dismiss()
29
29
  }, this.dismissDelay)
30
30
  }
31
31
  }
32
32
 
33
+ disconnectedCallback() {
34
+ if (this.#dismissTimeoutId !== null) {
35
+ clearTimeout(this.#dismissTimeoutId)
36
+ this.#dismissTimeoutId = null
37
+ }
38
+ }
39
+
33
40
  dismiss() {
34
41
  this.addEventListener("transitionend", () => this.remove())
35
42
  this.classList.add("dismissed")
@@ -1,13 +1,11 @@
1
- import { AlchemyHTMLElement } from "alchemy_admin/components/alchemy_html_element"
2
-
3
- class Overlay extends AlchemyHTMLElement {
4
- render() {
5
- return `
1
+ class Overlay extends HTMLElement {
2
+ connectedCallback() {
3
+ this.innerHTML = `
6
4
  <alchemy-spinner></alchemy-spinner>
7
5
  <div id="overlay_text_box">
8
- <span id="overlay_text">${this.getAttribute("text")}</span>
6
+ <span id="overlay_text">${this.getAttribute("text") ?? ""}</span>
9
7
  </div>
10
- `
8
+ `
11
9
  }
12
10
 
13
11
  set show(value) {