apostrophe 3.48.0 → 3.50.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 (45) hide show
  1. package/CHANGELOG.md +55 -2
  2. package/index.js +20 -2
  3. package/lib/locales.js +1 -1
  4. package/lib/moog-require.js +3 -0
  5. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBar.vue +12 -2
  6. package/modules/@apostrophecms/area/ui/apos/components/AposAreaEditor.vue +2 -0
  7. package/modules/@apostrophecms/area/ui/apos/components/AposAreaWidget.vue +7 -24
  8. package/modules/@apostrophecms/asset/index.js +27 -2
  9. package/modules/@apostrophecms/asset/lib/webpack/apos/webpack.config.js +23 -2
  10. package/modules/@apostrophecms/asset/lib/webpack/src/webpack.config.js +26 -2
  11. package/modules/@apostrophecms/doc/index.js +149 -0
  12. package/modules/@apostrophecms/doc-type/index.js +9 -1
  13. package/modules/@apostrophecms/global/index.js +4 -15
  14. package/modules/@apostrophecms/i18n/i18n/en.json +3 -2
  15. package/modules/@apostrophecms/i18n/index.js +76 -61
  16. package/modules/@apostrophecms/image/ui/apos/components/AposMediaManagerDisplay.vue +14 -1
  17. package/modules/@apostrophecms/login/ui/apos/components/AposForgotPasswordForm.vue +3 -60
  18. package/modules/@apostrophecms/login/ui/apos/components/AposLoginForm.vue +3 -231
  19. package/modules/@apostrophecms/login/ui/apos/components/AposResetPasswordForm.vue +3 -96
  20. package/modules/@apostrophecms/login/ui/apos/components/TheAposLogin.vue +2 -99
  21. package/modules/@apostrophecms/login/ui/apos/logic/AposForgotPasswordForm.js +68 -0
  22. package/modules/@apostrophecms/login/ui/apos/logic/AposLoginForm.js +239 -0
  23. package/modules/@apostrophecms/login/ui/apos/logic/AposResetPasswordForm.js +105 -0
  24. package/modules/@apostrophecms/login/ui/apos/logic/TheAposLogin.js +107 -0
  25. package/modules/@apostrophecms/modal/ui/apos/components/AposDocsManagerToolbar.vue +9 -3
  26. package/modules/@apostrophecms/modal/ui/apos/components/AposModalToolbar.vue +1 -0
  27. package/modules/@apostrophecms/page/index.js +124 -1
  28. package/modules/@apostrophecms/piece-type/index.js +57 -9
  29. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposImageControlDialog.vue +11 -8
  30. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +226 -72
  31. package/modules/@apostrophecms/schema/index.js +0 -1
  32. package/modules/@apostrophecms/schema/lib/addFieldTypes.js +35 -7
  33. package/modules/@apostrophecms/schema/ui/apos/components/AposInputSlug.vue +21 -1
  34. package/modules/@apostrophecms/schema/ui/apos/components/AposInputString.vue +12 -7
  35. package/modules/@apostrophecms/schema/ui/apos/components/AposSchema.vue +1 -0
  36. package/modules/@apostrophecms/ui/ui/apos/components/AposCombo.vue +178 -20
  37. package/modules/@apostrophecms/ui/ui/apos/components/AposFilterMenu.vue +1 -1
  38. package/modules/@apostrophecms/ui/ui/apos/components/AposPager.vue +4 -6
  39. package/modules/@apostrophecms/ui/ui/apos/scss/mixins/_theme_mixins.scss +1 -0
  40. package/modules/@apostrophecms/util/index.js +5 -6
  41. package/modules/@apostrophecms/util/ui/src/http.js +6 -3
  42. package/package.json +20 -3
  43. package/test/change-doc-ids.js +134 -0
  44. package/test/i18n.js +310 -0
  45. package/test/static-i18n.js +0 -105
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div>
2
+ <div :aria-controls="`insert-menu-${value._id}`" @keydown="handleUIKeydown">
3
3
  <bubble-menu
