@nascentdigital/funnel-cms 6.1.3 → 6.1.6

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.
@@ -16,6 +16,7 @@ var ContentClient;
16
16
  }
17
17
  Factory.get = get;
18
18
  function set(factory) {
19
+ log.d('factor - registered', factory);
19
20
  _factory = factory;
20
21
  }
21
22
  Factory.set = set;
@@ -32,3 +33,4 @@ var ContentClient;
32
33
  })(ContentClient || (exports.ContentClient = ContentClient = {}));
33
34
  // storage
34
35
  let _factory = undefined;
36
+ const log = funnel_core_1.Logger.get('cms');
@@ -132,7 +132,7 @@ export declare namespace PageClient {
132
132
  };
133
133
  type StaticPage = {
134
134
  type: Schema.Page.Type;
135
- refs: Record<Language, Schema.Page.Data.AsLink>;
135
+ refs: Schema.Page.Data.AsLink[];
136
136
  };
137
137
  type DynamicPage = {
138
138
  type: Schema.Page.Type;
@@ -179,19 +179,27 @@ export declare namespace PageClient {
179
179
  type Result = Schema.Page.Data.AsGroup;
180
180
  }
181
181
  namespace Duplicate {
182
- type CurrentArgs = {
183
- source: Schema.Page.Id;
184
- targetPage: 'current';
185
- targetLanguage: Language;
186
- targetSlug?: string;
187
- };
188
- type NewArgs = {
189
- source: Schema.Page.Id;
190
- targetPage: 'new';
191
- targetLanguage: Language;
192
- targetSlug: string;
193
- };
194
- type Args = CurrentArgs | NewArgs;
182
+ type Args = Args.Extend | Args.Fork;
183
+ namespace Args {
184
+ type Source = {
185
+ groupId: Schema.Page.GroupId;
186
+ region: string;
187
+ language: Language;
188
+ };
189
+ type Extend = {
190
+ operation: 'extend';
191
+ region: string;
192
+ language: Language;
193
+ source: Source;
194
+ };
195
+ type Fork = {
196
+ operation: 'fork';
197
+ region: string;
198
+ language: Language;
199
+ slug: string;
200
+ source: Source;
201
+ };
202
+ }
195
203
  type Result = Schema.Page.Data.AsGroup;
196
204
  }
197
205
  namespace Touch {
@@ -41,3 +41,9 @@ export declare namespace ContentfulCollectionClient {
41
41
  };
42
42
  }
43
43
  }
44
+ export type GetCollectionQueryParamsArgs = Omit<CollectionClient.Query.By<any>, 'version' | 'groupId'> & {
45
+ ids?: Schema.Collection.Id[];
46
+ groupId?: Schema.Collection.GroupId | ReadonlyArray<Schema.Collection.GroupId>;
47
+ };
48
+ export declare function getCollectionQueryParams(query: GetCollectionQueryParamsArgs): Record<string, any>;
49
+ export declare function getCollectionSelect(props: ReadonlyArray<Schema.Collection.Data.Props.All>): string | undefined;
@@ -12,6 +12,8 @@ var __rest = (this && this.__rest) || function (s, e) {
12
12
  };
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.ContentfulCollectionClient = void 0;
15
+ exports.getCollectionQueryParams = getCollectionQueryParams;
16
+ exports.getCollectionSelect = getCollectionSelect;
15
17
  const funnel_core_1 = require("@nascentdigital/funnel-core");
16
18
  const models_1 = require("./models");
17
19
  // class
@@ -251,6 +253,7 @@ class ContentfulCollectionClient {
251
253
  // always update
252
254
  name: data.name,
253
255
  errors: data.errors,
256
+ valid: data.errors ? null : true,
254
257
  references,
255
258
  // TODO: only update if changed
256
259
  // optional
@@ -278,7 +281,6 @@ class ContentfulCollectionClient {
278
281
  }
279
282
  }
280
283
  exports.ContentfulCollectionClient = ContentfulCollectionClient;
