adapt-authoring-content 3.1.0 → 3.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.
@@ -21,5 +21,12 @@
21
21
  },
22
22
  "description": "Resource is currently being used in courses",
23
23
  "statusCode": 400
24
+ },
25
+ "EMPTY_CONTAINERS": {
26
+ "data": {
27
+ "items": "The childless content items blocking the build"
28
+ },
29
+ "description": "Course cannot be built: one or more pages, articles or blocks have no content",
30
+ "statusCode": 400
24
31
  }
25
32
  }
@@ -59,6 +59,12 @@ class ContentModule extends AbstractApiModule {
59
59
 
60
60
  assets.preDeleteHook.tap(this.enforceAssetNotInUse.bind(this))
61
61
 
62
+ // block builds when the course structure has empty containers. adaptframework depends on
63
+ // content (not vice-versa), so tap its hook once available rather than awaiting it here.
64
+ this.app.waitForModule('adaptframework').then(framework => {
65
+ framework.preBuildHook.tap(this.enforceNoEmptyContainers.bind(this))
66
+ })
67
+
62
68
  await mongodb.setIndex(this.collectionName, { _courseId: 1, _parentId: 1, _type: 1 })
63
69
  await mongodb.setIndex(this.collectionName, { _parentId: 1 })
64
70
  await mongodb.setIndex(this.collectionName, { _type: 1, _courseId: 1 })
@@ -104,6 +110,26 @@ class ContentModule extends AbstractApiModule {
104
110
  throw this.app.errors.RESOURCE_IN_USE.setData({ type: 'asset', courses })
105
111
  }
106
112
 
113
+ /**
114
+ * preBuildHook observer: refuses a build when any non-component content item has no children
115
+ * (an empty page, article or block). Components are leaf nodes and config is exempt.
116
+ * @param {AdaptFrameworkBuild} build The build being run
117
+ * @return {Promise}
118
+ */
119
+ async enforceNoEmptyContainers (build) {
120
+ const _courseId = parseObjectId(build.courseId)
121
+ const items = await this.find(
122
+ { $or: [{ _id: _courseId }, { _courseId }] },
123
+ { validate: false },
124
+ { projection: { _type: 1, _parentId: 1, title: 1, displayTitle: 1 } }
125
+ )
126
+ const empty = new ContentTree(items).getEmptyContainers()
127
+ if (!empty.length) return
128
+ throw this.app.errors.EMPTY_CONTAINERS.setData({
129
+ items: empty.map(i => ({ _id: i._id.toString(), _type: i._type, title: i.displayTitle || i.title }))
130
+ })
131
+ }
132
+
107
133
  /**
108
134
  * Returns a map of asset _id to the number of distinct courses each asset is referenced by.
109
135
  * Reads the indexed `_assetIds` field. Accepts an optional `assetIds` array in the request body to
@@ -114,6 +114,20 @@ class ContentTree {
114
114
  return this.getChildren(item._parentId).filter(c => c._id.toString() !== id)
115
115
  }
116
116
 
117
+ /**
118
+ * Finds container items that have no children. Components are leaf nodes and
119
+ * config is a childless root — both are exempt; every other type (course, menu,
120
+ * page, article, block) is expected to contain at least one child.
121
+ * @returns {Array<Object>} Childless container items
122
+ */
123
+ getEmptyContainers () {
124
+ return this.items.filter(i =>
125
+ i._type !== 'component' &&
126
+ i._type !== 'config' &&
127
+ this.getChildren(i._id).length === 0
128
+ )
129
+ }
130
+
117
131
  /**
118
132
  * O(1) — unique component names across the course
119
133
  * @returns {Array<string>}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-content",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "Module for managing Adapt content",
5
5
  "homepage": "https://github.com/adapt-security/adapt-authoring-content",
6
6
  "license": "GPL-3.0",
@@ -227,4 +227,53 @@ describe('ContentTree', () => {
227
227
  assert.deepEqual(tree.getComponentNames(), [])
228
228
  })
229
229
  })
230
+
231
+ describe('getEmptyContainers', () => {
232
+ it('should return container items that have no children', () => {
233
+ // in the shared fixture the menu (id4) and second article (id6) are childless
234
+ const empty = new ContentTree(items).getEmptyContainers()
235
+ assert.deepEqual(empty.map(i => i._id.toString()).sort(), ['id4', 'id6'])
236
+ })
237
+
238
+ it('should never flag components (leaf nodes)', () => {
239
+ const tree = new ContentTree([
240
+ { _id: makeId(1), _type: 'block', _parentId: makeId(99) },
241
+ { _id: makeId(2), _type: 'component', _parentId: makeId(1), _component: 'adapt-contrib-text' }
242
+ ])
243
+ assert.equal(tree.getEmptyContainers().length, 0)
244
+ })
245
+
246
+ it('should never flag config (childless root)', () => {
247
+ const tree = new ContentTree([
248
+ { _id: makeId(1), _type: 'course' },
249
+ { _id: makeId(2), _type: 'config', _courseId: 'c1' },
250
+ { _id: makeId(3), _type: 'page', _parentId: makeId(1) },
251
+ { _id: makeId(4), _type: 'article', _parentId: makeId(3) },
252
+ { _id: makeId(5), _type: 'block', _parentId: makeId(4) },
253
+ { _id: makeId(6), _type: 'component', _parentId: makeId(5), _component: 'adapt-contrib-text' }
254
+ ])
255
+ assert.deepEqual(tree.getEmptyContainers(), [])
256
+ })
257
+
258
+ it('should flag a block with no components', () => {
259
+ const tree = new ContentTree([
260
+ { _id: makeId(1), _type: 'article', _parentId: makeId(99) },
261
+ { _id: makeId(2), _type: 'block', _parentId: makeId(1) }
262
+ ])
263
+ const empty = tree.getEmptyContainers()
264
+ assert.equal(empty.length, 1)
265
+ assert.equal(empty[0]._id.toString(), 'id2')
266
+ })
267
+
268
+ it('should flag a course with no children', () => {
269
+ const tree = new ContentTree([
270
+ { _id: makeId(1), _type: 'course' }
271
+ ])
272
+ assert.deepEqual(tree.getEmptyContainers().map(i => i._type), ['course'])
273
+ })
274
+
275
+ it('should return empty array for an empty tree', () => {
276
+ assert.deepEqual(new ContentTree([]).getEmptyContainers(), [])
277
+ })
278
+ })
230
279
  })