apostrophe 3.53.0 → 3.55.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.
- package/CHANGELOG.md +58 -1
- package/defaults.js +1 -0
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextModeAndSettings.vue +5 -2
- package/modules/@apostrophecms/admin-bar/ui/apos/components/TheAposContextTitle.vue +28 -19
- package/modules/@apostrophecms/any-doc-type/index.js +2 -2
- package/modules/@apostrophecms/any-page-type/index.js +2 -2
- package/modules/@apostrophecms/doc/index.js +55 -29
- package/modules/@apostrophecms/doc-type/index.js +11 -6
- package/modules/@apostrophecms/doc-type/ui/apos/components/AposDocContextMenu.vue +4 -440
- package/modules/@apostrophecms/doc-type/ui/apos/logic/AposDocContextMenu.js +445 -0
- package/modules/@apostrophecms/i18n/i18n/de.json +113 -105
- package/modules/@apostrophecms/i18n/i18n/es.json +10 -0
- package/modules/@apostrophecms/i18n/i18n/fr.json +8 -0
- package/modules/@apostrophecms/i18n/i18n/pt-BR.json +10 -0
- package/modules/@apostrophecms/i18n/i18n/sk.json +8 -0
- package/modules/@apostrophecms/image/ui/apos/components/AposMediaManager.vue +1 -0
- package/modules/@apostrophecms/log/index.js +429 -0
- package/modules/@apostrophecms/login/index.js +47 -4
- package/modules/@apostrophecms/modal/ui/apos/components/AposDocsManagerToolbar.vue +14 -1
- package/modules/@apostrophecms/modal/ui/apos/mixins/AposEditorMixin.js +1 -1
- package/modules/@apostrophecms/module/index.js +32 -6
- package/modules/@apostrophecms/module/lib/log.js +68 -0
- package/modules/@apostrophecms/page/index.js +71 -19
- package/modules/@apostrophecms/page/lib/legacy-migrations.js +0 -57
- package/modules/@apostrophecms/page/ui/apos/components/AposPagesManager.vue +8 -285
- package/modules/@apostrophecms/page/ui/apos/logic/AposPagesManager.js +291 -0
- package/modules/@apostrophecms/page-type/index.js +39 -26
- package/modules/@apostrophecms/piece-type/index.js +19 -11
- package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManager.vue +1 -0
- package/modules/@apostrophecms/schema/ui/apos/components/AposArrayEditor.vue +2 -357
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputArea.vue +2 -86
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputArray.vue +2 -254
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputAttachment.vue +2 -77
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputBoolean.vue +2 -44
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputCheckboxes.vue +2 -64
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputColor.vue +2 -94
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputDateAndTime.vue +3 -47
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputObject.vue +2 -82
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputPassword.vue +2 -37
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputRadio.vue +2 -26
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputRange.vue +2 -57
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputRelationship.vue +2 -259
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputSelect.vue +2 -38
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputSlug.vue +2 -275
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputString.vue +2 -167
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputWrapper.vue +2 -115
- package/modules/@apostrophecms/schema/ui/apos/components/AposSchema.vue +3 -279
- package/modules/@apostrophecms/schema/ui/apos/components/AposSearchList.vue +2 -83
- package/modules/@apostrophecms/schema/ui/apos/lib/detectChange.js +10 -1
- package/modules/@apostrophecms/schema/ui/apos/logic/AposArrayEditor.js +361 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputArea.js +89 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputArray.js +257 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputAttachment.js +81 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputBoolean.js +48 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputCheckboxes.js +68 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputColor.js +98 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputDateAndTime.js +49 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputObject.js +86 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputPassword.js +41 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputRadio.js +29 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputRange.js +60 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputRelationship.js +262 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputSelect.js +41 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputSlug.js +278 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputString.js +170 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposInputWrapper.js +118 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposSchema.js +281 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposSearchList.js +85 -0
- package/modules/@apostrophecms/template/index.js +1 -1
- package/modules/@apostrophecms/ui/ui/apos/components/AposTreeHeader.vue +2 -2
- package/modules/@apostrophecms/util/index.js +83 -13
- package/modules/@apostrophecms/util/lib/logger.js +19 -17
- package/package.json +1 -1
- package/test/docs.js +35 -2
- package/test/log.js +1765 -0
- package/test/pages.js +57 -0
- package/test-lib/util.js +1 -1
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import AposInputMixin from 'Modules/@apostrophecms/schema/mixins/AposInputMixin';
|
|
2
|
+
import { klona } from 'klona';
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
name: 'AposInputRelationship',
|
|
6
|
+
mixins: [ AposInputMixin ],
|
|
7
|
+
emits: [ 'input' ],
|
|
8
|
+
data () {
|
|
9
|
+
const next = (this.value && Array.isArray(this.value.data))
|
|
10
|
+
? klona(this.value.data) : (klona(this.field.def) || []);
|
|
11
|
+
|
|
12
|
+
// Remember relationship subfield values even if a document
|
|
13
|
+
// is temporarily deselected, easing the user's pain if they
|
|
14
|
+
// inadvertently deselect something for a moment
|
|
15
|
+
const subfields = Object.fromEntries(
|
|
16
|
+
(next || []).filter(doc => doc._fields)
|
|
17
|
+
.map(doc => [ doc._id, doc._fields ])
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
searchTerm: '',
|
|
22
|
+
searchList: [],
|
|
23
|
+
next,
|
|
24
|
+
subfields,
|
|
25
|
+
disabled: false,
|
|
26
|
+
searching: false,
|
|
27
|
+
choosing: false,
|
|
28
|
+
relationshipSchema: null
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
computed: {
|
|
32
|
+
limitReached() {
|
|
33
|
+
return this.field.max === this.next.length;
|
|
34
|
+
},
|
|
35
|
+
pluralLabel() {
|
|
36
|
+
return apos.modules[this.field.withType].pluralLabel;
|
|
37
|
+
},
|
|
38
|
+
// TODO get 'Search' server for better i18n
|
|
39
|
+
placeholder() {
|
|
40
|
+
return this.field.placeholder || {
|
|
41
|
+
key: 'apostrophe:searchDocType',
|
|
42
|
+
type: this.$t(this.pluralLabel)
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
// TODO get 'Browse' for better i18n
|
|
46
|
+
browseLabel() {
|
|
47
|
+
return {
|
|
48
|
+
key: 'apostrophe:browseDocType',
|
|
49
|
+
type: this.$t(this.pluralLabel)
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
suggestion() {
|
|
53
|
+
return {
|
|
54
|
+
disabled: true,
|
|
55
|
+
tooltip: false,
|
|
56
|
+
icon: false,
|
|
57
|
+
classes: [ 'suggestion' ],
|
|
58
|
+
title: this.$t(this.field.suggestionLabel),
|
|
59
|
+
help: this.$t({
|
|
60
|
+
key: this.field.suggestionHelp || 'apostrophe:relationshipSuggestionHelp',
|
|
61
|
+
type: this.$t(this.pluralLabel)
|
|
62
|
+
}),
|
|
63
|
+
customFields: [ 'help' ]
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
hint() {
|
|
67
|
+
return {
|
|
68
|
+
disabled: true,
|
|
69
|
+
tooltip: false,
|
|
70
|
+
icon: 'binoculars-icon',
|
|
71
|
+
iconSize: 35,
|
|
72
|
+
classes: [ 'hint' ],
|
|
73
|
+
title: this.$t('apostrophe:relationshipSuggestionNoResults'),
|
|
74
|
+
help: this.$t({
|
|
75
|
+
key: this.field.browse
|
|
76
|
+
? 'apostrophe:relationshipSuggestionSearchAndBrowse'
|
|
77
|
+
: 'apostrophe:relationshipSuggestionSearch',
|
|
78
|
+
type: this.$t(this.pluralLabel)
|
|
79
|
+
}),
|
|
80
|
+
customFields: [ 'help' ]
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
chooserComponent () {
|
|
84
|
+
return apos.modules[this.field.withType].components.managerModal;
|
|
85
|
+
},
|
|
86
|
+
disableUnpublished() {
|
|
87
|
+
return apos.modules[this.field.withType].localized;
|
|
88
|
+
},
|
|
89
|
+
buttonModifiers() {
|
|
90
|
+
const modifiers = [ 'small' ];
|
|
91
|
+
if (this.modifiers.includes('no-search')) {
|
|
92
|
+
modifiers.push('block');
|
|
93
|
+
}
|
|
94
|
+
return modifiers;
|
|
95
|
+
},
|
|
96
|
+
minSize() {
|
|
97
|
+
const [ widgetOptions = {} ] = apos.area.widgetOptions;
|
|
98
|
+
|
|
99
|
+
return widgetOptions.minSize || [];
|
|
100
|
+
},
|
|
101
|
+
duplicate () {
|
|
102
|
+
return this.value.duplicate ? 'apos-input--error' : null;
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
watch: {
|
|
106
|
+
next(after, before) {
|
|
107
|
+
for (const doc of before) {
|
|
108
|
+
this.subfields[doc._id] = doc._fields;
|
|
109
|
+
}
|
|
110
|
+
for (const doc of after) {
|
|
111
|
+
if (Object.keys(doc._fields || {}).length) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
doc._fields = this.field.schema && (this.subfields[doc._id]
|
|
115
|
+
? this.subfields[doc._id]
|
|
116
|
+
: this.getDefault());
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
mounted () {
|
|
121
|
+
this.checkLimit();
|
|
122
|
+
},
|
|
123
|
+
methods: {
|
|
124
|
+
validate(value) {
|
|
125
|
+
this.checkLimit();
|
|
126
|
+
|
|
127
|
+
if (this.field.required && !value.length) {
|
|
128
|
+
return { message: 'required' };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (this.field.min && this.field.min > value.length) {
|
|
132
|
+
return { message: `minimum of ${this.field.min} required` };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return false;
|
|
136
|
+
},
|
|
137
|
+
checkLimit() {
|
|
138
|
+
if (this.limitReached) {
|
|
139
|
+
this.searchTerm = 'Limit reached!';
|
|
140
|
+
} else if (this.searchTerm === 'Limit reached!') {
|
|
141
|
+
this.searchTerm = '';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
this.disabled = !!this.limitReached;
|
|
145
|
+
},
|
|
146
|
+
updateSelected(items) {
|
|
147
|
+
this.next = items;
|
|
148
|
+
},
|
|
149
|
+
async search(qs) {
|
|
150
|
+
if (this.field.suggestionLimit) {
|
|
151
|
+
qs.perPage = this.field.suggestionLimit;
|
|
152
|
+
}
|
|
153
|
+
if (this.field.suggestionSort) {
|
|
154
|
+
qs.sort = this.field.suggestionSort;
|
|
155
|
+
}
|
|
156
|
+
if (this.field.withType === '@apostrophecms/image') {
|
|
157
|
+
apos.bus.$emit('piece-relationship-query', qs);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
this.searching = true;
|
|
161
|
+
const list = await apos.http.get(
|
|
162
|
+
apos.modules[this.field.withType].action,
|
|
163
|
+
{
|
|
164
|
+
busy: false,
|
|
165
|
+
draft: true,
|
|
166
|
+
qs
|
|
167
|
+
}
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const removeSelectedItem = item => !this.next.map(i => i._id).includes(item._id);
|
|
171
|
+
const formatItems = item => ({
|
|
172
|
+
...item,
|
|
173
|
+
disabled: this.disableUnpublished && !item.lastPublishedAt
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const results = (list.results || [])
|
|
177
|
+
.filter(removeSelectedItem)
|
|
178
|
+
.map(formatItems);
|
|
179
|
+
|
|
180
|
+
const suggestion = !qs.autocomplete && this.suggestion;
|
|
181
|
+
const hint = (!qs.autocomplete || !results.length) && this.hint;
|
|
182
|
+
|
|
183
|
+
this.searchList = [ suggestion, ...results, hint ].filter(Boolean);
|
|
184
|
+
this.searching = false;
|
|
185
|
+
},
|
|
186
|
+
async input () {
|
|
187
|
+
if (this.searching) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const trimmed = this.searchTerm.trim();
|
|
192
|
+
const qs = trimmed.length
|
|
193
|
+
? {
|
|
194
|
+
autocomplete: trimmed
|
|
195
|
+
}
|
|
196
|
+
: {};
|
|
197
|
+
|
|
198
|
+
await this.search(qs);
|
|
199
|
+
},
|
|
200
|
+
handleFocusOut() {
|
|
201
|
+
// hide search list when click outside the input
|
|
202
|
+
// timeout to execute "@select" method before
|
|
203
|
+
setTimeout(() => {
|
|
204
|
+
this.searchList = [];
|
|
205
|
+
}, 200);
|
|
206
|
+
},
|
|
207
|
+
watchValue () {
|
|
208
|
+
this.error = this.value.error;
|
|
209
|
+
// Ensure the internal state is an array.
|
|
210
|
+
this.next = Array.isArray(this.value.data) ? this.value.data : [];
|
|
211
|
+
},
|
|
212
|
+
async choose () {
|
|
213
|
+
const result = await apos.modal.execute(this.chooserComponent, {
|
|
214
|
+
title: this.field.label || this.field.name,
|
|
215
|
+
moduleName: this.field.withType,
|
|
216
|
+
chosen: this.next,
|
|
217
|
+
relationshipField: this.field
|
|
218
|
+
});
|
|
219
|
+
if (result) {
|
|
220
|
+
this.updateSelected(result);
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
async editRelationship (item) {
|
|
224
|
+
const editor = this.field.editor || 'AposRelationshipEditor';
|
|
225
|
+
|
|
226
|
+
const result = await apos.modal.execute(editor, {
|
|
227
|
+
schema: this.field.schema,
|
|
228
|
+
item,
|
|
229
|
+
title: item.title,
|
|
230
|
+
value: item._fields
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
if (result) {
|
|
234
|
+
const index = this.next.findIndex(_item => _item._id === item._id);
|
|
235
|
+
this.$set(this.next, index, {
|
|
236
|
+
...this.next[index],
|
|
237
|
+
_fields: result
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
getEditRelationshipLabel () {
|
|
242
|
+
if (this.field.editor === 'AposImageRelationshipEditor') {
|
|
243
|
+
return 'apostrophe:editImageAdjustments';
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
getDefault() {
|
|
247
|
+
const object = {};
|
|
248
|
+
this.field.schema.forEach(field => {
|
|
249
|
+
if (field.name.startsWith('_')) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
// Using `hasOwn` here, not simply checking if `field.def` is truthy
|
|
253
|
+
// so that `false`, `null`, `''` or `0` are taken into account:
|
|
254
|
+
const hasDefaultValue = Object.hasOwn(field, 'def');
|
|
255
|
+
object[field.name] = hasDefaultValue
|
|
256
|
+
? klona(field.def)
|
|
257
|
+
: null;
|
|
258
|
+
});
|
|
259
|
+
return object;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import AposInputMixin from 'Modules/@apostrophecms/schema/mixins/AposInputMixin';
|
|
2
|
+
import AposInputChoicesMixin from 'Modules/@apostrophecms/schema/mixins/AposInputChoicesMixin';
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
name: 'AposInputSelect',
|
|
6
|
+
mixins: [ AposInputMixin, AposInputChoicesMixin ],
|
|
7
|
+
props: {
|
|
8
|
+
icon: {
|
|
9
|
+
type: String,
|
|
10
|
+
default: 'menu-down-icon'
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
data() {
|
|
14
|
+
return {
|
|
15
|
+
next: (this.value.data == null) ? null : this.value.data,
|
|
16
|
+
choices: []
|
|
17
|
+
};
|
|
18
|
+
},
|
|
19
|
+
computed: {
|
|
20
|
+
classes () {
|
|
21
|
+
return [ this.value.duplicate && 'apos-input--error' ];
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
methods: {
|
|
25
|
+
validate(value) {
|
|
26
|
+
if (this.field.required && (value === null)) {
|
|
27
|
+
return 'required';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (value && !this.choices.find(choice => choice.value === value)) {
|
|
31
|
+
return 'invalid';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return false;
|
|
35
|
+
},
|
|
36
|
+
change(value) {
|
|
37
|
+
// Allows expression of non-string values
|
|
38
|
+
this.next = this.choices.find(choice => choice.value === value).value;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// NOTE: This is a temporary component, copying AposInputString. Base modules
|
|
2
|
+
// already have `type: 'slug'` fields, so this is needed to avoid distracting
|
|
3
|
+
// errors.
|
|
4
|
+
import AposInputMixin from 'Modules/@apostrophecms/schema/mixins/AposInputMixin';
|
|
5
|
+
import sluggo from 'sluggo';
|
|
6
|
+
import debounce from 'debounce-async';
|
|
7
|
+
import { klona } from 'klona';
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
name: 'AposInputSlug',
|
|
11
|
+
mixins: [ AposInputMixin ],
|
|
12
|
+
emits: [ 'return' ],
|
|
13
|
+
data() {
|
|
14
|
+
return {
|
|
15
|
+
conflict: false,
|
|
16
|
+
isArchived: null,
|
|
17
|
+
originalSlugPartsLength: null
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
computed: {
|
|
21
|
+
tabindex () {
|
|
22
|
+
return this.field.disableFocus ? '-1' : '0';
|
|
23
|
+
},
|
|
24
|
+
type () {
|
|
25
|
+
if (this.field.type) {
|
|
26
|
+
return this.field.type;
|
|
27
|
+
} else {
|
|
28
|
+
return 'text';
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
classes () {
|
|
32
|
+
return [ 'apos-input', 'apos-input--text', 'apos-input--slug' ];
|
|
33
|
+
},
|
|
34
|
+
wrapperClasses () {
|
|
35
|
+
return [ 'apos-input-wrapper' ].concat(this.localePrefix ? [ 'apos-input-wrapper--with-prefix' ] : []);
|
|
36
|
+
},
|
|
37
|
+
icon () {
|
|
38
|
+
if (this.error) {
|
|
39
|
+
return 'circle-medium-icon';
|
|
40
|
+
} else if (this.field.icon) {
|
|
41
|
+
return this.field.icon;
|
|
42
|
+
} else {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
prefix () {
|
|
47
|
+
return this.field.prefix || '';
|
|
48
|
+
},
|
|
49
|
+
localePrefix() {
|
|
50
|
+
return this.field.page && apos.i18n.locales[apos.i18n.locale].prefix;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
watch: {
|
|
54
|
+
followingValues: {
|
|
55
|
+
// We are usually interested in followingValue.title, but a
|
|
56
|
+
// secondary slug field could be configured to watch
|
|
57
|
+
// one or more other fields
|
|
58
|
+
deep: true,
|
|
59
|
+
handler(newValue, oldValue) {
|
|
60
|
+
const newClone = klona(newValue);
|
|
61
|
+
const oldClone = klona(oldValue);
|
|
62
|
+
|
|
63
|
+
// Track whether the slug is archived for prefixing.
|
|
64
|
+
this.isArchived = newValue.archived;
|
|
65
|
+
// We only want the string properties to build the slug itself.
|
|
66
|
+
delete newClone.archived;
|
|
67
|
+
delete oldClone.archived;
|
|
68
|
+
|
|
69
|
+
oldValue = Object.values(oldClone).join(' ');
|
|
70
|
+
newValue = Object.values(newClone).join(' ');
|
|
71
|
+
|
|
72
|
+
if (this.compatible(oldValue, this.next) && !newValue.archived) {
|
|
73
|
+
// If this is a page slug, we only replace the last section of the slug.
|
|
74
|
+
if (this.field.page) {
|
|
75
|
+
let parts = this.next.split('/');
|
|
76
|
+
parts = parts.filter(part => part.length > 0);
|
|
77
|
+
if ((!this.originalSlugPartsLength && parts.length) || (this.originalSlugPartsLength && parts.length === (this.originalSlugPartsLength - 1))) {
|
|
78
|
+
// Remove last path component so we can replace it
|
|
79
|
+
parts.pop();
|
|
80
|
+
}
|
|
81
|
+
parts.push(this.slugify(newValue, { componentOnly: true }));
|
|
82
|
+
if (parts[0].length) {
|
|
83
|
+
// TODO: handle page archives.
|
|
84
|
+
this.next = `/${parts.join('/')}`;
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
this.next = this.slugify(newValue);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
async mounted() {
|
|
94
|
+
this.debouncedCheckConflict = debounce(() => this.checkConflict(), 250);
|
|
95
|
+
if (this.next.length) {
|
|
96
|
+
await this.debouncedCheckConflict();
|
|
97
|
+
}
|
|
98
|
+
this.originalSlugPartsLength = this.next.split('/').length;
|
|
99
|
+
},
|
|
100
|
+
methods: {
|
|
101
|
+
async watchNext() {
|
|
102
|
+
this.next = this.slugify(this.next);
|
|
103
|
+
this.validateAndEmit();
|
|
104
|
+
try {
|
|
105
|
+
await this.debouncedCheckConflict();
|
|
106
|
+
} catch (e) {
|
|
107
|
+
if (e === 'canceled') {
|
|
108
|
+
// That's fine
|
|
109
|
+
} else {
|
|
110
|
+
throw e;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
validate(value) {
|
|
115
|
+
if (this.conflict) {
|
|
116
|
+
return {
|
|
117
|
+
name: 'conflict',
|
|
118
|
+
message: 'apostrophe:slugInUse'
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (this.field.required) {
|
|
122
|
+
if (!value.length) {
|
|
123
|
+
return 'required';
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (this.field.min) {
|
|
127
|
+
if (value.length && (value.length < this.field.min)) {
|
|
128
|
+
return 'min';
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (this.field.max) {
|
|
132
|
+
if (value.length && (value.length > this.field.max)) {
|
|
133
|
+
return 'max';
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
},
|
|
138
|
+
compatible(title, slug) {
|
|
139
|
+
if ((typeof title) !== 'string') {
|
|
140
|
+
title = '';
|
|
141
|
+
}
|
|
142
|
+
if (this.field.page) {
|
|
143
|
+
const matches = slug.match(/[^/]+$/);
|
|
144
|
+
slug = (matches && matches[0]) || '';
|
|
145
|
+
}
|
|
146
|
+
return ((title === '') && (slug === `${this.prefix}`)) ||
|
|
147
|
+
this.slugify(title) === this.slugify(slug);
|
|
148
|
+
},
|
|
149
|
+
// if componentOnly is true, we are slugifying just one component of
|
|
150
|
+
// a slug as part of following the title field, and so we do *not*
|
|
151
|
+
// want to allow slashes (when editing a page) or set a prefix.
|
|
152
|
+
slugify(s, { componentOnly = false } = {}) {
|
|
153
|
+
const options = {
|
|
154
|
+
def: ''
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
if (this.field.page && !componentOnly) {
|
|
158
|
+
options.allow = '/';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let preserveDash = false;
|
|
162
|
+
// When you are typing a slug it feels wrong for hyphens you typed
|
|
163
|
+
// to disappear as you go, so if the last character is not valid in a slug,
|
|
164
|
+
// restore it after we call sluggo for the full string
|
|
165
|
+
if (this.focus && s.length && (sluggo(s.charAt(s.length - 1), options) === '')) {
|
|
166
|
+
preserveDash = true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
s = sluggo(s, options);
|
|
170
|
+
if (preserveDash) {
|
|
171
|
+
s += '-';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (this.field.page && !componentOnly) {
|
|
175
|
+
if (!this.followingValues?.title) {
|
|
176
|
+
const nextParts = this.next.split('/');
|
|
177
|
+
if (s === nextParts[nextParts.length - 1]) {
|
|
178
|
+
s = '';
|
|
179
|
+
if (this.originalSlugPartsLength === nextParts.length) {
|
|
180
|
+
nextParts.pop();
|
|
181
|
+
}
|
|
182
|
+
this.next = nextParts.join('/');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (!s.charAt(0) !== '/') {
|
|
186
|
+
s = `/${s}`;
|
|
187
|
+
}
|
|
188
|
+
s = s.replace(/\/+/g, '/');
|
|
189
|
+
if (s !== '/') {
|
|
190
|
+
s = s.replace(/\/$/, '');
|
|
191
|
+
}
|
|
192
|
+
if (!this.followingValues?.title && s.length) {
|
|
193
|
+
s += '/';
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!componentOnly) {
|
|
198
|
+
s = this.setPrefix(s);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return s;
|
|
202
|
+
},
|
|
203
|
+
setPrefix (slug) {
|
|
204
|
+
// Get a fresh clone of the slug.
|
|
205
|
+
let updated = slug;
|
|
206
|
+
const archivedRegexp = new RegExp(`^deduplicate-[a-z0-9]+-${this.prefix}`);
|
|
207
|
+
|
|
208
|
+
// Prefix if the slug doesn't start with the prefix OR if its archived
|
|
209
|
+
// and it doesn't start with the dedupe+prefix pattern.
|
|
210
|
+
if (
|
|
211
|
+
!updated.startsWith(this.prefix) ||
|
|
212
|
+
(this.isArchived && !updated.match(archivedRegexp))
|
|
213
|
+
) {
|
|
214
|
+
let archivePrefix = '';
|
|
215
|
+
// If archived, remove the dedupe pattern to add again later.
|
|
216
|
+
if (this.isArchived) {
|
|
217
|
+
archivePrefix = updated.match(/^deduplicate-[a-z0-9]+-/);
|
|
218
|
+
updated = updated.replace(archivePrefix, '');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (this.prefix.startsWith(updated)) {
|
|
222
|
+
// If they delete the `-`, and the prefix is `recipe-`,
|
|
223
|
+
// we want to restore `recipe-`, not set it to `recipe-recipe`
|
|
224
|
+
updated = this.prefix;
|
|
225
|
+
} else {
|
|
226
|
+
// Make sure we're not double prefixing archived slugs.
|
|
227
|
+
updated = updated.startsWith(this.prefix) ? updated : this.prefix + updated;
|
|
228
|
+
}
|
|
229
|
+
// Reapply the dedupe pattern if archived. If being restored from the
|
|
230
|
+
// doc editor modal it will momentarily be tracked as archived but
|
|
231
|
+
// without not have the archive prefix, so check that too.
|
|
232
|
+
updated = this.isArchived && archivePrefix ? `${archivePrefix}${updated}` : updated;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return updated;
|
|
236
|
+
},
|
|
237
|
+
async checkConflict() {
|
|
238
|
+
let slug;
|
|
239
|
+
try {
|
|
240
|
+
slug = this.next;
|
|
241
|
+
if (slug.length) {
|
|
242
|
+
await apos.http.post(`${apos.doc.action}/slug-taken`, {
|
|
243
|
+
body: {
|
|
244
|
+
slug,
|
|
245
|
+
_id: this.docId
|
|
246
|
+
},
|
|
247
|
+
draft: true
|
|
248
|
+
});
|
|
249
|
+
// Still relevant?
|
|
250
|
+
if (slug === this.next) {
|
|
251
|
+
this.conflict = false;
|
|
252
|
+
this.validateAndEmit();
|
|
253
|
+
} else {
|
|
254
|
+
// Can ignore it, another request
|
|
255
|
+
// probably already in-flight
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
} catch (e) {
|
|
259
|
+
// 409: Conflict (slug in use)
|
|
260
|
+
if (e.status === 409) {
|
|
261
|
+
// Still relevant?
|
|
262
|
+
if (slug === this.next) {
|
|
263
|
+
this.conflict = true;
|
|
264
|
+
this.validateAndEmit();
|
|
265
|
+
} else {
|
|
266
|
+
// Can ignore it, another request
|
|
267
|
+
// probably already in-flight
|
|
268
|
+
}
|
|
269
|
+
} else {
|
|
270
|
+
throw e;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
passFocus() {
|
|
275
|
+
this.$refs.input.focus();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
};
|