spina-blocks 0.2.0 → 0.3.1

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +38 -12
  3. data/Rakefile +20 -2
  4. data/app/assets/javascripts/spina/controllers/block_collection_controller.js +188 -44
  5. data/app/controllers/spina/blocks/admin/blocks_controller.rb +64 -20
  6. data/app/controllers/spina/blocks/admin/categories_controller.rb +6 -6
  7. data/app/controllers/spina/blocks/admin/page_blocks_controller.rb +9 -9
  8. data/app/helpers/spina/blocks/blocks_helper.rb +3 -3
  9. data/app/models/concerns/spina/parts/block_filterable.rb +18 -0
  10. data/app/models/spina/blocks/account_extension.rb +27 -0
  11. data/app/models/spina/blocks/block.rb +5 -5
  12. data/app/models/spina/blocks/category.rb +1 -1
  13. data/app/models/spina/blocks/page_block.rb +2 -2
  14. data/app/models/spina/blocks/page_extension.rb +14 -0
  15. data/app/models/spina/blocks/search_extension.rb +15 -0
  16. data/app/models/spina/parts/block_collection.rb +2 -0
  17. data/app/models/spina/parts/block_reference.rb +2 -0
  18. data/app/views/spina/admin/parts/block_collections/_form.html.erb +41 -22
  19. data/app/views/spina/admin/parts/block_references/_form.html.erb +2 -2
  20. data/app/views/spina/admin/pro/search/results/_block.html.erb +23 -0
  21. data/app/views/spina/blocks/admin/blocks/_form_block_content.html.erb +2 -2
  22. data/app/views/spina/blocks/admin/blocks/_modal_form.html.erb +61 -0
  23. data/app/views/spina/blocks/admin/blocks/_new_block_form.html.erb +1 -1
  24. data/app/views/spina/blocks/admin/blocks/_new_block_modal_form.html.erb +42 -0
  25. data/app/views/spina/blocks/admin/blocks/edit_modal.html.erb +3 -0
  26. data/app/views/spina/blocks/admin/blocks/index.html.erb +1 -1
  27. data/app/views/spina/blocks/admin/blocks/new.html.erb +5 -1
  28. data/app/views/spina/blocks/admin/page_blocks/index.html.erb +3 -3
  29. data/config/locales/en.yml +1 -0
  30. data/config/routes.rb +5 -3
  31. data/db/migrate/1_create_spina_blocks_categories.rb +36 -0
  32. data/db/migrate/2_create_spina_blocks_blocks.rb +39 -0
  33. data/db/migrate/3_create_spina_blocks_page_blocks.rb +36 -0
  34. data/db/migrate/5_rename_title_to_name_in_spina_blocks_blocks.rb +18 -0
  35. data/lib/generators/spina/blocks/install_generator.rb +17 -0
  36. data/lib/spina/blocks/engine.rb +25 -25
  37. data/lib/spina/blocks/version.rb +1 -1
  38. data/lib/spina-blocks.rb +3 -3
  39. metadata +73 -13
  40. data/app/overrides/spina/account_override.rb +0 -19
  41. data/app/overrides/spina/page_override.rb +0 -6
  42. data/db/migrate/20250101000001_create_spina_blocks_categories.rb +0 -31
  43. data/db/migrate/20250101000002_create_spina_blocks_blocks.rb +0 -35
  44. data/db/migrate/20250101000003_create_spina_blocks_page_blocks.rb +0 -31
  45. data/db/migrate/20250101000004_remove_name_from_spina_blocks_blocks.rb +0 -24
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '08e197d3a0201be80942ea3c1807a1c538312e4ca58ddf9cc0c8ce08f39a0c05'
4
- data.tar.gz: 04ae851acfdbfbecf0f30486748f0283b9afa9d18c0bb8f03cde8f83fec21450
3
+ metadata.gz: 4bb4bd87c20dc0578090ce0f7e9b26a0ff511f79c52c1e11319527fb9a132362
4
+ data.tar.gz: 768fbfeb07527abc7d752e7f8af4d51ebd0e86f0e7fc643f313e11d11874de3d
5
5
  SHA512:
