apostrophe 4.27.1 → 4.28.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 (55) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/index.js +3 -0
  3. package/lib/stream-proxy.js +49 -0
  4. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextTitle.vue +2 -11
  5. package/modules/@apostrophecms/area/ui/apos/apps/AposAreas.js +38 -6
  6. package/modules/@apostrophecms/area/ui/apos/components/AposAreaEditor.vue +12 -1
  7. package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +111 -41
  8. package/modules/@apostrophecms/area/ui/apos/components/AposBreadcrumbOperations.vue +1 -0
  9. package/modules/@apostrophecms/area/ui/apos/components/AposWidgetControls.vue +22 -10
  10. package/modules/@apostrophecms/area/ui/apos/logic/AposAreaEditor.js +40 -0
  11. package/modules/@apostrophecms/asset/index.js +3 -2
  12. package/modules/@apostrophecms/attachment/index.js +270 -0
  13. package/modules/@apostrophecms/doc/index.js +8 -2
  14. package/modules/@apostrophecms/doc-type/index.js +81 -1
  15. package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocEditor.vue +18 -2
  16. package/modules/@apostrophecms/express/index.js +30 -1
  17. package/modules/@apostrophecms/file/index.js +71 -6
  18. package/modules/@apostrophecms/i18n/index.js +20 -1
  19. package/modules/@apostrophecms/image/index.js +11 -0
  20. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposAreaLayoutEditor.vue +31 -6
  21. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridLayout.vue +12 -10
  22. package/modules/@apostrophecms/login/index.js +43 -11
  23. package/modules/@apostrophecms/modal/ui/apos/components/AposDocsManagerToolbar.vue +2 -1
  24. package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +5 -0
  25. package/modules/@apostrophecms/page/index.js +9 -11
  26. package/modules/@apostrophecms/page-type/index.js +6 -1
  27. package/modules/@apostrophecms/piece-page-type/index.js +100 -13
  28. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposImageControlDialog.vue +1 -0
  29. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +28 -12
  30. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapLink.vue +1 -0
  31. package/modules/@apostrophecms/schema/ui/apos/components/AposSearchList.vue +1 -1
  32. package/modules/@apostrophecms/styles/lib/apiRoutes.js +25 -5
  33. package/modules/@apostrophecms/styles/lib/handlers.js +19 -0
  34. package/modules/@apostrophecms/styles/lib/methods.js +35 -12
  35. package/modules/@apostrophecms/styles/ui/apos/components/TheAposStyles.vue +7 -2
  36. package/modules/@apostrophecms/task/index.js +9 -1
  37. package/modules/@apostrophecms/template/views/outerLayoutBase.html +3 -0
  38. package/modules/@apostrophecms/ui/index.js +2 -0
  39. package/modules/@apostrophecms/ui/ui/apos/components/AposButtonGroup.vue +1 -1
  40. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenu.vue +5 -0
  41. package/modules/@apostrophecms/ui/ui/apos/components/AposContextMenuDialog.vue +5 -0
  42. package/modules/@apostrophecms/ui/ui/apos/lib/vue.js +2 -0
  43. package/modules/@apostrophecms/ui/ui/apos/stores/widget.js +12 -7
  44. package/modules/@apostrophecms/ui/ui/apos/stores/widgetGraph.js +461 -0
  45. package/modules/@apostrophecms/ui/ui/apos/universal/graph.js +452 -0
  46. package/modules/@apostrophecms/ui/ui/apos/universal/widgetGraph.js +10 -0
  47. package/modules/@apostrophecms/uploadfs/index.js +15 -1
  48. package/modules/@apostrophecms/url/index.js +419 -1
  49. package/package.json +6 -6
  50. package/test/add-missing-schema-fields-project/node_modules/.package-lock.json +131 -0
  51. package/test/external-front.js +1 -0
  52. package/test/files.js +135 -0
  53. package/test/login-requirements.js +145 -3
  54. package/test/static-build.js +2701 -0
  55. package/test/universal-graph.js +1135 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.28.0
