apostrophe 4.1.1 → 4.2.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 (48) hide show
  1. package/CHANGELOG.md +34 -1
  2. package/lib/mongodb-connect.js +1 -1
  3. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposAdminBarMenu.vue +1 -0
  4. package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextBar.vue +22 -16
  5. package/modules/@apostrophecms/area/index.js +1 -1
  6. package/modules/@apostrophecms/area/ui/apos/components/AposAreaEditor.vue +6 -1
  7. package/modules/@apostrophecms/db/index.js +0 -6
  8. package/modules/@apostrophecms/doc/index.js +19 -31
  9. package/modules/@apostrophecms/doc/lib/migrations.js +59 -0
  10. package/modules/@apostrophecms/doc-type/index.js +5 -2
  11. package/modules/@apostrophecms/express/index.js +3 -2
  12. package/modules/@apostrophecms/i18n/i18n/de.json +5 -1
  13. package/modules/@apostrophecms/i18n/i18n/en.json +5 -1
  14. package/modules/@apostrophecms/i18n/i18n/es.json +5 -1
  15. package/modules/@apostrophecms/i18n/i18n/fr.json +5 -1
  16. package/modules/@apostrophecms/i18n/i18n/it.json +5 -1
  17. package/modules/@apostrophecms/i18n/i18n/pt-BR.json +5 -1
  18. package/modules/@apostrophecms/i18n/i18n/sk.json +5 -1
  19. package/modules/@apostrophecms/i18n/index.js +1 -1
  20. package/modules/@apostrophecms/migration/index.js +5 -0
  21. package/modules/@apostrophecms/modal/ui/apos/apps/AposModals.js +2 -2
  22. package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +188 -187
  23. package/modules/@apostrophecms/modal/ui/apos/composables/AposFocus.js +2 -2
  24. package/modules/@apostrophecms/notification/index.js +2 -0
  25. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManager.vue +2 -2
  26. package/modules/@apostrophecms/rich-text-widget/index.js +19 -8
  27. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +44 -15
  28. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapMarks.vue +226 -0
  29. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapStyles.vue +90 -18
  30. package/modules/@apostrophecms/rich-text-widget/ui/apos/tiptap-extensions/Classes.js +70 -6
  31. package/modules/@apostrophecms/rich-text-widget/ui/apos/tiptap-extensions/Default.js +2 -1
  32. package/modules/@apostrophecms/rich-text-widget/ui/apos/tiptap-extensions/Document.js +1 -1
  33. package/modules/@apostrophecms/rich-text-widget/ui/apos/tiptap-extensions/Heading.js +1 -1
  34. package/modules/@apostrophecms/schema/ui/apos/components/AposInputColor.vue +1 -1
  35. package/modules/@apostrophecms/schema/ui/apos/components/AposInputWrapper.vue +1 -1
  36. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputColor.js +4 -0
  37. package/modules/@apostrophecms/schema/ui/apos/logic/AposInputSlug.js +3 -0
  38. package/modules/@apostrophecms/ui/ui/apos/components/AposButtonSplit.vue +2 -2
  39. package/modules/@apostrophecms/ui/ui/apos/lib/i18next.js +4 -8
  40. package/modules/@apostrophecms/user/index.js +40 -14
  41. package/modules/@apostrophecms/user/lib/password-hash.js +122 -0
  42. package/modules/@apostrophecms/util/ui/src/http.js +7 -0
  43. package/package.json +3 -5
  44. package/test/docs.js +151 -0
  45. package/test/password-hash.js +56 -0
  46. package/test/users.js +19 -3
  47. package/.github/workflows/outdated-dependencies.yml +0 -43
  48. package/modules/@apostrophecms/modal/ui/apos/mixins/AposFocusMixin.js +0 -91