6
- metadata.gz: 4606d45194c8fabe89c79f51a8129af1d1fb8043ec07e6601a65c3c2d5f59997c475904ee04088f6966b3a56652584c20c41c0fb15433d22589b445373276af1
7
- data.tar.gz: cb7906b60e6100513d8e637759074e0e7ed6968a7a18deaa7a6b3806b93c86853efc9df43fdc0461e3c025d78f8070f6db2546bec0a60ab766929909b9440c2b
6
+ metadata.gz: 6887a086f16feb25de70d36706ef1f5492c8b3f2831a3e1e7d583b1e62dcf79d547fa9e46edeca27805260a3e68835d9b1234a7144df0a1affd7ef5acf548fbc
7
+ data.tar.gz: 3741e38abf77005b798a73bdfbfc335f10c7a6109c88551ad6c649f287761714f15aa2e4467e5d65feed3e957c233210da0411e3691dc5204b3b479311dc52a2
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # Spina Blocks
2
2
 
3
+ > ⚠️ **Warning**: This plugin is not production-ready and may cause issues. Use at your own risk.
4
+
3
5
  A plugin for [Spina CMS](https://www.spinacms.com) that adds reusable block components. Blocks are independent content units with their own templates and fields that can be assembled into pages.
4
6
 
5
7
  ## Installation
@@ -14,9 +16,15 @@ Run:
14
16
 
15
17
  ```bash
16
18
  bundle install
17
- rails db:migrate
19
+ rails generate spina:blocks:install
18
20
  ```
19
21
 
22
+ This copies the plugin's migrations into your app and runs `db:migrate`.
23
+
24
+ ## Upgrading
25
+
26
+ If you are upgrading from a version that used timestamp migrations (`20250101000001`–`20250101000004`), the new migrations will detect the old version numbers in your `schema_migrations` table, clean them up automatically, and skip any tables that already exist. No manual intervention is needed.
27
+
20
28
  ## Configuration
21
29
 
22
30
  ### Theme setup
@@ -111,6 +119,24 @@ theme.parts = [
111
119
  ]
112
120
  ```
113
121
 
122
+ #### Filtering blocks by template
123
+
124
+ By default, `BlockReference` and `BlockCollection` show all active blocks in their selects. To limit the list to blocks of a specific template, pass `block_template` in the part's `options`:
125
+
126
+ ```ruby
127
+ theme.parts = [
128
+ { name: "hero_block", title: "Hero Block",
129
+ part_type: "Spina::Parts::BlockReference",
130
+ options: { block_template: "hero" } },
131
+
132
+ { name: "sidebar_blocks", title: "Sidebar Blocks",
133
+ part_type: "Spina::Parts::BlockCollection",
134
+ options: { block_template: "sidebar_widget" } }
135
+ ]
136
+ ```
137
+
138
+ With this configuration, the "Hero Block" select will only show blocks created with the `hero` template, and "Sidebar Blocks" will only show `sidebar_widget` blocks. If `options` is omitted or does not contain `block_template`, all active blocks are shown (the default behavior).
139
+
114
140
  Then in your template:
115
141
 
116
142
  ```erb
@@ -125,10 +151,10 @@ Then in your template:
125
151
 
126
152
  ## Models
127
153
 
128
- | Model | Description |
129
- |-------|-------------|
130
- | `Spina::Blocks::Block` | Reusable content block with template and parts |
131
- | `Spina::Blocks::Category` | Block category for organizing the library |
154
+ | Model | Description |
155
+ | -------------------------- | -------------------------------------------------- |
156
+ | `Spina::Blocks::Block` | Reusable content block with template and parts |
157
+ | `Spina::Blocks::Category` | Block category for organizing the library |
132
158
  | `Spina::Blocks::PageBlock` | Join model linking blocks to pages (with position) |
133
159
 
134
160
  ## Admin interface
@@ -142,13 +168,13 @@ The plugin adds:
142
168
 
143
169
  ## Helper methods
144
170
 
145
- | Helper | Description |
146
- |--------|-------------|
147
- | `render_blocks(page)` | Render all blocks attached to a page via PageBlocks |
148
- | `render_block(block)` | Render a single block using its template partial |
149
- | `block_content(block, :part_name)` | Access a block's content |
150
- | `block_has_content?(block, :part_name)` | Check if a block has content |
171
+ | Helper | Description |
172
+ | --------------------------------------- | --------------------------------------------------- |
173
+ | `render_blocks(page)` | Render all blocks attached to a page via PageBlocks |
174
+ | `render_block(block)` | Render a single block using its template partial |
175
+ | `block_content(block, :part_name)` | Access a block's content |
176
+ | `block_has_content?(block, :part_name)` | Check if a block has content |
151
177
 
152
178
  ## License
153
179
 
154
- MIT
180
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
data/Rakefile CHANGED
@@ -1,4 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'bundler/setup'
4
- require 'bundler/gem_tasks'
3
+ require "bundler/gem_tasks"
4
+
5
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
6
+ load "rails/tasks/engine.rake"
7
+
8
+ # The engine.rake mechanism auto-appends db/migrate/ from ENGINE_ROOT to the
9
+ # migration paths. This causes ordering issues because engine migrations use
10
+ # sequential versions (1, 2, 3, 4) that sort before the timestamped copies in
11
+ # spec/dummy/db/migrate/. Since we copy engine migrations to the dummy app via
12
+ # railties:install:migrations, we remove the engine source path to avoid
13
+ # duplicates and ordering problems.
14
+ Rake::Task["app:db:load_config"].enhance do
15
+ engine_migrate = File.expand_path("db/migrate", ENGINE_ROOT)
16
+ ActiveRecord::Tasks::DatabaseTasks.migrations_paths.delete(engine_migrate)
17
+ end
18
+
19
+ require "rspec/core/rake_task"
20
+ RSpec::Core::RakeTask.new(spec: "app:db:test:prepare")
21
+
22
+ task default: :spec
@@ -2,24 +2,36 @@ import { Controller } from "@hotwired/stimulus";
2
2
  import Sortable from "libraries/sortablejs";
3
3
 
4
4
  export default class extends Controller {
5
+ #abortController = null;
6
+ #modalWasOpen = false;
7
+ #modalObserver = null;
8
+
9
+ get #searchQuery() {
10
+ return this.hasSearchInputTarget ? this.searchInputTarget.value : "";
11
+ }
12
+
5
13
  static get targets() {
6
14
  return [
7
15
  "list",
8
16
  "hiddenFields",
9
17
  "dropdown",
10
- "addButton",
18
+ "searchInput",
11
19
  "emptyMessage",
12
20
  "listItemTemplate",
13
21
  "groupHeaderTemplate",
14
22
  "dropdownOptionTemplate",
15
23
  "dropdownEmptyTemplate",
24
+ "newBlockTemplate",
16
25
  ];
17
26
  }
18
27
 
19
28
  static get values() {
20
29
  return {
21
- blocks: Array, // [{id, title, templateName, templateTitle}]
30
+ blocks: Array, // [{id, name, templateName, templateTitle}]
22
31
  selectedIds: Array, // [id, id, ...]
32
+ editUrl: String, // base edit_modal URL with __ID__ placeholder
33
+ newUrl: String, // URL for new block modal
34
+ blocksDataUrl: String, // JSON endpoint to refresh blocks list
23
35
  };
24
36
  }
25
37
 
@@ -35,10 +47,17 @@ export default class extends Controller {
35
47
  onEnd: this.reorderHiddenFields.bind(this),
36
48
  });
