collavre 0.22.0 → 0.24.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 (221) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
  4. data/app/assets/images/collavre/landing/interface-en.png +0 -0
  5. data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
  6. data/app/assets/images/collavre/landing/interface-ko.png +0 -0
  7. data/app/assets/stylesheets/collavre/actiontext.css +251 -90
  8. data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
  9. data/app/assets/stylesheets/collavre/comments_popup.css +158 -61
  10. data/app/assets/stylesheets/collavre/creatives.css +11 -2
  11. data/app/assets/stylesheets/collavre/landing.css +92 -0
  12. data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
  13. data/app/assets/stylesheets/collavre/popup.css +4 -2
  14. data/app/assets/stylesheets/collavre/tables.css +91 -0
  15. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  16. data/app/controllers/collavre/admin/settings_controller.rb +52 -67
  17. data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
  18. data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
  19. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  20. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  21. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  22. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  23. data/app/controllers/collavre/attachments_controller.rb +13 -3
  24. data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
  25. data/app/controllers/collavre/creatives_controller.rb +47 -103
  26. data/app/controllers/collavre/inbox_items_controller.rb +10 -4
  27. data/app/controllers/collavre/landing_controller.rb +5 -2
  28. data/app/controllers/collavre/tasks_controller.rb +13 -4
  29. data/app/controllers/collavre/topics_controller.rb +54 -1
  30. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  31. data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
  32. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  33. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  34. data/app/errors/collavre/api_error.rb +20 -0
  35. data/app/helpers/collavre/application_helper.rb +1 -0
  36. data/app/helpers/collavre/creatives_helper.rb +30 -8
  37. data/app/javascript/collavre.js +2 -0
  38. data/app/javascript/components/ImageResizer.jsx +9 -3
  39. data/app/javascript/components/InlineLexicalEditor.jsx +157 -72
  40. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
  41. data/app/javascript/components/creative_tree_row.js +28 -5
  42. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  43. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
  44. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  45. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  46. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
  47. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
  48. data/app/javascript/controllers/comment_controller.js +5 -4
  49. data/app/javascript/controllers/comment_version_controller.js +2 -1
  50. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  51. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
  52. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  53. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
  54. data/app/javascript/controllers/comments/form_controller.js +21 -5
  55. data/app/javascript/controllers/comments/list_controller.js +18 -17
  56. data/app/javascript/controllers/comments/presence_controller.js +2 -1
  57. data/app/javascript/controllers/comments/topics_controller.js +61 -8
  58. data/app/javascript/controllers/common_popup_controller.js +2 -2
  59. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  60. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  61. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  62. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  63. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  64. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  65. data/app/javascript/controllers/index.js +7 -1
  66. data/app/javascript/controllers/search_popup_controller.js +17 -1
  67. data/app/javascript/controllers/share_modal_controller.js +4 -3
  68. data/app/javascript/controllers/topic_list_controller.js +78 -0
  69. data/app/javascript/controllers/topic_search_controller.js +2 -1
  70. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  71. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  72. data/app/javascript/creatives/tree_renderer.js +11 -0
  73. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
  74. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  75. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  76. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  77. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  78. data/app/javascript/lib/api/api_error.js +108 -0
  79. data/app/javascript/lib/api/queue_manager.js +38 -4
  80. data/app/javascript/lib/common_popup.js +42 -10
  81. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  82. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  83. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  84. data/app/javascript/lib/editor/code_languages.js +173 -0
  85. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  86. data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
  87. data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
  88. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  89. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  90. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  91. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
  92. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  93. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  94. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  95. data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
  96. data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
  97. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  98. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  99. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  100. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  101. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  102. data/app/javascript/lib/turbo_confirm.js +46 -0
  103. data/app/javascript/lib/typo_correction.js +146 -0
  104. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  105. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  106. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  107. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  108. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  109. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  110. data/app/javascript/lib/utils/dialog.js +300 -0
  111. data/app/javascript/lib/utils/markdown.js +154 -67
  112. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  113. data/app/javascript/lib/utils/table_download.js +15 -0
  114. data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
  115. data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
  116. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
  117. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  118. data/app/javascript/modules/creative_inline_payload.js +86 -0
  119. data/app/javascript/modules/creative_row_editor.js +231 -471
  120. data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
  121. data/app/javascript/modules/creative_save_queue.js +95 -0
  122. data/app/javascript/modules/creative_tree_dom.js +260 -0
  123. data/app/javascript/modules/export_to_markdown.js +2 -1
  124. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  125. data/app/javascript/modules/slide_view.js +11 -2
  126. data/app/javascript/modules/typo_corrector.js +534 -0
  127. data/app/jobs/collavre/ai_agent_job.rb +7 -4
  128. data/app/jobs/collavre/compress_job.rb +6 -2
  129. data/app/jobs/collavre/permission_cache_job.rb +20 -2
  130. data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
  131. data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
  132. data/app/models/collavre/channel.rb +16 -0
  133. data/app/models/collavre/comment/approvable.rb +16 -0
  134. data/app/models/collavre/comment/broadcastable.rb +46 -7
  135. data/app/models/collavre/comment/notifiable.rb +15 -5
  136. data/app/models/collavre/comment.rb +87 -32
  137. data/app/models/collavre/comment_version.rb +1 -1
  138. data/app/models/collavre/creative/describable.rb +104 -19
  139. data/app/models/collavre/creative/linkable.rb +9 -0
  140. data/app/models/collavre/creative/permissible.rb +97 -49
  141. data/app/models/collavre/creative.rb +73 -5
  142. data/app/models/collavre/creative_share.rb +78 -26
  143. data/app/models/collavre/inbox_item.rb +2 -2
  144. data/app/models/collavre/task.rb +27 -0
  145. data/app/models/collavre/user.rb +90 -10
  146. data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
  147. data/app/services/collavre/agent_type_classifier.rb +23 -0
  148. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
  149. data/app/services/collavre/ai_agent/message_builder.rb +1 -1
  150. data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
  151. data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
  152. data/app/services/collavre/ai_client.rb +81 -12
  153. data/app/services/collavre/auto_theme_generator.rb +15 -11
  154. data/app/services/collavre/creatives/children_index.rb +86 -0
  155. data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
  156. data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
  157. data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
  158. data/app/services/collavre/creatives/index_query.rb +85 -16
  159. data/app/services/collavre/creatives/permission_checker.rb +1 -1
  160. data/app/services/collavre/creatives/permission_filter.rb +144 -17
  161. data/app/services/collavre/creatives/tree_builder.rb +107 -78
  162. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  163. data/app/services/collavre/http_client.rb +119 -0
  164. data/app/services/collavre/inbox_reply_service.rb +5 -0
  165. data/app/services/collavre/link_preview_fetcher.rb +129 -38
  166. data/app/services/collavre/markdown_converter.rb +13 -3
  167. data/app/services/collavre/markdown_importer.rb +30 -6
  168. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  169. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
  170. data/app/services/collavre/orchestration/agent_orchestrator.rb +34 -8
  171. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  172. data/app/services/collavre/orchestration/matcher.rb +79 -4
  173. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  174. data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
  175. data/app/services/collavre/system_events/context_builder.rb +1 -14
  176. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  177. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  178. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  179. data/app/services/collavre/tools/preview_attach_service.rb +5 -7
  180. data/app/services/collavre/typo_corrector.rb +188 -0
  181. data/app/views/collavre/comments/_comment.html.erb +9 -4
  182. data/app/views/collavre/comments/_comments_popup.html.erb +25 -4
  183. data/app/views/collavre/creatives/_inline_edit_form.html.erb +10 -0
  184. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  185. data/app/views/collavre/creatives/index.html.erb +14 -1
  186. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  187. data/app/views/collavre/landing/show.html.erb +52 -10
  188. data/app/views/collavre/users/show.html.erb +3 -0
  189. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  190. data/app/views/layouts/collavre/slide.html.erb +1 -0
  191. data/config/locales/comments.en.yml +16 -0
  192. data/config/locales/comments.ko.yml +16 -0
  193. data/config/locales/creatives.en.yml +6 -0
  194. data/config/locales/creatives.ko.yml +5 -0
  195. data/config/locales/integrations.en.yml +1 -1
  196. data/config/locales/integrations.ko.yml +1 -1
  197. data/config/locales/landing.en.yml +22 -10
  198. data/config/locales/landing.ko.yml +22 -10
  199. data/config/locales/mobile.en.yml +16 -0
  200. data/config/locales/mobile.ko.yml +16 -0
  201. data/config/locales/orchestration.en.yml +1 -0
  202. data/config/locales/orchestration.ko.yml +1 -0
  203. data/config/locales/users.en.yml +15 -0
  204. data/config/locales/users.ko.yml +15 -0
  205. data/config/routes.rb +13 -0
  206. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
  207. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  208. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  209. data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
  210. data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
  211. data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
  212. data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
  213. data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
  214. data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
  215. data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
  216. data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
  217. data/db/seeds.rb +51 -0
  218. data/lib/collavre/version.rb +1 -1
  219. data/lib/generators/collavre/install/install_generator.rb +1 -0
  220. metadata +96 -2
  221. data/app/services/collavre/tools/description_normalizable.rb +0 -16