4
+
5
+ ### Adds
6
+
7
+ - Adds support for static URLs and static external frontend builds.
8
+ - Adds widget graph store, accessible in Admin UI.
9
+ - Support for the new `prettyUrls: true` option for @apostrophecms/file, which enables "pretty URLs" for PDFs and other items in the file library, in exchange for a small performance impact. Edit the slug field to adjust the pretty URL
10
+
11
+
12
+ ### Fixes
13
+
14
+ - Fix a bug when rich text link open in new tab checkbox can't be cleared
15
+ - Ensures the `getOne` API can correctly retrieve documents that are not localized. Thanks to [Eduardo Correal](https://github.com/ecb34).
16
+ - Clicking a choice should dismiss the relationship suggestions dropdown. This regression was caused by part of the patch in release 4.27.1.
17
+ - lint on main
18
+ - Adds inner wrapper to area widget that creates a separate z-index context for the user, fixing widget/apostrophe UI conflicts
19
+ - Fixes a bug where Layout's mode erroneously switches state
20
+ - Fixes a bug where Layouts synthetic column styles were not reaching their children
21
+ - Fixes styles being sanitized as html (breaks quotes) and resolves duplicate styles on the page.
22
+ - Fix subtle bug in AposPermissionGrid that caused unrelated clicks to be "swallowed" due to a race condition at low network speeds
23
+ - Fixes two conditions where slow internet speed could cause input to lose focus before a selection can be registered.
24
+
25
+ ### Changes
26
+
27
+ - Improve re-rendering UX while keeping the performance optimization
28
+ - raise the user's widget z-index context only when focused
29
+ - Hide add content buttons on rich text editing, like widget controls
30
+ - Refine in-context focus states for calmer UX
31
+ - Simplifies some in-context UI rendering checks
32
+ - Updated dependencies
33
+
34
+ ### Security
35
+
36
+ - This previously undisclosed security vulnerability allowed users who had compromised a password to perform actions in the CMS without 2FA. For sites not using 2FA (e.g. our @apostrophecms/login-totp module), this changes nothing. But for those using our TOTP module or similar, upgrading to this release is urgent. Thanks to 0xkakashi for reporting the issue and recommending a fix.
37
+
3
38
  ## 4.27.1 (2026-03-03)
4
39
 
5
40
  ### Fixes
package/index.js CHANGED
@@ -479,6 +479,9 @@ async function apostrophe(options, telemetry, rootSpan) {
479
479
  // Environment variable override
480
480
  self.options.baseUrl = process.env.APOS_BASE_URL || self.options.baseUrl;
481
481
  self.baseUrl = self.options.baseUrl;
482
+ self.options.staticBaseUrl = process.env.APOS_STATIC_BASE_URL ||
483
+ self.options.staticBaseUrl;
484
+ self.staticBaseUrl = self.options.staticBaseUrl;
482
485
  self.prefix = self.options.prefix || '';
483
486
  }
484
487
 
@@ -0,0 +1,49 @@
1
+ // Make a request for url and stream it to req.res,
2
+ // passing through relevant headers, without downloading
3
+ // the whole thing to disk. Site-relative URLs are
4
+ // resolved via req.baseUrl. Server-side errors are logged to
5
+ // error() which should accept multiple arguments
6
+
7
+ module.exports = async function(req, url, { error }) {
8
+ const res = req.res;
9
+ if (url.startsWith('/')) {
10
+ // Can't make a self-request without an absolute URL
11
+ url = `${req.baseUrl}${url}`;
12
+ }
13
+ let response;
14
+ try {
15
+ response = await fetch(url);
16
+ } catch (e) {
17
+ return send502(e);
18
+ }
19
+ for (const header of [ 'content-type', 'etag', 'last-modified', 'content-disposition', 'cache-control' ]) {
20
+ const result = response.headers.get(header);
21
+ if (result != null) {
22
+ res.header(header, result);
23
+ }
24
+ }
25
+ res.status(response.status);
26
+ if (response.body == null) {
27
+ return res.end();
28
+ }
29
+ response.body.pipeTo(new WritableStream({
30
+ write(chunk) {
31
+ res.write(chunk);
32
+ },
33
+ close() {
34
+ res.end();
35
+ },
36
+ abort(reason) {
37
+ if (!res.headersSent) {
38
+ return send502(reason);
39
+ } else {
40
+ // Only way to signal failure after headers are sent
41
+ res.destroy();
42
+ }
43
+ }
44
+ }));
45
+ function send502(e) {
46
+ error(`Error fetching "ugly URL" ${url} to resolve pretty URL ${req.url}:`, e);
47
+ return res.status(502).send('upstream media error fetching data for pretty URL');
48
+ }
49
+ };
@@ -166,22 +166,13 @@ export default {
166
166
  color: var(--a-text-primary);
167
167
  }
168
168
 
169
- &__document-title {
170
- margin-top: 1px;
171
- }
172
-
173
169
  &__separator {
174
170
  align-items: center;
175
- margin-top: 1px;
176
171
  padding: 0 7px;
177
172
  }
178
173
 
179
- &__document {
180
- margin-top: 3.5px;
181
-
182
- :deep(.apos-context-menu__items) {
183
- min-width: 150px;
184
- }
174
+ &__document :deep(.apos-context-menu__items) {
175
+ min-width: 150px;
185
176
  }
186
177
  }
187
178
 
@@ -1,5 +1,6 @@
1
1
 
2
- import createApp from 'Modules/@apostrophecms/ui/lib/vue';
2
+ import createApp, { pinia } from 'Modules/@apostrophecms/ui/lib/vue';
3
+ import { useWidgetGraphStore } from 'Modules/@apostrophecms/ui/stores/widgetGraph.js';
3
4
  import { nextTick } from 'vue';
4
5
 
5
6
  export default function() {
@@ -24,6 +25,12 @@ export default function() {
24
25
  });
25
26
 
26
27
  apos.bus.$on('refreshed', function() {
28
+ // Re-instantiate the on-page widget graph before remounting areas.
29
+ // The normal mounted hooks will rebuild it from fresh data.
30
+ const graphStore = useWidgetGraphStore(pinia);
31
+ if (apos.adminBar?.contextId) {
32
+ graphStore.resetGraph(apos.adminBar.contextId);
33
+ }
27
34
  createAreaAppsAndRunPlayersIfDone();
28
35
  });
29
36
 
@@ -117,10 +124,18 @@ export default function() {
117
124
 
118
125
  el.parentNode.replaceChild(apos.area.activeEditor.$el, el);
119
126
  } else {
120
- observer = new IntersectionObserver(observed, {
121
- rootMargin: '600px'
122
- });
123
- observer.observe(el);
127
+ const rect = el.getBoundingClientRect();
128
+ const isInViewport = rect.bottom >= 0 &&
129
+ rect.top <= window.innerHeight;
130
+
131
+ if (isInViewport) {
132
+ mountApp();
133
+ } else {
134
+ observer = new IntersectionObserver(observed, {
135
+ rootMargin: '600px'
136
+ });
137
+ observer.observe(el);
138
+ }
124
139
  }
125
140
 
126
141
  function observed(entries) {
@@ -129,8 +144,14 @@ export default function() {
129
144
  return;
130
145
  }
131
146
  if (created) {
147
+ observer.disconnect();
132
148
  return;
133
149
  }
150
+ mountApp();
151
+ observer.disconnect();
152
+ }
153
+
154
+ function mountApp() {
134
155
  const app = createApp(component, {
135
156
  options,
136
157
  id: data._id,
@@ -142,10 +163,21 @@ export default function() {
142
163
  parentOptions,
143
164
  renderings
144
165
  });
166
+
167
+ // Resolve graphKey: if this area is inside a modal that owns a
168
+ // graph (data-apos-graph-key), use that key. Otherwise fall back
169
+ // to the on-page contextId. This single DOM lookup bridges the
170
+ // provide/inject gap created by createApp.
171
+ const graphKey = el.closest('[data-apos-graph-key]')
172
+ ?.getAttribute('data-apos-graph-key') || apos.adminBar?.contextId || null;
173
+ // Provide the resolved graphKey so every descendant component
174
+ // can simply inject('aposGraphKey') and get the correct value.
175
+ if (graphKey) {
176
+ app.provide('aposGraphKey', graphKey);
177
+ }
145
178
  app.mount(el);
146
179
  mountedApps.set(el, app);
147
180
  created = true;
148
- observer.disconnect();
149
181
  }
150
182
  }
151
183
 
@@ -3,7 +3,12 @@
3
3
  v-click-outside-element="resetFocusedArea"
4
4
  :data-apos-area="areaId"
5
5
  class="apos-area"
6
- :class="themeClass"
6
+ :class="[
7
+ themeClass,
8
+ {
9
+ 'apos-area--empty': next.length === 0
10
+ }
11
+ ]"
7
12
  @click="setFocusedArea(areaId, $event)"