37
49
  this.render();
50
+ this.#observeModal();
38
51
  }
39
52
 
40
53
  disconnect() {
41
54
  if (this.sortable) this.sortable.destroy();
55
+ if (this.#modalObserver) {
56
+ this.#modalObserver.disconnect();
57
+ this.#modalObserver = null;
58
+ }
59
+ this.#abortController?.abort();
60
+ this.#abortController = null;
42
61
  }
43
62
 
44
63
  // --- Actions ---
@@ -49,6 +68,9 @@ export default class extends Controller {
49
68
  if (this.selectedIdsValue.indexOf(id) !== -1) return;
50
69
 
51
70
  this.selectedIdsValue = this.selectedIdsValue.concat([id]);
71
+ if (this.hasSearchInputTarget) {
72
+ this.searchInputTarget.value = "";
73
+ }
52
74
  this.render();
53
75
  this.closeDropdown();
54
76
  }
@@ -60,19 +82,23 @@ export default class extends Controller {
60
82
  this.render();
61
83
  }
62
84
 
63
- toggleDropdown(event) {
64
- event.preventDefault();
65
- event.stopPropagation();
66
- const dropdown = this.dropdownTarget;
67
- if (dropdown.style.display === "none" || dropdown.style.display === "") {
68
- this.openDropdown();
69
- } else {
70
- this.closeDropdown();
71
- }
85
+ onSearchInput() {
86
+ this.renderDropdown();
87
+ this.openDropdown();
88
+ }
89
+
90
+ onSearchFocus() {
91
+ this.renderDropdown();
92
+ this.openDropdown();
72
93
  }
73
94
 
74
95
  closeOnOutsideClick(event) {
75
- if (!this.element.contains(event.target)) {
96
+ const clickedInsideSearch =
97
+ this.hasSearchInputTarget &&
98
+ this.searchInputTarget.contains(event.target);
99
+ const clickedInsideDropdown = this.dropdownTarget.contains(event.target);
100
+
101
+ if (!clickedInsideSearch && !clickedInsideDropdown) {
76
102
  this.closeDropdown();
77
103
  }
78
104
  }
@@ -117,43 +143,74 @@ export default class extends Controller {
117
143
  }
118
144
 
119
145
  renderDropdown() {
120
- const availableBlocks = this.blocksValue.filter((b) => {
146
+ let availableBlocks = this.blocksValue.filter((b) => {
121
147
  return this.selectedIdsValue.indexOf(b.id) === -1;
122
148
  });
123
149
 
150
+ // Filter by search query
151
+ const query = this.#searchQuery.toLowerCase().trim();
152
+ if (query) {
153
+ availableBlocks = availableBlocks.filter((b) => {
154
+ return (
155
+ b.name.toLowerCase().includes(query) ||
156
+ (b.templateTitle && b.templateTitle.toLowerCase().includes(query)) ||
157
+ (b.templateName && b.templateName.toLowerCase().includes(query))
158
+ );
159
+ });
160
+ }
161
+
124
162
  this.dropdownTarget.innerHTML = "";
125
163
 
126
164
  if (availableBlocks.length === 0) {
127
165
  const empty = this.dropdownEmptyTemplateTarget.content.cloneNode(true);
128
166
  this.dropdownTarget.appendChild(empty);
129
- return;
130
- }
167
+ } else {
168
+ // Group by templateTitle
169
+ const groups = {};
170
+ availableBlocks.forEach((b) => {
171
+ const key = b.templateTitle || b.templateName || "Other";
172
+ if (!groups[key]) groups[key] = [];
173
+ groups[key].push(b);
174
+ });
131
175
 
132
- // Group by templateTitle
133
- const groups = {};
134
- availableBlocks.forEach((b) => {
135
- const key = b.templateTitle || b.templateName || "Other";
136
- if (!groups[key]) groups[key] = [];
137
- groups[key].push(b);
138
- });
176
+ Object.keys(groups)
177
+ .sort()
178
+ .forEach((groupName) => {
179
+ const header = this.groupHeaderTemplateTarget.content.cloneNode(true);
180
+ header.querySelector("[data-role='group-name']").textContent =
181
+ groupName;
182
+ this.dropdownTarget.appendChild(header);
139
183
 
140
- Object.keys(groups)
141
- .sort()
142
- .forEach((groupName) => {
143
- const header = this.groupHeaderTemplateTarget.content.cloneNode(true);
144
- header.querySelector("[data-role='group-name']").textContent =
145
- groupName;
146
- this.dropdownTarget.appendChild(header);
147
-
148
- groups[groupName].forEach((block) => {
149
- const option =
150
- this.dropdownOptionTemplateTarget.content.cloneNode(true);
151
- const button = option.querySelector("button");
152
- button.dataset.blockId = block.id;
153
- option.querySelector("[data-role='title']").textContent = block.title;
154
- this.dropdownTarget.appendChild(option);
184
+ groups[groupName].forEach((block) => {
185
+ const option =
186
+ this.dropdownOptionTemplateTarget.content.cloneNode(true);
187
+ const button = option.querySelector("button");
188
+ button.dataset.blockId = block.id;
189
+ option.querySelector("[data-role='title']").textContent =
190
+ block.name;
191
+ this.dropdownTarget.appendChild(option);
192
+ });
155
193
  });
156
- });
194
+ }
195
+
196
+ // "New block" link at the bottom of the dropdown
197
+ if (
198
+ this.hasNewBlockTemplateTarget &&
199
+ this.hasNewUrlValue &&
200
+ this.newUrlValue
201
+ ) {
202
+ const newBlock = this.newBlockTemplateTarget.content.cloneNode(true);
203
+ const link = newBlock.querySelector("[data-role='new-block-link']");
204
+ if (link) {
205
+ link.href = this.newUrlValue;
206
+ }
207
+ this.dropdownTarget.appendChild(newBlock);
208
+ }
209
+
210
+ // Reposition if dropdown is currently visible
211
+ if (this.dropdownTarget.style.display !== "none") {
212
+ this.#positionDropdown();
213
+ }
157
214
  }
158
215
 
159
216
  renderEmptyMessage() {
@@ -169,12 +226,18 @@ export default class extends Controller {
169
226
  const fragment = this.listItemTemplateTarget.content.cloneNode(true);
170
227
  const root = fragment.querySelector("[data-block-id]");
171
228
  root.dataset.blockId = block.id;
172
- fragment.querySelector("[data-role='title']").textContent = block.title;
229
+ fragment.querySelector("[data-role='title']").textContent = block.name;
173
230
  fragment.querySelector("[data-role='template-label']").textContent =
174
231
  "(" + (block.templateTitle || block.templateName) + ")";
175
232
  fragment.querySelector(
176
233
  "[data-action='block-collection#remove']",
177
234
  ).dataset.blockId = block.id;
235
+
236
+ const editLink = fragment.querySelector("[data-role='edit-link']");
237
+ if (editLink && this.hasEditUrlValue && this.editUrlValue) {
238
+ editLink.href = this.editUrlValue.replace("__ID__", block.id);
239
+ }
240
+
178
241
  return fragment;
179
242
  }
180
243
 
@@ -190,20 +253,101 @@ export default class extends Controller {
190
253
  }
191
254
 
192
255
  openDropdown() {
256
+ if (this.dropdownTarget.style.display === "block") {
257
+ // Already open, just reposition
258
+ this.#positionDropdown();
259
+ return;
260
+ }
261
+
193
262
  this.dropdownTarget.style.display = "block";
194
- this._outsideClickHandler = this.closeOnOutsideClick.bind(this);
195
- document.addEventListener("click", this._outsideClickHandler, true);
263
+ this.#positionDropdown();
264
+
265
+ this.#abortController = new AbortController();
266
+ const { signal } = this.#abortController;
267
+
268
+ document.addEventListener("click", (e) => this.closeOnOutsideClick(e), {
269
+ capture: true,
270
+ signal,
271
+ });
272
+
273
+ let scrollTimeout;
274
+ const debouncedPosition = () => {
275
+ clearTimeout(scrollTimeout);
276
+ scrollTimeout = setTimeout(() => this.#positionDropdown(), 100);
277
+ };
278
+ window.addEventListener("scroll", debouncedPosition, {
279
+ capture: true,
280
+ signal,
281
+ });
282
+ window.addEventListener("resize", debouncedPosition, { signal });
196
283
  }
197
284
 
198
285
  closeDropdown() {
199
286
  this.dropdownTarget.style.display = "none";
200
- if (this._outsideClickHandler) {
201
- document.removeEventListener("click", this._outsideClickHandler, true);
202
- this._outsideClickHandler = null;
287
+ if (this.hasSearchInputTarget) {
288
+ this.searchInputTarget.value = "";
203
289
  }
290
+ this.#abortController?.abort();
291
+ this.#abortController = null;
204
292
  }
205
293
 
206
294
  findBlock(id) {
207
295
  return this.blocksValue.find((b) => b.id === id);
208
296
  }
297
+
298
+ // --- Private ---
299
+
300
+ #positionDropdown() {
301
+ const dropdown = this.dropdownTarget;
302
+ const container = dropdown.parentElement;
303
+ const containerRect = container.getBoundingClientRect();
304
+ const dropdownHeight = dropdown.offsetHeight;
305
+ const viewportHeight = window.innerHeight;
306
+
307
+ const spaceBelow = viewportHeight - containerRect.bottom;
308
+ const spaceAbove = containerRect.top;
309
+
310
+ if (spaceBelow < dropdownHeight && spaceAbove > spaceBelow) {
311
+ // Not enough space below and more space above, show above
312
+ dropdown.style.bottom = "100%";
313
+ dropdown.style.top = "auto";
314
+ dropdown.style.marginBottom = "4px";
315
+ dropdown.style.marginTop = "0";
316
+ } else {
317
+ // Default: show below
318
+ dropdown.style.bottom = "auto";
319
+ dropdown.style.top = "100%";
320
+ dropdown.style.marginTop = "4px";
321
+ dropdown.style.marginBottom = "0";
322
+ }
323
+ }
324
+
325
+ #observeModal() {
326
+ const modalFrame = document.querySelector("turbo-frame[id='modal']");
327
+ if (!modalFrame || !this.hasBlocksDataUrlValue || !this.blocksDataUrlValue)
328
+ return;
329
+
330
+ this.#modalWasOpen = false;
331
+ this.#modalObserver = new MutationObserver(() => {
332
+ const hasModal = modalFrame.querySelector(".modal");
333
+ if (hasModal) {
334
+ this.#modalWasOpen = true;
335
+ } else if (this.#modalWasOpen) {
336
+ this.#modalWasOpen = false;
337
+ this.refreshBlocks();
338
+ }
339
+ });
340
+ this.#modalObserver.observe(modalFrame, { childList: true });
341
+ }
342
+
343
+ refreshBlocks() {
344
+ fetch(this.blocksDataUrlValue, {
345
+ headers: { Accept: "application/json" },
346
+ })
347
+ .then((response) => response.json())
348
+ .then((blocks) => {
349
+ this.blocksValue = blocks;
350
+ this.render();
351
+ });
352
+ }
209
353
  }
@@ -7,13 +7,13 @@ module Spina
7
7
  admin_section :content
8
8
 
9
9
  before_action :set_locale
10
- before_action :set_block, only: %i[edit edit_content update destroy]
11
- before_action :set_tabs, only: %i[edit update]
10
+ before_action :set_block, only: [:edit, :edit_content, :edit_modal, :update, :destroy]
11
+ before_action :set_tabs, only: [:edit, :edit_modal, :update]
12
12
 
13
13
  helper ::Spina::Admin::PagesHelper
14
14
 
15
15
  def index
16
- add_breadcrumb I18n.t('spina.blocks.title'), spina.blocks_admin_blocks_path
16
+ add_breadcrumb(I18n.t("spina.blocks.title"), spina.blocks_admin_blocks_path)
17
17
 
18
18
  @block_templates = current_theme.try(:block_templates) || []
19
19
 
@@ -29,24 +29,37 @@ module Spina
29
29
  def new
30
30
  @block_templates = current_theme.try(:block_templates) || []
31
31
  @block = Spina::Blocks::Block.new(block_template: params[:block_template])
32
+ @modal = params[:modal]
32
33
  end
33
34
 
34
35
  def create
35
36
  @block = Spina::Blocks::Block.new(block_params)
36
37
  if @block.save
37
- redirect_to spina.edit_blocks_admin_block_url(@block)
38
+ if params[:modal]
39
+ redirect_to(spina.edit_modal_blocks_admin_block_url(@block))
40
+ else
41
+ redirect_to(spina.edit_blocks_admin_block_url(@block))
42
+ end
38
43
  else
39
44
  @block_templates = current_theme.try(:block_templates) || []
40
- render turbo_stream: turbo_stream.update(
41
- helpers.dom_id(@block, :new_block_form),
42
- partial: 'new_block_form'
43
- )
45
+ if params[:modal]
46
+ @modal = true
47
+ render(turbo_stream: turbo_stream.update(
48
+ helpers.dom_id(@block, :new_block_modal_form),
49
+ partial: "new_block_modal_form",
50
+ ))
51
+ else
52
+ render(turbo_stream: turbo_stream.update(
53
+ helpers.dom_id(@block, :new_block_form),
54
+ partial: "new_block_form",
55
+ ))
56
+ end
44
57
  end
45
58
  end
46
59
 
47
60
  def edit
48
- add_breadcrumb I18n.t('spina.blocks.title'), spina.blocks_admin_blocks_path, class: 'text-gray-400'
49
- add_breadcrumb @block.title
61
+ add_breadcrumb(I18n.t("spina.blocks.title"), spina.blocks_admin_blocks_path, class: "text-gray-400")
62
+ add_breadcrumb(@block.name)
50
63
  end
51
64
 
52
65
  def edit_content
@@ -55,33 +68,56 @@ module Spina
55
68
  end&.dig(:parts) || []
56
69
  end
57
70
 
71
+ def edit_modal
72
+ end
73
+
58
74
  def update
59
75
  Mobility.locale = @locale
60
76
  if @block.update(block_params)
61
- flash[:success] = I18n.t('spina.blocks.saved')
62
- redirect_to spina.edit_blocks_admin_block_url(@block, params: { locale: @locale })
77
+ if params[:modal]
78
+ render(turbo_stream: turbo_stream.update("modal", ""))
79
+ else
80
+ flash[:success] = I18n.t("spina.blocks.saved")
81
+ redirect_to(spina.edit_blocks_admin_block_url(@block, params: { locale: @locale }))
82
+ end
83
+ elsif params[:modal]
84
+ flash.now[:error] = I18n.t("spina.blocks.couldnt_be_saved")
85
+ render(:edit_modal, status: :unprocessable_entity)
63
86
  else
64
- add_breadcrumb I18n.t('spina.blocks.title'), spina.blocks_admin_blocks_path, class: 'text-gray-400'
87
+ add_breadcrumb(I18n.t("spina.blocks.title"), spina.blocks_admin_blocks_path, class: "text-gray-400")
65
88
  Mobility.locale = I18n.locale
66
- add_breadcrumb @block.title
67
- flash.now[:error] = I18n.t('spina.blocks.couldnt_be_saved')
68
- render :edit, status: :unprocessable_entity
89
+ add_breadcrumb(@block.name)
90
+ flash.now[:error] = I18n.t("spina.blocks.couldnt_be_saved")
91
+ render(:edit, status: :unprocessable_entity)
69
92
  end
70
93
  end
71
94
 
95
+ def blocks_data
96
+ blocks = Spina::Blocks::Block.active.sorted
97
+ template_titles = build_template_titles
98
+ render(json: blocks.map do |b|
99
+ {
100
+ id: b.id,
101
+ name: b.name,
102
+ templateName: b.block_template.to_s,
103
+ templateTitle: template_titles[b.block_template.to_s] || b.block_template.to_s.titleize,
104
+ }
105
+ end)
106
+ end
107
+
72
108
  def sort
73
109
  params[:ids].each.with_index do |id, index|
74
110
  Spina::Blocks::Block.where(id: id).update_all(position: index + 1)
75
111
  end
76
112
 
77
- flash.now[:info] = I18n.t('spina.blocks.sorting_saved')
113
+ flash.now[:info] = I18n.t("spina.blocks.sorting_saved")
78
114
  render_flash
79
115
  end
80
116
 
81
117
  def destroy
82
- flash[:info] = I18n.t('spina.blocks.deleted')
118
+ flash[:info] = I18n.t("spina.blocks.deleted")
83
119
  @block.destroy
84
- redirect_to spina.blocks_admin_blocks_url
120
+ redirect_to(spina.blocks_admin_blocks_url)
85
121
  end
86
122
 
87
123
  private
@@ -95,7 +131,15 @@ module Spina
95
131
  end
96
132
 
97
133
  def set_tabs
98
- @tabs = %w[block_content block_settings]
134
+ @tabs = ["block_content", "block_settings"]
135
+ end
136
+
137
+ def build_template_titles
138
+ titles = {}
139
+ (current_theme.try(:block_templates) || []).each do |bt|
140
+ titles[bt[:name].to_s] = bt[:title].to_s
141
+ end
142
+ titles
99
143
  end
100
144
 
101
145
  def block_params
@@ -12,18 +12,18 @@ module Spina
12
12
 
13
13
  def edit
14
14
  @category = Spina::Blocks::Category.find(params[:id])
15
- add_breadcrumb I18n.t('spina.blocks.title'), spina.blocks_admin_blocks_path, class: 'text-gray-400'
16
- add_breadcrumb @category.label
15
+ add_breadcrumb(I18n.t("spina.blocks.title"), spina.blocks_admin_blocks_path, class: "text-gray-400")
16
+ add_breadcrumb(@category.label)
17
17
  end
18
18
 
19
19
  def update
20
20
  @category = Spina::Blocks::Category.find(params[:id])
21
21
  if @category.update(category_params)
22
- flash[:success] = I18n.t('spina.block_categories.saved')
23
- redirect_to spina.blocks_admin_blocks_url
22
+ flash[:success] = I18n.t("spina.block_categories.saved")
23
+ redirect_to(spina.blocks_admin_blocks_url)
24
24
  else
25
- flash.now[:error] = I18n.t('spina.block_categories.couldnt_be_saved')
26
- render :edit, status: :unprocessable_entity
25
+ flash.now[:error] = I18n.t("spina.block_categories.couldnt_be_saved")
26
+ render(:edit, status: :unprocessable_entity)
27
27
  end
28
28
  end
29
29
 
@@ -9,9 +9,9 @@ module Spina
9
9
  before_action :set_page
10
10
 
11
11
  def index
12
- add_breadcrumb I18n.t('spina.website.pages'), spina.admin_pages_path, class: 'text-gray-400'
13
- add_breadcrumb @page.title, spina.edit_admin_page_path(@page), class: 'text-gray-400'
14
- add_breadcrumb I18n.t('spina.page_blocks.title')
12
+ add_breadcrumb(I18n.t("spina.website.pages"), spina.admin_pages_path, class: "text-gray-400")
13
+ add_breadcrumb(@page.title, spina.edit_admin_page_path(@page), class: "text-gray-400")
14
+ add_breadcrumb(I18n.t("spina.page_blocks.title"))
15
15
 
16
16
  @page_blocks = @page.page_blocks.sorted.includes(:block)
17
17
  @available_blocks = Spina::Blocks::Block.active.sorted.where.not(id: @page.block_ids)
@@ -22,19 +22,19 @@ module Spina
22
22
  @page_block.position = @page.page_blocks.maximum(:position).to_i + 1
23
23
 
24
24
  if @page_block.save
25
- flash[:success] = I18n.t('spina.page_blocks.added')
25
+ flash[:success] = I18n.t("spina.page_blocks.added")
26
26
  else
27
- flash[:error] = I18n.t('spina.page_blocks.couldnt_be_added')
27
+ flash[:error] = I18n.t("spina.page_blocks.couldnt_be_added")
28
28
  end
29
29
 
30
- redirect_to spina.blocks_admin_page_page_blocks_url(page_id: @page.id)
30
+ redirect_to(spina.blocks_admin_page_page_blocks_url(page_id: @page.id))
31
31
  end
32
32
 
33
33
  def destroy
34
34
  @page_block = @page.page_blocks.find(params[:id])
35
35
  @page_block.destroy
36
- flash[:info] = I18n.t('spina.page_blocks.removed')
37
- redirect_to spina.blocks_admin_page_page_blocks_url(page_id: @page.id)
36
+ flash[:info] = I18n.t("spina.page_blocks.removed")
37
+ redirect_to(spina.blocks_admin_page_page_blocks_url(page_id: @page.id))
38
38
  end
39
39
 
40
40
  def sort
@@ -42,7 +42,7 @@ module Spina
42
42
  @page.page_blocks.where(id: id).update_all(position: index + 1)
43
43
  end
44
44
 
45
- flash.now[:info] = I18n.t('spina.page_blocks.sorting_saved')
45
+ flash.now[:info] = I18n.t("spina.page_blocks.sorting_saved")
46
46
  render_flash
47
47
  end
48
48