@stackbit/cms-contentful 1.0.6-staging.1 → 1.0.6-staging.2

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.
@@ -1,8 +1,8 @@
1
1
  import _ from 'lodash';
2
2
  import { omitByNil } from '@stackbit/utils';
3
- import { Field, FieldCrossReferenceModel, Model } from '@stackbit/types';
4
- import { FieldType } from 'contentful-management';
5
- import { ContentFields, ContentTypeProps, Control, EditorInterfaceProps } from 'contentful-management';
3
+ import * as StackbitTypes from '@stackbit/types';
4
+ import type { Field, FieldSpecificProps, RequiredBy } from '@stackbit/types';
5
+ import type { FieldType, ContentFields, ContentTypeProps, Control, EditorInterfaceProps, ContentTypeFieldValidation } from 'contentful-management';
6
6
 
7
7
  import {
8
8
  CONTENTFUL_NODE_TYPES_MAP,
@@ -13,6 +13,13 @@ import {
13
13
  } from './contentful-consts';
14
14
 
15
15
  type ExtendedFieldType = FieldType | ResourceLink | ResourceLinkArray;
16
+ type ContentfulField = ContentFields & ExtendedFieldType;
17
+
18
+ declare module 'contentful-management' {
19
+ interface ContentTypeFieldValidation {
20
+ message?: string;
21
+ }
22
+ }
16
23
 
17
24
  interface ResourceLink {
18
25
  type: 'ResourceLink';
@@ -42,7 +49,7 @@ export interface ConvertSchemaOptions {
42
49
  }
43
50
 
44
51
  export function convertSchema({ contentTypes, editorInterfaces, defaultLocaleCode, cloudinaryImagesAsList, bynderImagesAsList }: ConvertSchemaOptions): {
45
- models: Model[];
52
+ models: StackbitTypes.Model[];
46
53
  } {
47
54
  const editorInterfaceByContentTypeId = _.chain(editorInterfaces)
48
55
  .filter({
@@ -81,7 +88,7 @@ interface MapModelOptions {
81
88
  bynderImagesAsList: boolean;
82
89
  }
83
90
 
84
- function mapModel(options: MapModelOptions): Model {
91
+ function mapModel(options: MapModelOptions): StackbitTypes.Model {
85
92
  const contentType = options.contentType;
86
93
  const contentTypeId = contentType.sys.id;
87
94
  const mappedFields = mapFields(options);
@@ -110,7 +117,7 @@ interface MapFieldsOptions {
110
117
 
111
118
  function mapFields({ contentType, editorInterfaceByContentTypeId, defaultLocaleCode, cloudinaryImagesAsList, bynderImagesAsList }: MapFieldsOptions) {
112
119
  const contentTypeId = contentType.sys.id;
113
- const fields = contentType.fields;
120
+ const fields = contentType.fields as ContentfulField[];
114
121
  const editorInterfaceControls = editorInterfaceByContentTypeId[contentTypeId]?.controls;
115
122
  const editorInterfaceControlsByFieldId = _.keyBy(editorInterfaceControls, 'fieldId');
116
123
 
@@ -126,7 +133,7 @@ function mapFields({ contentType, editorInterfaceByContentTypeId, defaultLocaleC
126
133
  }
127
134
 
128
135
  interface MapFieldOptions {
129
- field: ContentFields;
136
+ field: ContentfulField;
130
137
  editorInterfaceControlsByFieldId: Record<string, Control>;
131
138
  defaultLocaleCode: string;
132
139
  cloudinaryImagesAsList: boolean;
@@ -139,10 +146,11 @@ function mapField({ field, editorInterfaceControlsByFieldId, defaultLocaleCode,
139
146
  const isRequired = field.required;
140
147
  const isReadonly = _.get(editorInterfaceControl, 'settings.readOnly');
141
148
  const isHidden = field.disabled ?? isReadonly;
149
+ const validations = field.validations;
142
150
  const localized = field.localized;
143
151
  const defaultValue = getDefaultValue(field, defaultLocaleCode, editorInterfaceControl);
144
152
  const defaultAsConst = isRequired && isReadonly && !_.isUndefined(defaultValue);
145
- const extra = convertField({ field, editorInterfaceControl, cloudinaryImagesAsList, bynderImagesAsList });
153
+ const fieldSpecificProps = convertField({ field, editorInterfaceControl, cloudinaryImagesAsList, bynderImagesAsList });
146
154
 
147
155
  return _.assign(
148
156
  {
@@ -157,13 +165,14 @@ function mapField({ field, editorInterfaceControlsByFieldId, defaultLocaleCode,
157
165
  const: defaultAsConst ? defaultValue : undefined,
158
166
  readOnly: isReadonly,
159
167
  hidden: isHidden,
168
+ validations: convertValidations(validations),
160
169
  localized: localized
161
170
  }),
162
- extra
171
+ fieldSpecificProps
163
172
  );
164
173
  }
165
174
 
166
- function getDefaultValue(field: ContentFields, defaultLocaleCode: string, editorInterfaceControl?: Control) {
175
+ function getDefaultValue(field: ContentfulField, defaultLocaleCode: string, editorInterfaceControl?: Control) {
167
176
  // Contentful defaultValue is an object with locale keys and default values per locale
168
177
  const defaultValue = field.defaultValue;
169
178
  if (typeof defaultValue !== 'undefined') {
@@ -180,14 +189,14 @@ function getDefaultValue(field: ContentFields, defaultLocaleCode: string, editor
180
189
  }
181
190
 
182
191
  interface ConvertFieldOptions {
183
- field: ContentFields;
192
+ field: ContentfulField;
184
193
  editorInterfaceControl?: Control;
185
194
  cloudinaryImagesAsList: boolean;
186
195
  bynderImagesAsList: boolean;
187
196
  }
188
197
 
189
- function convertField({ field, editorInterfaceControl, cloudinaryImagesAsList, bynderImagesAsList }: ConvertFieldOptions) {
190
- const typedField = field as ContentFields & ExtendedFieldType;
198
+ function convertField({ field, editorInterfaceControl, cloudinaryImagesAsList, bynderImagesAsList }: ConvertFieldOptions): FieldSpecificProps {
199
+ const typedField = field;
191
200
  const type = typedField.type;
192
201
  // return fieldConverterMap[typedField.type](typedField, editorInterfaceControl, cloudinaryImagesAsList);
193
202
  switch (typedField.type) {
@@ -221,16 +230,19 @@ function convertField({ field, editorInterfaceControl, cloudinaryImagesAsList, b
221
230
  }
222
231
 
223
232
  type FieldConverterMap = {
224
- [P in ExtendedFieldType as P['type']]: (
225
- field: ContentFields & P,
233
+ [CtflField in ContentfulField as CtflField['type']]: (
234
+ field: CtflField,
226
235
  editorInterfaceControl?: Control,
227
236
  cloudinaryImagesAsList?: boolean,
228
237
  bynderImagesAsList?: boolean
229
- ) => Omit<Field, 'name'>;
238
+ ) => FieldSpecificProps;
230
239
  };
231
240
 
232
241
  const fieldConverterMap: FieldConverterMap = {
233
- Symbol: function (field, editorInterfaceControl) {
242
+ Symbol: function (
243
+ field,
244
+ editorInterfaceControl
245
+ ): StackbitTypes.FieldStringProps | StackbitTypes.FieldSlugProps | StackbitTypes.FieldUrlProps | StackbitTypes.FieldEnumProps {
234
246
  const options = getOptions(field);
235
247
  if (options) {
236
248
  return { type: 'enum', ...options };
@@ -245,55 +257,62 @@ const fieldConverterMap: FieldConverterMap = {
245
257
  }
246
258
  }
247
259
  },
248
- Text: function (field, editorInterfaceControl) {
260
+ Text: function (
261
+ field,
262
+ editorInterfaceControl
263
+ ): StackbitTypes.FieldStringProps | StackbitTypes.FieldTextProps | StackbitTypes.FieldMarkdownProps | StackbitTypes.FieldEnumProps {
249
264
  const widgetId = _.get(editorInterfaceControl, 'widgetId');
250
- if (widgetId === 'singleLine') {
251
- return { type: 'string' };
252
- } else if (widgetId === 'multipleLine') {
253
- // can also be {type: 'html'} but we have no way of knowing that
254
- return { type: 'text' };
255
- } else if (widgetId === 'markdown') {
256
- return { type: 'markdown' };
257
- } else if (widgetId === 'dropdown' || widgetId === 'radio') {
265
+ const options = getOptions(field);
266
+ if (options) {
258
267
  return {
259
268
  type: 'enum',
260
- ...getOptions(field)
269
+ ...(widgetId === 'radio' ? { controlType: 'button-group' } : null),
270
+ ...options
261
271
  };
272
+ } else if (widgetId === 'singleLine') {
273
+ return { type: 'string' };
262
274
  } else {
263
- return { type: 'text' };
275
+ if (widgetId === 'multipleLine') {
276
+ // can also be {type: 'html'} but we have no way of knowing that
277
+ return { type: 'text' };
278
+ } else if (widgetId === 'markdown') {
279
+ return { type: 'markdown' };
280
+ } else {
281
+ return { type: 'text' };
282
+ }
264
283
  }
265
284
  },
266
- Integer: function (field, editorInterfaceControl) {
267
- const widgetId = _.get(editorInterfaceControl, 'widgetId');
268
- if (widgetId === 'dropdown' || widgetId === 'radio') {
285
+ Integer: function (field, editorInterfaceControl): StackbitTypes.FieldNumberProps | StackbitTypes.FieldEnumProps {
286
+ const options = getOptions(field);
287
+ if (options) {
269
288
  return {
270
289
  type: 'enum',
271
- ...getOptions(field)
290
+ ...options
272
291
  };
273
292
  } else {
274
293
  return {
275
294
  type: 'number',
276
295
  subtype: 'int',
277
- ...getMinMax(field)
296
+ ...getMinMax(field) // backward compatibility
278
297
  };
279
298
  }
280
299
  },
281
- Number: function (field, editorInterfaceControl) {
282
- const widgetId = _.get(editorInterfaceControl, 'widgetId');
283
- if (widgetId === 'dropdown' || widgetId === 'radio') {
300
+ Number: function (field, editorInterfaceControl): StackbitTypes.FieldNumberProps | StackbitTypes.FieldEnumProps {
301
+ const options = getOptions(field);
302
+ if (options) {
284
303
  return {
285
304
  type: 'enum',
286
- ...getOptions(field)
305
+ ...options
287
306
  };
288
307
  } else {
289
308
  return {
290
309
  type: 'number',
291
310
  subtype: 'float',
292
- ...getMinMax(field)
311
+ ...getMinMax(field) // backward compatibility
293
312
  };
294
313
  }
295
314
  },
296
- Date: function (field, editorInterfaceControl) {
315
+ Date: function (field, editorInterfaceControl): StackbitTypes.FieldDateProps | StackbitTypes.FieldDatetimeProps {
297
316
  const format = _.get(editorInterfaceControl, 'settings.format');
298
317
  const type = format === 'dateonly' ? 'date' : 'datetime';
299
318
  return { type: type };
@@ -301,10 +320,10 @@ const fieldConverterMap: FieldConverterMap = {
301
320
  Location: function () {
302
321
  return { type: 'json' };
303
322
  },
304
- Boolean: function () {
323
+ Boolean: function (): StackbitTypes.FieldBooleanProps {
305
324
  return { type: 'boolean' };
306
325
  },
307
- Link: function (field) {
326
+ Link: function (field): StackbitTypes.FieldImageProps | StackbitTypes.FieldReferenceProps {
308
327
  const linkType = field.linkType;
309
328
  if (linkType === 'Asset') {
310
329
  return { type: 'image' };
@@ -320,17 +339,17 @@ const fieldConverterMap: FieldConverterMap = {
320
339
  throw new Error(`not supported linkType: ${linkType}`);
321
340
  }
322
341
  },
323
- ResourceLink: function (field) {
342
+ ResourceLink: function (field): StackbitTypes.FieldCrossReferenceProps {
324
343
  return {
325
344
  type: 'cross-reference',
326
- models: field.allowedResources.reduce((models: FieldCrossReferenceModel[], allowedResource) => {
345
+ models: field.allowedResources.reduce((models: StackbitTypes.FieldCrossReferenceModel[], allowedResource) => {
327
346
  const match = allowedResource.source.match(/crn:contentful:::content:spaces\/(.+)$/);
328
347
  if (!match) {
329
348
  return models;
330
349
  }
331
350
  const spaceId = match[1]!;
332
351
  return models.concat(
333
- allowedResource.contentTypes.reduce((models: FieldCrossReferenceModel[], contentType) => {
352
+ allowedResource.contentTypes.reduce((models: StackbitTypes.FieldCrossReferenceModel[], contentType) => {
334
353
  return models.concat({
335
354
  modelName: contentType,
336
355
  srcType: 'contentful',
@@ -341,9 +360,13 @@ const fieldConverterMap: FieldConverterMap = {
341
360
  }, [])
342
361
  };
343
362
  },
344
- Array: function (field, editorInterfaceControl) {
363
+ Array: function (field, editorInterfaceControl): StackbitTypes.FieldListProps {
345
364
  const widgetId = _.get(editorInterfaceControl, 'widgetId');
346
- const items = getListItems(field);
365
+ const items = getListItems(field) as StackbitTypes.FieldListItems;
366
+ const validations = convertValidations(field.items.validations);
367
+ if (validations) {
368
+ items.validations = validations;
369
+ }
347
370
  if (widgetId === 'checkbox' && items.type === 'enum') {
348
371
  return {
349
372
  type: 'list',
@@ -356,7 +379,7 @@ const fieldConverterMap: FieldConverterMap = {
356
379
  items
357
380
  };
358
381
  },
359
- Object: function (field, editorInterfaceControl, cloudinaryImagesAsList, bynderImagesAsList) {
382
+ Object: function (field, editorInterfaceControl, cloudinaryImagesAsList, bynderImagesAsList): StackbitTypes.FieldSpecificProps {
360
383
  const widgetId = _.get(editorInterfaceControl, 'widgetId');
361
384
  if (widgetId === CONTENTFUL_CLOUDINARY_APP) {
362
385
  if (cloudinaryImagesAsList) {
@@ -411,7 +434,7 @@ const fieldConverterMap: FieldConverterMap = {
411
434
  }
412
435
  };
413
436
 
414
- function getMinMax(field: ContentFields) {
437
+ function getMinMax(field: ContentfulField) {
415
438
  const validations = field.validations;
416
439
  const rangeValidation = _.find(validations, 'range');
417
440
  return rangeValidation
@@ -422,24 +445,24 @@ function getMinMax(field: ContentFields) {
422
445
  : null;
423
446
  }
424
447
 
425
- function getOptions(field: ContentFields) {
448
+ function getOptions(field: ContentfulField) {
426
449
  const validations = field.validations;
427
450
  const inValidation = _.find(validations, 'in');
428
- return inValidation ? { options: inValidation.in! } : null;
451
+ return inValidation?.in && inValidation.in.length > 0 ? { options: inValidation.in } : null;
429
452
  }
430
453
 
431
- function getListItems(field: ContentFields & Extract<ExtendedFieldType, { type: 'Array' }>) {
454
+ function getListItems(field: ContentfulField & Extract<ExtendedFieldType, { type: 'Array' }>) {
432
455
  const items = field.items;
433
456
  const itemType = items.type;
434
457
  if (items.type === 'Symbol') {
435
- return fieldConverterMap.Symbol(items as ContentFields & Extract<ExtendedFieldType, { type: 'Symbol' }>);
458
+ return fieldConverterMap.Symbol(items as ContentfulField & Extract<ExtendedFieldType, { type: 'Symbol' }>);
436
459
  } else if (items.type === 'Link') {
437
- return fieldConverterMap.Link(items as ContentFields & Extract<ExtendedFieldType, { type: 'Link' }>);
460
+ return fieldConverterMap.Link(items as ContentfulField & Extract<ExtendedFieldType, { type: 'Link' }>);
438
461
  } else if (items.type === 'ResourceLink') {
439
462
  return fieldConverterMap.ResourceLink({
440
463
  ...items,
441
464
  allowedResources: (field as ResourceLinkArray).allowedResources
442
- } as ContentFields & Extract<ExtendedFieldType, { type: 'ResourceLink' }>);
465
+ } as ContentfulField & Extract<ExtendedFieldType, { type: 'ResourceLink' }>);
443
466
  } else {
444
467
  throw new Error(`not supported list items.type: ${itemType}, fieldId: ${field.id}`);
445
468
  }
@@ -465,3 +488,141 @@ function resolveLabelFieldForModel(contentType: ContentTypeProps, modelLabelFiel
465
488
  }
466
489
  return labelField;
467
490
  }
491
+
492
+ type UnionToIntersection<T> = (T extends any ? (x: T) => any : never) extends (x: infer R) => any ? R : never;
493
+ type StackbitFieldValidations =
494
+ | StackbitTypes.FieldValidationsUnique
495
+ | StackbitTypes.FieldValidationsRegExp
496
+ | StackbitTypes.FieldValidationsStringLength
497
+ | StackbitTypes.FieldValidationsListLength
498
+ | StackbitTypes.FieldValidationsDateRange
499
+ | StackbitTypes.FieldValidationsNumberRange
500
+ | StackbitTypes.FieldValidationsFile
501
+ | StackbitTypes.FieldValidationsImage;
502
+ type ValidationsIntersection = UnionToIntersection<StackbitFieldValidations>;
503
+
504
+ function convertValidations(validations: ContentTypeFieldValidation[] | undefined): StackbitTypes.Field['validations'] {
505
+ if (!validations) {
506
+ return undefined;
507
+ }
508
+ const stackbitValidations: RequiredBy<ValidationsIntersection, 'errors'> = {
509
+ errors: {}
510
+ };
511
+ for (const validation of validations) {
512
+ if (validation.unique) {
513
+ stackbitValidations.unique = true;
514
+ } else if (validation.size) {
515
+ const size = validation.size;
516
+ const message = validation.message;
517
+ if (!_.isNil(size.min)) {
518
+ (stackbitValidations as StackbitTypes.FieldValidationsStringLength).min = size.min;
519
+ stackbitValidations.errors.min = message;
520
+ }
521
+ if (!_.isNil(size.max)) {
522
+ (stackbitValidations as StackbitTypes.FieldValidationsStringLength).max = size.max;
523
+ stackbitValidations.errors.max = message;
524
+ }
525
+ } else if (validation.range) {
526
+ const range = validation.range;
527
+ const message = validation.message;
528
+ if (!_.isNil(range.min)) {
529
+ (stackbitValidations as StackbitTypes.FieldValidationsNumberRange).min = range.min;
530
+ stackbitValidations.errors.min = message;
531
+ }
532
+ if (!_.isNil(range.max)) {
533
+ (stackbitValidations as StackbitTypes.FieldValidationsNumberRange).max = range.max;
534
+ stackbitValidations.errors.max = message;
535
+ }
536
+ } else if (validation.dateRange) {
537
+ const dateRange = validation.dateRange;
538
+ const message = validation.message;
539
+ if (!_.isNil(dateRange.min)) {
540
+ (stackbitValidations as StackbitTypes.FieldValidationsDateRange).min = dateRange.min;
541
+ stackbitValidations.errors.min = message;
542
+ }
543
+ if (!_.isNil(dateRange.max)) {
544
+ (stackbitValidations as StackbitTypes.FieldValidationsDateRange).max = dateRange.max;
545
+ stackbitValidations.errors.max = message;
546
+ }
547
+ } else if (validation.regexp) {
548
+ stackbitValidations.regexp = validation.regexp.pattern;
549
+ stackbitValidations.errors.regexp = validation.message;
550
+ } else if (validation.prohibitRegexp) {
551
+ stackbitValidations.regexpNot = validation.prohibitRegexp.pattern;
552
+ stackbitValidations.errors.regexpNot = validation.message;
553
+ } else if (validation.assetImageDimensions) {
554
+ const message = validation.message;
555
+ const { width, height } = validation.assetImageDimensions;
556
+ stackbitValidations.minWidth = width?.min;
557
+ stackbitValidations.maxWidth = width?.max;
558
+ stackbitValidations.minHeight = height?.min;
559
+ stackbitValidations.maxHeight = height?.max;
560
+ stackbitValidations.errors.minWidth = _.isNil(width?.min) ? undefined : message;
561
+ stackbitValidations.errors.maxWidth = _.isNil(width?.max) ? undefined : message;
562
+ stackbitValidations.errors.minHeight = _.isNil(height?.min) ? undefined : message;
563
+ stackbitValidations.errors.maxHeight = _.isNil(height?.max) ? undefined : message;
564
+ } else if (validation.assetFileSize) {
565
+ const message = validation.message;
566
+ const { min, max } = validation.assetFileSize;
567
+ stackbitValidations.fileMinSize = min;
568
+ stackbitValidations.fileMaxSize = max;
569
+ stackbitValidations.errors.fileMinSize = _.isNil(min) ? undefined : message;
570
+ stackbitValidations.errors.fileMaxSize = _.isNil(max) ? undefined : message;
571
+ } else if (validation.linkMimetypeGroup) {
572
+ stackbitValidations.fileTypeGroups = convertMimeTypesToFileTypeGroups(validation.linkMimetypeGroup);
573
+ stackbitValidations.errors.fileTypeGroups = validation.message;
574
+ }
575
+ }
576
+ return undefinedIfEmpty(
577
+ omitByNil({
578
+ ...stackbitValidations,
579
+ errors: undefinedIfEmpty(omitByNil(stackbitValidations.errors))
580
+ })
581
+ );
582
+ }
583
+
584
+ function convertMimeTypesToFileTypeGroups(mimeTypes?: string[]): StackbitTypes.FieldValidationsFileTypesGroup[] | undefined {
585
+ // contentful mimeTypes:
586
+ // "image", "audio", "video", "plaintext", "markup", "richtext", "code",
587
+ // "pdfdocument", "presentation", "spreadsheet", "attachment", "archive"
588
+ const map: Record<string, StackbitTypes.FieldValidationsFileTypesGroup> = {
589
+ image: 'image',
590
+ video: 'video',
591
+ audio: 'audio',
592
+ plaintext: 'text',
593
+ markup: 'markup',
594
+ code: 'code',
595
+ pdfdocument: 'document',
596
+ presentation: 'presentation',
597
+ spreadsheet: 'spreadsheet',
598
+ archive: 'archive'
599
+ };
600
+ const fileTypeGroups = (mimeTypes ?? []).reduce((accum: StackbitTypes.FieldValidationsFileTypesGroup[], mimeType) => {
601
+ const fileTypeGroup = map[mimeType];
602
+ if (fileTypeGroup) {
603
+ accum.push(fileTypeGroup);
604
+ }
605
+ return accum;
606
+ }, []);
607
+ return undefinedIfEmpty(fileTypeGroups);
608
+ }
609
+
610
+ function wrapWithMessageIfNeeded<V extends string | number | string[]>(
611
+ value: V | undefined,
612
+ message: string | undefined
613
+ ): V | { value: V; message: string } | undefined {
614
+ if (typeof value === 'undefined' || value === null) {
615
+ return undefined;
616
+ }
617
+ if (message) {
618
+ return {
619
+ value,
620
+ message
621
+ };
622
+ }
623
+ return value;
624
+ }
625
+
626
+ function undefinedIfEmpty<T extends Record<string, unknown> | unknown[]>(value: T): T | undefined {
627
+ return _.isEmpty(value) ? undefined : value;
628
+ }