apostrophe 4.30.0 → 4.31.0-alpha.1
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 +67 -2
- package/claude-tools/detect-handles.js +46 -0
- package/claude-tools/minimal-hang-test.js +28 -0
- package/claude-tools/mongo-close-test.js +11 -0
- package/claude-tools/stdin-ref-test.js +14 -0
- package/eslint.config.js +3 -1
- package/modules/@apostrophecms/area/index.js +94 -2
- package/modules/@apostrophecms/area/lib/custom-tags/area.js +1 -40
- package/modules/@apostrophecms/area/ui/apos/components/AposBreadcrumbOperations.vue +0 -1
- package/modules/@apostrophecms/area/ui/apos/components/AposWidgetControls.vue +0 -1
- package/modules/@apostrophecms/attachment/index.js +4 -1
- package/modules/@apostrophecms/db/index.js +68 -27
- package/modules/@apostrophecms/doc-type/ui/apos/logic/AposDocContextMenu.js +5 -3
- package/modules/@apostrophecms/express/index.js +2 -0
- package/modules/@apostrophecms/http/index.js +1 -1
- package/modules/@apostrophecms/i18n/i18n/en.json +3 -0
- package/modules/@apostrophecms/image/ui/apos/components/AposMediaManagerEditor.vue +2 -2
- package/modules/@apostrophecms/job/index.js +9 -7
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridColumn.vue +0 -1
- package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue +0 -1
- package/modules/@apostrophecms/login/ui/apos/components/TheAposLogin.vue +10 -2
- package/modules/@apostrophecms/login/ui/apos/components/TheAposLoginHeader.vue +3 -3
- package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +52 -23
- package/modules/@apostrophecms/modal/ui/apos/components/AposModalTabs.vue +6 -1
- package/modules/@apostrophecms/oembed/index.js +2 -1
- package/modules/@apostrophecms/piece-page-type/index.js +7 -0
- package/modules/@apostrophecms/piece-type/index.js +2 -1
- package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManagerDisplay.vue +7 -2
- package/modules/@apostrophecms/recently-edited/ui/apos/components/AposCellTitle.vue +1 -0
- package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +21 -4
- package/modules/@apostrophecms/schema/ui/apos/components/AposArrayEditor.vue +1 -0
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputDateAndTime.vue +7 -2
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputSelect.vue +1 -0
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputWrapper.vue +1 -1
- package/modules/@apostrophecms/schema/ui/apos/components/AposSubform.vue +1 -0
- package/modules/@apostrophecms/schema/ui/apos/logic/AposSubform.js +10 -0
- package/modules/@apostrophecms/styles/ui/apos/components/TheAposStyles.vue +1 -0
- package/modules/@apostrophecms/template/index.js +117 -11
- package/modules/@apostrophecms/template/lib/jsxLoader.js +128 -0
- package/modules/@apostrophecms/template/lib/jsxRender.js +490 -0
- package/modules/@apostrophecms/template/lib/jsxRuntime.js +276 -0
- package/modules/@apostrophecms/template/lib/nunjucksLoader.js +11 -36
- package/modules/@apostrophecms/template/lib/viewWatcher.js +113 -0
- package/modules/@apostrophecms/ui/ui/apos/components/AposButtonGroup.vue +1 -1
- package/modules/@apostrophecms/ui/ui/apos/components/AposCellLastEdited.vue +1 -1
- package/modules/@apostrophecms/ui/ui/apos/components/AposSelect.vue +1 -0
- package/modules/@apostrophecms/ui/ui/apos/components/AposSlat.vue +10 -4
- package/modules/@apostrophecms/ui/ui/apos/components/AposSlatList.vue +6 -1
- package/modules/@apostrophecms/ui/ui/apos/components/AposSubformPreview.vue +1 -1
- package/modules/@apostrophecms/ui/ui/apos/components/AposTreeHeader.vue +1 -1
- package/modules/@apostrophecms/ui/ui/apos/scss/global/_theme.scss +1 -0
- package/modules/@apostrophecms/uploadfs/index.js +3 -0
- package/modules/@apostrophecms/util/index.js +3 -3
- package/package.json +14 -10
- package/test/add-missing-schema-fields-project/test.js +22 -3
- package/test/assets.js +110 -67
- package/test/db-tools.js +365 -0
- package/test/db.js +24 -15
- package/test/default-adapter.js +256 -0
- package/test/external-front.js +419 -1
- package/test/job.js +1 -1
- package/test/modules/jsx-area-test/index.js +23 -0
- package/test/modules/jsx-area-test/views/bad-area.jsx +7 -0
- package/test/modules/jsx-area-test/views/with-area-ctx.jsx +13 -0
- package/test/modules/jsx-area-test/views/with-area.jsx +7 -0
- package/test/modules/jsx-area-test/views/with-widget-ctx.jsx +12 -0
- package/test/modules/jsx-area-test/views/with-widget.jsx +7 -0
- package/test/modules/jsx-async-widget/index.js +6 -0
- package/test/modules/jsx-async-widget/views/widget.jsx +11 -0
- package/test/modules/jsx-bridge-test/index.js +1 -0
- package/test/modules/jsx-bridge-test/views/cross-module.jsx +7 -0
- package/test/modules/jsx-bridge-test/views/disambig-name-only.jsx +7 -0
- package/test/modules/jsx-bridge-test/views/disambig-target.jsx +8 -0
- package/test/modules/jsx-bridge-test/views/disambig-with-template-name.jsx +7 -0
- package/test/modules/jsx-bridge-test/views/include-html.jsx +7 -0
- package/test/modules/jsx-bridge-test/views/include-target.html +4 -0
- package/test/modules/jsx-bridge-test/views/jsx-extends-via-extend.jsx +9 -0
- package/test/modules/jsx-bridge-test/views/jsx-extends.jsx +9 -0
- package/test/modules/jsx-bridge-test/views/jsx-layout.jsx +14 -0
- package/test/modules/jsx-bridge-test/views/njk-extends.jsx +14 -0
- package/test/modules/jsx-bridge-test/views/njk-layout.html +9 -0
- package/test/modules/jsx-bridge-test/views/short-form.jsx +7 -0
- package/test/modules/jsx-bridge-test/views/short-target.jsx +3 -0
- package/test/modules/jsx-component-test/index.js +15 -0
- package/test/modules/jsx-component-test/views/greet.html +1 -0
- package/test/modules/jsx-component-test/views/uses-component.jsx +8 -0
- package/test/modules/jsx-ctx-widget/index.js +6 -0
- package/test/modules/jsx-ctx-widget/views/widget.jsx +4 -0
- package/test/modules/jsx-mixed-test/index.js +9 -0
- package/test/modules/jsx-mixed-test/views/apos-full.jsx +21 -0
- package/test/modules/jsx-mixed-test/views/async-list.jsx +12 -0
- package/test/modules/jsx-mixed-test/views/lib/format.js +3 -0
- package/test/modules/jsx-mixed-test/views/localized.jsx +3 -0
- package/test/modules/jsx-mixed-test/views/partial.jsx +3 -0
- package/test/modules/jsx-mixed-test/views/safe-helper.jsx +3 -0
- package/test/modules/jsx-mixed-test/views/syntax-error.jsx +3 -0
- package/test/modules/jsx-mixed-test/views/throws.jsx +5 -0
- package/test/modules/jsx-mixed-test/views/uses-import.jsx +5 -0
- package/test/modules/jsx-mixed-test/views/uses-require.jsx +5 -0
- package/test/modules/jsx-watcher-cross-test/index.js +5 -0
- package/test/modules/jsx-watcher-cross-test/views/cross-template.jsx +3 -0
- package/test/modules/jsx-watcher-test/index.js +5 -0
- package/test/modules/jsx-watcher-test/views/watcher-test.jsx +3 -0
- package/test/modules/template-jsx-options-test/index.js +12 -0
- package/test/modules/template-jsx-options-test/views/options-test.jsx +9 -0
- package/test/modules/template-jsx-subclass-test/index.js +3 -0
- package/test/modules/template-jsx-subclass-test/views/override-test.jsx +3 -0
- package/test/modules/template-jsx-test/index.js +9 -0
- package/test/modules/template-jsx-test/views/boolean-attrs.jsx +11 -0
- package/test/modules/template-jsx-test/views/class-and-for.jsx +7 -0
- package/test/modules/template-jsx-test/views/dangerously-set.jsx +3 -0
- package/test/modules/template-jsx-test/views/escape-attr.jsx +3 -0
- package/test/modules/template-jsx-test/views/escape-body.jsx +3 -0
- package/test/modules/template-jsx-test/views/inherit-test.jsx +3 -0
- package/test/modules/template-jsx-test/views/list.jsx +7 -0
- package/test/modules/template-jsx-test/views/override-test.jsx +3 -0
- package/test/modules/template-jsx-test/views/svg-attrs.jsx +27 -0
- package/test/modules/template-jsx-test/views/test.jsx +3 -0
- package/test/modules/template-jsx-test/views/void-elements.jsx +9 -0
- package/test/templates-jsx-watcher.js +135 -0
- package/test/templates-jsx.js +537 -0
- package/test-lib/util.js +50 -14
- package/.claude/settings.local.json +0 -15
- package/lib/mongodb-connect.js +0 -62
- package/test/add-missing-schema-fields-project/node_modules/.package-lock.json +0 -131
package/test/external-front.js
CHANGED
|
@@ -17,12 +17,430 @@ describe('External Front', function() {
|
|
|
17
17
|
|
|
18
18
|
it('apostrophe should initialize normally', async function() {
|
|
19
19
|
apos = await t.create({
|
|
20
|
-
root: module
|
|
20
|
+
root: module,
|
|
21
|
+
modules: {
|
|
22
|
+
product: {
|
|
23
|
+
extend: '@apostrophecms/piece-type',
|
|
24
|
+
options: {
|
|
25
|
+
alias: 'product'
|
|
26
|
+
},
|
|
27
|
+
fields: {
|
|
28
|
+
add: {
|
|
29
|
+
main: {
|
|
30
|
+
type: 'area',
|
|
31
|
+
options: {
|
|
32
|
+
widgets: {
|
|
33
|
+
'@apostrophecms/rich-text': {}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
extra: {
|
|
38
|
+
type: 'area',
|
|
39
|
+
options: {
|
|
40
|
+
widgets: {
|
|
41
|
+
'@apostrophecms/rich-text': {}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
21
49
|
});
|
|
22
50
|
|
|
23
51
|
assert(apos.page.__meta.name === '@apostrophecms/page');
|
|
24
52
|
});
|
|
25
53
|
|
|
54
|
+
// A doc as it would look in memory, with `extra` simulating an area field
|
|
55
|
+
// added to the schema after the doc was created (so it has no value).
|
|
56
|
+
function productMissingExtra() {
|
|
57
|
+
const doc = apos.product.newInstance();
|
|
58
|
+
doc._id = 'product1:en:published';
|
|
59
|
+
doc.metaType = 'doc';
|
|
60
|
+
doc.title = 'Product 1';
|
|
61
|
+
doc.slug = 'product-1';
|
|
62
|
+
delete doc.extra;
|
|
63
|
+
return doc;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
it('missingSchemaAreas returns unfilled area fields, ignores non-schema objects', function() {
|
|
67
|
+
const missing = apos.template.missingSchemaAreas(productMissingExtra());
|
|
68
|
+
assert.strictEqual(missing.length, 1);
|
|
69
|
+
assert.strictEqual(missing[0].name, 'extra');
|
|
70
|
+
|
|
71
|
+
// An area object carries `_edit` but has no schema manager: returns []
|
|
72
|
+
// quietly (getManagerOf called with log: false).
|
|
73
|
+
const area = {
|
|
74
|
+
metaType: 'area',
|
|
75
|
+
_edit: true
|
|
76
|
+
};
|
|
77
|
+
assert.deepStrictEqual(apos.template.missingSchemaAreas(area), []);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('annotateDocForExternalFront materializes missing areas on an editable doc', async function() {
|
|
81
|
+
const doc = productMissingExtra();
|
|
82
|
+
doc._edit = true;
|
|
83
|
+
// As loaded for an editor, existing areas carry _edit too
|
|
84
|
+
doc.main._edit = true;
|
|
85
|
+
|
|
86
|
+
await apos.template.annotateDocForExternalFront(doc);
|
|
87
|
+
|
|
88
|
+
// Existing area still annotated, unchanged behavior
|
|
89
|
+
assert(doc.main.field && doc.main.field.name === 'main');
|
|
90
|
+
assert(doc.main.options);
|
|
91
|
+
// Carries the provenance signal, and is never flagged as orphan
|
|
92
|
+
assert.strictEqual(doc.main._aposAnnotated, true);
|
|
93
|
+
assert.strictEqual(doc.main._isOrphan, undefined);
|
|
94
|
+
|
|
95
|
+
// Missing area added as an empty, editable, annotated area
|
|
96
|
+
assert(doc.extra, 'extra area was materialized');
|
|
97
|
+
assert.strictEqual(doc.extra.metaType, 'area');
|
|
98
|
+
assert.deepStrictEqual(doc.extra.items, []);
|
|
99
|
+
assert.strictEqual(doc.extra._edit, true);
|
|
100
|
+
assert.strictEqual(doc.extra._docId, doc._id);
|
|
101
|
+
assert(doc.extra._id, 'has an id');
|
|
102
|
+
assert(doc.extra.field && doc.extra.field.name === 'extra');
|
|
103
|
+
assert(doc.extra.options, 'annotated with options');
|
|
104
|
+
assert(Array.isArray(doc.extra.choices));
|
|
105
|
+
assert.strictEqual(doc.extra._aposAnnotated, true);
|
|
106
|
+
assert.strictEqual(doc.extra._isOrphan, undefined);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('flags a genuine orphan area, leaves valid areas alone, and never persists the flag', async function() {
|
|
110
|
+
// Insert a doc with an area whose field is no longer in the schema.
|
|
111
|
+
const docId = 'orphan-test:en:draft';
|
|
112
|
+
await apos.doc.db.deleteOne({ _id: docId });
|
|
113
|
+
await apos.doc.db.insertOne({
|
|
114
|
+
_id: docId,
|
|
115
|
+
type: 'product',
|
|
116
|
+
metaType: 'doc',
|
|
117
|
+
aposMode: 'draft',
|
|
118
|
+
aposDocId: 'orphan-test',
|
|
119
|
+
aposLocale: 'en:draft',
|
|
120
|
+
title: 'Orphan Test',
|
|
121
|
+
slug: 'orphan-test',
|
|
122
|
+
main: {
|
|
123
|
+
metaType: 'area',
|
|
124
|
+
_id: 'orphan-main',
|
|
125
|
+
items: []
|
|
126
|
+
},
|
|
127
|
+
// `ghost` is not a field in the product schema (simulates a removed field)
|
|
128
|
+
ghost: {
|
|
129
|
+
metaType: 'area',
|
|
130
|
+
_id: 'orphan-ghost',
|
|
131
|
+
items: []
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const doc = await apos.doc.db.findOne({ _id: docId });
|
|
136
|
+
doc._edit = true;
|
|
137
|
+
doc.main._edit = true;
|
|
138
|
+
doc.ghost._edit = true;
|
|
139
|
+
|
|
140
|
+
await apos.template.annotateDocForExternalFront(doc);
|
|
141
|
+
|
|
142
|
+
// The annotator owns the doc, so the orphan is flagged — and has no field.
|
|
143
|
+
// It is NOT marked `_aposAnnotated` (that signals a fully annotated area).
|
|
144
|
+
assert.strictEqual(doc.ghost._isOrphan, true, 'orphan flagged');
|
|
145
|
+
assert.strictEqual(doc.ghost.field, undefined, 'orphan has no schema field');
|
|
146
|
+
assert.strictEqual(doc.ghost._aposAnnotated, undefined, 'orphan is not _aposAnnotated');
|
|
147
|
+
// The valid area is annotated normally and never flagged orphan.
|
|
148
|
+
assert(doc.main.field && doc.main._isOrphan === undefined);
|
|
149
|
+
assert.strictEqual(doc.main._aposAnnotated, true);
|
|
150
|
+
|
|
151
|
+
// Neither flag is written to the database.
|
|
152
|
+
const persisted = await apos.doc.db.findOne({ _id: docId });
|
|
153
|
+
assert.strictEqual(persisted.ghost._isOrphan, undefined, 'flag not persisted');
|
|
154
|
+
assert.strictEqual(persisted.main._isOrphan, undefined, 'flag not persisted');
|
|
155
|
+
assert.strictEqual(persisted.main._aposAnnotated, undefined, 'signal not persisted');
|
|
156
|
+
|
|
157
|
+
await apos.doc.db.deleteOne({ _id: docId });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('annotateDocForExternalFront leaves missing areas alone on a non-editable doc', async function() {
|
|
161
|
+
const doc = productMissingExtra();
|
|
162
|
+
|
|
163
|
+
await apos.template.annotateDocForExternalFront(doc);
|
|
164
|
+
|
|
165
|
+
assert.strictEqual(doc.extra, undefined, 'extra not added for anonymous');
|
|
166
|
+
// Existing area annotated as before
|
|
167
|
+
assert(doc.main.field && doc.main.field.name === 'main');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('annotateDocForExternalFront persists materialized areas at their schema path with the same _id sent to the UI', async function() {
|
|
171
|
+
// Insert a doc directly with only `main`, no `extra`, simulating a field
|
|
172
|
+
// added to the schema after the doc was created.
|
|
173
|
+
const docId = 'persist-test:en:draft';
|
|
174
|
+
await apos.doc.db.deleteOne({ _id: docId });
|
|
175
|
+
await apos.doc.db.insertOne({
|
|
176
|
+
_id: docId,
|
|
177
|
+
type: 'product',
|
|
178
|
+
metaType: 'doc',
|
|
179
|
+
aposMode: 'draft',
|
|
180
|
+
aposDocId: 'persist-test',
|
|
181
|
+
aposLocale: 'en:draft',
|
|
182
|
+
title: 'Persist Test',
|
|
183
|
+
slug: 'persist-test',
|
|
184
|
+
main: {
|
|
185
|
+
metaType: 'area',
|
|
186
|
+
_id: 'main-id-persist',
|
|
187
|
+
items: []
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Load the doc and mark editable (mirroring what doc-type load does)
|
|
192
|
+
const doc = await apos.doc.db.findOne({ _id: docId });
|
|
193
|
+
doc._edit = true;
|
|
194
|
+
doc.main._edit = true;
|
|
195
|
+
|
|
196
|
+
await apos.template.annotateDocForExternalFront(doc);
|
|
197
|
+
|
|
198
|
+
// In-memory: extra was materialized and annotated
|
|
199
|
+
assert(doc.extra && doc.extra._id, 'extra has an id in memory');
|
|
200
|
+
const inMemoryId = doc.extra._id;
|
|
201
|
+
|
|
202
|
+
// In the DB: extra was written at the schema path with the same _id, so a
|
|
203
|
+
// subsequent editor patch using @<inMemoryId>.items will resolve
|
|
204
|
+
const persisted = await apos.doc.db.findOne({ _id: docId });
|
|
205
|
+
assert(persisted.extra, 'extra was persisted');
|
|
206
|
+
assert.strictEqual(persisted.extra.metaType, 'area');
|
|
207
|
+
assert.deepStrictEqual(persisted.extra.items, []);
|
|
208
|
+
assert.strictEqual(
|
|
209
|
+
persisted.extra._id, inMemoryId,
|
|
210
|
+
'persisted _id matches the one sent to the UI'
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// Idempotent: a second annotate keeps the same persisted _id (the
|
|
214
|
+
// $eq: null condition prevents overwrite)
|
|
215
|
+
const doc2 = await apos.doc.db.findOne({ _id: docId });
|
|
216
|
+
doc2._edit = true;
|
|
217
|
+
delete doc2.extra; // simulate the missing-area branch firing again
|
|
218
|
+
await apos.template.annotateDocForExternalFront(doc2);
|
|
219
|
+
const persistedAgain = await apos.doc.db.findOne({ _id: docId });
|
|
220
|
+
assert.strictEqual(
|
|
221
|
+
persistedAgain.extra._id, inMemoryId,
|
|
222
|
+
'persisted _id is unchanged on re-annotate'
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
await apos.doc.db.deleteOne({ _id: docId });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('does not write missing areas at an in-memory path when a relationship target needs one (regression)', async function() {
|
|
229
|
+
// The editor's in-memory graph contains loaded relationship data. A widget
|
|
230
|
+
// in the host doc relates to a SEPARATE editable doc that is missing a
|
|
231
|
+
// schema area (added after it was created). The area must be stubbed at the
|
|
232
|
+
// related doc's OWN path — never at a path derived from the host's
|
|
233
|
+
// in-memory traversal, which would write into the wrong document and pad
|
|
234
|
+
// arrays with nulls. This reproduces the production corruption that left
|
|
235
|
+
// null items and stray fragments in published docs.
|
|
236
|
+
const hostId = 'reg-host:en:draft';
|
|
237
|
+
const relatedId = 'reg-related:en:draft';
|
|
238
|
+
await apos.doc.db.deleteMany({ _id: { $in: [ hostId, relatedId ] } });
|
|
239
|
+
|
|
240
|
+
// Host has both areas filled (nothing missing of its own) and a single
|
|
241
|
+
// rich-text widget in `main`.
|
|
242
|
+
await apos.doc.db.insertOne({
|
|
243
|
+
_id: hostId,
|
|
244
|
+
type: 'product',
|
|
245
|
+
metaType: 'doc',
|
|
246
|
+
aposMode: 'draft',
|
|
247
|
+
aposDocId: 'reg-host',
|
|
248
|
+
aposLocale: 'en:draft',
|
|
249
|
+
title: 'Host',
|
|
250
|
+
slug: 'reg-host',
|
|
251
|
+
main: {
|
|
252
|
+
metaType: 'area',
|
|
253
|
+
_id: 'reg-host-main',
|
|
254
|
+
items: [
|
|
255
|
+
{
|
|
256
|
+
_id: 'reg-host-widget',
|
|
257
|
+
metaType: 'widget',
|
|
258
|
+
type: '@apostrophecms/rich-text',
|
|
259
|
+
content: '<p>hi</p>'
|
|
260
|
+
}
|
|
261
|
+
]
|
|
262
|
+
},
|
|
263
|
+
extra: {
|
|
264
|
+
metaType: 'area',
|
|
265
|
+
_id: 'reg-host-extra',
|
|
266
|
+
items: []
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
// Related has `main` but no `extra` (field added to the schema later).
|
|
270
|
+
await apos.doc.db.insertOne({
|
|
271
|
+
_id: relatedId,
|
|
272
|
+
type: 'product',
|
|
273
|
+
metaType: 'doc',
|
|
274
|
+
aposMode: 'draft',
|
|
275
|
+
aposDocId: 'reg-related',
|
|
276
|
+
aposLocale: 'en:draft',
|
|
277
|
+
title: 'Related',
|
|
278
|
+
slug: 'reg-related',
|
|
279
|
+
main: {
|
|
280
|
+
metaType: 'area',
|
|
281
|
+
_id: 'reg-related-main',
|
|
282
|
+
items: []
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const hostStored = await apos.doc.db.findOne({ _id: hostId });
|
|
287
|
+
|
|
288
|
+
// Build the in-memory graph: the host widget carries a loaded relationship
|
|
289
|
+
// to the related doc, which is editable and missing `extra`.
|
|
290
|
+
const related = await apos.doc.db.findOne({ _id: relatedId });
|
|
291
|
+
related._edit = true;
|
|
292
|
+
related._docId = relatedId;
|
|
293
|
+
related.main._edit = true;
|
|
294
|
+
delete related.extra;
|
|
295
|
+
|
|
296
|
+
const host = await apos.doc.db.findOne({ _id: hostId });
|
|
297
|
+
host._edit = true;
|
|
298
|
+
host._docId = hostId;
|
|
299
|
+
host.main._edit = true;
|
|
300
|
+
host.main.items[0]._docId = hostId;
|
|
301
|
+
host.main.items[0]._related = [ related ];
|
|
302
|
+
|
|
303
|
+
await apos.template.annotateDocForExternalFront(host);
|
|
304
|
+
|
|
305
|
+
// Related doc: `extra` stubbed at its OWN top-level path, clean, and its
|
|
306
|
+
// existing `main` is untouched (no host-relative path leaked in).
|
|
307
|
+
const relatedAfter = await apos.doc.db.findOne({ _id: relatedId });
|
|
308
|
+
assert(relatedAfter.extra, 'extra stubbed on the related doc');
|
|
309
|
+
assert.strictEqual(relatedAfter.extra.metaType, 'area');
|
|
310
|
+
assert.deepStrictEqual(relatedAfter.extra.items, []);
|
|
311
|
+
assert.strictEqual(relatedAfter.extra._id, related.extra._id, 'same _id as in memory');
|
|
312
|
+
assert.strictEqual(relatedAfter.main.items.length, 0, 'related main not padded');
|
|
313
|
+
assert.ok(!Object.prototype.hasOwnProperty.call(relatedAfter, '0'), 'no numeric-key fragment');
|
|
314
|
+
|
|
315
|
+
// Host doc: completely untouched in the database.
|
|
316
|
+
const hostAfter = await apos.doc.db.findOne({ _id: hostId });
|
|
317
|
+
assert.strictEqual(hostAfter.main.items.length, 1, 'host main.items not extended');
|
|
318
|
+
assert(
|
|
319
|
+
hostAfter.main.items.every(i => i && i.metaType === 'widget'),
|
|
320
|
+
'no null/typeless items in host'
|
|
321
|
+
);
|
|
322
|
+
assert.deepStrictEqual(hostAfter, hostStored, 'host doc unchanged in the DB');
|
|
323
|
+
|
|
324
|
+
await apos.doc.db.deleteMany({ _id: { $in: [ hostId, relatedId ] } });
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('annotateAreaForExternalFront drops corrupt items so they never reach the front end', function() {
|
|
328
|
+
const field = {
|
|
329
|
+
name: 'main',
|
|
330
|
+
options: {
|
|
331
|
+
widgets: { '@apostrophecms/rich-text': {} }
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
const area = {
|
|
335
|
+
metaType: 'area',
|
|
336
|
+
_id: 'guard-area',
|
|
337
|
+
_docId: 'guard-doc:en:published',
|
|
338
|
+
items: [
|
|
339
|
+
{
|
|
340
|
+
_id: 'w1',
|
|
341
|
+
metaType: 'widget',
|
|
342
|
+
type: '@apostrophecms/rich-text',
|
|
343
|
+
content: '<p>ok</p>'
|
|
344
|
+
},
|
|
345
|
+
// The two shapes the production corruption produced. A `null` left in
|
|
346
|
+
// place would crash the Astro area renderer (`...item._options`).
|
|
347
|
+
null,
|
|
348
|
+
{
|
|
349
|
+
_id: 'frag',
|
|
350
|
+
foo: 'bar'
|
|
351
|
+
}
|
|
352
|
+
]
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
assert.doesNotThrow(() => {
|
|
356
|
+
apos.template.annotateAreaForExternalFront(field, area, { scene: 'apos' });
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// Corrupt items are removed; only the valid, annotated widget remains.
|
|
360
|
+
assert.strictEqual(area.items.length, 1, 'corrupt items dropped');
|
|
361
|
+
assert.strictEqual(area.items[0]._id, 'w1');
|
|
362
|
+
assert(area.items[0]._options, 'valid widget annotated');
|
|
363
|
+
assert.strictEqual(area.items[0]._docId, area._docId, 'valid widget got _docId');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('annotateAreaForExternalFront keeps an unknown widget type un-annotated instead of throwing', function() {
|
|
367
|
+
const field = {
|
|
368
|
+
name: 'main',
|
|
369
|
+
options: {
|
|
370
|
+
widgets: { '@apostrophecms/rich-text': {} }
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
const area = {
|
|
374
|
+
metaType: 'area',
|
|
375
|
+
_id: 'unknown-area',
|
|
376
|
+
_docId: 'unknown-doc:en:published',
|
|
377
|
+
items: [
|
|
378
|
+
{
|
|
379
|
+
_id: 'w1',
|
|
380
|
+
metaType: 'widget',
|
|
381
|
+
type: '@apostrophecms/rich-text',
|
|
382
|
+
content: '<p>ok</p>'
|
|
383
|
+
},
|
|
384
|
+
// A real widget whose module is not registered (e.g. `custom-layout`).
|
|
385
|
+
{
|
|
386
|
+
_id: 'w2',
|
|
387
|
+
metaType: 'widget',
|
|
388
|
+
type: 'definitely-not-a-registered-widget'
|
|
389
|
+
}
|
|
390
|
+
]
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
// No throw — a missing widget module must not 500 the whole render.
|
|
394
|
+
assert.doesNotThrow(() => {
|
|
395
|
+
apos.template.annotateAreaForExternalFront(field, area, { scene: 'apos' });
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// The unknown widget is preserved (so its content survives a save) but left
|
|
399
|
+
// un-annotated; the front end skips it.
|
|
400
|
+
assert.strictEqual(area.items.length, 2, 'unknown-type item preserved');
|
|
401
|
+
assert(area.items[0]._options, 'valid widget annotated');
|
|
402
|
+
assert.strictEqual(area.items[1].type, 'definitely-not-a-registered-widget');
|
|
403
|
+
assert.strictEqual(area.items[1]._options, undefined, 'unknown widget not annotated');
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('addMissingArea honors throwIfNotFound (tag behavior) and defaults to graceful (annotator)', async function() {
|
|
407
|
+
// Doc-backed parent whose document is not in the database (the missing-doc
|
|
408
|
+
// race the `{% area %}` tag historically treated as notfound).
|
|
409
|
+
const ghost = {
|
|
410
|
+
_id: 'ghost:en:published',
|
|
411
|
+
metaType: 'doc',
|
|
412
|
+
type: 'product'
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
// Opt-in (tag): throws notfound rather than persisting nothing silently.
|
|
416
|
+
await assert.rejects(
|
|
417
|
+
() => apos.area.addMissingArea(ghost, 'extra', { throwIfNotFound: true }),
|
|
418
|
+
err => err && err.name === 'notfound'
|
|
419
|
+
);
|
|
420
|
+
// The in-memory stub is still attached for the caller.
|
|
421
|
+
assert(ghost.extra && ghost.extra.metaType === 'area');
|
|
422
|
+
|
|
423
|
+
// Default (annotator): degrades to an in-memory stub, never throws.
|
|
424
|
+
const ghost2 = {
|
|
425
|
+
_id: 'ghost2:en:published',
|
|
426
|
+
metaType: 'doc',
|
|
427
|
+
type: 'product'
|
|
428
|
+
};
|
|
429
|
+
const area = await apos.area.addMissingArea(ghost2, 'extra');
|
|
430
|
+
assert.strictEqual(area.metaType, 'area');
|
|
431
|
+
assert(area._id, 'in-memory stub has an _id');
|
|
432
|
+
assert.deepStrictEqual(area.items, []);
|
|
433
|
+
assert.strictEqual(ghost2.extra, area, 'stub attached to the parent');
|
|
434
|
+
|
|
435
|
+
// A parent with no docId is never an error, even with throwIfNotFound.
|
|
436
|
+
const unsaved = {
|
|
437
|
+
metaType: 'doc',
|
|
438
|
+
type: 'product'
|
|
439
|
+
};
|
|
440
|
+
const unsavedArea = await apos.area.addMissingArea(unsaved, 'extra', { throwIfNotFound: true });
|
|
441
|
+
assert.strictEqual(unsavedArea.metaType, 'area');
|
|
442
|
+
});
|
|
443
|
+
|
|
26
444
|
it('fetch home with external front', async function() {
|
|
27
445
|
const data = await await apos.http.get('/', {
|
|
28
446
|
headers: {
|
package/test/job.js
CHANGED
|
@@ -311,7 +311,7 @@ describe('Job module', function() {
|
|
|
311
311
|
req,
|
|
312
312
|
async function(_req, reporters) {
|
|
313
313
|
let count = 1;
|
|
314
|
-
reporters.setTotal(articleIds.length);
|
|
314
|
+
await reporters.setTotal(articleIds.length);
|
|
315
315
|
|
|
316
316
|
for (const id of articleIds) {
|
|
317
317
|
await delay(3);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
extend: '@apostrophecms/piece-type',
|
|
3
|
+
options: {
|
|
4
|
+
alias: 'jsxAreaTest',
|
|
5
|
+
name: 'jsx-area-test',
|
|
6
|
+
label: 'JSX Area Test'
|
|
7
|
+
},
|
|
8
|
+
fields: {
|
|
9
|
+
add: {
|
|
10
|
+
main: {
|
|
11
|
+
type: 'area',
|
|
12
|
+
label: 'Main',
|
|
13
|
+
options: {
|
|
14
|
+
widgets: {
|
|
15
|
+
'@apostrophecms/rich-text': {},
|
|
16
|
+
'jsx-async': {},
|
|
17
|
+
'jsx-ctx': {}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = {};
|