8
13
  >
9
14
  <div
@@ -61,6 +66,12 @@
61
66
  :disabled="field && field.readOnly"
62
67
  :max-reached="maxReached"
63
68
  :rendering="rendering(widget)"
69
+ :raised="raisedWidgets.has(widget._id)"
70
+ :style="{
71
+ 'z-index': raisedWidgets.has(widget._id)
72
+ ? next.length + 1
73
+ : null
74
+ }"
64
75
  @up="up"
65
76
  @down="down"
66
77
  @remove="remove"
@@ -23,14 +23,13 @@
23
23
  @keyup.enter="onKeyup"
24
24
  >
25
25
  <div
26
- v-if="!breadcrumbDisabled"
27
26
  ref="label"
28
27
  class="apos-area-widget-controls apos-area-widget__label"
29
28
  :class="labelsClasses"
30
29
  >
31
30
  <ol
32
31
  class="apos-area-widget__breadcrumbs"
33
- @click="isSuppressingWidgetControls = false"
32
+ @click="clearSuppressionFlags"
34
33
  >
35
34
  <li
36
35
  class="
@@ -92,7 +91,7 @@
92
91
  />
93
92
  </div>
94
93
  <div
95
- v-if="!controlsDisabled"
94
+ v-if="!controlsDisabled && !maxReached && !isSuppressingAddContentButtons"
96
95
  class="