@@ -0,0 +1,228 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { jest } from '@jest/globals';
5
+ import { CreativeSaveQueue } from '../creative_save_queue';
6
+
7
+ // A controllable timer harness so debounce behavior is deterministic without
8
+ // wall-clock waits. Mirrors setTimeout/clearTimeout semantics closely enough
9
+ // for the queue: fire() runs the most recently scheduled, uncleared callback.
10
+ function fakeTimers() {
11
+ const scheduled = new Map();
12
+ let nextId = 1;
13
+ return {
14
+ setTimer: (fn, ms) => {
15
+ const id = nextId++;
16
+ scheduled.set(id, { fn, ms });
17
+ return id;
18
+ },
19
+ clearTimer: (id) => {
20
+ scheduled.delete(id);
21
+ },
22
+ fire: (id) => {
23
+ const entry = scheduled.get(id);
24
+ if (!entry) throw new Error(`timer ${id} not scheduled (cleared or never set)`);
25
+ scheduled.delete(id);
26
+ entry.fn();
27
+ },
28
+ pending: () => scheduled.size,
29
+ lastMs: () => [...scheduled.values()].map((e) => e.ms).pop(),
30
+ };
31
+ }
32
+
33
+ // A save request whose settlement we control from the test.
34
+ function deferred() {
35
+ let resolve;
36
+ let reject;
37
+ const promise = new Promise((res, rej) => {
38
+ resolve = res;
39
+ reject = rej;
40
+ });
41
+ return { promise, resolve, reject };
42
+ }
43
+
44
+ describe('CreativeSaveQueue', () => {
45
+ describe('debounce (schedule / cancelTimer)', () => {
46
+ test('schedule arms a timer with the configured debounce', () => {
47
+ const timers = fakeTimers();
48
+ const q = new CreativeSaveQueue({ debounceMs: 5000, setTimer: timers.setTimer, clearTimer: timers.clearTimer });
49
+ const flush = jest.fn();
50
+
51
+ q.schedule(flush);
52
+
53
+ expect(timers.pending()).toBe(1);
54
+ expect(timers.lastMs()).toBe(5000);
55
+ expect(flush).not.toHaveBeenCalled();
56
+ });
57
+
58
+ test('flush runs when the timer fires', () => {
59
+ const timers = fakeTimers();
60
+ const q = new CreativeSaveQueue({ setTimer: timers.setTimer, clearTimer: timers.clearTimer });
61
+ const flush = jest.fn();
62
+
63
+ q.schedule(flush);
64
+ timers.fire(1);
65
+
66
+ expect(flush).toHaveBeenCalledTimes(1);
67
+ expect(timers.pending()).toBe(0);
68
+ });
69
+
70
+ test('re-scheduling cancels the previous timer so only the latest fires', () => {
71
+ const timers = fakeTimers();
72
+ const q = new CreativeSaveQueue({ setTimer: timers.setTimer, clearTimer: timers.clearTimer });
73
+ const first = jest.fn();
74
+ const second = jest.fn();
75
+
76
+ q.schedule(first);
77
+ q.schedule(second);
78
+
79
+ // Only one timer is live; the first was cleared.
80
+ expect(timers.pending()).toBe(1);
81
+ expect(() => timers.fire(1)).toThrow(); // first timer id was cleared
82
+ timers.fire(2);
83
+ expect(first).not.toHaveBeenCalled();
84
+ expect(second).toHaveBeenCalledTimes(1);
85
+ });
86
+
87
+ test('default timers (no injection) schedule against the real timer API without illegal-invocation', () => {
88
+ jest.useFakeTimers();
89
+ try {
90
+ // No setTimer/clearTimer injected — exercises the window-bound wrappers.
91
+ // A regression that called setTimeout as a method of the queue threw
92
+ // "Illegal invocation" in browsers; this guards the default path.
93
+ const q = new CreativeSaveQueue({ debounceMs: 5000 });
94
+ const flush = jest.fn();
95
+
96
+ expect(() => q.schedule(flush)).not.toThrow();
97
+ expect(flush).not.toHaveBeenCalled();
98
+ jest.advanceTimersByTime(5000);
99
+ expect(flush).toHaveBeenCalledTimes(1);
100
+
101
+ // cancelTimer against the real timer API must also not throw.
102
+ q.schedule(flush);
103
+ expect(() => q.cancelTimer()).not.toThrow();
104
+ jest.advanceTimersByTime(5000);
105
+ expect(flush).toHaveBeenCalledTimes(1); // canceled — no second flush
106
+ } finally {
107
+ jest.useRealTimers();
108
+ }
109
+ });
110
+
111
+ test('cancelTimer prevents a scheduled flush and is idempotent', () => {
112
+ const timers = fakeTimers();
113
+ const q = new CreativeSaveQueue({ setTimer: timers.setTimer, clearTimer: timers.clearTimer });
114
+ const flush = jest.fn();
115
+
116
+ q.schedule(flush);
117
+ q.cancelTimer();
118
+ q.cancelTimer(); // no throw on second call
119
+
120
+ expect(timers.pending()).toBe(0);
121
+ });
122
+ });
123
+
124
+ describe('single-flight (runExclusive)', () => {
125
+ test('runs execute and tracks saving state across the request lifecycle', async () => {
126
+ const q = new CreativeSaveQueue();
127
+ const d = deferred();
128
+ const execute = jest.fn(() => d.promise);
129
+
130
+ expect(q.saving).toBe(false);
131
+ const p = q.runExclusive(execute);
132
+
133
+ expect(execute).toHaveBeenCalledTimes(1);
134
+ expect(q.saving).toBe(true);
135
+ expect(q.promise).toBe(p);
136
+
137
+ d.resolve('ok');
138
+ await p;
139
+ expect(q.saving).toBe(false);
140
+ });
141
+
142
+ test('coalesces: a second call while saving returns the in-flight promise without re-running execute', async () => {
143
+ const q = new CreativeSaveQueue();
144
+ const d = deferred();
145
+ const execute = jest.fn(() => d.promise);
146
+ const second = jest.fn(() => deferred().promise);
147
+
148
+ const p1 = q.runExclusive(execute);
149
+ const p2 = q.runExclusive(second);
150
+
151
+ expect(p2).toBe(p1); // same in-flight promise
152
+ expect(second).not.toHaveBeenCalled(); // no double save
153
+ expect(execute).toHaveBeenCalledTimes(1);
154
+
155
+ d.resolve();
156
+ await p1;
157
+ });
158
+
159
+ test('a fresh save runs after the previous one settles', async () => {
160
+ const q = new CreativeSaveQueue();
161
+ const d1 = deferred();
162
+ const execute1 = jest.fn(() => d1.promise);
163
+ const d2 = deferred();
164
+ const execute2 = jest.fn(() => d2.promise);
165
+
166
+ const p1 = q.runExclusive(execute1);
167
+ d1.resolve();
168
+ await p1;
169
+ expect(q.saving).toBe(false);
170
+
171
+ q.runExclusive(execute2);
172
+ expect(execute2).toHaveBeenCalledTimes(1);
173
+ expect(q.saving).toBe(true);
174
+ d2.resolve();
175
+ });
176
+
177
+ test('execute returning null (nothing to persist) does not enter the saving state', async () => {
178
+ const q = new CreativeSaveQueue();
179
+ const execute = jest.fn(() => null);
180
+
181
+ const p = q.runExclusive(execute);
182
+
183
+ expect(execute).toHaveBeenCalledTimes(1);
184
+ expect(q.saving).toBe(false);
185
+ await expect(p).resolves.toBeUndefined();
186
+ });
187
+
188
+ test('runExclusive cancels a pending debounce timer before saving', () => {
189
+ const timers = fakeTimers();
190
+ const q = new CreativeSaveQueue({ setTimer: timers.setTimer, clearTimer: timers.clearTimer });
191
+ const flush = jest.fn();
192
+
193
+ q.schedule(flush);
194
+ expect(timers.pending()).toBe(1);
195
+
196
+ q.runExclusive(() => deferred().promise);
197
+
198
+ expect(timers.pending()).toBe(0); // debounce canceled — no redundant trailing save
199
+ expect(flush).not.toHaveBeenCalled();
200
+ });
201
+
202
+ test('clears saving even when the request rejects', async () => {
203
+ const q = new CreativeSaveQueue();
204
+ const d = deferred();
205
+ const execute = jest.fn(() => d.promise);
206
+
207
+ const p = q.runExclusive(execute);
208
+ expect(q.saving).toBe(true);
209
+
210
+ d.reject(new Error('boom'));
211
+ await expect(p).rejects.toThrow('boom');
212
+ expect(q.saving).toBe(false);
213
+ });
214
+
215
+ test('after a rejection a subsequent save can run', async () => {
216
+ const q = new CreativeSaveQueue();
217
+ const d1 = deferred();
218
+ const p1 = q.runExclusive(() => d1.promise);
219
+ d1.reject(new Error('fail'));
220
+ await expect(p1).rejects.toThrow();
221
+
222
+ const execute2 = jest.fn(() => deferred().promise);
223
+ q.runExclusive(execute2);
224
+ expect(execute2).toHaveBeenCalledTimes(1);
225
+ expect(q.saving).toBe(true);
226
+ });
227
+ });
228
+ });
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Characterization tests for the tree-DOM navigation/geometry cluster extracted
3
+ * from creative_row_editor.js (slice 2 of the god-file decomposition).
4
+ *
5
+ * These pin the CURRENT behavior of the sibling/level/parent computations that
6
+ * feed the structural-move paths (levelUp/levelDown/addNew/move) — the inputs to
7
+ * the recurring "parentId loss / wrong ordering" bug class. They are written
8
+ * against a jsdom fixture that mirrors the real creative-tree render: a
9
+ * `creative-tree-row` custom element wrapping a `.creative-tree` node, with
10
+ * `.creative-children` containers as siblings of the row.
11
+ *
12
+ * Intentionally behavior-preserving: any surprising result below (e.g. the
13
+ * before/after id inversion in siblingOrderingForRow) documents existing
14
+ * behavior, not an endorsement of it.
15
+ */
16
+ import {
17
+ creativeTreeElement,
18
+ creativeIdFrom,
19
+ siblingTreeRow,
20
+ siblingOrderingForRow,
21
+ treeContainerElement,
22
+ nodeAfterTreeBlock,
23
+ normalizeRowNode,
24
+ childrenContainerForTree,
25
+ ensureChildrenContainer,
26
+ expandChildrenContainer,
27
+ moveTreeBlock,
28
+ listAllTreeNodes,
29
+ findPreviousTree,
30
+ getTreeLevel,
31
+ updateTreeLevels,
32
+ setTreeLevel,
33
+ removeTreeElement,
34
+ } from '../creative_tree_dom';
35
+
36
+ // Build one row: <creative-tree-row><div.creative-tree/></creative-tree-row>
37
+ function makeRow(id, { parentId = '', level = 1 } = {}) {
38
+ const row = document.createElement('creative-tree-row');
39
+ row.setAttribute('creative-id', String(id));
40
+ row.setAttribute('level', String(level));
41
+ const tree = document.createElement('div');
42
+ tree.className = 'creative-tree';
43
+ tree.id = `creative-${id}`;
44
+ tree.dataset.id = String(id);
45
+ tree.dataset.parentId = parentId;
46
+ tree.dataset.level = String(level);
47
+ row.appendChild(tree);
48
+ return { row, tree };
49
+ }
50
+
51
+ function makeChildrenContainer(parentId) {
52
+ const c = document.createElement('div');
53
+ c.className = 'creative-children';
54
+ c.id = `creative-children-${parentId}`;
55
+ return c;
56
+ }
57
+
58
+ function treeOf(row) {
59
+ return row.querySelector('.creative-tree');
60
+ }
61
+
62
+ let root;
63
+ beforeEach(() => {
64
+ document.body.innerHTML = '';
65
+ root = document.createElement('div');
66
+ root.id = 'creatives';
67
+ document.body.appendChild(root);
68
+ });
69
+
70
+ describe('creativeTreeElement / creativeIdFrom', () => {
71
+ test('creativeTreeElement returns the node itself when it is a .creative-tree', () => {
72
+ const { tree } = makeRow(1);
73
+ expect(creativeTreeElement(tree)).toBe(tree);
74
+ });
75
+
76
+ test('creativeTreeElement descends into a wrapping row', () => {
77
+ const { row, tree } = makeRow(1);
78
+ expect(creativeTreeElement(row)).toBe(tree);
79
+ });
80
+
81
+ test('creativeTreeElement returns null for a nullish node', () => {
82
+ expect(creativeTreeElement(null)).toBeNull();
83
+ });
84
+
85
+ test('creativeIdFrom reads the inner .creative-tree data-id from a row', () => {
86
+ const { row } = makeRow(42);
87
+ expect(creativeIdFrom(row)).toBe('42');
88
+ });
89
+
90
+ test('creativeIdFrom falls back to creative-id / data-id attributes', () => {
91
+ const el = document.createElement('div');
92
+ el.setAttribute('creative-id', '7');
93
+ expect(creativeIdFrom(el)).toBe('7');
94
+ });
95
+
96
+ test('creativeIdFrom returns empty string when nothing identifies the node', () => {
97
+ expect(creativeIdFrom(null)).toBe('');
98
+ });
99
+ });
100
+
101
+ describe('siblingTreeRow', () => {
102
+ test('finds the next/previous row, skipping text nodes', () => {
103
+ const a = makeRow(1);
104
+ const b = makeRow(2);
105
+ root.appendChild(a.row);
106
+ root.appendChild(document.createTextNode('\n '));
107
+ root.appendChild(b.row);
108
+
109
+ expect(siblingTreeRow(a.row, 'next')).toBe(b.row);
110
+ expect(siblingTreeRow(b.row, 'previous')).toBe(a.row);
111
+ });
112
+
113
+ test('skips over intervening .creative-children containers', () => {
114
+ const a = makeRow(1);
115
+ const b = makeRow(2);
116
+ root.appendChild(a.row);
117
+ root.appendChild(makeChildrenContainer(1)); // between the two rows
118
+ root.appendChild(b.row);
119
+
120
+ expect(siblingTreeRow(a.row, 'next')).toBe(b.row);
121
+ expect(siblingTreeRow(b.row, 'previous')).toBe(a.row);
122
+ });
123
+
124
+ test('returns null at the ends', () => {
125
+ const a = makeRow(1);
126
+ root.appendChild(a.row);
127
+ expect(siblingTreeRow(a.row, 'next')).toBeNull();
128
+ expect(siblingTreeRow(a.row, 'previous')).toBeNull();
129
+ expect(siblingTreeRow(null, 'next')).toBeNull();
130
+ });
131
+ });
132
+
133
+ describe('siblingOrderingForRow', () => {
134
+ // Pins the (surprising) mapping: beforeId := NEXT sibling, afterId := PREVIOUS
135
+ // sibling. This is existing behavior consumed by persistStructureChange.
136
+ test('beforeId comes from the next sibling, afterId from the previous', () => {
137
+ const a = makeRow(1);
138
+ const b = makeRow(2);
139
+ const c = makeRow(3);
140
+ root.appendChild(a.row);
141
+ root.appendChild(b.row);
142
+ root.appendChild(c.row);
143
+
144
+ expect(siblingOrderingForRow(b.row)).toEqual({ beforeId: '3', afterId: '1' });
145
+ });
146
+
147
+ test('empty ids at the boundaries', () => {
148
+ const a = makeRow(1);
149
+ root.appendChild(a.row);
150
+ expect(siblingOrderingForRow(a.row)).toEqual({ beforeId: '', afterId: '' });
151
+ });
152
+ });
153
+
154
+ describe('treeContainerElement', () => {
155
+ test('returns the row parent when the tree has a wrapping row', () => {
156
+ const { row, tree } = makeRow(1);
157
+ root.appendChild(row);
158
+ expect(treeContainerElement(tree)).toBe(root);
159
+ });
160
+
161
+ test('returns null for nullish tree', () => {
162
+ expect(treeContainerElement(null)).toBeNull();
163
+ });
164
+ });
165
+
166
+ describe('nodeAfterTreeBlock', () => {
167
+ test('returns the next row when there is no children container', () => {
168
+ const a = makeRow(1);
169
+ const b = makeRow(2);
170
+ root.appendChild(a.row);
171
+ root.appendChild(b.row);
172
+ expect(nodeAfterTreeBlock(a.tree)).toBe(b.row);
173
+ });
174
+
175
+ test('skips the tree\'s own children container to the node after it', () => {
176
+ const a = makeRow(1);
177
+ const b = makeRow(2);
178
+ const children = makeChildrenContainer(1);
179
+ root.appendChild(a.row);
180
+ root.appendChild(children);
181
+ root.appendChild(b.row);
182
+ expect(nodeAfterTreeBlock(a.tree)).toBe(b.row);
183
+ });
184
+ });
185
+
186
+ describe('normalizeRowNode', () => {
187
+ test('returns the row unchanged', () => {
188
+ const { row } = makeRow(1);
189
+ expect(normalizeRowNode(row)).toBe(row);
190
+ });
191
+
192
+ test('maps a .creative-tree node to its wrapping row', () => {
193
+ const { row, tree } = makeRow(1);
194
+ expect(normalizeRowNode(tree)).toBe(row);
195
+ });
196
+
197
+ test('returns nullish input as null', () => {
198
+ expect(normalizeRowNode(null)).toBeNull();
199
+ });
200
+ });
201
+
202
+ describe('childrenContainerForTree', () => {
203
+ test('finds the container by id', () => {
204
+ const { row, tree } = makeRow(1);
205
+ const children = makeChildrenContainer(1);
206
+ root.appendChild(row);
207
+ root.appendChild(children);
208
+ expect(childrenContainerForTree(tree)).toBe(children);
209
+ });
210
+
211
+ test('finds the container as a following sibling of the row', () => {
212
+ const { row, tree } = makeRow(5);
213
+ const children = document.createElement('div');
214
+ children.className = 'creative-children'; // no matching id
215
+ root.appendChild(row);
216
+ root.appendChild(children);
217
+ expect(childrenContainerForTree(tree)).toBe(children);
218
+ });
219
+
220
+ test('returns null when no container exists', () => {
221
+ const { row, tree } = makeRow(9);
222
+ root.appendChild(row);
223
+ expect(childrenContainerForTree(tree)).toBeNull();
224
+ });
225
+ });
226
+
227
+ describe('ensureChildrenContainer', () => {
228
+ test('reuses an existing container', () => {
229
+ const { row, tree } = makeRow(1);
230
+ const existing = makeChildrenContainer(1);
231
+ root.appendChild(row);
232
+ root.appendChild(existing);
233
+ expect(ensureChildrenContainer(tree)).toBe(existing);
234
+ });
235
+
236
+ test('creates a container with child level (+1) load url and inserts after the row', () => {
237
+ const { row, tree } = makeRow(3, { level: 2 });
238
+ root.appendChild(row);
239
+
240
+ const created = ensureChildrenContainer(tree);
241
+
242
+ expect(created).not.toBeNull();
243
+ expect(created.id).toBe('creative-children-3');
244
+ expect(created.classList.contains('creative-children')).toBe(true);
245
+ expect(created.dataset.expanded).toBe('true');
246
+ // level+1 = 3, select_mode 0
247
+ expect(created.dataset.loadUrl).toBe('/creatives/3/children?level=3&select_mode=0');
248
+ // inserted into the row's parent, right after the row
249
+ expect(created.previousElementSibling).toBe(row);
250
+ });
251
+
252
+ test('returns null when the tree has no id', () => {
253
+ const tree = document.createElement('div');
254
+ tree.className = 'creative-tree';
255
+ root.appendChild(tree);
256
+ expect(ensureChildrenContainer(tree)).toBeNull();
257
+ });
258
+ });
259
+
260
+ describe('expandChildrenContainer', () => {
261
+ test('clears display and marks expanded', () => {
262
+ const c = makeChildrenContainer(1);
263
+ c.style.display = 'none';
264
+ expandChildrenContainer(c);
265
+ expect(c.style.display).toBe('');
266
+ expect(c.dataset.expanded).toBe('true');
267
+ });
268
+
269
+ test('no-ops on nullish container', () => {
270
+ expect(() => expandChildrenContainer(null)).not.toThrow();
271
+ });
272
+ });
273
+
274
+ describe('moveTreeBlock', () => {
275
+ test('moves the row and its children container into the target (append)', () => {
276
+ const src = makeRow(1);
277
+ const children = makeChildrenContainer(1);
278
+ const targetParent = makeChildrenContainer(99);
279
+ root.appendChild(src.row);
280
+ root.appendChild(children);
281
+ root.appendChild(targetParent);
282
+
283
+ moveTreeBlock(src.tree, targetParent);
284
+
285
+ expect(src.row.parentNode).toBe(targetParent);
286
+ expect(children.parentNode).toBe(targetParent);
287
+ // row moved before its children container (append order preserved)
288
+ expect([...targetParent.children]).toEqual([src.row, children]);
289
+ });
290
+
291
+ test('inserts before a reference node', () => {
292
+ const src = makeRow(1);
293
+ const ref = makeRow(2);
294
+ const targetParent = makeChildrenContainer(99);
295
+ // Keep src.row's next sibling from being a .creative-children, otherwise
296
+ // childrenContainerForTree's sibling fallback would treat targetParent as
297
+ // src's own children container (covered separately above).
298
+ const srcHolder = document.createElement('div');
299
+ srcHolder.appendChild(src.row);
300
+ root.appendChild(srcHolder);
301
+ targetParent.appendChild(ref.row);
302
+ root.appendChild(targetParent);
303
+
304
+ moveTreeBlock(src.tree, targetParent, ref.row);
305
+
306
+ expect([...targetParent.children]).toEqual([src.row, ref.row]);
307
+ });
308
+
309
+ test('no-ops without a target', () => {
310
+ const src = makeRow(1);
311
+ root.appendChild(src.row);
312
+ expect(() => moveTreeBlock(src.tree, null)).not.toThrow();
313
+ });
314
+ });
315
+
316
+ describe('listAllTreeNodes', () => {
317
+ test('returns all .creative-tree under #creatives in document order', () => {
318
+ const a = makeRow(1);
319
+ const b = makeRow(2);
320
+ root.appendChild(a.row);
321
+ root.appendChild(b.row);
322
+ expect(listAllTreeNodes()).toEqual([a.tree, b.tree]);
323
+ });
324
+ });
325
+
326
+ describe('getTreeLevel', () => {
327
+ test('reads data-level when present and positive', () => {
328
+ const { tree } = makeRow(1, { level: 4 });
329
+ expect(getTreeLevel(tree)).toBe(4);
330
+ });
331
+
332
+ test('falls back to the wrapping row level attribute when data-level absent', () => {
333
+ const { row, tree } = makeRow(1, { level: 3 });
334
+ delete tree.dataset.level;
335
+ root.appendChild(row);
336
+ expect(getTreeLevel(tree)).toBe(3);
337
+ });
338
+
339
+ test('defaults to 1 for nullish tree', () => {
340
+ expect(getTreeLevel(null)).toBe(1);
341
+ });
342
+ });
343
+
344
+ describe('findPreviousTree', () => {
345
+ // #creatives: row1(L1), [children-1: row11(L2)], row2(L1)
346
+ function buildNested() {
347
+ const one = makeRow(1, { level: 1 });
348
+ const children1 = makeChildrenContainer(1);
349
+ const eleven = makeRow(11, { parentId: '1', level: 2 });
350
+ children1.appendChild(eleven.row);
351
+ const two = makeRow(2, { level: 1 });
352
+ root.appendChild(one.row);
353
+ root.appendChild(children1);
354
+ root.appendChild(two.row);
355
+ return { one, eleven, two };
356
+ }
357
+
358
+ test('returns the nearest previous tree at the same level', () => {
359
+ const { one, two } = buildNested();
360
+ expect(findPreviousTree(two.tree)).toBe(one.tree);
361
+ });
362
+
363
+ test('returns null when the previous tree is at a shallower level', () => {
364
+ const { eleven } = buildNested();
365
+ // eleven (L2) is preceded only by one (L1) which is shallower -> null
366
+ expect(findPreviousTree(eleven.tree)).toBeNull();
367
+ });
368
+
369
+ test('returns null for the first tree', () => {
370
+ const { one } = buildNested();
371
+ expect(findPreviousTree(one.tree)).toBeNull();
372
+ });
373
+ });
374
+
375
+ describe('updateTreeLevels / setTreeLevel', () => {
376
+ test('updateTreeLevels applies delta to the tree and its child rows recursively', () => {
377
+ const parent = makeRow(1, { level: 1 });
378
+ const children = makeChildrenContainer(1);
379
+ const child = makeRow(11, { parentId: '1', level: 2 });
380
+ children.appendChild(child.row);
381
+ root.appendChild(parent.row);
382
+ root.appendChild(children);
383
+
384
+ updateTreeLevels(parent.tree, 1);
385
+
386
+ expect(parent.tree.dataset.level).toBe('2');
387
+ expect(parent.row.getAttribute('level')).toBe('2');
388
+ expect(child.tree.dataset.level).toBe('3');
389
+ expect(child.row.getAttribute('level')).toBe('3');
390
+ });
391
+
392
+ test('updateTreeLevels floors the level at 1', () => {
393
+ const { tree, row } = makeRow(1, { level: 1 });
394
+ root.appendChild(row);
395
+ updateTreeLevels(tree, -5);
396
+ expect(tree.dataset.level).toBe('1');
397
+ });
398
+
399
+ test('setTreeLevel computes the delta to reach the target level', () => {
400
+ const { tree, row } = makeRow(1, { level: 2 });
401
+ root.appendChild(row);
402
+ setTreeLevel(tree, 4);
403
+ expect(tree.dataset.level).toBe('4');
404
+ });
405
+
406
+ test('setTreeLevel is a no-op when already at the target', () => {
407
+ const { tree, row } = makeRow(1, { level: 2 });
408
+ root.appendChild(row);
409
+ setTreeLevel(tree, 2);
410
+ expect(tree.dataset.level).toBe('2');
411
+ });
412
+ });
413
+
414
+ describe('removeTreeElement', () => {
415
+ test('removes the wrapping row', () => {
416
+ const { row, tree } = makeRow(1);
417
+ root.appendChild(row);
418
+ removeTreeElement(tree);
419
+ expect(root.querySelector('creative-tree-row')).toBeNull();
420
+ });
421
+
422
+ test('no-ops on nullish tree', () => {
423
+ expect(() => removeTreeElement(null)).not.toThrow();
424
+ });
425
+ });