spina-blocks 0.3.0 → 0.4.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.
- checksums.yaml +4 -4
- data/README.md +126 -8
- data/app/assets/javascripts/spina/controllers/block_collection_controller.js +155 -42
- data/app/assets/javascripts/spina/controllers/content_blocks_controller.js +118 -0
- data/app/assets/javascripts/spina/controllers/nested_content_blocks_controller.js +4 -0
- data/app/assets/javascripts/spina/utils/dropdown_position.js +30 -0
- data/app/controllers/spina/blocks/admin/blocks_controller.rb +61 -9
- data/app/controllers/spina/blocks/admin/page_blocks_controller.rb +7 -0
- data/app/helpers/spina/blocks/blocks_helper.rb +20 -4
- data/app/models/concerns/spina/parts/block_filterable.rb +18 -0
- data/app/models/spina/blocks/account_extension.rb +32 -0
- data/app/models/spina/blocks/block.rb +27 -1
- data/app/models/spina/parts/block_collection.rb +2 -0
- data/app/models/spina/parts/block_reference.rb +21 -1
- data/app/models/spina/parts/content_block.rb +18 -0
- data/app/models/spina/parts/content_blocks.rb +25 -0
- data/app/models/spina/parts/nested_content_blocks.rb +15 -0
- data/app/views/spina/admin/parts/block_collections/_form.html.erb +33 -21
- data/app/views/spina/admin/parts/block_references/_form.html.erb +16 -5
- data/app/views/spina/admin/parts/content_blocks/_fields.html.erb +57 -0
- data/app/views/spina/admin/parts/content_blocks/_form.html.erb +73 -0
- data/app/views/spina/admin/parts/nested_content_blocks/_form.html.erb +75 -0
- data/app/views/spina/blocks/admin/blocks/_form.html.erb +9 -5
- data/app/views/spina/blocks/admin/blocks/_form_block_settings.html.erb +12 -0
- data/app/views/spina/blocks/admin/blocks/_new_block_modal_form.html.erb +42 -0
- data/app/views/spina/blocks/admin/blocks/index.html.erb +30 -5
- data/app/views/spina/blocks/admin/blocks/new.html.erb +5 -1
- data/app/views/spina/blocks/admin/page_blocks/index.html.erb +14 -8
- data/config/locales/en.yml +22 -0
- data/config/routes.rb +1 -0
- data/db/migrate/6_add_custom_block_fields_to_spina_blocks_blocks.rb +14 -0
- data/lib/spina/blocks/engine.rb +15 -2
- data/lib/spina/blocks/version.rb +1 -1
- metadata +13 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 24a864994819ca4dd7ee93b0cd6957d008d08586659ad9672654014b2add6961
|
|
4
|
+
data.tar.gz: 264aca6e16771526496af183ef5443091dc4ae70ea5f550bc0e29f0e9c5d1d5a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 297624cd733819fbdb489b8a6cfe6a5290959eac521aa11117fbf11aa4b50f999c9b4fccba3c3821e7d16a679b5023943b8bd2470f8a9f114529591a4bdfa899
|
|
7
|
+
data.tar.gz: 448f9470f24ae571bb6ac2f95aa882bca7b6504c968d5369d591b8e8a1d53711f6fc0d60becb2a793c228e0768b13ab63fc4ea3eb7add2bbf883b864d2fc42af
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Spina Blocks
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> **Warning**: This plugin is not production-ready and may cause issues. Use at your own risk.
|
|
4
4
|
|
|
5
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.
|
|
6
6
|
|
|
@@ -72,6 +72,12 @@ Spina::Theme.register do |theme|
|
|
|
72
72
|
}
|
|
73
73
|
]
|
|
74
74
|
|
|
75
|
+
# Custom blocks (layout blocks, created automatically during bootstrap)
|
|
76
|
+
theme.custom_blocks = [
|
|
77
|
+
{ name: "header", title: "Header Block", block_template: "header", category: "heroes" },
|
|
78
|
+
{ name: "footer", title: "Footer Block", block_template: "footer", category: "cta" }
|
|
79
|
+
]
|
|
80
|
+
|
|
75
81
|
# Page templates (can use BlockCollection or BlockReference parts)
|
|
76
82
|
theme.view_templates = [
|
|
77
83
|
{
|
|
@@ -119,6 +125,24 @@ theme.parts = [
|
|
|
119
125
|
]
|
|
120
126
|
```
|
|
121
127
|
|
|
128
|
+
#### Filtering blocks by template
|
|
129
|
+
|
|
130
|
+
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`:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
theme.parts = [
|
|
134
|
+
{ name: "hero_block", title: "Hero Block",
|
|
135
|
+
part_type: "Spina::Parts::BlockReference",
|
|
136
|
+
options: { block_template: "hero" } },
|
|
137
|
+
|
|
138
|
+
{ name: "sidebar_blocks", title: "Sidebar Blocks",
|
|
139
|
+
part_type: "Spina::Parts::BlockCollection",
|
|
140
|
+
options: { block_template: "sidebar_widget" } }
|
|
141
|
+
]
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
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).
|
|
145
|
+
|
|
122
146
|
Then in your template:
|
|
123
147
|
|
|
124
148
|
```erb
|
|
@@ -131,6 +155,94 @@ Then in your template:
|
|
|
131
155
|
<% end %>
|
|
132
156
|
```
|
|
133
157
|
|
|
158
|
+
## Custom blocks (layout blocks)
|
|
159
|
+
|
|
160
|
+
Custom blocks are non-deletable blocks that are automatically created during `rake spina:bootstrap`. They work similarly to Spina's `custom_pages` — once defined in the theme configuration, they are created on bootstrap and cannot be deleted through the admin interface. In the admin UI they are labeled as **Layout** blocks.
|
|
161
|
+
|
|
162
|
+
### Defining custom blocks
|
|
163
|
+
|
|
164
|
+
Add `custom_blocks` to your theme initializer:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
theme.custom_blocks = [
|
|
168
|
+
{ name: "header", title: "Header Block", block_template: "header", category: "heroes" },
|
|
169
|
+
{ name: "footer", title: "Footer Block", block_template: "footer", category: "cta" }
|
|
170
|
+
]
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Each custom block accepts:
|
|
174
|
+
|
|
175
|
+
| Key | Description |
|
|
176
|
+
| ---------------- | ------------------------------------------------------------------------ |
|
|
177
|
+
| `name` | Immutable machine key (used for lookups in code and templates) |
|
|
178
|
+
| `title` | (Optional) Initial display name shown in admin. Defaults to `name.titleize`. Editable by users after creation |
|
|
179
|
+
| `block_template` | Must match a template defined in `theme.block_templates` |
|
|
180
|
+
| `category` | (Optional) Name of a category from `theme.block_categories` |
|
|
181
|
+
|
|
182
|
+
### Key vs Name
|
|
183
|
+
|
|
184
|
+
Custom blocks have two identifiers:
|
|
185
|
+
|
|
186
|
+
- **`key`** — an immutable machine identifier (set from `name` in the config). Used by `render_custom_block`, `BlockReference` with `custom_block` option, and for bootstrap lookups. Cannot be changed after creation. Shown as read-only in the block settings tab.
|
|
187
|
+
- **`name`** — the display name shown in the admin UI. Initially set from `title` (or `name.titleize` if `title` is omitted). Users can freely rename it without breaking any template references.
|
|
188
|
+
|
|
189
|
+
Regular (non-layout) blocks do not have a `key` — it is `nil` for them.
|
|
190
|
+
|
|
191
|
+
### How it works
|
|
192
|
+
|
|
193
|
+
- **Bootstrap**: Custom blocks are created (or updated) automatically whenever `Spina::Account` is saved — this includes `rake spina:bootstrap` and admin account edits. They are created with `deletable: false` and an immutable `key`.
|
|
194
|
+
- **Idempotent**: Running bootstrap multiple times will not duplicate blocks. Existing blocks are found by `key` and updated. The display `name` is only set on initial creation — subsequent bootstraps preserve user edits to the display name.
|
|
195
|
+
- **Deletion protection**: Layout blocks cannot be deleted through the admin UI or programmatically via `destroy`. In the block editor, the `...` menu shows "Block can't be deleted" (matching Spina's page behavior). The controller also rejects delete requests.
|
|
196
|
+
- **Template restriction**: Block templates used by custom blocks are excluded from the "New block" form, preventing users from manually creating blocks with layout templates.
|
|
197
|
+
- **Admin UI**: Layout blocks are marked with a **Layout** badge (right-aligned) in the block library. A **Layout blocks** filter is available in the template filter dropdown. On page block lists, the remove button is replaced with a "Layout" label. The `key` is displayed as read-only in the block settings tab.
|
|
198
|
+
|
|
199
|
+
### Using custom blocks in templates
|
|
200
|
+
|
|
201
|
+
#### Direct rendering by key
|
|
202
|
+
|
|
203
|
+
Use `render_custom_block` to render a system block directly in any template:
|
|
204
|
+
|
|
205
|
+
```erb
|
|
206
|
+
<%# app/views/my_theme/pages/homepage.html.erb %>
|
|
207
|
+
<%= render_custom_block("header") %>
|
|
208
|
+
|
|
209
|
+
<main>
|
|
210
|
+
<%= render_blocks %>
|
|
211
|
+
</main>
|
|
212
|
+
|
|
213
|
+
<%= render_custom_block("footer") %>
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
This looks up the block by its immutable `key` and renders it using the block's template partial, just like `render_block`. Renaming the block's display name in the admin will not affect this lookup.
|
|
217
|
+
|
|
218
|
+
#### As a pre-defined BlockReference
|
|
219
|
+
|
|
220
|
+
Use the `custom_block` option in a `BlockReference` part to hardcode which block it points to:
|
|
221
|
+
|
|
222
|
+
```ruby
|
|
223
|
+
theme.parts = [
|
|
224
|
+
{ name: "header_block", title: "Header",
|
|
225
|
+
part_type: "Spina::Parts::BlockReference",
|
|
226
|
+
options: { custom_block: "header" } },
|
|
227
|
+
|
|
228
|
+
{ name: "footer_block", title: "Footer",
|
|
229
|
+
part_type: "Spina::Parts::BlockReference",
|
|
230
|
+
options: { custom_block: "footer" } }
|
|
231
|
+
]
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
When `custom_block` is set:
|
|
235
|
+
- The part's `content` method returns the block by `key` (ignoring `block_id`)
|
|
236
|
+
- In the admin form, instead of a dropdown, a clickable link to the block's edit page is shown
|
|
237
|
+
- If the custom block does not exist yet, a warning message is displayed
|
|
238
|
+
|
|
239
|
+
Then in your template:
|
|
240
|
+
|
|
241
|
+
```erb
|
|
242
|
+
<%= render_block(content(:header_block)) %>
|
|
243
|
+
<%= render_block(content(:footer_block)) %>
|
|
244
|
+
```
|
|
245
|
+
|
|
134
246
|
## Models
|
|
135
247
|
|
|
136
248
|
| Model | Description |
|
|
@@ -144,18 +256,24 @@ Then in your template:
|
|
|
144
256
|
The plugin adds:
|
|
145
257
|
|
|
146
258
|
- **Blocks** link in the Content section of the admin sidebar
|
|
147
|
-
- Block library with
|
|
259
|
+
- Block library with template filter dropdown
|
|
148
260
|
- Block editor with content fields (same as page editor)
|
|
149
261
|
- Page Blocks management page (per-page block assignment and ordering)
|
|
262
|
+
- **Layout** badge on non-deletable blocks in the block library (right-aligned)
|
|
263
|
+
- **Layout blocks** filter in the template filter dropdown
|
|
264
|
+
- `...` menu shows "Block can't be deleted" for layout blocks (like Spina pages)
|
|
265
|
+
- Block templates used by layout blocks are hidden from the "New block" form
|
|
266
|
+
- Immutable **key** shown as read-only in block settings for layout blocks
|
|
150
267
|
|
|
151
268
|
## Helper methods
|
|
152
269
|
|
|
153
|
-
| Helper | Description
|
|
154
|
-
| --------------------------------------- |
|
|
155
|
-
| `render_blocks(page)` | Render all blocks attached to a page via PageBlocks
|
|
156
|
-
| `render_block(block)` | Render a single block using its template partial
|
|
157
|
-
| `
|
|
158
|
-
| `
|
|
270
|
+
| Helper | Description |
|
|
271
|
+
| --------------------------------------- | ------------------------------------------------------ |
|
|
272
|
+
| `render_blocks(page)` | Render all blocks attached to a page via PageBlocks |
|
|
273
|
+
| `render_block(block)` | Render a single block using its template partial |
|
|
274
|
+
| `render_custom_block(key)` | Render a custom (layout) block by its immutable key |
|
|
275
|
+
| `block_content(block, :part_name)` | Access a block's content |
|
|
276
|
+
| `block_has_content?(block, :part_name)` | Check if a block has content |
|
|
159
277
|
|
|
160
278
|
## License
|
|
161
279
|
|
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus";
|
|
2
2
|
import Sortable from "libraries/sortablejs";
|
|
3
|
+
import { positionDropdown } from "spina/utils/dropdown_position";
|
|
3
4
|
|
|
4
5
|
export default class extends Controller {
|
|
6
|
+
#abortController = null;
|
|
7
|
+
#modalWasOpen = false;
|
|
8
|
+
#modalObserver = null;
|
|
9
|
+
|
|
10
|
+
get #searchQuery() {
|
|
11
|
+
return this.hasSearchInputTarget ? this.searchInputTarget.value : "";
|
|
12
|
+
}
|
|
13
|
+
|
|
5
14
|
static get targets() {
|
|
6
15
|
return [
|
|
7
16
|
"list",
|
|
8
17
|
"hiddenFields",
|
|
9
18
|
"dropdown",
|
|
10
|
-
"
|
|
19
|
+
"searchInput",
|
|
11
20
|
"emptyMessage",
|
|
12
21
|
"listItemTemplate",
|
|
13
22
|
"groupHeaderTemplate",
|
|
14
23
|
"dropdownOptionTemplate",
|
|
15
24
|
"dropdownEmptyTemplate",
|
|
25
|
+
"newBlockTemplate",
|
|
16
26
|
];
|
|
17
27
|
}
|
|
18
28
|
|
|
@@ -21,6 +31,8 @@ export default class extends Controller {
|
|
|
21
31
|
blocks: Array, // [{id, name, templateName, templateTitle}]
|
|
22
32
|
selectedIds: Array, // [id, id, ...]
|
|
23
33
|
editUrl: String, // base edit_modal URL with __ID__ placeholder
|
|
34
|
+
newUrl: String, // URL for new block modal
|
|
35
|
+
blocksDataUrl: String, // JSON endpoint to refresh blocks list
|
|
24
36
|
};
|
|
25
37
|
}
|
|
26
38
|
|
|
@@ -36,10 +48,17 @@ export default class extends Controller {
|
|
|
36
48
|
onEnd: this.reorderHiddenFields.bind(this),
|
|
37
49
|
});
|
|
38
50
|
this.render();
|
|
51
|
+
this.#observeModal();
|
|
39
52
|
}
|
|
40
53
|
|
|
41
54
|
disconnect() {
|
|
42
55
|
if (this.sortable) this.sortable.destroy();
|
|
56
|
+
if (this.#modalObserver) {
|
|
57
|
+
this.#modalObserver.disconnect();
|
|
58
|
+
this.#modalObserver = null;
|
|
59
|
+
}
|
|
60
|
+
this.#abortController?.abort();
|
|
61
|
+
this.#abortController = null;
|
|
43
62
|
}
|
|
44
63
|
|
|
45
64
|
// --- Actions ---
|
|
@@ -50,6 +69,9 @@ export default class extends Controller {
|
|
|
50
69
|
if (this.selectedIdsValue.indexOf(id) !== -1) return;
|
|
51
70
|
|
|
52
71
|
this.selectedIdsValue = this.selectedIdsValue.concat([id]);
|
|
72
|
+
if (this.hasSearchInputTarget) {
|
|
73
|
+
this.searchInputTarget.value = "";
|
|
74
|
+
}
|
|
53
75
|
this.render();
|
|
54
76
|
this.closeDropdown();
|
|
55
77
|
}
|
|
@@ -61,19 +83,23 @@ export default class extends Controller {
|
|
|
61
83
|
this.render();
|
|
62
84
|
}
|
|
63
85
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
86
|
+
onSearchInput() {
|
|
87
|
+
this.renderDropdown();
|
|
88
|
+
this.openDropdown();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
onSearchFocus() {
|
|
92
|
+
this.renderDropdown();
|
|
93
|
+
this.openDropdown();
|
|
73
94
|
}
|
|
74
95
|
|
|
75
96
|
closeOnOutsideClick(event) {
|
|
76
|
-
|
|
97
|
+
const clickedInsideSearch =
|
|
98
|
+
this.hasSearchInputTarget &&
|
|
99
|
+
this.searchInputTarget.contains(event.target);
|
|
100
|
+
const clickedInsideDropdown = this.dropdownTarget.contains(event.target);
|
|
101
|
+
|
|
102
|
+
if (!clickedInsideSearch && !clickedInsideDropdown) {
|
|
77
103
|
this.closeDropdown();
|
|
78
104
|
}
|
|
79
105
|
}
|
|
@@ -118,43 +144,74 @@ export default class extends Controller {
|
|
|
118
144
|
}
|
|
119
145
|
|
|
120
146
|
renderDropdown() {
|
|
121
|
-
|
|
147
|
+
let availableBlocks = this.blocksValue.filter((b) => {
|
|
122
148
|
return this.selectedIdsValue.indexOf(b.id) === -1;
|
|
123
149
|
});
|
|
124
150
|
|
|
151
|
+
// Filter by search query
|
|
152
|
+
const query = this.#searchQuery.toLowerCase().trim();
|
|
153
|
+
if (query) {
|
|
154
|
+
availableBlocks = availableBlocks.filter((b) => {
|
|
155
|
+
return (
|
|
156
|
+
b.name.toLowerCase().includes(query) ||
|
|
157
|
+
(b.templateTitle && b.templateTitle.toLowerCase().includes(query)) ||
|
|
158
|
+
(b.templateName && b.templateName.toLowerCase().includes(query))
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
125
163
|
this.dropdownTarget.innerHTML = "";
|
|
126
164
|
|
|
127
165
|
if (availableBlocks.length === 0) {
|
|
128
166
|
const empty = this.dropdownEmptyTemplateTarget.content.cloneNode(true);
|
|
129
167
|
this.dropdownTarget.appendChild(empty);
|
|
130
|
-
|
|
131
|
-
|
|
168
|
+
} else {
|
|
169
|
+
// Group by templateTitle
|
|
170
|
+
const groups = {};
|
|
171
|
+
availableBlocks.forEach((b) => {
|
|
172
|
+
const key = b.templateTitle || b.templateName || "Other";
|
|
173
|
+
if (!groups[key]) groups[key] = [];
|
|
174
|
+
groups[key].push(b);
|
|
175
|
+
});
|
|
132
176
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
177
|
+
Object.keys(groups)
|
|
178
|
+
.sort()
|
|
179
|
+
.forEach((groupName) => {
|
|
180
|
+
const header = this.groupHeaderTemplateTarget.content.cloneNode(true);
|
|
181
|
+
header.querySelector("[data-role='group-name']").textContent =
|
|
182
|
+
groupName;
|
|
183
|
+
this.dropdownTarget.appendChild(header);
|
|
140
184
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const option =
|
|
151
|
-
this.dropdownOptionTemplateTarget.content.cloneNode(true);
|
|
152
|
-
const button = option.querySelector("button");
|
|
153
|
-
button.dataset.blockId = block.id;
|
|
154
|
-
option.querySelector("[data-role='title']").textContent = block.name;
|
|
155
|
-
this.dropdownTarget.appendChild(option);
|
|
185
|
+
groups[groupName].forEach((block) => {
|
|
186
|
+
const option =
|
|
187
|
+
this.dropdownOptionTemplateTarget.content.cloneNode(true);
|
|
188
|
+
const button = option.querySelector("button");
|
|
189
|
+
button.dataset.blockId = block.id;
|
|
190
|
+
option.querySelector("[data-role='title']").textContent =
|
|
191
|
+
block.name;
|
|
192
|
+
this.dropdownTarget.appendChild(option);
|
|
193
|
+
});
|
|
156
194
|
});
|
|
157
|
-
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// "New block" link at the bottom of the dropdown
|
|
198
|
+
if (
|
|
199
|
+
this.hasNewBlockTemplateTarget &&
|
|
200
|
+
this.hasNewUrlValue &&
|
|
201
|
+
this.newUrlValue
|
|
202
|
+
) {
|
|
203
|
+
const newBlock = this.newBlockTemplateTarget.content.cloneNode(true);
|
|
204
|
+
const link = newBlock.querySelector("[data-role='new-block-link']");
|
|
205
|
+
if (link) {
|
|
206
|
+
link.href = this.newUrlValue;
|
|
207
|
+
}
|
|
208
|
+
this.dropdownTarget.appendChild(newBlock);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Reposition if dropdown is currently visible
|
|
212
|
+
if (this.dropdownTarget.style.display !== "none") {
|
|
213
|
+
positionDropdown(this.dropdownTarget);
|
|
214
|
+
}
|
|
158
215
|
}
|
|
159
216
|
|
|
160
217
|
renderEmptyMessage() {
|
|
@@ -197,20 +254,76 @@ export default class extends Controller {
|
|
|
197
254
|
}
|
|
198
255
|
|
|
199
256
|
openDropdown() {
|
|
257
|
+
if (this.dropdownTarget.style.display === "block") {
|
|
258
|
+
// Already open, just reposition
|
|
259
|
+
positionDropdown(this.dropdownTarget);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
200
263
|
this.dropdownTarget.style.display = "block";
|
|
201
|
-
this.
|
|
202
|
-
|
|
264
|
+
positionDropdown(this.dropdownTarget);
|
|
265
|
+
|
|
266
|
+
this.#abortController = new AbortController();
|
|
267
|
+
const { signal } = this.#abortController;
|
|
268
|
+
|
|
269
|
+
document.addEventListener("click", (e) => this.closeOnOutsideClick(e), {
|
|
270
|
+
capture: true,
|
|
271
|
+
signal,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
let scrollTimeout;
|
|
275
|
+
const debouncedPosition = () => {
|
|
276
|
+
clearTimeout(scrollTimeout);
|
|
277
|
+
scrollTimeout = setTimeout(() => positionDropdown(this.dropdownTarget), 100);
|
|
278
|
+
};
|
|
279
|
+
window.addEventListener("scroll", debouncedPosition, {
|
|
280
|
+
capture: true,
|
|
281
|
+
signal,
|
|
282
|
+
});
|
|
283
|
+
window.addEventListener("resize", debouncedPosition, { signal });
|
|
203
284
|
}
|
|
204
285
|
|
|
205
286
|
closeDropdown() {
|
|
206
287
|
this.dropdownTarget.style.display = "none";
|
|
207
|
-
if (this.
|
|
208
|
-
|
|
209
|
-
this._outsideClickHandler = null;
|
|
288
|
+
if (this.hasSearchInputTarget) {
|
|
289
|
+
this.searchInputTarget.value = "";
|
|
210
290
|
}
|
|
291
|
+
this.#abortController?.abort();
|
|
292
|
+
this.#abortController = null;
|
|
211
293
|
}
|
|
212
294
|
|
|
213
295
|
findBlock(id) {
|
|
214
296
|
return this.blocksValue.find((b) => b.id === id);
|
|
215
297
|
}
|
|
298
|
+
|
|
299
|
+
// --- Private ---
|
|
300
|
+
|
|
301
|
+
#observeModal() {
|
|
302
|
+
const modalFrame = document.querySelector("turbo-frame[id='modal']");
|
|
303
|
+
if (!modalFrame || !this.hasBlocksDataUrlValue || !this.blocksDataUrlValue)
|
|
304
|
+
return;
|
|
305
|
+
|
|
306
|
+
this.#modalWasOpen = false;
|
|
307
|
+
this.#modalObserver = new MutationObserver(() => {
|
|
308
|
+
const hasModal = modalFrame.querySelector(".modal");
|
|
309
|
+
if (hasModal) {
|
|
310
|
+
this.#modalWasOpen = true;
|
|
311
|
+
} else if (this.#modalWasOpen) {
|
|
312
|
+
this.#modalWasOpen = false;
|
|
313
|
+
this.refreshBlocks();
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
this.#modalObserver.observe(modalFrame, { childList: true });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
refreshBlocks() {
|
|
320
|
+
fetch(this.blocksDataUrlValue, {
|
|
321
|
+
headers: { Accept: "application/json" },
|
|
322
|
+
})
|
|
323
|
+
.then((response) => response.json())
|
|
324
|
+
.then((blocks) => {
|
|
325
|
+
this.blocksValue = blocks;
|
|
326
|
+
this.render();
|
|
327
|
+
});
|
|
328
|
+
}
|
|
216
329
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import Sortable from "libraries/sortablejs"
|
|
3
|
+
import { positionDropdown } from "spina/utils/dropdown_position"
|
|
4
|
+
|
|
5
|
+
// Manages a list of typed content blocks with add, remove, reorder, and collapse.
|
|
6
|
+
//
|
|
7
|
+
// Each block type has a pre-rendered <template> element. When the user adds a
|
|
8
|
+
// block, the template is cloned and the placeholder child_index is replaced
|
|
9
|
+
// with a unique timestamp so Rails form params are unique.
|
|
10
|
+
export default class extends Controller {
|
|
11
|
+
static targets = [
|
|
12
|
+
"list",
|
|
13
|
+
"block",
|
|
14
|
+
"blockContent",
|
|
15
|
+
"emptyMessage",
|
|
16
|
+
"typeMenu",
|
|
17
|
+
"blockTemplate"
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
#abortController = null
|
|
21
|
+
|
|
22
|
+
connect() {
|
|
23
|
+
this.sortable = Sortable.create(this.listTarget, {
|
|
24
|
+
handle: "[data-sortable-handle]",
|
|
25
|
+
animation: 150
|
|
26
|
+
})
|
|
27
|
+
this._updateEmptyMessage()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
disconnect() {
|
|
31
|
+
if (this.sortable) this.sortable.destroy()
|
|
32
|
+
this.#abortController?.abort()
|
|
33
|
+
this.#abortController = null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// --- Actions ---
|
|
37
|
+
|
|
38
|
+
addBlock(event) {
|
|
39
|
+
event.preventDefault()
|
|
40
|
+
const blockType = event.currentTarget.dataset.blockType
|
|
41
|
+
const template = this.blockTemplateTargets.find(
|
|
42
|
+
t => t.dataset.blockType === blockType
|
|
43
|
+
)
|
|
44
|
+
if (!template) return
|
|
45
|
+
|
|
46
|
+
const childIndex = template.dataset.childIndex
|
|
47
|
+
const uniqueId = new Date().getTime().toString()
|
|
48
|
+
const regex = new RegExp(childIndex, "g")
|
|
49
|
+
|
|
50
|
+
// Clone template content and replace placeholder child_index
|
|
51
|
+
const html = template.innerHTML.replace(regex, uniqueId)
|
|
52
|
+
this.listTarget.insertAdjacentHTML("beforeend", html)
|
|
53
|
+
|
|
54
|
+
this._closeTypeMenu()
|
|
55
|
+
this._updateEmptyMessage()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
removeBlock(event) {
|
|
59
|
+
event.preventDefault()
|
|
60
|
+
if (!confirm("Are you sure you want to remove this block?")) return
|
|
61
|
+
|
|
62
|
+
const block = event.currentTarget.closest(`[data-${this.identifier}-target='block']`)
|
|
63
|
+
if (block) {
|
|
64
|
+
block.remove()
|
|
65
|
+
this._updateEmptyMessage()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
toggleTypeMenu(event) {
|
|
70
|
+
event.preventDefault()
|
|
71
|
+
const menu = this.typeMenuTarget
|
|
72
|
+
const isOpen = menu.style.display !== "none"
|
|
73
|
+
|
|
74
|
+
if (isOpen) {
|
|
75
|
+
this._closeTypeMenu()
|
|
76
|
+
} else {
|
|
77
|
+
menu.style.display = "block"
|
|
78
|
+
positionDropdown(menu)
|
|
79
|
+
this.#abortController = new AbortController()
|
|
80
|
+
const { signal } = this.#abortController
|
|
81
|
+
|
|
82
|
+
// Close on outside click (delayed to avoid catching the current click)
|
|
83
|
+
setTimeout(() => {
|
|
84
|
+
document.addEventListener("click", (e) => {
|
|
85
|
+
if (!menu.contains(e.target) && !event.currentTarget.contains(e.target)) {
|
|
86
|
+
this._closeTypeMenu()
|
|
87
|
+
}
|
|
88
|
+
}, { capture: true, once: true, signal })
|
|
89
|
+
}, 0)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
toggleCollapse(event) {
|
|
94
|
+
event.preventDefault()
|
|
95
|
+
const block = event.currentTarget.closest(`[data-${this.identifier}-target='block']`)
|
|
96
|
+
if (!block) return
|
|
97
|
+
|
|
98
|
+
const content = block.querySelector(`[data-${this.identifier}-target='blockContent']`)
|
|
99
|
+
if (content) {
|
|
100
|
+
content.style.display = content.style.display === "none" ? "" : "none"
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- Private ---
|
|
105
|
+
|
|
106
|
+
_closeTypeMenu() {
|
|
107
|
+
this.typeMenuTarget.style.display = "none"
|
|
108
|
+
this.#abortController?.abort()
|
|
109
|
+
this.#abortController = null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
_updateEmptyMessage() {
|
|
113
|
+
if (this.hasEmptyMessageTarget) {
|
|
114
|
+
const hasBlocks = this.listTarget.children.length > 0
|
|
115
|
+
this.emptyMessageTarget.style.display = hasBlocks ? "none" : ""
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Re-exports the content_blocks_controller under the name "nested-content-blocks".
|
|
2
|
+
// This provides Stimulus scope isolation when ContentBlocks are used inside
|
|
3
|
+
// Repeaters or other ContentBlocks containers.
|
|
4
|
+
export { default } from "controllers/content_blocks_controller"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Positions an absolutely-positioned dropdown element above or below its
|
|
2
|
+
// container based on available viewport space.
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// import { positionDropdown } from "spina/utils/dropdown_position"
|
|
6
|
+
// positionDropdown(this.dropdownTarget)
|
|
7
|
+
//
|
|
8
|
+
export function positionDropdown(dropdown) {
|
|
9
|
+
const container = dropdown.parentElement
|
|
10
|
+
const containerRect = container.getBoundingClientRect()
|
|
11
|
+
const dropdownHeight = dropdown.offsetHeight
|
|
12
|
+
const viewportHeight = window.innerHeight
|
|
13
|
+
|
|
14
|
+
const spaceBelow = viewportHeight - containerRect.bottom
|
|
15
|
+
const spaceAbove = containerRect.top
|
|
16
|
+
|
|
17
|
+
if (spaceBelow < dropdownHeight && spaceAbove > spaceBelow) {
|
|
18
|
+
// Not enough space below and more space above — show above
|
|
19
|
+
dropdown.style.bottom = "100%"
|
|
20
|
+
dropdown.style.top = "auto"
|
|
21
|
+
dropdown.style.marginBottom = "4px"
|
|
22
|
+
dropdown.style.marginTop = "0"
|
|
23
|
+
} else {
|
|
24
|
+
// Default: show below
|
|
25
|
+
dropdown.style.bottom = "auto"
|
|
26
|
+
dropdown.style.top = "100%"
|
|
27
|
+
dropdown.style.marginTop = "4px"
|
|
28
|
+
dropdown.style.marginBottom = "0"
|
|
29
|
+
}
|
|
30
|
+
}
|