@@ -0,0 +1,226 @@
1
+ <template>
2
+ <div class="apos-marks-control">
3
+ <AposButton
4
+ type="rich-text"
5
+ class="apos-marks-control__button"
6
+ :label="buttonLabel"
7
+ :icon="tool.icon"
8
+ :icon-size="16"
9
+ :modifiers="['no-border', 'no-motion']"
10
+ :tooltip="{
11
+ content: $t(tool.label),
12
+ placement: 'top',
13
+ delay: 650
14
+ }"
15
+ @click="click"
16
+ />
17
+ <div
18
+ v-if="open"
19
+ v-click-outside-element="close"
20
+ class="apos-popover apos-marks-control__dialog"
21
+ x-placement="bottom"
22
+ >
23
+ <AposContextMenuDialog
24
+ menu-placement="bottom-start"
25
+ class-list="apos-context-menu__dialog--unpadded"
26
+ >
27
+ <div class="apos-marks-control__content-wrapper">
28
+ <ul class="apos-marks-control__items">
29
+ <li
30
+ v-for="mark in options.marks"
31
+ :key="mark.class"
32
+ class="apos-marks-control__item"
33
+ :class="{ 'apos-marks-control__item--is-active': activeClasses.includes(mark.class) }"
34
+ >
35
+ <button class="apos-marks-control__button" @click="toggleStyle(mark)">
36
+ <span class="apos-marks-control__label" :class="mark.class">
37
+ {{ $t(mark.label) }}
38
+ </span>
39
+ </button>
40
+ </li>
41
+ </ul>
42
+ </div>
43
+ </AposContextMenuDialog>
44
+ </div>
45
+ </div>
46
+ </template>
47
+
48
+ <script>
49
+ import AposEditorMixin from 'Modules/@apostrophecms/modal/mixins/AposEditorMixin';
50
+
51
+ export default {
52
+ name: 'AposTiptapMarks',
53
+ mixins: [ AposEditorMixin ],
54
+ props: {
55
+ name: {
56
+ type: String,
57
+ required: true
58
+ },
59
+ tool: {
60
+ type: Object,
61
+ required: true
62
+ },
63
+ editor: {
64
+ type: Object,
65
+ required: true
66
+ },
67
+ options: {
68
+ type: Object,
69
+ default() {
70
+ return {};
71
+ }
72
+ }
73
+ },
74
+ data() {
75
+ return {
76
+ active: false,
77
+ open: false,
78
+ classes: this.options.marks.map(m => m.class)
79
+ };
80
+ },
81
+ computed: {
82
+ activeClasses() {
83
+ let activeClasses = [];
84
+ const { selection } = this.editor.state;
85
+ const content = selection.content();
86
+
87
+ traverseContent(content.content);
88
+
89
+ function traverseContent(content) {
90
+ content.forEach(item => {
91
+ if (item.attrs.class) {
92
+ activeClasses = activeClasses.concat(item.attrs.class.split(' '));
93
+ }
94
+ if (item?.marks?.length) {
95
+ traverseContent(item.marks);
96
+ }
97
+ if (item?.content?.content) {
98
+ traverseContent(item.content.content);
99
+ }
100
+ });
101
+ }
102
+ // Filter out classes that are not in the list of available classes
103
+ return activeClasses.filter(value => this.classes.includes(value));
104
+ },
105
+ buttonLabel() {
106
+ let label;
107
+ if (this.activeClasses.length > 1) {
108
+ label = this.$t('apostrophe:richTextMarkMultipleStyles');
109
+ }
110
+ if (this.activeClasses.length === 1) {
111
+ label = this.options.marks.find(m => m.class === this.activeClasses[0])?.label;
112
+ }
113
+ return label || this.$t('apostrophe:richTextMarkApplyStyles');
114
+ },
115
+ hasSelection() {
116
+ const { state } = this.editor;
117
+ const { selection } = this.editor.state;
118
+ const { from, to } = selection;
119
+ const text = state.doc.textBetween(from, to, '');
120
+ return text !== '';
121
+ }
122
+ },
123
+ watch: {
124
+ hasSelection(newVal) {
125
+ if (!newVal) {
126
+ this.close();
127
+ }
128
+ }
129
+ },
130
+ methods: {
131
+ toggleStyle(mark) {
132
+ this.editor.commands.focus();
133
+ this.editor.commands[mark.command](mark.type, mark.options || {});
134
+ this.close();
135
+ },
136
+ click() {
137
+ this.toggleOpen();
138
+ },
139
+ toggleOpen() {
140
+ this.open = !this.open;
141
+ },
142
+ close() {
143
+ this.open = false;
144
+ }
145
+ }
146
+ };
147
+ </script>
148
+
149
+ <style lang="scss" scoped>
150
+ .apos-marks-control {
151
+ position: relative;
152
+ }
153
+
154
+ .apos-marks-control__button:deep(.apos-button--rich-text) {
155
+ &:active:after, &:focus:after {
156
+ background-color: var(--a-base-8);
157
+ }
158
+
159
+ .apos-button__label {
160
+ max-width: 200px;
161
+ overflow: hidden;
162
+ text-overflow: ellipsis;
163
+ white-space: nowrap;
164
+ }
165
+ }
166
+
167
+ .apos-marks-control__content-wrapper {
168
+ max-height: 200px;
169
+ overflow-y: scroll;
170
+ }
171
+
172
+ .apos-marks-control__dialog {
173
+ position: absolute;
174
+ top: calc(100% + $spacing-base);
175
+ left: 0;
176
+ }
177
+
178
+ .apos-marks-control__items {
179
+ display: flex;
180
+ flex-direction: column;
181
+ gap: 3px;
182
+ margin: 0;
183
+ padding: $spacing-base;
184
+ list-style: none;
185
+ }
186
+
187
+ .apos-marks-control__item {
188
+ white-space: nowrap;
189
+ max-width: 230px;
190
+ text-overflow: ellipsis;
191
+ overflow: hidden;
192
+ border-radius: var(--a-border-radius);
193
+
194
+ // We are adding dev-defined styles into the Apostrophe admin UI,
195
+ // attempt clamp down the dimensions of the label to prevent broken UI
196
+ // stylelint-disable declaration-no-important
197
+ .apos-marks-control__label {
198
+ position: static !important;
199
+ height: auto !important;
200
+ margin: 0 !important;
201
+ padding: 0 !important;
202
+ font-size: var(--a-type-large) !important;
203
+ }
204
+ // stylelint-enable declaration-no-important
205
+
206
+ &:hover {
207
+ background-color: var(--a-base-10);
208
+ cursor: pointer;
209
+ }
210
+
211
+ &--is-active {
212
+ background-color: var(--a-base-10);
213
+ &:hover {
214
+ background-color: var(--a-base-9);
215
+ }
216
+ }
217
+
218
+ .apos-marks-control__button {
219
+ @include apos-button-reset();
220
+ display: block;
221
+ width: 100%;
222
+ padding: $spacing-base;
223
+ }
224
+ }
225
+
226
+ </style>
@@ -1,27 +1,29 @@
1
1
  <template>