281
- // helpers
282
284
  function getCollectionQueryParams(query) {
283
285
  // build filters
284
286
  const { ids = [], groupId, brand, type, slug, language, name, tags = [], tagsMatch = 'any', version } = query;
@@ -292,7 +294,12 @@ function getCollectionQueryParams(query) {
292
294
  }
293
295
  // filter group
294
296
  if (groupId) {
295
- filters['fields.groupId'] = groupId;
297
+ if (Array.isArray(groupId)) {
298
+ filters['fields.groupId[in]'] = groupId.join(',');
299
+ }
300
+ else {
301
+ filters['fields.groupId'] = groupId;
302
+ }
296
303
  }
297
304
  // filter brand
298
305
  if (brand) {
@@ -1,4 +1,5 @@
1
1
  import { CMAClient, KnownAppSDK } from '@contentful/app-sdk';
2
+ import { Logger } from '@nascentdigital/funnel-core';
2
3
  import { PlainClientAPI } from 'contentful-management';
3
4
  import { AuthClient, ContentClient } from '../api';
4
5
  import { User } from '../models';
@@ -13,6 +14,7 @@ import { ContentfulUserClient } from './ContentfulUserClient';
13
14
  import { IContentfulContentClient } from './IContentfulContentClient';
14
15
  import { ContentfulData } from './models';
15
16
  export declare class ContentfulContentClient implements ContentClient, IContentfulContentClient {
17
+ readonly log: Logger;
16
18
  readonly user: User;
17
19
  readonly locale: string;
18
20
  readonly deliveryAccessToken: string;
@@ -38,6 +40,7 @@ export declare class ContentfulContentClient implements ContentClient, IContentf
38
40
  getEntries<TEntry extends ContentfulData>({ pagination, version, includeLinks, ...query }: IContentfulContentClient.GetEntries.Args<TEntry>): Promise<IContentfulContentClient.GetEntries.Result<TEntry>>;
39
41
  updateEntry<TEntry extends ContentfulData>(entry: ContentfulData.Update<TEntry>): Promise<TEntry>;
40
42
  uploadAsset(args: IContentfulContentClient.UploadAsset.Args): Promise<IContentfulContentClient.UploadAsset.Result>;
43
+ getTranslatedReferences({ to, pages, collections }: IContentfulContentClient.GetTranslatedReferences.Args): Promise<IContentfulContentClient.GetTranslatedReferences.Result>;
41
44
  static forEditor({ sdk, contentApiPath }: ContentfulContentClient.ForEditorArgs): Promise<ContentfulContentClient>;
42
45
  }
43
46
  export declare namespace ContentfulContentClient {
@@ -30,7 +30,10 @@ class ContentfulContentClient {
30
30
  constructor(args) {
31
31
  var _a;
32
32
  // initialize instance variables
33
+ const log = funnel_core_1.Logger.get('cms:client');
34
+ this.log = log;
33
35
  if (ContentfulContentClient.ConstructorArgs.isForServer(args)) {
36
+ log.d('constructor - created for server (headless)');
34
37
  this.user = models_1.User.UNAUTHENTICATED;
35
38
  this.locale = args.locale;
36
39
  const options = { defaults: { spaceId: args.spaceId, environmentId: args.environment } };
@@ -52,6 +55,7 @@ class ContentfulContentClient {
52
55
  .map(({ name }) => models_2.ContentfulUserData.parseRole(name))
53
56
  .sort((lhs, rhs) => lhs.compareTo(rhs))[0].id
54
57
  });
58
+ log.d('constructor - created for user', this.user);
55
59
  this.locale = locales.default;
56
60
  this.cma = cma;
57
61
  const options = { defaults: { spaceId: ids.space, environmentId: (_a = ids.environmentAlias) !== null && _a !== void 0 ? _a : ids.environment } };
@@ -97,6 +101,7 @@ class ContentfulContentClient {
97
101
  async getEntry(_a) {
98
102
  var _b, _c;
99
103
  var { entryId, includeLinks = false, version = 'draft' } = _a, query = __rest(_a, ["entryId", "includeLinks", "version"]);
104
+ this.log.v('getEntry - fetching for', Object.assign({ entryId, version, includeLinks }, query));
100
105
  // get entry
101
106
  const client = version === 'published' ? this.cda : this.cpa;
102
107
  const result = await client.entry.getMany({
@@ -105,8 +110,7 @@ class ContentfulContentClient {
105
110
  : includeLinks || undefined })
106
111
  });
107
112
  const { items: [item], includes } = result;
108
- console.debug('[ContentfulContentClient/getEntry] fetched', result);
109
- // skip
113
+ this.log.v('getEntry - fetched', result);
110
114
  // bind references
111
115
  const entry = item ? models_2.ContentfulData.fromCdaData(item) : undefined;
112
116
  const references = (_c = (_b = includes === null || includes === void 0 ? void 0 : includes.Entry) === null || _b === void 0 ? void 0 : _b.map(link => models_2.ContentfulData.fromCdaData(link))) !== null && _c !== void 0 ? _c : [];
@@ -116,6 +120,7 @@ class ContentfulContentClient {
116
120
  async getEntries(_a) {
117
121
  var _b, _c;
118
122
  var { pagination = 'all', version = 'draft', includeLinks = false } = _a, query = __rest(_a, ["pagination", "version", "includeLinks"]);
123
+ this.log.v('getEntries - fetching for', Object.assign({ version, includeLinks, pagination }, query));
119
124
  // setup pagination
120
125
  const itemsPerRequest = pagination === 'all' ? 1000 : Math.min(pagination.pageSize, 1000);
121
126
  const page = pagination === 'all' ? 1 : pagination.page;
@@ -134,7 +139,7 @@ class ContentfulContentClient {
134
139
  ? 10
135
140
  : includeLinks || undefined })
136
141
  }), { items } = _d, response = __rest(_d, ["items"]);
137
- console.debug('[ContentfulContentClient/getEntries] fetched page', { query, items, response });
142
+ this.log.v('getEntries - fetching page', Object.assign({ items }, response));
138
143
  // add items
139
144
  items.forEach(item => entries.push(models_2.ContentfulData.fromCdaData(item)));
140
145
  includes.push(...((_c = (_b = response.includes) === null || _b === void 0 ? void 0 : _b.Entry) !== null && _c !== void 0 ? _c : []));
@@ -146,7 +151,7 @@ class ContentfulContentClient {
146
151
  ? skip < totalItems
147
152
  : totalItemFetched < pagination.pageSize && skip < totalItems;
148
153
  } while (hasMorePages);
149
- console.debug('[ContentfulContentClient/getEntries] fetched entries', { entries, includes });
154
+ this.log.v('getEntries - fetched all', { entries, includes });
150
155
  // normalize references
151
156
  const references = includes.map(link => models_2.ContentfulData.fromCdaData(link));
152
157
  // return
@@ -235,8 +240,58 @@ class ContentfulContentClient {
235
240
  // return asset
236
241
  return models_2.ContentfulData.fromCmaData(entry, locale);
237
242
  }
243
+ async getTranslatedReferences({ to, pages, collections }) {
244
+ // lookup pages/collections
245
+ const [translatedPages, translatedCollections] = await Promise.all([
246
+ pages.length === 0
247
+ ? Promise.resolve([])
248
+ : this.getEntries(Object.assign({ content_type: models_2.ContentfulPageData.contentType, version: 'draft', select: (0, ContentfulPageClient_1.getPageSelect)(funnel_core_1.Schema.Page.Data.Props.id) }, (0, ContentfulPageClient_1.getPageQueryParams)({
249
+ groupId: pages.map(page => page.groupId)
250
+ })))
251
+ .then(({ entries }) => {
252
+ return entries
253
+ .filter(entry => entry.region === to.region && entry.language === to.language)
254
+ .map(entry => {
255
+ // lookup original
256
+ const source = pages.find(page => page.groupId === entry.groupId);
257
+ // return translation (or drop it)
258
+ return source && {
259
+ from: source.id,
260
+ to: entry.id
261
+ };
262
+ })
263
+ .filter(funnel_core_1.Value.isDefined);
264
+ }),
265
+ collections.length === 0
266
+ ? Promise.resolve([])
267
+ : this.getEntries(Object.assign({ content_type: models_2.ContentfulCollectionData.contentType, version: 'draft', select: (0, ContentfulCollectionClient_1.getCollectionSelect)(funnel_core_1.Schema.Collection.Data.Props.id) }, (0, ContentfulCollectionClient_1.getCollectionQueryParams)({
268
+ groupId: collections.map(collection => collection.groupId)
269
+ })))
270
+ .then(({ entries }) => {
271
+ return entries
272
+ .filter(entry => entry.language === to.language)
273
+ .map(entry => {
274
+ // lookup original
275
+ const source = collections.find(collection => collection.groupId === entry.groupId);
276
+ // return translation (or drop it)
277
+ return source && {
278
+ from: source.id,
279
+ to: entry.id
280
+ };
281
+ })
282
+ .filter(funnel_core_1.Value.isDefined);
283
+ })
284
+ ]);
285
+ // return translated references
286
+ return {
287
+ pages: translatedPages,
288
+ collections: translatedCollections
289
+ };
290
+ }
238
291
  static async forEditor({ sdk, contentApiPath }) {
239
292
  var _a;
293
+ const log = funnel_core_1.Logger.get('cms:client');
294
+ log.v('forEditor - creating for', { sdk, contentApiPath });
240
295
  // get current site config (throw if we can't)
241
296
  const { user, cma, locales: { default: locale } } = sdk;
242
297
  const userId = user.sys.id;
@@ -278,7 +333,7 @@ class ContentfulContentClient {
278
333
  if (!deliveryAccessToken || !previewAccessToken) {
279
334
  throw new funnel_core_1.IllegalStateError('Invalid data return from API');
280
335
  }
281
- console.debug('[ContentfulContentClient/forEditor] creating with', { sdk, deliveryAccessToken, previewAccessToken });
336
+ log.v('forEditor - creating with', { sdk, deliveryAccessToken, previewAccessToken });
282
337
  // create + return new client
283
338
  return new ContentfulContentClient({ sdk, deliveryAccessToken, previewAccessToken });
284
339
  }
@@ -1,11 +1,12 @@
1
- import { Schema } from '@nascentdigital/funnel-core';
1
+ import { Logger, Schema } from '@nascentdigital/funnel-core';
2
2
  import { PageClient } from '../api';
3
3
  import { IContentfulContentClient } from './IContentfulContentClient';
4
4
  import { ContentfulPageData } from './models';
5
5
  export declare class ContentfulPageClient implements PageClient {
6
6
  readonly cms: IContentfulContentClient;
7
+ readonly log: Logger;
7
8
  constructor(cms: IContentfulContentClient);
8
- getOverview(args: PageClient.GetOverview.Args): Promise<PageClient.GetOverview.Result>;
9
+ getOverview({ version, region, ...args }: PageClient.GetOverview.Args): Promise<PageClient.GetOverview.Result>;
9
10
  getSummaries(args: PageClient.GetSummaries.Args): Promise<PageClient.GetSummaries.Result>;
10
11
  getPreviews(args: PageClient.GetPreviews.Args): Promise<PageClient.GetPreviews.Result>;
11
12
  search({ version, pagination, ...query }: PageClient.Search.Args): Promise<PageClient.Search.Result>;
@@ -14,7 +15,7 @@ export declare class ContentfulPageClient implements PageClient {
14
15
  getGroup(args: PageClient.GetGroup.Args): Promise<ContentfulPageClient.GetGroup.Result>;
15
16
  get(args: PageClient.Get.Args): Promise<ContentfulPageClient.Get.Result>;
16
17
  create(args: PageClient.Create.Args): Promise<PageClient.Create.Result>;
17
- duplicate(args: PageClient.Duplicate.Args): Promise<PageClient.Duplicate.Result>;
18
+ duplicate({ source, ...target }: PageClient.Duplicate.Args): Promise<PageClient.Duplicate.Result>;
18
19
  update(args: PageClient.Update.Args): Promise<ContentfulPageClient.Update.Result>;
19
20
  replace(args: PageClient.Replace.Args): Promise<PageClient.Replace.Result>;
20
21
  touch(args: PageClient.Touch.Args): Promise<PageClient.Touch.Result>;
@@ -41,3 +42,9 @@ export declare namespace ContentfulPageClient {
41
42
  };
42
43
  }
43
44
  }
45
+ export type GetPageQueryParamsArgs = Omit<PageClient.Query.By<any>, 'version' | 'groupId'> & {
46
+ ids?: Schema.Page.Id[];
47
+ groupId?: Schema.Page.GroupId | ReadonlyArray<Schema.Page.GroupId>;
48
+ };
49
+ export declare function getPageQueryParams(query: GetPageQueryParamsArgs): Record<string, any>;
50
+ export declare function getPageSelect(props: ReadonlyArray<Schema.Page.Data.Props.All>): string | undefined;
@@ -12,6 +12,8 @@ var __rest = (this && this.__rest) || function (s, e) {
12
12
  };
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.ContentfulPageClient = void 0;
15
+ exports.getPageQueryParams = getPageQueryParams;
16
+ exports.getPageSelect = getPageSelect;
15
17
  const funnel_core_1 = require("@nascentdigital/funnel-core");
16
18
  const errors_1 = require("../errors");
17
19
  const models_1 = require("./models");
@@ -19,11 +21,13 @@ const models_1 = require("./models");
19
21
  class ContentfulPageClient {
20
22
  constructor(cms) {
21
23
  this.cms = cms;
24
+ this.log = funnel_core_1.Logger.get('cms:page');
22
25
  }
23
- async getOverview(args) {
26
+ async getOverview(_a) {
27
+ var { version, region } = _a, args = __rest(_a, ["version", "region"]);
24
28
  // fetch all pages
25
29
  const { cms } = this;
26
- const { entries } = await cms.getEntries(Object.assign({ content_type: models_1.ContentfulPageData.contentType, version: args.version, select: getPageSelect(funnel_core_1.Schema.Page.Data.AsLink.props) }, getPageQueryParams(args)));
30
+ const { entries } = await cms.getEntries(Object.assign({ content_type: models_1.ContentfulPageData.contentType, version, select: getPageSelect(funnel_core_1.Schema.Page.Data.AsLink.props) }, getPageQueryParams(args)));
27
31
  // convert entries to page data
28
32
  const pagesData = entries.map(entry => models_1.ContentfulPageData.deserialize(entry, funnel_core_1.Schema.Page.Data.AsLink.props));
29
33
  // setup maps
@@ -31,34 +35,38 @@ class ContentfulPageClient {
31
35
  const dynamicPages = new Map();
32
36
  // go over each page data
33
37
  for (const data of pagesData) {
34
- // use appropriate map (determine if page is static/dynamic)
35
- const pagesMap = data.slug === undefined
36
- ? staticPages
37
- : dynamicPages;
38
- // get references by page type
39
- let refs = pagesMap.get(data.type);
40
- if (!refs) {
41
- pagesMap.set(data.type, refs = []);
38
+ // add static page
39
+ if (data.slug === undefined) {
40
+ // get references (or create it)
41
+ let refs = staticPages.get(data.type);
42
+ if (!refs) {
43
+ staticPages.set(data.type, refs = []);
44
+ }
45
+ // add page
46
+ refs.push(data);
47
+ }
48
+ // or dynamic
49
+ else {
50
+ // get references (or create it)
51
+ let refs = dynamicPages.get(data.type);
52
+ if (!refs) {
53
+ dynamicPages.set(data.type, refs = new Set());
54
+ }
55
+ // add page
56
+ refs.add(data.groupId);
42
57
  }
43
- // add to refs
44
- refs.push(data);
45
58
  }
46
59
  // return
47
60
  return {
48
61
  // static pages
49
62
  static: staticPages.entries().map(([type, refs]) => ({
50
63
  type,
51
- refs: refs.reduce((refs, ref) => {
52
- // add page
53
- refs[ref.language] = ref;
54
- // keep reducing
55
- return refs;
56
- }, {})
64
+ refs
57
65
  })).toArray(),
58
66
  // dynamic pages
59
67
  dynamic: dynamicPages.entries().map(([type, refs]) => ({
60
68
  type,
61
- count: refs.length
69
+ count: refs.size
62
70
  })).toArray()
63
71
  };
64
72
  }
@@ -93,27 +101,27 @@ class ContentfulPageClient {
93
101
  async getGroup(args) {
94
102
  // fetch items (bail if there are no instances
95
103
  const { language, region, version } = args, queryArgs = __rest(args, ["language", "region", "version"]);
96
- const { cms } = this;
97
- console.debug('[ContentfulPageClient/getGroup] query group entries for', JSON.stringify(queryArgs, undefined, ' '));
104
+ const { cms, log } = this;
105
+ log.v('getGroup - querying', args);
98
106
  const { entries } = await cms.getEntries(Object.assign({ content_type: models_1.ContentfulPageData.contentType, version, select: getPageSelect(funnel_core_1.Schema.Page.Data.Props.id) }, getPageQueryParams(queryArgs)));
99
107
  // bail if no items
100
108
  if (entries.length === 0) {
101
- console.debug('[ContentfulPageClient/getGroup] no items in group', JSON.stringify(args, undefined, ' '));
109
+ log.v('getGroup - no items in group');
102
110
  const { brand, groupId: possibleGroupId, type, slug } = queryArgs;
103
111
  return {
104
112
  group: { groupId: possibleGroupId !== null && possibleGroupId !== void 0 ? possibleGroupId : funnel_core_1.Schema.Page.GroupId.empty, brand, type, slug, instances: [] }
105
113
  };
106
114
  }
107
- console.debug('[ContentfulPageClient/getGroup] processing group items', JSON.stringify(args, undefined, ' '));
108
115
  // collate the group ids into summaries (fetches full page if region/language match exists
109
116
  const instances = entries.map(entry => models_1.ContentfulPageData.deserialize(entry, funnel_core_1.Schema.Page.Data.AsGroup.Instance.props));
110
- // bind page (if it exists)
111
117
  const pageInstance = instances
112
118
  .find(instance => instance.region === region && instance.language === language);
113
- console.debug('[ContentfulPageClient/getGroup] getting page using this.get...', { id: pageInstance === null || pageInstance === void 0 ? void 0 : pageInstance.id, version });
119
+ log.v('getGroup - processing group items', { entries, instances, pageInstance });
120
+ // bind page (if it exists)
114
121
  const page = pageInstance
115
122
  ? await this.get({ id: pageInstance.id, version })
116
123
  : undefined;
124
+ log.v('getGroup - fetched page', page);
117
125
  // return group (and optional page)
118
126
  const { type, brand, groupId, slug } = entries[0];
119
127
  return {
@@ -122,8 +130,9 @@ class ContentfulPageClient {
122
130
  };
123
131
  }
124
132
  async get(args) {
133
+ const { log, cms } = this;
134
+ log.v('get - fetching page for', args);
125
135
  // fetch entries (should be one entry)
126
- const { cms } = this;
127
136
  const [{ entries, references }, { entry: entrySnapshot }] = await Promise.all([
128
137
  // query target (could be published/draft)
129
138
  cms.getEntries(Object.assign({ content_type: models_1.ContentfulPageData.contentType, includeLinks: true, version: args.version, pagination: { page: 1, pageSize: 1 }, select: getPageSelect(args.select === 'content'
@@ -136,6 +145,7 @@ class ContentfulPageClient {
136
145
  ]);
137
146
  // skip if no entries
138
147
  if (entries.length === 0) {
148
+ log.v('get - nothing found');
139
149
  return undefined;
140
150
  }
141
151
  // stitch together snapshot with entry (if available
@@ -143,6 +153,7 @@ class ContentfulPageClient {
143
153
  const snapshot = entrySnapshot
144
154
  ? models_1.ContentfulPageData.Snapshot.deserialize(entrySnapshot)
145
155
  : undefined;
156
+ log.v('get - fetched page', { data, snapshot });
146
157
  // use first entry and deserialize
147
158
  return Object.assign({ data: Object.assign(Object.assign({}, data), { snapshotId: snapshot === null || snapshot === void 0 ? void 0 : snapshot.id, snapshot }) }, models_1.ContentfulPageData.deserializeRefs(references));
148
159
  }
@@ -343,8 +354,150 @@ class ContentfulPageClient {
343
354
  }
344
355
  }
345
356
  }
346
- duplicate(args) {
347
- throw new funnel_core_1.NotImplementedError();
357
+ async duplicate(_a) {
358
+ var _b, _c, _d;
359
+ var { source } = _a, target = __rest(_a, ["source"]);
360
+ console.debug('[ContentfulPageClient/duplicate] processing duplicate page request', target);
361
+ // get page by id
362
+ const { groupId, region: sourceRegion, language: sourceLanguage } = source;
363
+ const { items: sourceGroup } = await this.search({
364
+ groupId,
365
+ version: 'draft',
366
+ pagination: 'all'
367
+ });
368
+ // throw if there's no page
369
+ const sourceRef = sourceGroup.find(page => page.region === sourceRegion && page.language === sourceLanguage);
370
+ if (!sourceRef) {
371
+ throw new funnel_core_1.NotFoundError(`Unable to resolve page matching group "${groupId}" in ${sourceRegion}/${sourceLanguage}`);
372
+ }
373
+ // TODO: check schema to see if language is possible
374
+ // make sure target language doesn't already exist if we are targeting the current group
375
+ if (target.operation === 'extend') {
376
+ if (sourceGroup.some(page => page.region === target.region && page.language === target.language)) {
377
+ throw new funnel_core_1.InvalidOperationError(`There is already a page in group "${groupId}" created for ${target.region}/${target.language}`);
378
+ }
379
+ }
380
+ // make sure a static page isn't getting duplicated and that the slug is good
381
+ else {
382
+ // get source page's schema
383
+ const site = funnel_core_1.Schema.Site.current();
384
+ const schema = site.regions[sourceRef.region][sourceRef.type];
385
+ // cannot duplicate a static page
386
+ if (schema.variant === 'static') {
387
+ throw new funnel_core_1.InvalidOperationError(`cannot duplicate a static page "${sourceRef.name}"`);
388
+ }
389
+ // slug must be provided
390
+ if (target.slug === undefined) {
391
+ throw new funnel_core_1.ArgumentError('slug must be provided when duplicating a page');
392
+ }
393
+ // validate slug
394
+ const validateSlugResult = await this.validateSlug({
395
+ brand: sourceRef.brand,
396
+ type: sourceRef.type,
397
+ region: sourceRef.region,
398
+ slug: target.slug
399
+ });
400
+ if (validateSlugResult.status !== 'available') {
401
+ throw new errors_1.SlugValidationError({
402
+ slug: target.slug,
403
+ reason: validateSlugResult
404
+ });
405
+ }
406
+ }
407
+ // get full source with refs
408
+ const { cms } = this;
409
+ const { entries: [sourceEntry], references } = await cms.getEntries(Object.assign({ content_type: models_1.ContentfulPageData.contentType, includeLinks: true, version: 'draft', pagination: { page: 1, pageSize: 1 }, select: getPageSelect(funnel_core_1.Schema.Page.Data.AsContent.props) }, getPageQueryParams({ ids: [sourceRef.id] })));
410
+ if (!sourceEntry) {
411
+ throw new funnel_core_1.NotFoundError(`Unable to resolve page with id "${sourceRef.id}"`);
412
+ }
413
+ const sourcePage = models_1.ContentfulPageData.deserialize(sourceEntry);
414
+ console.debug('[ContentfulPageClient/duplicate] fetched source', { sourceEntry, sourcePage });
415
+ const sourceRefIds = ((_b = sourceEntry.references) !== null && _b !== void 0 ? _b : [])
416
+ .filter(link => link.sys.linkType === 'Entry')
417
+ .map(link => link.sys.id);
418
+ const sourceReferences = models_1.ContentfulPageData.deserializeRefs(references);
419
+ const sourcePageRefs = Object.values(sourceReferences.pages)
420
+ .filter(page => sourceRefIds.includes(page.id))
421
+ .map(page => ({
422
+ id: page.id,
423
+ groupId: page.groupId
424
+ }));
425
+ const sourceCollectionRefs = Object.values(sourceReferences.collections)
426
+ .filter(collection => sourceRefIds.includes(collection.id))
427
+ .map(collection => ({
428
+ id: collection.id,
429
+ groupId: collection.groupId
430
+ }));
431
+ console.debug('[ContentfulPageClient/duplicate] fetched source', sourcePage);
432
+ // translate references (falls back to default if there's no mapping
433
+ const { pages: translatedPageIds, collections: translatedCollectionIds } = await cms.getTranslatedReferences({
434
+ from: source,
435
+ to: target,
436
+ pages: sourcePageRefs,
437
+ collections: sourceCollectionRefs
438
+ });
439
+ const refIds = [...translatedPageIds, ...translatedCollectionIds].filter(funnel_core_1.Value.isDefined);
440
+ console.debug('[ContentfulPageClient/duplicate] found translated ids', refIds);
441
+ // update refs
442
+ const updateRefs = (source, mappings) => {
443
+ // skip if there's no json
444
+ if (!source) {
445
+ return source;
446
+ }
447
+ // or if there's no mappings
448
+ else if (mappings.length === 0) {
449
+ return source;
450
+ }
451
+ // serialize
452
+ let serialized = JSON.stringify(source);
453
+ // replace
454
+ for (const { from, to } of mappings) {
455
+ serialized = serialized.replaceAll(`"${from}"`, `"${to}"`);
456
+ }
457
+ // deserialize
458
+ return JSON.parse(serialized);
459
+ };
460
+ const targetBody = {
461
+ discover: updateRefs(sourcePage.discover, refIds),
462
+ preview: updateRefs(sourcePage.preview, refIds),
463
+ content: updateRefs(sourcePage.content, refIds)
464
+ };
465
+ // create duplicate page
466
+ try {
467
+ // setup data
468
+ const targetData = Object.assign({ groupId: target.operation === 'extend'
469
+ ? sourcePage.groupId
470
+ : funnel_core_1.Value.uuid(), type: sourcePage.type, brand: sourcePage.brand, region: target.region, language: target.language, slug: target.operation === 'extend'
471
+ ? sourcePage.slug
472
+ : target.slug, name: sourcePage.name, tags: sourcePage.tags, url: sourcePage.url, errors: true }, targetBody);
473
+ const targetModel = funnel_core_1.Schema.PageModel.from(targetData);
474
+ targetModel.sync();
475
+ console.debug('[ContentfulPageClient/duplicate] about to create page with data', targetData);
476
+ // create target page using content of source
477
+ const targetEntry = await cms.createEntry(models_1.ContentfulPageData.contentType, Object.assign(Object.assign({}, targetData), { references: ((_c = sourceEntry.references) !== null && _c !== void 0 ? _c : []).map(link => {
478
+ var _a, _b;
479
+ // replace entry ids if mapped
480
+ if (link.sys.linkType === 'Entry') {
481
+ link.sys.id = (_b = (_a = refIds.find(({ from }) => from === link.sys.id)) === null || _a === void 0 ? void 0 : _a.to) !== null && _b !== void 0 ? _b : link.sys.id;
482
+ }
483
+ // return link
484
+ return link;
485
+ }) }));
486
+ // return group for duplicated page
487
+ return this.getGroup({
488
+ brand: targetEntry.brand,
489
+ groupId: targetEntry.groupId,
490
+ type: targetEntry.type,
491
+ slug: (_d = targetEntry.slug) !== null && _d !== void 0 ? _d : undefined,
492
+ region: targetEntry.region,
493
+ language: targetEntry.language,
494
+ version: 'draft'
495
+ });
496
+ }
497
+ // handle errors
498
+ catch (e) {
499
+ throw funnel_core_1.IsometricErrorUtils.coerceError(e);
500
+ }
348
501
  }
349
502
  update(args) {
350
503
  throw new funnel_core_1.NotImplementedError();
@@ -387,7 +540,6 @@ class ContentfulPageClient {
387
540
  }
388
541
  }
389
542
  exports.ContentfulPageClient = ContentfulPageClient;
390
- // helpers
391
543
  function getPageQueryParams(query) {
392
544
  // build filters
393
545
  const { ids = [], groupId, brand, type, slug, region, language, name, tags = [], tagsMatch = 'any' } = query;
@@ -401,7 +553,12 @@ function getPageQueryParams(query) {
401
553
  }
402
554
  // filter group
403
555
  if (groupId) {
404
- filters['fields.groupId'] = groupId;
556
+ if (Array.isArray(groupId)) {
557
+ filters['fields.groupId[in]'] = groupId.join(',');
558
+ }
559
+ else {
560
+ filters['fields.groupId'] = groupId;
561
+ }
405
562
  }
406
563
  // filter brand
407
564
  if (brand) {
@@ -1,4 +1,5 @@
1
1
  import { CMAClient } from '@contentful/app-sdk';
2
+ import { Locale, Schema } from '@nascentdigital/funnel-core';
2
3
  import { PlainClientAPI, QueryOptions } from 'contentful-management';
3
4
  import { AssetClient } from '../api';
4
5
  import { Environment, Pagination, User } from '../models';
@@ -35,6 +36,7 @@ export interface IContentfulContentClient {
35
36
  getEntries<TEntry extends ContentfulData>(args: IContentfulContentClient.GetEntries.Args<TEntry>): Promise<IContentfulContentClient.GetEntries.Result<TEntry>>;
36
37
  updateEntry<TEntry extends ContentfulData>(entry: ContentfulData.Update<TEntry>): Promise<TEntry>;
37
38
  uploadAsset(args: IContentfulContentClient.UploadAsset.Args): Promise<IContentfulContentClient.UploadAsset.Result>;
39
+ getTranslatedReferences(args: IContentfulContentClient.GetTranslatedReferences.Args): Promise<IContentfulContentClient.GetTranslatedReferences.Result>;
38
40
  }
39
41
  export declare namespace IContentfulContentClient {
40
42
  type EntryQueryOptions = {
@@ -60,6 +62,30 @@ export declare namespace IContentfulContentClient {
60
62
  references: ContentfulData[];
61
63
  };
62
64
  }
65
+ namespace GetTranslatedReferences {
66
+ type Args = {
67
+ from: Locale;
68
+ to: Locale;
69
+ pages: ReadonlyArray<PageRef>;
70
+ collections: ReadonlyArray<CollectionRef>;
71
+ };
72
+ type Result = {
73
+ pages: Translation<Schema.Page.Id>[];
74
+ collections: Translation<Schema.Collection.Id>[];
75
+ };
76
+ type PageRef = {
77
+ id: Schema.Page.Id;
78
+ groupId: Schema.Page.GroupId;
79
+ };
80
+ type CollectionRef = {
81
+ id: Schema.Collection.Id;
82
+ groupId: Schema.Collection.GroupId;
83
+ };
84
+ type Translation<TId> = {
85
+ from: TId;
86
+ to: TId;
87
+ };
88
+ }
63
89
  namespace UploadAsset {
64
90
  type Args = AssetClient.CreateAsset.Args & {
65
91
  file: Blob;
@@ -1,7 +1,12 @@
1
1
  import { RequiredBy, Schema } from '@nascentdigital/funnel-core';
2
2
  import { ContentfulData } from './ContentfulData';
3
3
  export type ContentfulCollectionData = ContentfulData<ContentfulCollectionData.ContentType, Schema.Collection.Id, Schema.Collection.State> & Omit<Schema.Collection.Data, 'id' | 'changeSetId' | 'snapshotId' | 'snapshot'> & {
4
- references: ContentfulData.References;
4
+ references?: ContentfulData.References;
5
+ /**
6
+ * Used specifically to prevent publishing in Contentful if the collection is not valid.
7
+ * The `valid` field is required so setting to null blocks publishing.
8
+ */
9
+ valid?: boolean;
5
10
  };
6
11
  export declare namespace ContentfulCollectionData {
7
12
  const contentType: "collection";
@@ -24,9 +24,7 @@ var ContentfulCollectionData;
24
24
  };
25
25
  // helper
26
26
  const bindProp = (prop, target, transform) => {
27
- if (prop in data) {
28
- collection[target] = transform(data[prop]);
29
- }
27
+ collection[target] = transform(data[prop]);
30
28
  };
31
29
  // bind required props to page
32
30
  for (const prop of props) {
@@ -46,10 +44,12 @@ var ContentfulCollectionData;
46
44
  case 'version':
47
45
  case 'name':
48
46
  case 'errors':
49
- case 'pageIds':
50
- case 'tags':
51
47
  bindProp(prop, prop, value => value !== null && value !== void 0 ? value : undefined);
52
48
  break;
49
+ // arrays
50
+ case 'tags':
51
+ bindProp(prop, prop, value => value !== null && value !== void 0 ? value : []);
52
+ break;
53
53
  // content
54
54
  case 'fields':
55
55
  bindProp(prop, prop, value => value !== null && value !== void 0 ? value : {});
@@ -60,6 +60,7 @@ var ContentfulCollectionData;
60
60
  case 'snapshot':
61
61
  case 'snapshotId':
62
62
  case 'changeSetId':
63
+ case 'pageIds':
63
64
  break;
64
65
  // unhandled
65
66
  default:
@@ -15,7 +15,9 @@ export declare namespace ContentfulData {
15
15
  type EntryId<T extends ContentfulData> = T['id'];
16
16
  type References = Array<Link.Entry | Link.Asset>;
17
17
  type Draft<T extends ContentfulData> = Omit<T, keyof ContentfulData | '__root'>;
18
- type Update<T extends ContentfulData> = Pick<ContentfulData, 'id' | '__version'> & Partial<Draft<T>>;
18
+ type Update<T extends ContentfulData> = Pick<ContentfulData, 'id' | '__version'> & {
19
+ [TKey in keyof Draft<T>]?: Draft<T>[TKey] | null;
20
+ };
19
21
  type Link<TType extends Link.Type = Link.Type> = {
20
22
  sys: {
21
23
  type: 'Link';
@@ -1,7 +1,12 @@
1
1
  import { RequiredBy, Schema } from '@nascentdigital/funnel-core';
2
2
  import { ContentfulData } from './ContentfulData';
3
3
  export type ContentfulPageData = ContentfulData<ContentfulPageData.ContentType, Schema.Page.Id, Schema.Page.State> & Omit<Schema.Page.Data, 'id' | 'changeSetId' | 'snapshotId' | 'snapshot'> & {
4
- references: ContentfulData.References;
4
+ references?: ContentfulData.References;
5
+ /**
6
+ * Used specifically to prevent publishing in Contentful if the page is not valid.
7
+ * The `valid` field is required so setting to null blocks publishing.
8
+ */
9
+ valid?: boolean;
5
10
  };
6
11
  export declare namespace ContentfulPageData {
7
12
  const contentType: "page";
@@ -38,9 +38,7 @@ var ContentfulPageData;
38
38
  };
39
39
  // helper
40
40
  const bindProp = (prop, target, transform) => {
41
- if (prop in data) {
42
- page[target] = transform(data[prop]);
43
- }
41
+ page[target] = transform(data[prop]);
44
42
  };
45
43
  // bind required props to page
46
44
  for (const prop of props) {
@@ -63,10 +61,13 @@ var ContentfulPageData;
63
61
  case 'name':
64
62
  case 'preview':
65
63
  case 'errors':
66
- case 'tags':
67
64
  case 'publication':
68
65
  bindProp(prop, prop, value => value !== null && value !== void 0 ? value : undefined);
69
66
  break;
67
+ // arrays
68
+ case 'tags':
69
+ bindProp(prop, prop, value => value !== null && value !== void 0 ? value : []);
70
+ break;
70
71
  // content
71
72
  case 'discover':
72
73
  case 'content':
@@ -12,7 +12,7 @@ export declare class StrapiPageClient implements PageClient {
12
12
  getGroup({ brand, groupId, type, slug, region, language, version, select }: PageClient.GetGroup.Args): Promise<PageClient.GetGroup.Result>;
13
13
  get(args: PageClient.Get.Args): Promise<PageClient.Get.Result>;
14
14
  create(args: PageClient.Create.Args): Promise<PageClient.Create.Result>;
15
- duplicate({ source: pageId, targetPage, targetLanguage, targetSlug }: PageClient.Duplicate.Args): Promise<PageClient.Duplicate.Result>;
15
+ duplicate(args: PageClient.Duplicate.Args): Promise<PageClient.Duplicate.Result>;
16
16
  touch({ id: pageId, version: status }: PageClient.Touch.Args): Promise<PageClient.Touch.Result>;
17
17
  update(args: PageClient.Update.Args): Promise<PageClient.Update.Result>;
18
18
  replace({ page, version, skipSync }: PageClient.Replace.Args): Promise<PageClient.Replace.Result>;
@@ -24,18 +24,13 @@ class StrapiPageClient {
24
24
  async getOverview({ brand, region, version }) {
25
25
  const { static: staticPages, dynamic } = await this.provider.customRequest({
26
26
  command: 'query/pages',
27
- params: { brand, region, preview: version === 'draft' ? 'true' : 'false' }
27
+ params: { brand, preview: version === 'draft' ? 'true' : 'false' }
28
28
  });
29
29
  // transform static links
30
30
  return {
31
31
  static: staticPages.map(({ type, refs }) => ({
32
32
  type,
33
- refs: Object.entries(refs).reduce((refs, [language, ref]) => {
34
- // map page
35
- refs[language] = serialization_1.PageData.deserializeRef(ref);
36
- // keep reducing
37
- return refs;
38
- }, {})
33
+ refs: Object.values(refs).map(serialization_1.PageData.deserializeRef)
39
34
  })),
40
35
  dynamic
41
36
  };
@@ -417,7 +412,7 @@ class StrapiPageClient {
417
412
  }
418
413
  }
419
414
  }
420
- async duplicate({ source: pageId, targetPage, targetLanguage, targetSlug }) {
415
+ async duplicate(args) {
421
416
  var _a;
422
417
  // resolve current user (or throw)
423
418
  const { client, users } = this.provider;
@@ -427,64 +422,76 @@ class StrapiPageClient {
427
422
  throw new funnel_core_1.NotAuthenticatedError();
428
423
  }
429
424
  // get page by id
425
+ const { source } = args;
430
426
  const pageCollection = client.collection('pages');
431
- const { data: sourceData } = await pageCollection.findOne(pageId, {
432
- fields: serialization_1.PageData.getStrapiFilter(funnel_core_1.Schema.Page.Data.Props.all),
433
- status: 'draft',
434
- populate: serialization_1.PageData.Populate.full
435
- });
436
- const sourcePage = sourceData;
427
+ const { items: [sourceRef] } = await this.search(Object.assign(Object.assign({}, source), { version: 'draft', pagination: { page: 1, pageSize: 1 } }));
437
428
  // throw if there's no page
438
- if (!sourcePage) {
439
- throw new funnel_core_1.NotFoundError(`Unable to resolve page with id "${pageId}"`);
429
+ if (!sourceRef) {
430
+ throw new funnel_core_1.NotFoundError(`Unable to resolve page for group "${source.groupId}" in ${source.region}/${source.language}`);
440
431
  }
441
432
  // TODO: check schema to see if language is possible
442
433
  // make sure target language doesn't already exist if we are targeting the current group
443
- if (targetPage === 'current') {
444
- const { data: pagesWithTargetLanguage } = await pageCollection.find(Object.assign({ fields: ['documentId'] }, getPageQueryParams({ version: 'draft', groupId: sourcePage.groupId, language: targetLanguage })));
445
- if (pagesWithTargetLanguage.length > 0) {
446
- throw new funnel_core_1.InvalidOperationError(`There is already a page in group "${sourcePage.groupId}" with ${targetLanguage} language`);
434
+ if (args.operation === 'extend') {
435
+ const { region, language } = args;
436
+ const { data: duplicates } = await pageCollection.find(Object.assign({ fields: ['documentId'] }, getPageQueryParams({
437
+ version: 'draft',
438
+ groupId: sourceRef.groupId,
439
+ region: args.region,
440
+ language: args.language
441
+ })));
442
+ if (duplicates.length > 0) {
443
+ throw new funnel_core_1.InvalidOperationError(`There is already a page in group "${sourceRef.groupId}" in ${region}/${language}`);
447
444
  }
448
445
  }
449
446
  // make sure a static page isn't getting duplicated and that the slug is good
450
- else if (targetPage === 'new') {
447
+ else {
451
448
  // get source page's schema
452
449
  const site = funnel_core_1.Schema.Site.current();
453
- const schema = site.regions[sourcePage.region][sourcePage.type];
450
+ const schema = site.regions[sourceRef.region][sourceRef.type];
454
451
  // cannot duplicate a static page
455
452
  if (schema.variant === 'static') {
456
- throw new funnel_core_1.InvalidOperationError(`cannot duplicate a static page "${sourcePage.name}"`);
453
+ throw new funnel_core_1.InvalidOperationError(`cannot duplicate a static page "${sourceRef.name}"`);
457
454
  }
458
455
  // slug must be provided
459
- if (targetSlug === undefined) {
456
+ const { region, slug } = args;
457
+ if (slug === undefined) {
460
458
  throw new funnel_core_1.ArgumentError('slug must be provided when duplicating a page');
461
459
  }
462
460
  // validate slug
463
461
  const validateSlugResult = await this.validateSlug({
464
- brand: sourcePage.brand,
465
- type: sourcePage.type,
466
- region: sourcePage.region,
467
- slug: targetSlug
462
+ brand: sourceRef.brand,
463
+ type: sourceRef.type,
464
+ region,
465
+ slug
468
466
  });
469
467
  if (validateSlugResult.status !== 'available') {
470
468
  throw new errors_1.SlugValidationError({
471
- slug: targetSlug,
469
+ slug,
472
470
  reason: validateSlugResult
473
471
  });
474
472
  }
475
473
  }
476
- // get refs from source page
477
- const { assetRefs, pageRefs, collectionRefs } = sourcePage;
474
+ // fetch the page for the source
475
+ const { data: sourceData } = await pageCollection.findOne(sourceRef.id, {
476
+ fields: serialization_1.PageData.getStrapiFilter(funnel_core_1.Schema.Page.Data.Props.all),
477
+ status: 'draft',
478
+ populate: serialization_1.PageData.Populate.full
479
+ });
480
+ const sourcePage = sourceData;
481
+ if (!sourcePage) {
482
+ throw new funnel_core_1.NotFoundError(`Unable to resolve page with id "${sourceRef.id}" in ${source.region}/${source.language}`);
483
+ }
484
+ const { pageRefs, collectionRefs, assetRefs } = sourcePage;
478
485
  // target refs and body
479
486
  let targetPageRefIds;
480
487
  let targetCollectionRefIds;
481
488
  let targetBody;
482
489
  // if we are targeting a different language then we need to "translate" the references
483
- if (targetLanguage !== sourcePage.lang) {
490
+ if (args.language !== sourcePage.lang) {
484
491
  // fetch target language refs
485
492
  const { pageIdMappings, collectionIdMappings } = await Strapi_1.Strapi.translateReferences({
486
493
  client,
487
- targetLanguage,
494
+ targetLanguage: args.language,
488
495
  pageRefs,
489
496
  collectionRefs
490
497
  });
@@ -515,9 +522,11 @@ class StrapiPageClient {
515
522
  // create duplicate page
516
523
  try {
517
524
  // create target page using content of source
518
- const { data: dupData } = await pageCollection.create(Object.assign({ groupId: targetPage === 'current'
525
+ const { data: dupData } = await pageCollection.create(Object.assign({ groupId: args.operation === 'extend'
519
526
  ? sourcePage.groupId
520
- : funnel_core_1.Value.uuid(), type: sourcePage.type, brand: sourcePage.brand, region: sourcePage.region, lang: targetLanguage, slug: targetSlug !== null && targetSlug !== void 0 ? targetSlug : sourcePage.slug, name: sourcePage.name, tags: sourcePage.tags, url: sourcePage.url, state: 'draft', errors: sourcePage.errors, assetRefs: { set: assetRefs.map(ref => ref.documentId) }, pageRefs: { set: targetPageRefIds }, collectionRefs: { set: targetCollectionRefIds } }, targetBody), { status: 'draft', populate: serialization_1.PageData.Populate.full });
527
+ : funnel_core_1.Value.uuid(), type: sourcePage.type, brand: sourcePage.brand, region: sourcePage.region, lang: args.language, slug: args.operation === 'extend'
528
+ ? sourcePage.slug
529
+ : args.slug, name: sourcePage.name, tags: sourcePage.tags, url: sourcePage.url, state: 'draft', errors: sourcePage.errors, assetRefs: { set: assetRefs.map(ref => ref.documentId) }, pageRefs: { set: targetPageRefIds }, collectionRefs: { set: targetCollectionRefIds } }, targetBody), { status: 'draft', populate: serialization_1.PageData.Populate.full });
521
530
  const dupPageData = dupData;
522
531
  console.debug('[StrapiPageClient] duplicate: created duplicate page', { dupPageData });
523
532
  // touch new duplicated page to update url/name
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nascentdigital/funnel-cms",
3
- "version": "6.1.3",
3
+ "version": "6.1.6",
4
4
  "repository": {
5
5
  "url": "git+https://github.com/nascentdigital/funnel.git",
6
6
  "directory": "libs/cms"
@@ -48,5 +48,5 @@
48
48
  "ts-jest": "^29",
49
49
  "type-fest": "^4"
50
50
  },
51
- "gitHead": "f22371fb54de07cd115522c18e5c2c51ba72aa49"
51
+ "gitHead": "63e7e3fa90a0d490576311a388e8a0d99bd4b5e4"
52
52
  }