4
4
  class="bubble-menu"
5
5
  :tippy-options="{ maxWidth: 'none', duration: 100, zIndex: 2000 }"
@@ -26,44 +26,64 @@
26
26
  </AposContextMenuDialog>
27
27
  </bubble-menu>
28
28
  <floating-menu
29
- class="apos-rich-text-insert-menu" :should-show="showFloatingMenu"
30
- :editor="editor"
31
- :tippy-options="{ duration: 100, zIndex: 2000 }"
32
29
  v-if="editor"
30
+ class="apos-rich-text-insert-menu"
31
+ :tippy-options="{ duration: 100, zIndex: 2000, placement: 'bottom-start' }"
32
+ :should-show="showFloatingMenu"
33
+ :editor="editor"
34
+ role="listbox"
35
+ tabindex="0"
36
+ ref="insertMenu"
37
+ :id="`insert-menu-${value._id}`"
38
+ :key="insertMenuKey"
33
39
  >
34
40
  <div class="apos-rich-text-insert-menu-heading">
35
41
  {{ $t('apostrophe:richTextInsertMenuHeading') }}
36
42
  </div>
37
43
  <div
38
- v-for="(item, index) in insert"
39
- :key="`${item}-${index}`"
40
- class="apos-rich-text-insert-menu-item"
44
+ class="apos-rich-text-insert-menu-wrapper"
45
+ @keydown.prevent.arrow-up="focusInsertMenuItem(true)"
46
+ @keydown.prevent.arrow-down="focusInsertMenuItem()"
47
+ @keydown="closeInsertMenu"
41
48
  >
42
- <div class="apos-rich-text-insert-menu-icon">
43
- <AposIndicator
44
- :icon="insertMenu[item].icon"
45
- :icon-size="35"
46
- class="apos-button__icon"
47
- fill-color="currentColor"
48
- @click="activateInsertMenuItem(item, insertMenu[item])"
49
- />
50
- <component
51
- v-if="item === activeInsertMenuComponent?.name"
52
- :is="activeInsertMenuComponent.component"
53
- :active="true"
54
- :editor="editor"
55
- :options="editorOptions"
56
- @before-commands="removeSlash"
57
- @close="closeInsertMenuItem"
58
- @click.stop="$event => null"
59
- />
60
- </div>
61
- <div
62
- class="apos-rich-text-insert-menu-label"
49
+ <button
50
+ v-for="(item, index) in insert"
51
+ :key="`${item}-${index}`"
52
+ class="apos-rich-text-insert-menu-item"
53
+ role="option"
54
+ data-insert-menu-item
63
55
  @click="activateInsertMenuItem(item, insertMenu[item])"
64
56
  >
65
- <h4>{{ $t(insertMenu[item].label) }}</h4>
66
- <p>{{ $t(insertMenu[item].description) }}</p>
57
+ <div class="apos-rich-text-insert-menu-icon">
58
+ <AposIndicator
59
+ :icon="insertMenu[item].icon"
60
+ :icon-size="24"
61
+ class="apos-button__icon"
62
+ fill-color="currentColor"
63
+ />
64
+ </div>
65
+ <div class="apos-rich-text-insert-menu-label">
66
+ <h4>{{ $t(insertMenu[item].label) }}</h4>
67
+ <p>{{ $t(insertMenu[item].description) }}</p>
68
+ </div>
69
+ </button>
70
+ <div class="apos-rich-text-insert-menu-components">
71
+ <div
72
+ v-for="(item, index) in insert"
73
+ :key="`${item}-${index}-component`"
74
+ >
75
+ <component
76
+ v-if="item === activeInsertMenuComponent?.name"
77
+ :is="activeInsertMenuComponent.component"
78
+ :active="true"
79
+ :editor="editor"
80
+ :options="editorOptions"
81
+ @before-commands="removeSlash"
82
+ @cancel="cancelInsertMenuItem"
83
+ @done="closeInsertMenuItem"
84
+ @close="closeInsertMenuItem"
85
+ />
86
+ </div>
67
87
  </div>