2
2
  <div class="apos-tiptap-select">
3
- <format-text-icon
3
+ <component
4
+ :is="tool.icon"
4
5
  :size="16"
5
6
  class="apos-tiptap-select__type-icon"
6
7
  fill-color="currentColor"
7
8
  />
8
9
  <select
9
10
  v-apos-tooltip="{
10
- content: 'apostrophe:richTextStyles',
11
+ content: $t(tool.label),
11
12
  placement: 'top',
12
13
  delay: 650
13
14
  }"
14
- :model-value="active"
15
+ :value="active"
15
16
  class="apos-tiptap-control apos-tiptap-control--select"
16
- :style="`width:${options.styles[active].label.length * 6.5}px`"
17
+ :style="`width:${$t(nodeOptions[active].label).length * 6.5}px`"
17
18
  @change="setStyle"
18
19
  >
19
20
  <option
20
- v-for="(style, i) in options.styles"
21
+ v-for="(style, i) in nodeOptions"
21
22
  :key="style.label"
22
23
  :value="i"
24
+ :hidden="style.attr === 'hidden'"
23
25
  >
24
- {{ style.label }}
26
+ {{ $t(style.label) }}
25
27
  </option>
26
28
  </select>
27
29
  <chevron-down-icon
@@ -56,27 +58,92 @@ export default {
56
58
  }
57
59
  }
58
60
  },