97
96
  apos-area-widget-controls
98
97
  apos-area-widget-controls--add--top
@@ -143,43 +142,52 @@
143
142
  @operation="onOperation"
144
143
  />
145
144
  </div>
146
-
147
- <!-- Still used for contextual editing components -->
148
- <component
149
- :is="widgetEditorComponent(widget.type)"
150
- v-if="isContextual && !foreign"
151
- :key="generation"
152
- :class="adminContentDirectionClass"
153
- :options="widgetOptions"
154
- :type="widget.type"
155
- :model-value="widget"
156
- :meta="meta"
157
- :doc-id="docId"
158
- :focused="isFocused"
159
- @update="$emit('update', $event)"
160
- @suppress-widget-controls="isSuppressingWidgetControls = true"
161
- />
162
- <component
163
- :is="widgetComponent(widget.type)"
164
- v-else
165
- :id="widget._id"
166
- :key="`${generation}-preview`"
167
- :class="adminContentDirectionClass"
168
- :options="widgetOptions"
169
- :type="widget.type"
170
- :area-field-id="fieldId"
171
- :following-values="followingValuesWithParent"
172
- :model-value="widget"
173
- :value="widget"
174
- :meta="meta"
175
- :foreign="foreign"
176
- :doc-id="docId"
177
- :rendering="rendering"
178
- @edit="$emit('edit', i);"
179
- @update="$emit('update', $event);"
180
- />
181
145
  <div
182
- v-if="!controlsDisabled"
146
+ class="apos-area-widget-rendered-widget"
147
+ :style="{
148
+ 'z-index': raised
149
+ ? 0
150
+ : null
151
+ }"
152
+ >
153
+ <!-- Still used for contextual editing components -->
154
+ <component
155
+ :is="widgetEditorComponent(widget.type)"
156
+ v-if="isContextual && !foreign"
157
+ :key="generation"
158
+ :class="adminContentDirectionClass"
159
+ :options="widgetOptions"
160
+ :type="widget.type"
161
+ :model-value="widget"
162
+ :meta="meta"
163
+ :doc-id="docId"
164
+ :focused="isFocused"
165
+ @update="$emit('update', $event)"
166
+ @suppress-widget-controls="doSuppressWidgetControls"
167
+ @suppress-add-content-buttons="doSuppressAddContentButtons"
168
+ />
169
+ <component
170
+ :is="widgetComponent(widget.type)"
171
+ v-else
172
+ :id="widget._id"
173
+ :key="`${generation}-preview`"
174
+ :class="adminContentDirectionClass"
175
+ :options="widgetOptions"
176
+ :type="widget.type"
177
+ :area-field-id="fieldId"
178
+ :following-values="followingValuesWithParent"
179
+ :model-value="widget"
180
+ :value="widget"
181
+ :meta="meta"
182
+ :foreign="foreign"
183
+ :doc-id="docId"
184
+ :rendering="rendering"
185
+ @edit="$emit('edit', i);"
186
+ @update="$emit('update', $event);"
187
+ />
188
+ </div>
189
+ <div
190
+ v-if="!controlsDisabled && !maxReached && !isSuppressingAddContentButtons"
183
191
  class="