68
88
  </div>
69
89
  </floating-menu>
@@ -86,7 +106,23 @@ import {
86
106
  BubbleMenu,
87
107
  FloatingMenu
88
108
  } from '@tiptap/vue-2';
89
- import StarterKit from '@tiptap/starter-kit';
109
+ // Starter Kit extensions
110
+ import BlockQuote from '@tiptap/extension-blockquote';
111
+ import Bold from '@tiptap/extension-bold';
112
+ import BulletList from '@tiptap/extension-bullet-list';
113
+ import Code from '@tiptap/extension-code';
114
+ import CodeBlock from '@tiptap/extension-code-block';
115
+ import Dropcursor from '@tiptap/extension-dropcursor';
116
+ import Gapcursor from '@tiptap/extension-gapcursor';
117
+ import HardBreak from '@tiptap/extension-hard-break';
118
+ import History from '@tiptap/extension-history';
119
+ import HorizontalRule from '@tiptap/extension-horizontal-rule';
120
+ import Italic from '@tiptap/extension-italic';
121
+ import OrderedList from '@tiptap/extension-ordered-list';
122
+ import Paragraph from '@tiptap/extension-paragraph';
123
+ import Strike from '@tiptap/extension-strike';
124
+ import Text from '@tiptap/extension-text';
125
+ // End starter kit extensions
90
126
  import TextAlign from '@tiptap/extension-text-align';
91
127
  import Highlight from '@tiptap/extension-highlight';
92
128
  import Underline from '@tiptap/extension-underline';
@@ -144,8 +180,11 @@ export default {
144
180
  },
145
181
  pending: null,
146
182
  isFocused: null,
183
+ isShowingInsert: false,
147
184
  showPlaceholder: null,
148
- activeInsertMenuComponent: null
185
+ activeInsertMenuComponent: null,
186
+ suppressInsertMenu: false,
187
+ insertMenuKey: null
149
188
  };
150
189
  },
151
190
  computed: {
@@ -246,17 +285,33 @@ export default {
246
285
  this.emitWidgetUpdate();
247
286
  }
248
287
  }
288
+ },
289
+ isShowingInsert(newVal) {
290
+ if (newVal) {
291
+ this.focusInsertMenuItem(false, 0);
292
+ }
249
293
  }
250
294
  },
251
295
  mounted() {
296
+ this.insertMenuKey = this.generateKey();
252
297
  // Cleanly namespace it so we don't conflict with other uses and instances
253
298
  const CustomPlaceholder = Placeholder.extend();
254
299
  const extensions = [
255
- StarterKit.configure({
256
- document: false,
257
- heading: false,
258
- listItem: false
259
- }),
300
+ BlockQuote,
301
+ Bold,
302
+ BulletList,
303
+ Code,
304
+ CodeBlock,
305
+ Dropcursor,
306
+ Gapcursor,
307
+ HardBreak,
308
+ History,
309
+ HorizontalRule,
310
+ Italic,
311
+ OrderedList,
312
+ Paragraph,
313
+ Strike,
314
+ Text,
260
315
  TextAlign.configure({
261
316
  types: [ 'heading', 'paragraph', 'defaultNode' ]
262
317
  }),
@@ -273,7 +328,7 @@ export default {
273
328
  const text = this.$t(this.placeholderText);
274
329
  return text;
275
330
  },
276
- emptyNodeClass: this.insert.length ? 'apos-is-empty' : 'apos-is-empty-without-insert'
331
+ emptyNodeClass: 'apos-is-empty'
277
332
  }),
278
333
  FloatingMenu
279
334
  ]
@@ -316,6 +371,22 @@ export default {
316
371
  apos.bus.$off('apos-refreshing', this.onAposRefreshing);
317
372
  },