61
+ data() {
62
+ return {
63
+ multipleSelected: false
64
+ };
65
+ },
59
66
  computed: {
67
+ nodeOptions() {
68
+ return [ {
69
+ label: 'apostrophe:richTextNodeMultipleStyles',
70
+ attr: this.multipleSelected ? '' : 'hidden'
71
+ },
72
+ ...this.options.nodes ];
73
+ },
60
74
  active() {
61
- const styles = this.options.styles || [];
62
- for (let i = 0; (i < styles.length); i++) {
63
- const style = styles[i];
64
- if (this.editor.isActive(style.type, (style.options || {}))) {
65
- return i;
66
- } else if (this.editor.state.selection.$head.parent.type.name === 'defaultNode' && style.def) {
67
- // Look deeper to see if custom defaultNode is active
68
- return i;
75
+ const { selection } = this.editor.state;
76
+ const content = selection.content();
77
+ let activeEls = [];
78
+ const nodes = this.options.nodes.map(n => {
79
+ return {
80
+ type: n.type,
81
+ class: n.options.class || null,
82
+ level: n.options.level || null
83
+ };
84
+ });
85
+
86
+ if (content?.content?.content?.length) {
87
+ activeEls = content.content.content.map(n => {
88
+ return {
89
+ name: n.type.name,
90
+ class: n.attrs.class || null,
91
+ level: n.attrs.level || null
92
+ };
93
+ });
94
+ }
95
+
96
+ // Remove duplicates
97
+ activeEls = activeEls.filter((item, index, self) => {
98
+ // Find the index of the first occurrence of the current item
99
+ const firstIndex = self.findIndex(t =>
100
+ t.name === item.name &&
101
+ t.class === item.class &&
102
+ t.level === item.level
103
+ );
104
+ // If the index of the current item is the same as the first index, keep it
105
+ return index === firstIndex;
106
+ });
107
+
108
+ if (activeEls.length) {
109
+ if (activeEls.length > 1) {
110
+ // More than one node, show 'multiple styles' label
111
+ return 0;
112
+ } else {
113
+ // Only one node, show the style label
114
+ // the default style will look different, detect it specifically
115
+ if (activeEls[0].name === 'defaultNode') {
116
+ return 1;
117
+ } else {
118
+ const match = nodes.findIndex(node =>
119
+ node.class === activeEls[0].class &&
120
+ node.type === activeEls[0].name &&
121
+ node.level === activeEls[0].level
122
+ );
123
+ return match + 1;
124
+ }
69
125
  }
126
+ } else {
127
+ // No nodes, show the default label
128
+ return 1;
70
129
  }
71
- return 0;
72
130
  },
73
131
  moduleOptions() {
74
132
  return window.apos.modules['@apostrophecms/rich-text-widget'];
75
133
  }
76
134
  },
135
+ watch: {
136
+ active(newValue) {
137
+ if (newValue === 0) {
138
+ this.multipleSelected = true;
139
+ } else {
140
+ this.multipleSelected = false;
141
+ }
142
+ }
143
+ },
77
144
  methods: {
78
145
  setStyle($event) {
79
- const style = this.options.styles[$event.target.value];
146
+ const style = this.nodeOptions[$event.target.value];
80
147
  this.editor.commands.focus();
81
148
  this.editor.commands[style.command](style.type, style.options || {});
82
149
  }
@@ -90,7 +157,7 @@ export default {
90
157
  @include apos-button-reset();
91
158
  @include apos-transition();
92
159
  height: 100%;
93
- padding: 0 10px;
160
+ padding: 0 $spacing-half;
94
161
  font-size: var(--a-type-smaller);
95
162
 
96
163
  &:focus, &:active {
@@ -102,7 +169,7 @@ export default {
102
169
  position: relative;
103
170
  display: flex;
104
171
  align-items: center;
105
- padding: 0 4px;
172
+ padding: 0 $spacing-half;
106
173
  color: var(--a-base-1);
107
174
  border-radius: var(--a-border-radius);
108
175
  transition: all 0.5s ease;
@@ -112,6 +179,11 @@ export default {
112
179
  }
113
180
  }
114
181
 
182
+ .apos-tiptap-select__icon {
183
+ position: absolute;
184
+ right: 0;
185
+ }
186
+
115
187
  .apos-tiptap-select__type-icon {
116
188
  padding-top: 2px;
117
189
  }
@@ -1,14 +1,74 @@
1
1
  // Enhances common node/mark types to accept a class parameter
2
2
  // and filter out classes that don't match the list on paste/parse
3
3
  import { Extension } from '@tiptap/core';
4
+
4
5
  export default (options) => {
5
6
  // Create a class allowlist map for each element
6
7
  const allow = {};
7
- options.styles.forEach(style => {
8
- const tag = style.tag.toLowerCase();
9
- allow[tag] = (allow[tag] || []).concat(...(style.class ? style.class.split(' ') : [ null ]));
8
+ const styles = [ ...options.nodes || [], ...options.marks || [] ];
9
+ styles.forEach((style) => {
10
+ const tag = style.tag?.toLowerCase();
11
+ if (tag) {
12
+ allow[tag] = (allow[tag] || []).concat(
13
+ ...(style.class ? style.class.split(' ') : [])
14
+ );
15
+ }
10
16
  });
17
+
11
18
  return Extension.create({
19
+ addCommands() {
20
+ return {
21
+ toggleClassOrToggleMark:
22
+ (type, options) =>
23
+ ({ editor, commands }) => {
24
+
25
+ // If we're in a span we need to toggle the class
26
+ if (editor.isActive('textStyle')) {
27
+ let finalClasses;
28
+ let currentClasses = editor.getAttributes('textStyle').class;
29
+
30
+ // // Classes can come back as null, string, or array
31
+ // // normalize them to an array
32
+
33
+ if (typeof currentClasses === 'string') {
34
+ currentClasses = currentClasses.split(' ');
35
+ }
36
+
37
+ if (Array.isArray(currentClasses)) {
38
+ currentClasses = currentClasses.filter((c) => {
39
+ return typeof c === 'string' && c.length > 0;
40
+ });
41
+ } else {
42
+ currentClasses = [];
43
+ }
44
+
45
+ // If this el already has this class, remove it
46
+ if (currentClasses.includes(options.class)) {
47
+ finalClasses = currentClasses.filter(
48
+ (c) => c !== options.class
49
+ );
50
+ // If not, add it
51
+ } else {
52
+ finalClasses = currentClasses.concat(options.class);
53
+ }
54
+
55
+ // If we're removing the last class, remove the span
56
+ if (finalClasses.length === 0) {
57
+ commands.toggleMark('textStyle', { class: options.class });
58
+ } else {
59
+ // Update the el we found with the final classes
60
+ commands.updateAttributes('textStyle', {
61
+ class: finalClasses.length
62
+ ? finalClasses.join(' ')
63
+ : null
64
+ });
65
+ }
66
+ } else {
67
+ commands.toggleMark('textStyle', { class: options.class });
68
+ }
69
+ }
70
+ };
71
+ },
12
72
  addGlobalAttributes() {
13
73
  return [
14
74
  {
@@ -27,18 +87,22 @@ export default (options) => {
27
87
  if (!allow[tag]) {
28
88
  return null;
29
89
  }
90
+
30
91
  const classes = (element.getAttribute('class') || '')
31
92
  .split(' ')
32
93
  .filter(c => allow[tag].includes(c));
94
+
33
95
  // If we have valid classes, join and return them.
34
96
  // If no valid classes for this parse, default to the
35
97
  // the first setting for this tag (including null for tags defined without classes).
36
98
  // else, remove classes.
99
+ const defaultOrNull =
100
+ options.nodes.find(s => s.tag === tag)?.class ||
101
+ options.marks.find(s => s.tag === tag)?.class ||
102
+ null;
37
103
  return classes.length
38
104
  ? classes.join(' ')
39
- : (
40
- allow[tag].length ? allow[tag][0] : null
41
- );
105
+ : defaultOrNull;
42
106
  }
43
107
  }
44
108
  }
@@ -22,7 +22,8 @@ const nodeMap = {
22
22
  };
23
23
 
24
24
  export default (options) => {
25
- const [ def ] = options.styles.filter(style => style.def);
25
+ const styles = [ ...options.nodes, ...options.marks ];
26
+ const [ def ] = styles.filter(style => style.def);
26
27
 
27
28
  if (!def) {
28
29
  return;
@@ -1,7 +1,7 @@
1
1
  // Acts as a custom Document extension
2
2
  import { Node } from '@tiptap/core';
3
3
  export default (options) => {
4
- const def = options.styles.filter(style => style.def)[0];
4
+ const def = options.nodes.filter(style => style.def)[0];
5
5
  let content = 'block+'; // one or more block nodes (default Document setting)
6
6
  if (def) {
7
7
  // one/more defaultNodes (created in ./Default) or one/more other block nodes
@@ -2,7 +2,7 @@
2
2
  import Heading from '@tiptap/extension-heading';
3
3
 
4
4
  export default (options) => {
5
- const headings = options.styles.filter(style => style.type === 'heading');
5
+ const headings = options.nodes.filter(style => style.type === 'heading');
6
6
  const levels = headings.map(heading => heading.options.level);
7
7
  const defaultLevel = headings.filter(heading => heading.def).length
8
8
  ? headings.filter(heading => heading.def)[0].options.level
@@ -20,7 +20,7 @@
20
20
  >
21
21
  <Picker
22
22
  v-bind="pickerOptions"
23
- :model-value="next"
23
+ :model-value="pickerValue"
24
24
  @update:model-value="update"
25
25
  />
26
26
  </AposContextMenu>
@@ -58,9 +58,9 @@
58
58
  </span>
59
59
  <span data-apos-test="field-meta-wrapper" class="apos-field__label-meta">
60
60
  <component
61
+ :is="name"
61
62
  v-for="{name, namespace, data} in metaComponents"
62
63
  :key="name"
63
- :is="name"
64
64
  :field="field"
65
65
  :items="items"
66
66
  :namespace="namespace"
@@ -27,6 +27,10 @@ export default {
27
27
  };
28
28
  },
29
29
  computed: {
30
+ // Color picker doesn't allow null or undefined values
31
+ pickerValue() {
32
+ return this.next || '';
33
+ },
30
34
  buttonOptions() {
31
35
  return {
32
36
  label: this.field.label,
@@ -67,7 +67,10 @@ export default {
67
67
  delete oldClone.archived;
68
68
 
69
69
  oldValue = Object.values(oldClone).join(' ');
70
+ oldValue = oldValue.replace(/\//g, ' ');
71
+
70
72
  newValue = Object.values(newClone).join(' ');
73
+ newValue = newValue.replace(/\//g, ' ');
71
74
 
72
75
  if (this.compatible(oldValue, this.next) && !newValue.archived) {
73
76
  // If this is a page slug, we only replace the last section of the slug.
@@ -65,7 +65,7 @@ const {
65
65
  elementsToFocus,
66
66
  cycleElementsToFocus,
67
67
  focusElement,
68
- focuslastmodalfocusedelement
68
+ focusLastModalFocusedElement
69
69
  } = useAposFocus();
70
70
 
71
71
  const props = defineProps({
@@ -164,7 +164,7 @@ function menuOpen() {
164
164
  }
165
165
 
166
166
  function menuClose() {
167
- focuslastmodalfocusedelement();
167
+ focusLastModalFocusedElement();
168
168
  }
169
169
  </script>
170
170
  <style lang="scss" scoped>
@@ -62,15 +62,11 @@ export default {
62
62
  }
63
63
  }
64
64
 
65
- // Like standard i18next $t, but also with support
66
- // for just one object argument with at least a `key`
67
- // property, which makes it easier to pass both
68
- // a label and its interpolation values through
69
- // multiple layers of code, as a single `label`
70
- // property for instance. You may also specify
71
- // `localize: false` to pass a string through without
72
- // invoking i18next.
65
+ // Makes available the $t function in all components through `this`.
73
66
  app.config.globalProperties.$t = $t;
67
+
68
+ // This is for the composition API, allowing to inject $t in any component.
69
+ app.provide('i18n', $t);
74
70
  }
75
71
  };
76
72