Skip to content

Commit f67c272

Browse files
authored
Fix new schema areas in existing documents (Astro) (#5434)
* Fix orphan or new-in-the-schema areas in external front-ends * Save missing empty areas in the DB, refactor nunjucks path
1 parent 950927d commit f67c272

7 files changed

Lines changed: 299 additions & 56 deletions

File tree

‎.changeset/ripe-terms-happen.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@apostrophecms/apostrophe-astro": minor
3+
"apostrophe": minor
4+
---
5+
6+
Fixed adding or removing an area field from a schema breaking existing documents on an external front such as Astro.
7+
8+
- `AposArea` now renders only schema-backed areas. A missing area no longer throws, and an area orphaned by removing its field from the schema (while its content remains in the document) renders nothing instead of breaking sibling areas in edit mode. Logged-in editors get a diagnostic message in place of an orphaned area; anonymous visitors see nothing.
9+
- Editable documents sent to an external front now materialize empty area objects for schema area fields added after the document was created, so they can be edited in context.
10+
- `apos.util.getManagerOf` accepts a `{ log }` option to suppress its error log when probing objects that may not have a manager.
11+

‎packages/apostrophe-astro/components/AposArea.astro‎

Lines changed: 52 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,13 @@ const {
1313
1414
let attributes = {};
1515
16-
const widgets = area?.items || [];
16+
// The backend sets `field` only on areas still in the schema; a missing or
17+
// orphaned area lacks it and renders nothing.
18+
const isArea = Boolean(area?.field);
1719
18-
const isEdit = area?._edit && Astro.url.searchParams.get("aposEdit");
20+
const widgets: Record<string, any>[] = area?.items || [];
21+
22+
const isEdit = isArea && area?._edit && Astro.url.searchParams.get("aposEdit");
1923
const forceWrapper = aposAttributes || aposStyle || aposClassName;
2024
2125
const WidgetComponent = widgetComponent ?? AposWidget;
@@ -43,39 +47,62 @@ if (isEdit) {
4347
};
4448
}
4549
const Wrapper = isEdit || forceWrapper ? "div" : Fragment;
46-
const widgetOptions = getWidgetOptions(area.options);
50+
const widgetOptions = getWidgetOptions(area?.options);
4751
48-
function getWidgetOptions(options) {
49-
let widgets = options.widgets || {};
52+
function getWidgetOptions(options: any = {}) {
53+
let widgets = { ...(options.widgets || {}) };
5054
5155
if (options.groups) {
5256
for (const group of Object.keys(options.groups)) {
5357
widgets = {
5458
...widgets,
55-
...options.groups[group].widgets,
59+
...(options.groups[group]?.widgets || {}),
5660
};
5761
}
5862
}
5963
return widgets;
6064
}
6165
---
6266

63-
<Wrapper {...attributes}>
64-
{
65-
widgets?.map((item) => {
66-
const options = {
67-
...item._options,
68-
...widgetOptions[item.type],
69-
};
70-
return (
71-
<WidgetComponent
72-
widget={item}
73-
options={options}
74-
area={area}
75-
canEdit={isEdit}
76-
{...props}
77-
/>
78-
);
79-
})
80-
}
81-
</Wrapper>
67+
{
68+
isArea ? (
69+
<Wrapper {...attributes}>
70+
{widgets.map((item) => {
71+
const options = {
72+
...item._options,
73+
...widgetOptions[item.type],
74+
};
75+
return (
76+
<WidgetComponent
77+
widget={item}
78+
options={options}
79+
area={area}
80+
canEdit={isEdit}
81+
{...props}
82+
/>
83+
);
84+
})}
85+
</Wrapper>
86+
) : (
87+
// Orphaned area. Dev-only diagnostic: `import.meta.env.DEV` is replaced
88+
// with `false` in production, so this branch is dead-code eliminated.
89+
(import.meta as any).env.DEV &&
90+
area?.metaType === "area" && (
91+
<div
92+
data-apos-area-error
93+
role="alert"
94+
style="margin:1rem 0;padding:0.75rem 1rem;border:2px solid #dc2626;border-radius:4px;background:#fff1f2;color:#991b1b;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:0.85rem;line-height:1.5;"
95+
>
96+
<strong>ApostropheCMS:</strong> an area passed to{" "}
97+
<code>{"<AposArea />"}</code> is not defined in the schema, so it was
98+
not rendered. Its field was likely removed from the schema while content
99+
remains in the document. Restore the field in the schema, or remove the
100+
matching <code>{"<AposArea />"}</code> from this template.
101+
<br />
102+
<small>
103+
area _id: {area._id} — document: {area._docId}
104+
</small>
105+
</div>
106+
)
107+
)
108+
}

‎packages/apostrophe/modules/@apostrophecms/area/index.js‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,37 @@ module.exports = {
285285
self.missingWidgetTypes[name] = true;
286286
}
287287
},
288+
// Build an empty area and attach it to `parent[name]`. When `parent`
289+
// is doc-backed (has `_docId`, or is itself a doc) and `areaDotPath`
290+
// is provided, also persist the area at that dot-path. The write is
291+
// idempotent via `$eq: null`, so concurrent renders won't clobber each
292+
// other. Returns the area.
293+
//
294+
// Used by the `{% area %}` tag and the external front annotator as a
295+
// single source of truth for stubbing schema areas that have no value
296+
// yet.
297+
async addMissingArea(parent, name, areaDotPath) {
298+
const area = {
299+
metaType: 'area',
300+
_id: self.apos.util.generateId(),
301+
items: []
302+
};
303+
parent[name] = area;
304+
const docId = parent._docId ??
305+
(parent.metaType === 'doc' ? parent._id : null);
306+
if (docId && areaDotPath) {
307+
await self.apos.doc.db.updateOne(
308+
{
309+
_id: docId,
310+
[areaDotPath]: { $eq: null }
311+
},
312+
{
313+
$set: { [areaDotPath]: self.apos.util.clonePermanent(area) }
314+
}
315+
);
316+
}
317+
return area;
318+
},
288319
prepForRender(area, context, fieldName) {
289320
const manager = self.apos.util.getManagerOf(context);
290321
const field = manager.schema.find(field => field.name === fieldName);
@@ -451,7 +482,10 @@ module.exports = {
451482
// Loop over the docs in the array passed in.
452483
for (const doc of within) {
453484
if (self.apos.externalFrontKey) {
454-
self.apos.template.annotateDocForExternalFront(doc, { scene: req.scene });
485+
await self.apos.template.annotateDocForExternalFront(
486+
doc,
487+
{ scene: req.scene }
488+
);
455489
}
456490

457491
const rendered = [];

‎packages/apostrophe/modules/@apostrophecms/area/lib/custom-tags/area.js‎

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -63,15 +63,10 @@ module.exports = function(self) {
6363
// has a value, for instance the field could be new in the schema.
6464
// But we need an area _id. Stub it into the db on the fly
6565
// without race conditions
66-
area = {
67-
metaType: 'area',
68-
_id: self.apos.util.generateId(),
69-
items: []
70-
};
71-
doc[name] = area;
7266
const docId = doc._docId || ((doc.metaType === 'doc') ? doc._id : null);
67+
let areaDotPath;
7368
if (docId) {
74-
let mainDoc = await self.apos.doc.db.findOne({ _id: docId });
69+
const mainDoc = await self.apos.doc.db.findOne({ _id: docId });
7570
if (!mainDoc) {
7671
throw self.apos.error('notfound');
7772
}
@@ -83,21 +78,14 @@ module.exports = function(self) {
8378
// Unlikely thanks to advisory locking
8479
throw self.apos.error('notfound');
8580
}
86-
const areaDotPath = docDotPath ? `${docDotPath}.${name}` : name;
87-
await self.apos.doc.db.updateOne({
88-
_id: docId,
89-
// Prevent race condition
90-
[areaDotPath]: {
91-
$eq: null
92-
}
93-
}, {
94-
$set: {
95-
[areaDotPath]: self.apos.util.clonePermanent(area)
96-
}
97-
});
98-
mainDoc = await self.apos.doc.db.findOne({ _id: docId });
99-
// Prevent race condition
100-
area._id = self.apos.util.get(mainDoc, areaDotPath)._id;
81+
areaDotPath = docDotPath ? `${docDotPath}.${name}` : name;
82+
}
83+
area = await self.apos.area.addMissingArea(doc, name, areaDotPath);
84+
if (docId) {
85+
// Race-safety: re-read the persisted _id in case another request
86+
// wrote first (our $eq: null write was a no-op in that case).
87+
const refreshed = await self.apos.doc.db.findOne({ _id: docId });
88+
area._id = self.apos.util.get(refreshed, areaDotPath)._id;
10189
}
10290
}
10391
const manager = self.apos.util.getManagerOf(doc);

‎packages/apostrophe/modules/@apostrophecms/template/index.js‎

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1231,7 +1231,7 @@ module.exports = {
12311231
async annotateDataForExternalFront(req, template, data, moduleName) {
12321232
const docs = self.getDocsForExternalFront(req, template, data, moduleName);
12331233
for (const doc of docs) {
1234-
self.annotateDocForExternalFront(doc, { scene: req.scene });
1234+
await self.annotateDocForExternalFront(doc, { scene: req.scene });
12351235
}
12361236
data.aposBodyData = await self.getBodyData(req);
12371237
// Already contains module name too
@@ -1283,12 +1283,27 @@ module.exports = {
12831283
].filter(doc => !!doc);
12841284
},
12851285

1286-
annotateDocForExternalFront(doc, { scene } = {}) {
1287-
self.apos.doc.walk(doc, (o, k, v) => {
1286+
async annotateDocForExternalFront(doc, { scene } = {}) {
1287+
const handled = new WeakSet();
1288+
const missingAreas = [];
1289+
self.apos.doc.walk(doc, (o, k, v, __dotPath) => {
1290+
if (o._edit === true && !handled.has(o)) {
1291+
handled.add(o);
1292+
// `__dotPath` is the path to `v` (= o[k]); the container `o` lives
1293+
// one segment up — '' for the top-level doc.
1294+
const dot = __dotPath.lastIndexOf('.');
1295+
const containerDotPath = dot === -1 ? '' : __dotPath.substring(0, dot);
1296+
for (const field of self.missingSchemaAreas(o)) {
1297+
missingAreas.push([ o, field, containerDotPath ]);
1298+
}
1299+
}
12881300
if (v && v.metaType === 'area') {
12891301
const manager = self.apos.util.getManagerOf(o);
12901302
if (!manager) {
1291-
self.apos.util.warnDevOnce('noManagerForDocInExternalFront', `No manager for: ${o.metaType} ${o.type || ''}`);
1303+
self.apos.util.warnDevOnce(
1304+
'noManagerForDocInExternalFront',
1305+
`No manager for: ${o.metaType} ${o.type || ''}`
1306+
);
12921307
return;
12931308
}
12941309
const field = manager.schema.find(f => f.name === k);
@@ -1302,6 +1317,21 @@ module.exports = {
13021317
return self.annotateAreaForExternalFront(field, v, { scene });
13031318
}
13041319
});
1320+
// Materialize every missing area, after the walk so we never add keys
1321+
// to an object while it is being traversed.
1322+
for (const [ o, field, containerDotPath ] of missingAreas) {
1323+
const areaDotPath = containerDotPath
1324+
? `${containerDotPath}.${field.name}`
1325+
: field.name;
1326+
const area = await self.apos.area.addMissingArea(
1327+
o,
1328+
field.name,
1329+
areaDotPath
1330+
);
1331+
area._edit = true;
1332+
area._docId = o._docId ?? (o.metaType === 'doc' ? o._id : null);
1333+
self.annotateAreaForExternalFront(field, area, { scene });
1334+
}
13051335
},
13061336

13071337
// Annotate an area for easy rendering by an external front end
@@ -1343,6 +1373,13 @@ module.exports = {
13431373
throw self.apos.error('invalid', 'Missing widget type');
13441374
}
13451375
}
1376+
},
1377+
1378+
// The schema area fields of `object` that have no value yet. Returns an
1379+
// empty array for anything without a schema manager.
1380+
missingSchemaAreas(object) {
1381+
const schema = self.apos.util.getManagerOf(object, { log: false })?.schema ?? [];
1382+
return schema.filter(field => field.type === 'area' && !object[field.name]);
13461383
}
13471384
};
13481385
}

‎packages/apostrophe/modules/@apostrophecms/util/index.js‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -724,7 +724,7 @@ module.exports = {
724724
},
725725
// Given a widget or doc, return the appropriate manager module. If the manager
726726
// cannot be determined for any reason, undefined is returned.
727-
getManagerOf(object) {
727+
getManagerOf(object, { log = true } = {}) {
728728
if (object.metaType === 'doc') {
729729
return self.apos.doc.getManager(object.type);
730730
} else if (object.metaType === 'widget') {
@@ -733,10 +733,10 @@ module.exports = {
733733
return self.apos.schema.getArrayManager(object.scopedArrayName);
734734
} else if (object.metaType === 'object') {
735735
return self.apos.schema.getObjectManager(object.scopedObjectName);
736-
} else {
736+
} else if (log) {
737737
self.apos.util.error(`Unsupported metaType in getManagerOf: ${object.metaType}`);
738-
return undefined;
739738
}
739+
return undefined;
740740
},
741741
// fetch the value at the given path from the object or
742742
// array `o`. `path` supports dot notation like MongoDB, and

0 commit comments

Comments
 (0)