alchemy-widget 0.3.0-alpha.2 → 0.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.
@@ -65,6 +65,10 @@ Toolbar.addElementGetter('watchers_element', '.watchers');
65
65
  Toolbar.setMethod(function onToolbarManagerAssignment(manager, old_manager) {
66
66
  if (manager != old_manager) {
67
67
  this.prepareToolbarManager(manager, old_manager);
68
+ } else if (manager) {
69
+ // Same instance re-assigned - state may have changed
70
+ // Re-attach the document watcher to pick up any new viewers
71
+ this.attachDocumentWatcher(manager.state?.document_watcher);
68
72
  }
69
73
  });
70
74
 
@@ -97,6 +101,15 @@ Toolbar.setMethod(function prepareToolbarManager(manager, old_manager) {
97
101
  return;
98
102
  }
99
103
 
104
+ // Listen for the manager being replaced (e.g., after server restart and reconnection)
105
+ manager.on('replaced', (new_manager) => {
106
+ if (new_manager && new_manager !== manager) {
107
+ // Clear the PREPARED flag so the new manager can be prepared
108
+ new_manager[PREPARED] = false;
109
+ this.prepareToolbarManager(new_manager, manager);
110
+ }
111
+ });
112
+
100
113
  let clear_counts = {};
101
114
 
102
115
  manager.watchProperty('document_watcher', watcher => {
@@ -195,10 +208,18 @@ Toolbar.setMethod(function attachDocumentWatcher(watcher) {
195
208
 
196
209
  if (!viewer.info) {
197
210
  // The info isn't always set due to race conditions
198
- viewer.info = await watcher.getUserInfo(viewer.user_id);
211
+ try {
212
+ viewer.info = await watcher.getUserInfo(viewer.user_id);
213
+ } catch (err) {
214
+ // getUserInfo might fail if WebSocket is disconnected
215
+ continue;
216
+ }
199
217
  }
200
218
 
201
- users.push(viewer.info);
219
+ // Only push if we have valid info
220
+ if (viewer.info) {
221
+ users.push(viewer.info);
222
+ }
202
223
  }
203
224
  }
204
225
 
@@ -229,3 +250,169 @@ Toolbar.setMethod(function getAreaElement(area) {
229
250
 
230
251
  return this.querySelector('[data-area="' + area + '"]');
231
252
  });
253
+
254
+ /**
255
+ * Get the widget containers to edit.
256
+ * Child classes should override this.
257
+ *
258
+ * @author Jelle De Loecker <jelle@elevenways.be>
259
+ * @since 0.3.0
260
+ * @version 0.3.0
261
+ *
262
+ * @returns {Array} Array of widget elements
263
+ */
264
+ Toolbar.setMethod(function getTargetWidgets() {
265
+ return [];
266
+ });
267
+
268
+ /**
269
+ * Start editing the target widgets
270
+ *
271
+ * @author Jelle De Loecker <jelle@elevenways.be>
272
+ * @since 0.3.0
273
+ * @version 0.3.0
274
+ */
275
+ Toolbar.setMethod(function startEditing() {
276
+
277
+ let elements = this.getTargetWidgets();
278
+
279
+ if (!elements.length) {
280
+ return;
281
+ }
282
+
283
+ document.body.classList.add('editing-blocks');
284
+
285
+ for (let element of elements) {
286
+ element.startEditor();
287
+ }
288
+
289
+ this.setState('editing');
290
+ });
291
+
292
+ /**
293
+ * Stop editing the target widgets
294
+ *
295
+ * @author Jelle De Loecker <jelle@elevenways.be>
296
+ * @since 0.3.0
297
+ * @version 0.3.0
298
+ */
299
+ Toolbar.setMethod(function stopEditing() {
300
+
301
+ let elements = this.getTargetWidgets();
302
+
303
+ document.body.classList.remove('editing-blocks');
304
+
305
+ for (let element of elements) {
306
+ element.stopEditor();
307
+ }
308
+
309
+ this.setState('ready');
310
+ });
311
+
312
+ /**
313
+ * Save all the target widgets
314
+ *
315
+ * @author Jelle De Loecker <jelle@elevenways.be>
316
+ * @since 0.3.0
317
+ * @version 0.3.0
318
+ */
319
+ Toolbar.setMethod(async function saveAll() {
320
+
321
+ if (this._saving) {
322
+ try {
323
+ await this._saving;
324
+ } catch (err) {
325
+ // Ignore
326
+ }
327
+ }
328
+
329
+ this._saving = null;
330
+
331
+ let elements = this.getTargetWidgets();
332
+ let widget_data = [];
333
+ let pledge;
334
+
335
+ for (let element of elements) {
336
+ let entry = element.gatherSaveData();
337
+ if (entry) {
338
+ widget_data.push(entry);
339
+ }
340
+ }
341
+
342
+ if (widget_data.length) {
343
+ let config = {
344
+ href: alchemy.routeUrl('AlchemyWidgets#save'),
345
+ post: {
346
+ widgets: widget_data
347
+ }
348
+ };
349
+
350
+ pledge = alchemy.fetch(config);
351
+ this._saving = pledge;
352
+ }
353
+
354
+ return pledge;
355
+ });
356
+
357
+ /**
358
+ * Save all and update button states
359
+ *
360
+ * @author Jelle De Loecker <jelle@elevenways.be>
361
+ * @since 0.3.0
362
+ * @version 0.3.0
363
+ *
364
+ * @param {Boolean} before_stop Whether this is before stopping editing
365
+ */
366
+ Toolbar.setMethod(async function saveAllAndUpdateButtonStates(before_stop) {
367
+
368
+ let state = 'saving',
369
+ button;
370
+
371
+ if (before_stop) {
372
+ button = this.button_stop_and_save;
373
+ state += '-before-stop';
374
+ } else {
375
+ button = this.button_save_all;
376
+ }
377
+
378
+ if (!button) {
379
+ // No button available, just save
380
+ return this.saveAll();
381
+ }
382
+
383
+ this.setState(state);
384
+ button.setState(state);
385
+
386
+ let save_error = null;
387
+
388
+ let restore_toolbar_state = this.wrapForCurrentState(() => {
389
+ if (save_error) {
390
+ this.setState('error');
391
+ } else {
392
+ this.setState('editing');
393
+ }
394
+ });
395
+
396
+ let restore_button_state = button.wrapForCurrentState(() => {
397
+ if (save_error) {
398
+ button.setState('error');
399
+ } else {
400
+ button.setState('saved', 2500, 'ready');
401
+ }
402
+ });
403
+
404
+ try {
405
+ await this.saveAll();
406
+ } catch (err) {
407
+ save_error = err;
408
+ }
409
+
410
+ restore_toolbar_state();
411
+ restore_button_state();
412
+
413
+ if (save_error) {
414
+ return false;
415
+ }
416
+
417
+ return true;
418
+ });
@@ -16,15 +16,135 @@ let Toolbar = Function.inherits('Alchemy.Element.Widget.BaseToolbar', 'EditorToo
16
16
  */
17
17
  Toolbar.setTemplateFile('widget/elements/al_editor_toolbar');
18
18
 
19
+ /**
20
+ * The start-edit button
21
+ *
22
+ * @author Jelle De Loecker <jelle@elevenways.be>
23
+ * @since 0.3.0
24
+ * @version 0.3.0
25
+ */
26
+ Toolbar.addElementGetter('button_start', 'al-button.start-edit');
27
+
28
+ /**
29
+ * The stop-edit button
30
+ *
31
+ * @author Jelle De Loecker <jelle@elevenways.be>
32
+ * @since 0.3.0
33
+ * @version 0.3.0
34
+ */
35
+ Toolbar.addElementGetter('button_stop', 'al-button.stop-edit');
36
+
37
+ /**
38
+ * The stop-and-save button
39
+ *
40
+ * @author Jelle De Loecker <jelle@elevenways.be>
41
+ * @since 0.3.0
42
+ * @version 0.3.0
43
+ */
44
+ Toolbar.addElementGetter('button_stop_and_save', 'al-button.stop-and-save');
45
+
46
+ /**
47
+ * The save-all button
48
+ *
49
+ * @author Jelle De Loecker <jelle@elevenways.be>
50
+ * @since 0.3.0
51
+ * @version 0.3.0
52
+ */
53
+ Toolbar.addElementGetter('button_save_all', 'al-button.save-all');
54
+
55
+ /**
56
+ * Get the target widgets - only those matching the toolbar manager's document
57
+ *
58
+ * @author Jelle De Loecker <jelle@elevenways.be>
59
+ * @since 0.3.0
60
+ * @version 0.3.0
61
+ */
62
+ Toolbar.setMethod(function getTargetWidgets() {
63
+
64
+ let manager = this.toolbar_manager;
65
+
66
+ if (!manager) {
67
+ return [];
68
+ }
69
+
70
+ // Get the document from the manager
71
+ let doc = manager.state?.document_watcher;
72
+
73
+ if (!doc) {
74
+ // No document set, return empty
75
+ return [];
76
+ }
77
+
78
+ let doc_pk = doc.state?.pk;
79
+
80
+ if (!doc_pk) {
81
+ return [];
82
+ }
83
+
84
+ // Find all al-widgets elements and filter to those with matching record
85
+ let all_widgets = document.querySelectorAll('al-widgets');
86
+ let result = [];
87
+
88
+ for (let widget of all_widgets) {
89
+ if (widget.record && widget.record.$pk == doc_pk) {
90
+ result.push(widget);
91
+ }
92
+ }
93
+
94
+ return result;
95
+ });
96
+
19
97
  /**
20
98
  * Added to the dom for the first time
21
99
  *
22
100
  * @author Jelle De Loecker <jelle@elevenways.be>
23
101
  * @since 0.2.7
24
- * @version 0.2.7
102
+ * @version 0.3.0
25
103
  */
26
104
  Toolbar.setMethod(async function introduced() {
27
105
 
28
106
  this.prepareToolbarManager(this.toolbar_manager);
29
107
 
108
+ // Set up button event listeners
109
+ if (this.button_start) {
110
+ this.button_start.addEventListener('activate', async e => {
111
+ this.startEditing();
112
+ });
113
+ }
114
+
115
+ if (this.button_stop) {
116
+ this.button_stop.addEventListener('activate', async e => {
117
+ this.stopEditing();
118
+ });
119
+ }
120
+
121
+ if (this.button_save_all) {
122
+ this.button_save_all.addEventListener('activate', e => {
123
+ this.saveAllAndUpdateButtonStates(false);
124
+ });
125
+ }
126
+
127
+ if (this.button_stop_and_save) {
128
+ this.button_stop_and_save.addEventListener('activate', async e => {
129
+ let saved = await this.saveAllAndUpdateButtonStates(true);
130
+ if (saved) {
131
+ this.stopEditing();
132
+ }
133
+ });
134
+ }
135
+
136
+ // Handle new toolbar manager from server during client-side navigation
137
+ hawkejs.scene.on('rendered', (variables, renderer) => {
138
+ if (variables.toolbar_manager) {
139
+ this.prepareToolbarManager(variables.toolbar_manager, this.toolbar_manager);
140
+ }
141
+ });
142
+
143
+ // Handle navigation - stop editing when leaving the page
144
+ hawkejs.scene.on('opening_url', (href, options) => {
145
+ if (options?.history === false) {
146
+ return;
147
+ }
148
+ this.stopEditing();
149
+ });
30
150
  });
@@ -70,6 +70,11 @@ UserAvatarGroup.setMethod(function setUsers(users) {
70
70
  for (let i = 0; i < users.length; i++) {
71
71
  user = users[i];
72
72
 
73
+ // Skip null/undefined users
74
+ if (!user) {
75
+ continue;
76
+ }
77
+
73
78
  updated = false;
74
79
 
75
80
  for (let j = 0; j < existing_avatars.length; j++) {
@@ -0,0 +1,333 @@
1
+ /**
2
+ * The widget picker element
3
+ * A Notion-style dialog for selecting widgets to add
4
+ *
5
+ * @author Jelle De Loecker <jelle@elevenways.be>
6
+ * @since 0.3.0
7
+ * @version 0.3.0
8
+ */
9
+ const WidgetPicker = Function.inherits('Alchemy.Element.Widget.Base', 'WidgetPicker');
10
+
11
+ /**
12
+ * The template to use
13
+ */
14
+ WidgetPicker.setTemplateFile('widget/elements/widget_picker');
15
+
16
+ /**
17
+ * The stylesheet to use
18
+ */
19
+ WidgetPicker.setStylesheetFile('widget_picker');
20
+
21
+ /**
22
+ * The container element that will receive the selected widget
23
+ */
24
+ WidgetPicker.setAssignedProperty('target_container');
25
+
26
+ /**
27
+ * Search query attribute
28
+ */
29
+ WidgetPicker.setAttribute('search');
30
+
31
+ /**
32
+ * Currently selected category filter
33
+ */
34
+ WidgetPicker.setAttribute('category');
35
+
36
+ /**
37
+ * Get available widgets that can be added to the target container
38
+ *
39
+ * @return {Array} Array of widget info objects
40
+ */
41
+ WidgetPicker.setMethod(async function getAvailableWidgets() {
42
+
43
+ const Widget = Classes.Alchemy.Widget.Widget;
44
+ const widgets = Object.values(alchemy.getClassGroup('widgets'));
45
+ const container = this.target_container;
46
+ const results = [];
47
+
48
+ for (let widget of widgets) {
49
+ // Check if widget can be added to this container
50
+ let canAdd = widget.canBeAdded(container);
51
+
52
+ if (Pledge.isThenable(canAdd)) {
53
+ canAdd = await canAdd;
54
+ }
55
+
56
+ if (!canAdd) {
57
+ continue;
58
+ }
59
+
60
+ results.push({
61
+ type_name: widget.type_name,
62
+ title: widget.title || widget.type_name.titleize(),
63
+ description: widget.description || null, // Hardcoded description takes precedence
64
+ description_key: 'widget-' + widget.type_name + '-description',
65
+ icon: widget.icon || 'puzzle-piece',
66
+ category: widget.category || 'advanced',
67
+ categoryInfo: Widget.getCategoryInfo(widget.category)
68
+ });
69
+ }
70
+
71
+ // Sort by title
72
+ return results.sortByPath(1, 'title');
73
+ });
74
+
75
+ /**
76
+ * Get all categories that have widgets
77
+ *
78
+ * @param {Array} widgets Optional array of widgets (to avoid re-fetching)
79
+ *
80
+ * @return {Array} Array of category info objects
81
+ */
82
+ WidgetPicker.setMethod(function getUsedCategories(widgets) {
83
+
84
+ if (!widgets) {
85
+ widgets = this.available_widgets || [];
86
+ }
87
+
88
+ const Widget = Classes.Alchemy.Widget.Widget;
89
+ const categoryMap = new Map();
90
+
91
+ for (let widget of widgets) {
92
+ if (!categoryMap.has(widget.category)) {
93
+ categoryMap.set(widget.category, Widget.getCategoryInfo(widget.category));
94
+ }
95
+ }
96
+
97
+ // Convert to array and sort by order
98
+ return Array.from(categoryMap.values()).sortByPath(1, 'order');
99
+ });
100
+
101
+ /**
102
+ * Prepare render variables
103
+ */
104
+ WidgetPicker.setMethod(async function prepareRenderVariables() {
105
+
106
+ const widgets = await this.getAvailableWidgets();
107
+ const categories = this.getUsedCategories(widgets);
108
+
109
+ // Group widgets by category
110
+ const widgetsByCategory = {};
111
+ for (let widget of widgets) {
112
+ const cat = widget.category || 'advanced';
113
+ if (!widgetsByCategory[cat]) {
114
+ widgetsByCategory[cat] = [];
115
+ }
116
+ widgetsByCategory[cat].push(widget);
117
+ }
118
+
119
+ // Attach widgets directly to each category object
120
+ // This avoids bracket notation issues in templates
121
+ for (let category of categories) {
122
+ category.widgets = widgetsByCategory[category.name] || [];
123
+ }
124
+
125
+ return {
126
+ widgets,
127
+ categories
128
+ };
129
+ });
130
+
131
+ /**
132
+ * Filter widgets based on search and category
133
+ */
134
+ WidgetPicker.setMethod(function filterWidgets() {
135
+
136
+ const search = (this.search || '').toLowerCase().trim();
137
+ const category = this.category;
138
+ const items = this.querySelectorAll('.widget-item');
139
+ const categoryGroups = this.querySelectorAll('.widget-category-group');
140
+
141
+ // Track which categories have visible items
142
+ const visibleCategories = new Set();
143
+
144
+ items.forEach(item => {
145
+ const title = (item.dataset.title || '').toLowerCase();
146
+ const desc = (item.dataset.description || '').toLowerCase();
147
+ const itemCategory = item.dataset.category;
148
+
149
+ const matchesSearch = !search ||
150
+ title.includes(search) ||
151
+ desc.includes(search);
152
+ const matchesCategory = !category || itemCategory === category;
153
+
154
+ const visible = matchesSearch && matchesCategory;
155
+ item.hidden = !visible;
156
+
157
+ if (visible) {
158
+ visibleCategories.add(itemCategory);
159
+ }
160
+ });
161
+
162
+ // Show/hide category groups based on whether they have visible items
163
+ categoryGroups.forEach(group => {
164
+ const groupCategory = group.dataset.category;
165
+ group.hidden = !visibleCategories.has(groupCategory);
166
+ });
167
+
168
+ this.updateEmptyState();
169
+ });
170
+
171
+ /**
172
+ * Update the empty state message
173
+ */
174
+ WidgetPicker.setMethod(function updateEmptyState() {
175
+
176
+ const emptyEl = this.querySelector('.widget-picker-empty');
177
+ const visibleItems = this.querySelectorAll('.widget-item:not([hidden])');
178
+
179
+ if (emptyEl) {
180
+ emptyEl.hidden = visibleItems.length > 0;
181
+ }
182
+ });
183
+
184
+ /**
185
+ * Select a widget
186
+ */
187
+ WidgetPicker.setMethod(function selectWidget(type_name) {
188
+
189
+ // Emit the select event with the widget type name
190
+ this.dispatchEvent(new CustomEvent('select', {
191
+ bubbles: true,
192
+ detail: {type_name}
193
+ }));
194
+
195
+ // Close the dialog
196
+ const dialog = this.queryParents('he-dialog');
197
+ if (dialog) {
198
+ dialog.close();
199
+ }
200
+ });
201
+
202
+ /**
203
+ * Set up the search input
204
+ */
205
+ WidgetPicker.setMethod(function setupSearch() {
206
+
207
+ const input = this.querySelector('.widget-picker-search');
208
+
209
+ if (!input) return;
210
+
211
+ input.addEventListener('input', (e) => {
212
+ this.search = e.target.value;
213
+ this.filterWidgets();
214
+ });
215
+
216
+ // Focus search on open
217
+ requestAnimationFrame(() => input.focus());
218
+ });
219
+
220
+ /**
221
+ * Set up category filter buttons
222
+ */
223
+ WidgetPicker.setMethod(function setupCategoryFilters() {
224
+
225
+ const buttons = this.querySelectorAll('.category-btn');
226
+
227
+ buttons.forEach(btn => {
228
+ btn.addEventListener('click', (e) => {
229
+ e.preventDefault();
230
+
231
+ // Update active state
232
+ buttons.forEach(b => b.classList.remove('active'));
233
+ btn.classList.add('active');
234
+
235
+ // Set category filter
236
+ this.category = btn.dataset.category || '';
237
+ this.filterWidgets();
238
+ });
239
+ });
240
+ });
241
+
242
+ /**
243
+ * Set up widget item click handlers
244
+ */
245
+ WidgetPicker.setMethod(function setupWidgetItems() {
246
+
247
+ const items = this.querySelectorAll('.widget-item');
248
+
249
+ items.forEach(item => {
250
+ item.addEventListener('click', (e) => {
251
+ e.preventDefault();
252
+ this.selectWidget(item.dataset.type);
253
+ });
254
+ });
255
+ });
256
+
257
+ /**
258
+ * Set up keyboard navigation
259
+ */
260
+ WidgetPicker.setMethod(function setupKeyboard() {
261
+
262
+ this.addEventListener('keydown', (e) => {
263
+ if (e.key === 'Escape') {
264
+ const dialog = this.queryParents('he-dialog');
265
+ if (dialog) {
266
+ dialog.close();
267
+ }
268
+ return;
269
+ }
270
+
271
+ if (e.key === 'Enter') {
272
+ const focused = this.querySelector('.widget-item:focus');
273
+ if (focused) {
274
+ e.preventDefault();
275
+ this.selectWidget(focused.dataset.type);
276
+ }
277
+ return;
278
+ }
279
+
280
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
281
+ e.preventDefault();
282
+ this.navigateItems(e.key === 'ArrowDown' ? 1 : -1);
283
+ }
284
+ });
285
+ });
286
+
287
+ /**
288
+ * Navigate through widget items with arrow keys
289
+ */
290
+ WidgetPicker.setMethod(function navigateItems(direction) {
291
+
292
+ const items = Array.from(this.querySelectorAll('.widget-item:not([hidden])'));
293
+ if (!items.length) return;
294
+
295
+ const focused = this.querySelector('.widget-item:focus');
296
+ let index = focused ? items.indexOf(focused) : -1;
297
+
298
+ index += direction;
299
+
300
+ if (index < 0) index = items.length - 1;
301
+ if (index >= items.length) index = 0;
302
+
303
+ items[index].focus();
304
+ });
305
+
306
+ /**
307
+ * Show loading state when element is first connected to the DOM
308
+ */
309
+ WidgetPicker.setMethod(function connected() {
310
+
311
+ // Guard against re-entry if element is moved in DOM
312
+ if (this._initialized) {
313
+ return;
314
+ }
315
+
316
+ this._initialized = true;
317
+
318
+ // Show loading state immediately while prepareRenderVariables() runs
319
+ this.innerHTML = '<div class="widget-picker-loading"><div class="spinner"></div><p>Loading widgets...</p></div>';
320
+ });
321
+
322
+ /**
323
+ * Added to the DOM for the first time
324
+ */
325
+ WidgetPicker.setMethod(function introduced() {
326
+
327
+ introduced.super.call(this);
328
+
329
+ this.setupSearch();
330
+ this.setupCategoryFilters();
331
+ this.setupWidgetItems();
332
+ this.setupKeyboard();
333
+ });