184
192
  apos-area-widget-controls
185
193
  apos-area-widget-controls--add
@@ -209,13 +217,25 @@
209
217
 
210
218
  <script>
211
219
  import { mapState, mapActions } from 'pinia';
220
+ import { unref } from 'vue';
212
221
  import { useWidgetStore } from 'Modules/@apostrophecms/ui/stores/widget';
213
222
  import { useBreakpointPreviewStore } from 'Modules/@apostrophecms/ui/stores/breakpointPreview.js';
214
223
  import { useModalStore } from 'Modules/@apostrophecms/ui/stores/modal.js';
224
+ import { useWidgetGraphStore } from 'Modules/@apostrophecms/ui/stores/widgetGraph.js';
215
225
 
216
226
  export default {
217
227
  name: 'AposAreaWidget',
228
+ inject: {
229
+ aposGraphKey: {
230
+ from: 'aposGraphKey',
231
+ default: null
232
+ }
233
+ },
218
234
  props: {
235
+ raised: {
236
+ type: Boolean,
237
+ default: false
238
+ },
219
239
  docId: {
220
240
  type: String,
221
241
  required: false,
@@ -316,6 +336,7 @@ export default {
316
336
  mounted: false, // hack around needing DOM to be rendered for computed classes
317
337
  menuOpen: null,
318
338
  isSuppressingWidgetControls: false,
339
+ isSuppressingAddContentButtons: false,
319
340
  hasClickOutsideListener: false,
320
341
  classes: {
321
342
  show: 'apos-is-visible',
@@ -447,7 +468,7 @@ export default {
447
468
  },
448
469
  labelsClasses() {
449
470
  return {
450
- [this.classes.show]: this.isHovered || this.isFocused || this.isEmphasized,
471
+ [this.classes.show]: (this.isHovered && !this.focusedWidget) || this.isFocused,
451
472
  // Force LTR for breadcrumbs for now so that nested AreaWidgets
452
473
  // behave properly.
453
474
  'apos-ltr': true
@@ -455,7 +476,7 @@ export default {
455
476
  },
456
477
  addClasses() {
457
478
  return {
458
- [this.classes.show]: this.isHovered || this.isFocused,
479
+ [this.classes.show]: (this.isHovered && !this.focusedWidget) || this.isFocused,
459
480
  [`${this.classes.open}--menu-${this.menuOpen}`]: !!this.menuOpen
460
481
  };
461
482
  },
@@ -493,6 +514,7 @@ export default {
493
514
  } else {
494
515
  this.menuOpen = null;
495
516
  this.isSuppressingWidgetControls = false;
517
+ this.isSuppressingAddContentButtons = false;
496
518
  this.removeClickOutsideListener();
497
519
  }
498
520
  // Helps get scroll tracking unstuck on new/modified widgets
@@ -515,11 +537,14 @@ export default {
515
537
  // a 'focus my parent' plea
516
538
  apos.bus.$on('widget-focus-parent', this.focusParent);
517
539
  apos.bus.$on('context-menu-toggled', this.getFocusForMenu);
540
+ apos.bus.$on('suppress-focused-widget-controls', this.doSuppressWidgetControls);
518
541
 
519
542
  this.breadcrumbs.$lastEl = this.$el;
520
543
 
521
544
  this.getBreadcrumbs();
522
545
 
546
+ this.registerInGraph();
547
+
523
548
  if (this.focusedWidget) {
524
549
  // If another widget was in focus (because the user clicked the "add"
525
550
  // menu, for example), and this widget was created, give the new widget
@@ -549,12 +574,50 @@ export default {
549
574
  unmounted() {
550
575
  // Remove the focus parent listener when unmounted
551
576
  apos.bus.$off('widget-focus-parent', this.focusParent);
577
+ apos.bus.$off('suppress-focused-widget-controls', this.doSuppressWidgetControls);
552
578
  window.removeEventListener('scroll', this.stickyControlsScroll);
553
579
  window.removeEventListener('resize', this.stickyControlsResize);
580
+ this.unregisterFromGraph();
554
581
  },
555
582
  methods: {
583
+ clearSuppressionFlags() {
584
+ this.isSuppressingWidgetControls = false;
585
+ this.isSuppressingAddContentButtons = false;
586
+ },
556
587
  ...mapActions(useWidgetStore, [ 'setFocusedWidget', 'setHoveredWidget' ]),
557
588
  ...mapActions(useModalStore, [ 'getAdminContentDirectionClass' ]),
589
+ ...mapActions(useWidgetGraphStore, {
590
+ storeRegisterWidget: 'registerWidget',
591
+ storeUnregisterWidget: 'unregisterWidget'
592
+ }),
593
+ registerInGraph() {
594
+ if (this.foreign) {
595
+ return;
596
+ }
597
+ const graphKey = unref(this.aposGraphKey);
598
+ if (!graphKey) {
599
+ return;
600
+ }
601
+ this.storeRegisterWidget(graphKey, this.widget, {
602
+ areaId: this.areaId
603
+ });
604
+ },
605
+ unregisterFromGraph() {
606
+ if (this.foreign) {
607
+ return;
608
+ }
609
+ const graphKey = unref(this.aposGraphKey);
610
+ if (!graphKey) {
611
+ return;
612
+ }
613
+ this.storeUnregisterWidget(graphKey, this.widget._id);
614
+ },
615
+ doSuppressWidgetControls() {
616
+ this.isSuppressingWidgetControls = true;
617
+ },
618
+ doSuppressAddContentButtons() {
619
+ this.isSuppressingAddContentButtons = true;
620
+ },
558
621
  // Emits same actions as the native operations,
559
622
  // e.g ('edit', { index }), ('remove', { index }), etc.
560
623
  onOperation({ name, payload }) {
@@ -716,6 +779,9 @@ export default {
716
779
  }
717
780
  },
718
781
  unfocus(event) {
782
+ if (event.target.closest('[data-apos-ignore-unfocus="true"]')) {
783
+ return;
784
+ }
719
785
  if (!this.$el.contains(event.target)) {
720
786
  this.removeClickOutsideListener();
721
787
 
@@ -925,6 +991,10 @@ export default {
925
991
  }
926
992
  }
927
993
 
994
+ .apos-area-widget-rendered-widget {
995
+ position: relative;
996
+ }
997
+
928
998
  .apos-area-widget-controls {
929
999
  z-index: $z-index-widget-controls;
930
1000
  position: absolute;
@@ -209,6 +209,7 @@ export default {
209
209
  ...this.operationButtonDefault,
210
210
  icon: operation.icon
211
211
  },
212
+ ignoreUnfocus: true,
212
213
  teleportContent: this.teleportModals,
213
214
  disabled,
214
215
  tooltip
@@ -97,17 +97,29 @@ export default {
97
97
  };
98
98
  },
99
99
  widgetPrimaryControls() {
100
+ const removeForSingleWidget = [ 'nudgeUp', 'nudgeDown' ];
100
101
  // Custom widget operations displayed in the primary controls
101
- return this.widgetPrimaryOperations.map(operation => {
102
- const disabled = this.disabled || isOperationDisabled(operation, this.$props);
103
- const tooltip = getOperationTooltip(operation, { disabled });
104
- return {
105
- ...this.widgetDefaultControl,
106
- ...operation,
107
- disabled,
108
- tooltip
109
- };
110
- });
102
+ return this.widgetPrimaryOperations
103
+ .map(operation => {
104
+ const disabled = this.disabled || isOperationDisabled(operation, this.$props);
105
+ const tooltip = getOperationTooltip(operation, { disabled });
106
+ return {
107
+ ...this.widgetDefaultControl,
108
+ ...operation,
109
+ disabled,
110
+ tooltip
111
+ };
112
+ })
113
+ .filter(operation => {
114
+ if (
115
+ removeForSingleWidget.includes(operation.action) &&
116
+ this.first &&
117
+ this.last
118
+ ) {
119
+ return false;
120
+ }
121
+ return true;
122
+ });
111
123
  },
112
124
  widgetSecondaryControls() {
113
125
  const renderOperation = (operation) => {
@@ -1,14 +1,22 @@
1
1
  import { createId } from '@paralleldrive/cuid2';
2
+ import { unref } from 'vue';
2
3
  import { mapState, mapActions } from 'pinia';
3
4
  import AposThemeMixin from 'Modules/@apostrophecms/ui/mixins/AposThemeMixin';
4
5
  import newInstance from 'apostrophe/modules/@apostrophecms/schema/lib/newInstance.js';
5
6
  import { useModalStore } from 'Modules/@apostrophecms/ui/stores/modal';
6
7
  import { useWidgetStore } from 'Modules/@apostrophecms/ui/stores/widget';
8
+ import { useWidgetGraphStore } from 'Modules/@apostrophecms/ui/stores/widgetGraph';
7
9
  import cloneWidget from 'Modules/@apostrophecms/area/lib/clone-widget.js';
8
10
  import { klona } from 'klona';
9
11
 
10
12
  export default {
11
13
  mixins: [ AposThemeMixin ],
14
+ inject: {
15
+ aposGraphKey: {
16
+ from: 'aposGraphKey',
17
+ default: null
18
+ }
19
+ },
12
20
  props: {
13
21
  docId: {
14
22
  type: String,
@@ -130,6 +138,35 @@ export default {
130
138
  }
131
139
 
132
140
  return this.next.findIndex(widget => widget._id === this.focusedWidget);
141
+ },
142
+ /**
143
+ * Set of widget _ids (from `next`) that should have a raised z-index
144
+ * because they are, or contain, the currently focused widget.
145
+ * Computed once per focusedWidget / graph change; O(depth) ancestor
146
+ * walk + O(1) per-widget lookup in the template.
147
+ */
148
+ raisedWidgets() {
149
+ const raised = new Set();
150
+ if (!this.focusedWidget) {
151
+ return raised;
152
+ }
153
+ const graphKey = unref(this.aposGraphKey);
154
+ if (!graphKey) {
155
+ // No graph — fall back to exact match only
156
+ if (this.next.some(w => w._id === this.focusedWidget)) {
157
+ raised.add(this.focusedWidget);
158
+ }
159
+ return raised;
160
+ }
161
+ const ancestors = this.storeGetAncestors(graphKey, this.focusedWidget);
162
+ const chain = new Set([ this.focusedWidget, ...ancestors ]);
163
+
164
+ for (const widget of this.next) {
165
+ if (chain.has(widget._id)) {
166
+ raised.add(widget._id);
167
+ }
168
+ }
169
+ return raised;
133
170
  }
134
171
  },
135
172
  watch: {
@@ -176,6 +213,9 @@ export default {
176
213
  methods: {
177
214
  ...mapActions(useWidgetStore, [ 'setFocusedArea', 'setFocusedWidget' ]),
178
215
  ...mapActions(useModalStore, [ 'isOnTop' ]),
216
+ ...mapActions(useWidgetGraphStore, {
217
+ storeGetAncestors: 'getAncestors'
218
+ }),
179
219
  bindEventListeners() {
180
220
  apos.bus.$on('area-updated', this.areaUpdatedHandler);
181
221
  apos.bus.$on('command-menu-area-copy-widget', this.handleCopy);
@@ -1187,9 +1187,10 @@ module.exports = {
1187
1187
  if (!self.shouldRefreshOnRestart()) {
1188
1188
  return '';
1189
1189
  }
1190
+ const prefix = self.apos.prefix || '';
1190
1191
  return self.apos.template.safe(
1191
- `<script data-apos-refresh-on-restart="${self.action}/restart-id" ` +
1192
- `src="${self.action}/refresh-on-restart"></script>`
1192
+ `<script data-apos-refresh-on-restart="${prefix}${self.action}/restart-id" ` +
1193
+ `src="${prefix}${self.action}/refresh-on-restart"></script>`
1193
1194
  );
1194
1195
  },
1195
1196
  // Return the URL of the release asset with the given path, taking into