318
373
  methods: {
374
+ generateKey() {
375
+ return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
376
+ },
377
+ handleUIKeydown(e) {
378
+ if (e.key === 'Escape') {
379
+ this.doSuppressInsertMenu();
380
+ } else {
381
+ this.suppressInsertMenu = false;
382
+ }
383
+ },
384
+ doSuppressInsertMenu() {
385
+ this.suppressInsertMenu = true;
386
+ this.activeInsertMenuComponent = null;
387
+ this.insertMenuKey = this.generateKey();
388
+ this.editor.commands.focus();
389
+ },
319
390
  onAposRefreshing(refreshOptions) {
320
391
  if (this.activeInsertMenuComponent) {
321
392
  refreshOptions.refresh = false;
@@ -335,6 +406,7 @@ export default {
335
406
  // least once per second if the user is actively typing
336
407
  return;
337
408
  }
409
+
338
410
  this.pending = setTimeout(() => {
339
411
  this.emitWidgetUpdate();
340
412
  }, 1000);
@@ -472,24 +544,34 @@ export default {
472
544
  types: this.tiptapTypes
473
545
  }));
474
546
  },
475
- showFloatingMenu({ state }) {
476
- if (!this.insertMenu || !this.insert.length) {
547
+ showFloatingMenu({
548
+ state, oldState
549
+ }) {
550
+ const hasChanges = JSON.stringify(state?.doc.toJSON()) !== JSON.stringify(oldState?.doc.toJSON());
551
+ const { $to } = state.selection;
552
+
553
+ if (
554
+ !this.insertMenu ||
555
+ !this.insert.length ||
556
+ !hasChanges ||
557
+ ($to.nodeAfter && $to.nodeAfter.text) ||
558
+ this.suppressInsertMenu
559
+ ) {
560
+ this.isShowingInsert = false;
477
561
  return false;
478
562
  }
479
- const { $from, $to } = state.selection;
563
+
480
564
  if (state.selection.empty) {
481
565
  if ($to.nodeBefore && $to.nodeBefore.text) {
482
566
  const text = $to.nodeBefore.text;
483
- // Only show when the user has just entered a '/' character or
484
- // an insert menu component is active
485
- if (text === '/') {
567
+ if (text.slice(-1) === '/') {
568
+ this.isShowingInsert = true;
486
569
  return true;
487
570
  }
488
571
  }
489
- return false;
490
- } else if (state.doc.textBetween($from, $to, ' ') === '/') {
491
- return true;
492
572
  }
573
+
574
+ this.isShowingInsert = false;
493
575
  return false;
494
576
  },
495
577
  activateInsertMenuItem(name, info) {
@@ -523,6 +605,38 @@ export default {
523
605
  closeInsertMenuItem() {
524
606
  this.removeSlash();
525
607
  this.activeInsertMenuComponent = null;
608
+ },
609
+ cancelInsertMenuItem() {
610
+ this.doSuppressInsertMenu();
611
+ },
612
+ closeInsertMenu(e) {
613
+ if (
614
+ [ 'ArrowUp', 'ArrowDown', 'Enter', ' ' ].includes(e.key) ||
615
+ this.activeInsertMenuComponent
616
+ ) {
617
+ return;
618
+ }
619
+ this.editor.commands.focus();
620
+ this.activeInsertMenuComponent = null;
621
+ // Only insert character keys
622
+ if (e.key.length === 1) {
623
+ this.editor.commands.insertContent(e.key);
624
+ }
625
+ },
626
+ focusInsertMenuItem(prev = false, index) {
627
+ if (this.activeInsertMenuComponent) {
628
+ return;
629
+ }
630
+ const buttons = Array.from(this.$refs.insertMenu.$el.querySelectorAll('[data-insert-menu-item]'));
631
+ const currentIndex = buttons.findIndex(el => el === document.activeElement);
632
+ let targetIndex = prev ? currentIndex - 1 : currentIndex + 1;
633
+ if (targetIndex >= buttons.length) {
634
+ targetIndex = 0;
635
+ }
636
+ if (targetIndex < 0) {
637
+ targetIndex = buttons.length - 1;
638
+ }
639
+ buttons[index || targetIndex]?.focus();
526
640
  }
527
641
  }
528
642
  };
@@ -569,27 +683,41 @@ function traverseNextNode(node) {
569
683
  background-color: var(--a-base-9);
570
684
  }
571
685
 
686
+ .apos-rich-text-editor__editor ::v-deep .ProseMirror {
687
+ @include apos-transition();
688
+ }
689
+
572
690
  .apos-rich-text-editor__editor ::v-deep .ProseMirror:focus {
573
691
  outline: none;
574
692
  }
575
693
 
576
- .apos-rich-text-editor__editor ::v-deep .ProseMirror:focus p.apos-is-empty::before,
577
- .apos-rich-text-editor__editor.apos-is-visually-empty ::v-deep .ProseMirror:focus p:first-of-type::before {
694
+ .apos-rich-text-editor__editor ::v-deep .ProseMirror {
695
+ padding: 10px 0;
696
+ }
697
+
698
+ .apos-rich-text-editor__editor ::v-deep .ProseMirror:focus p.apos-is-empty::after {
699
+ display: block;
700
+ margin: 5px 0 10px;
701
+ color: var(--a-primary-transparent-50);
702
+ font-size: var(--a-type-smaller);
703
+ text-transform: uppercase;
704
+ letter-spacing: 0.5px;
705
+ font-weight: 600;
706
+ border-top: 1px solid var(--a-primary-transparent-50);
707
+ padding-top: 5px;
578
708
  content: attr(data-placeholder);
579
- float: left;
580
709
  pointer-events: none;
581
- height: 0;
582
- color: var(--a-base-4);
583
710
  }
584
711
 
585
712
  .apos-rich-text-editor__editor {
586
713
  @include apos-transition();
587
714
  position: relative;
588
715
  border-radius: var(--a-border-radius);
589
- box-shadow: 0 0 0 1px transparent;
716
+ background-color: transparent;
590
717
  }
591
718
  .apos-rich-text-editor__editor.apos-is-visually-empty {
592
- box-shadow: 0 0 0 1px var(--a-primary-transparent-50);
719
+ background-color: var(--a-primary-transparent-10);
720
+ min-height: 50px;
593
721
  }
594
722
  .apos-rich-text-editor__editor_after {
595
723
  @include type-small;
@@ -602,9 +730,7 @@ function traverseNextNode(node) {
602
730
  width: 200px;
603
731
  height: 10px;
604
732
  margin: auto;
605
- margin-top: 7.5px;
606
- margin-bottom: 7.5px;
607
- color: var(--a-base-5);
733
+ color: var(--a-primary-transparent-50);
608
734
  opacity: 0;
609
735
  visibility: hidden;
610
736
  pointer-events: none;
@@ -655,12 +781,9 @@ function traverseNextNode(node) {
655
781
  }
656
782
 
657
783
  .apos-rich-text-insert-menu {
658
- display: flex;
659
- flex-direction: column;
660
784
  cursor: pointer;
661
785
  user-select: none;
662
- gap: 16px;
663
- padding: 16px;
786
+ min-width: 350px;
664
787
  border-radius: var(--a-border-radius);
665
788
  box-shadow: var(--a-box-shadow);
666
789
  background-color: var(--a-background-primary);
@@ -670,44 +793,75 @@ function traverseNextNode(node) {
670
793
  font-size: var(--a-type-base);
671
794
  }
672
795
 
796
+ .apos-rich-text-insert-menu-wrapper {
797
+ display: flex;
798
+ flex-direction: column;
799
+ }
800
+
673
801
  .apos-rich-text-insert-menu-item {
802
+ all: unset;
674
803
  display: flex;
675
804
  flex-direction: row;
676
- gap: 16px;
805
+ align-items: center;
806
+ gap: 12px;
807
+ padding: 14px 16px;
808
+ border-bottom: 1px solid var(--a-base-9);
809
+ @include apos-transition();
810
+ &:last-of-type {
811
+ border-bottom: none;
812
+ }
677
813
  &:hover {
678
- color: var(--a-text-primary);
814
+ background-color: var(--a-primary-transparent-10);
815
+ }
816
+ &:active, &:focus {
817
+ background-color: var(--a-primary);
818
+ color: var(--a-white);
679
819
  }
680
820
  }
681
821
 
682
822
  .apos-rich-text-insert-menu-label {
683
823
  display: flex;
684
824
  flex-direction: column;
825
+ gap: 5px;
685
826
  h4, p {
686
- margin: 4px;
827
+ margin: 0;
687
828
  font-family: var(--a-family-default);
688
- font-size: var(--a-type-base);
689
829
  }
690
830
  h4 {
691
- font-weight: bold;
831
+ font-weight: 500;
832
+ font-size: var(--a-type-large);
833
+ }
834
+ p {
835
+ font-size: var(--a-type-label);
692
836
  }
693
837
  }
694
838
  .apos-rich-text-insert-menu-icon {
695
- // Positions the popover meaningfully
696
839
  position: relative;
840
+ display: flex;
841
+ width: 40px;
842
+ height: 40px;
843
+ align-items: center;
844
+ justify-content: center;
845
+ border: 1px solid var(--a-base-8);
846
+ color: var(--a-text-primary);
847
+ background-color: var(--a-white);
848
+ border-radius: var(--a-border-radius);
697
849
  }
698
850
 
699
851
  .apos-rich-text-insert-menu-heading {
700
- color: var(--a-base-5);
852
+ padding: 12px 16px;
853
+ background-color: var(--a-base-9);
854
+ color: var(--a-base-2);
855
+ font-weight: 500;
856
+ border-bottom: 1px solid var(--a-base-7);
857
+ font-size: var(--a-type-label);
858
+ letter-spacing: 0.25px;
701
859
  }
702
860
 
703
861
  ::v-deep .ProseMirror {
704
862
  > * + * {
705
863
  margin-top: 0.75em;
706
864
  }
707
-
708
- > :last-child {
709
- margin-bottom: 1.75em;
710
- }
711
865
  }
712
866
 
713
867
  ::v-deep .ProseMirror-gapcursor {
@@ -1615,7 +1615,6 @@ module.exports = {
1615
1615
  throw self.apos.error('invalid', error.message);
1616
1616
  }
1617
1617
  }
1618
-
1619
1618
  };
1620
1619
  },
1621
1620
  apiRoutes(self) {
@@ -95,9 +95,8 @@ module.exports = (self) => {
95
95
 
96
96
  self.addFieldType({
97
97
  name: 'string',
98
- convert: function (req, field, data, destination) {
98
+ convert(req, field, data, destination) {
99
99
  destination[field.name] = self.apos.launder.string(data[field.name], field.def);
100
-
101
100
  destination[field.name] = checkStringLength(destination[field.name], field.min, field.max);
102
101
  // If field is required but empty (and client side didn't catch that)
103
102
  // This is new and until now if JS client side failed, then it would
@@ -105,8 +104,16 @@ module.exports = (self) => {
105
104
  if (field.required && (_.isUndefined(data[field.name]) || !data[field.name].toString().length)) {
106
105
  throw self.apos.error('required');
107
106
  }
107
+
108
+ if (field.pattern) {
109
+ const regex = new RegExp(field.pattern);
110
+
111
+ if (!regex.test(destination[field.name])) {
112
+ throw self.apos.error('invalid');
113
+ }
114
+ }
108
115
  },
109
- index: function (value, field, texts) {
116
+ index(value, field, texts) {
110
117
  const silent = field.silent === undefined ? true : field.silent;
111
118
  texts.push({
112
119
  weight: field.weight || 15,
@@ -114,10 +121,22 @@ module.exports = (self) => {
114
121
  silent: silent
115
122
  });
116
123
  },
117
- isEmpty: function (field, value) {
124
+ isEmpty(field, value) {
118
125
  return !value.length;
119
126
  },
120
- addQueryBuilder: function (field, query) {
127
+ validate(field, options, warn, fail) {
128
+ if (!field.pattern) {
129
+ return;
130
+ }
131
+
132
+ const isRegexInstance = field.pattern instanceof RegExp;
133
+ if (!isRegexInstance && typeof field.pattern !== 'string') {
134
+ fail('The pattern property must be a RegExp or a String');
135
+ }
136
+
137
+ field.pattern = isRegexInstance ? field.pattern.source : field.pattern;
138
+ },
139
+ addQueryBuilder(field, query) {
121
140
  query.addBuilder(field.name, {
122
141
  finalize: function () {
123
142
  if (self.queryBuilderInterested(query, field.name)) {
@@ -150,6 +169,9 @@ module.exports = (self) => {
150
169
  if (field.page) {
151
170
  options.allow = '/';
152
171
  }
172
+ if (data.aposIsTemplate) {
173
+ options.allow = field.page ? [ '/', '@' ] : '@';
174
+ }
153
175
  destination[field.name] = self.apos.util.slugify(self.apos.launder.string(data[field.name], field.def), options);
154
176
 
155
177
  if (field.page) {
@@ -756,9 +778,9 @@ module.exports = (self) => {
756
778
  const { name: uniqueFieldName, label: uniqueFieldLabel } = field.schema.find(subfield => subfield.unique) || [];
757
779
  if (uniqueFieldName) {
758
780
  const duplicates = data
759
- .map(item => Array.isArray(item[uniqueFieldName])
781
+ .map(item => (Array.isArray(item[uniqueFieldName])
760
782
  ? item[uniqueFieldName][0]._id
761
- : item[uniqueFieldName])
783
+ : item[uniqueFieldName]))
762
784
  .filter((item, index, array) => array.indexOf(item) !== index);
763
785
  if (duplicates.length) {
764
786
  throw self.apos.error('duplicate', `${req.t(uniqueFieldLabel)} in ${req.t(field.label)} must be unique`);
@@ -978,6 +1000,9 @@ module.exports = (self) => {
978
1000
  },
979
1001
 
980
1002
  relate: async function (req, field, objects, options) {
1003
+ if ((!self.apos.doc?.replicateReached) && (!field.idsStorage)) {
1004
+ self.apos.util.warnDevOnce('premature-relationship-query', 'Database queries for types with relationships may fail if made before the @apostrophecms/doc:beforeReplicate event');
1005
+ }
981
1006
  return self.relationshipDriver(req, joinr.byArray, false, objects, field.idsStorage, field.fieldsStorage, field.name, options);
982
1007
  },
983
1008
 
@@ -1109,6 +1134,9 @@ module.exports = (self) => {
1109
1134
  name: 'relationshipReverse',
1110
1135
  vueComponent: false,
1111
1136
  relate: async function (req, field, objects, options) {
1137
+ if ((!self.apos.doc?.replicateReached) && (!field.idsStorage)) {
1138
+ self.apos.util.warnDevOnce('premature-relationship-query', 'Database queries for types with relationships may fail if made before the @apostrophecms/doc:beforeReplicate event');
1139
+ }
1112
1140
  return self.relationshipDriver(req, joinr.byArrayReverse, true, objects, field.idsStorage, field.fieldsStorage, field.name, options);
1113
1141
  },
1114
1142
  validate: function (field, options, warn, fail) {
@@ -190,9 +190,13 @@ export default {
190
190
  const options = {
191
191
  def: ''
192
192
  };
193
- if (this.field.page && !componentOnly) {
193
+
194
+ if (this.field.aposIsTemplate) {
195
+ options.allow = this.field.page ? [ '/', '@' ] : '@';
196
+ } else if (this.field.page && !componentOnly) {
194
197
  options.allow = '/';
195
198
  }
199
+
196
200
  let preserveDash = false;
197
201
  // When you are typing a slug it feels wrong for hyphens you typed
198
202
  // to disappear as you go, so if the last character is not valid in a slug,
@@ -200,10 +204,12 @@ export default {
200
204
  if (this.focus && s.length && (sluggo(s.charAt(s.length - 1), options) === '')) {
201
205
  preserveDash = true;
202
206
  }
207
+
203
208
  s = sluggo(s, options);
204
209
  if (preserveDash) {
205
210
  s += '-';
206
211
  }
212
+
207
213
  if (this.field.page && !componentOnly) {
208
214
  if (!this.followingValues?.title) {
209
215
  const nextParts = this.next.split('/');
@@ -226,6 +232,7 @@ export default {
226
232
  s += '/';
227
233
  }
228
234
  }
235
+
229
236
  if (!componentOnly) {
230
237
  s = this.setPrefix(s);
231
238
  }
@@ -262,7 +269,20 @@ export default {
262
269
  // doc editor modal it will momentarily be tracked as archived but
263
270
  // without not have the archive prefix, so check that too.
264
271
  updated = this.isArchived && archivePrefix ? `${archivePrefix}${updated}` : updated;
272
+ } else if (this.field.aposIsTemplate) {
273
+ let prefix = '';
274
+ if (this.field.page) {
275
+ if (!updated.startsWith('/@')) {
276
+ prefix = '/@';
277
+ }
278
+ } else {
279
+ if (!updated.startsWith('@')) {
280
+ prefix = '@';
281
+ }
282
+ }
283
+ updated = prefix + updated;
265
284
  }
285
+
266
286
  return updated;
267
287
  },
268
288
  async checkConflict() {
@@ -11,7 +11,8 @@
11
11
  v-if="field.textarea && field.type === 'string'" rows="5"
12
12
  v-model="next" :placeholder="$t(field.placeholder)"
13
13
  @keydown.enter="enterEmit"
14
- :disabled="field.readOnly" :required="field.required"
14
+ :disabled="field.readOnly"
15
+ :required="field.required"
15
16
  :id="uid" :tabindex="tabindex"
16
17
  />
17
18
  <input
@@ -123,12 +124,9 @@ export default {
123
124
  if (typeof value === 'string' && !value.length) {
124
125
  // Also correct for float and integer because Vue coerces
125
126
  // number fields to either a number or the empty string
126
- if (this.field.required) {
127
- return 'required';
128
- } else {
129
- return false;
130
- }
127
+ return this.field.required ? 'required' : false;
131
128
  }
129
+
132
130
  const minMaxFields = [
133
131
  'integer',
134
132
  'float',
@@ -137,6 +135,13 @@ export default {
137
135
  'password'
138
136
  ];
139
137
 
138
+ if (typeof value === 'string' && this.field.pattern) {
139
+ const regex = new RegExp(this.field.pattern);
140
+ if (!regex.test(value)) {
141
+ return 'invalid';
142
+ }
143
+ }
144
+
140
145
  if (this.field.min && minMaxFields.includes(this.field.type)) {
141
146
  if ((value != null) && value.length && (this.minMaxComparable(value) < this.field.min)) {
142
147
  return 'min';
@@ -192,7 +197,7 @@ export default {
192
197
  },
193
198
  minMaxComparable(s) {
194
199
  const converted = this.convert(s);
195
- if ((this.field.type === 'integer') || (this.field.type === 'float') || (this.field.type === 'date') || (this.field.type === 'range') || (this.field.type === 'time')) {
200
+ if ([ 'integer', 'float', 'date', 'range', 'time' ].includes(this.field.type)) {
196
201
  // Compare the actual values for these types
197
202
  return converted;
198
203
  } else {
@@ -158,6 +158,7 @@ export default {
158
158
  this.schema.forEach(item => {
159
159
  fields[item.name] = {};
160
160
  fields[item.name].field = item;
161
+ fields[item.name].field.aposIsTemplate = this.value?.data?.aposIsTemplate;
161
162
  fields[item.name].value = {
162
163
  data: this.value[item.name]
163
164
  };