Skip to content

Commit 8ab39bd

Browse files
author
naman-contentstack
committed
feat: add global field rule handling to content type
1 parent babff65 commit 8ab39bd

3 files changed

Lines changed: 244 additions & 9 deletions

File tree

packages/contentstack-import/src/import/modules/content-types.ts

Lines changed: 96 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { sanitizePath, log, handleAndLogError } from '@contentstack/cli-utilitie
1111
import { fsUtil, schemaTemplate, lookupExtension, lookUpTaxonomy, fileHelper } from '../../utils';
1212
import { ImportConfig, ModuleClassParams } from '../../types';
1313
import BaseClass, { ApiOptions } from './base-class';
14-
import { updateFieldRules } from '../../utils/content-type-helper';
14+
import { updateFieldRules, isGlobalFieldRule } from '../../utils/content-type-helper';
1515

1616
export default class ContentTypesImport extends BaseClass {
1717
private cTsMapperPath: string;
@@ -34,7 +34,7 @@ export default class ContentTypesImport extends BaseClass {
3434
private reqConcurrency: number;
3535
private ignoredFilesInContentTypesFolder: Map<string, string>;
3636
private titleToUIdMap: Map<string, string>;
37-
private fieldRules: Array<Record<string, unknown>>;
37+
private fieldRules: string[];
3838
private installedExtensions: Record<string, unknown>;
3939
private cTsConfig: {
4040
dirName: string;
@@ -206,13 +206,103 @@ export default class ContentTypesImport extends BaseClass {
206206
this.pendingGFs = fsUtil.readFile(this.gFsPendingPath) as any;
207207
if (!this.pendingGFs || isEmpty(this.pendingGFs)) {
208208
log.info('No pending global fields found to update.', this.importConfig.context);
209-
return;
209+
} else {
210+
await this.updatePendingGFs().catch((error) => {
211+
handleAndLogError(error, { ...this.importConfig.context });
212+
});
213+
log.success('Updated pending global fields with content type with references', this.importConfig.context);
210214
}
211-
await this.updatePendingGFs().catch((error) => {
215+
216+
// Global field rules were skipped during the content type update (see updateFieldRules) because
217+
// the embedded global field schema was not yet complete on the stack. By this point every global
218+
// field is complete — deferred ones via updatePendingGFs above, non-deferred ones already applied
219+
// in the global-fields module, and pre-existing ones already on the stack for module-only imports.
220+
// So re-apply the global field rules now. This runs UNCONDITIONALLY (outside the pending check):
221+
// non-deferred and module-only imports have no pending global fields but still need their rules.
222+
const failedGFFieldRuleCTs = await this.updateGFFieldRules().catch((error) => {
212223
handleAndLogError(error, { ...this.importConfig.context });
224+
return [] as string[];
213225
});
214-
log.success('Updated pending global fields with content type with references', this.importConfig.context);
215-
log.success('Content types have been imported successfully!', this.importConfig.context);
226+
227+
if (failedGFFieldRuleCTs.length) {
228+
// Surface the partial failure instead of claiming an unqualified success.
229+
log.error(
230+
`Content types imported, but failed to apply global field rules for: ${failedGFFieldRuleCTs.join(', ')}`,
231+
this.importConfig.context,
232+
);
233+
} else {
234+
log.success('Content types have been imported successfully!', this.importConfig.context);
235+
}
236+
}
237+
238+
/**
239+
* Applies the global field rules that were skipped during the content type update (updateFieldRules
240+
* strips rules flagged is_global_field_rule, because their paths reference an embedded global field
241+
* whose schema is not yet complete when the content type is first updated). By the time this runs,
242+
* every embedded global field is complete, so the rules validate. Runs for deferred, non-deferred
243+
* and module-only imports alike.
244+
* @returns the uids of content types whose global field rule update failed.
245+
*/
246+
async updateGFFieldRules(): Promise<string[]> {
247+
const failedCTs: string[] = [];
248+
249+
if (!this.fieldRules?.length) {
250+
log.debug('No content types with field rules; skipping global field rules update.', this.importConfig.context);
251+
return failedCTs;
252+
}
253+
254+
const cTs = (fsUtil.readFile(path.join(this.cTsFolderPath, 'schema.json')) || []) as Record<string, any>[];
255+
256+
for (const cTUid of this.fieldRules) {
257+
const contentType: any = find(cTs, { uid: cTUid });
258+
if (!contentType?.field_rules?.length) {
259+
continue;
260+
}
261+
262+
// Only content types carrying a global field rule need re-applying; the rest were fully
263+
// updated (schema + their own rules) in updateCTs.
264+
const hasGFFieldRule = contentType.field_rules.some((rule: any) => isGlobalFieldRule(rule));
265+
if (!hasGFFieldRule) {
266+
continue;
267+
}
268+
269+
log.info(`Re-applying global field rules for content type: ${contentType.uid}`, this.importConfig.context);
270+
271+
const contentTypeResponse: any = await this.stack
272+
.contentType(contentType.uid)
273+
.fetch()
274+
.catch((error: unknown) => {
275+
handleAndLogError(error, { ...this.importConfig.context, uid: contentType.uid });
276+
});
277+
if (!contentTypeResponse) {
278+
log.debug(
279+
`Skipping global field rules update for ${contentType.uid} - content type not found`,
280+
this.importConfig.context,
281+
);
282+
failedCTs.push(contentType.uid);
283+
continue;
284+
}
285+
286+
// Send the global field rules together with the content type's own non-reference rules,
287+
// NOT the raw on-disk set. updateFieldRules(..., { keepGlobalFieldRules: true }) keeps the
288+
// now-valid global field rules while still dropping reference-condition rules, which are
289+
// owned by the entries module (it remaps their entry-uid values post entry-import). Sending
290+
// the raw set here would resurrect those reference rules prematurely with stale uids.
291+
// NOTE: field_rules is a whole-array PUT — if any single rule is invalid the API rejects the
292+
// entire array, so a malformed rule would take the global field rules down with it.
293+
contentTypeResponse.field_rules = updateFieldRules(contentType, { keepGlobalFieldRules: true });
294+
await contentTypeResponse
295+
.update()
296+
.then(() => {
297+
log.success(`Re-applied global field rules for content type: ${contentType.uid}`, this.importConfig.context);
298+
})
299+
.catch((error: Error) => {
300+
handleAndLogError(error, { ...this.importConfig.context, uid: contentType.uid });
301+
failedCTs.push(contentType.uid);
302+
});
303+
}
304+
305+
return failedCTs;
216306
}
217307

218308
async seedCTs(): Promise<any> {

packages/contentstack-import/src/utils/content-type-helper.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,38 @@ export const removeReferenceFields = async function (
200200
log.debug('Reference field removal process completed');
201201
};
202202

203-
export const updateFieldRules = function (contentType: any) {
203+
/**
204+
* A global field rule is a field rule whose conditions/actions reference fields of an embedded
205+
* global field via dotted paths (e.g. `global_field.reference`). Such rules cannot be validated
206+
* while the embedded global field schema is still incomplete on the stack, so they are skipped
207+
* during the content type update and re-applied once all global fields are fully created.
208+
* This predicate is the single source of truth for identifying them.
209+
*/
210+
export const isGlobalFieldRule = (rule: any): boolean => Boolean(rule?.is_global_field_rule);
211+
212+
/**
213+
* Returns the content type's field rules filtered to those safe to apply at the current import
214+
* stage. Two kinds of rules are dropped:
215+
*
216+
* 1. Reference-condition rules — a condition whose operand is a `reference`-type field. Their
217+
* `value` holds entry uids that do not exist until entries are imported, so they are always
218+
* deferred to the entries module (entries.updateFieldRules), which re-applies them with the
219+
* entry-uid mapping. These are dropped in every mode.
220+
* 2. Global field rules (`is_global_field_rule`) — their operand/target are dotted paths into an
221+
* embedded global field (e.g. `global_field.reference`) that cannot be validated until that
222+
* global field's schema is complete on the stack. Dropped during the content type update; once
223+
* the global fields are complete they are re-applied via `keepGlobalFieldRules: true`.
224+
*
225+
* @param contentType the content type whose `field_rules` to filter
226+
* @param options.keepGlobalFieldRules when true, global field rules are retained (reference-condition
227+
* rules are still dropped). Used after global fields are complete to apply the GF rules without
228+
* prematurely resurrecting the reference-condition rules that entries owns.
229+
*/
230+
export const updateFieldRules = function (
231+
contentType: any,
232+
options: { keepGlobalFieldRules?: boolean } = {},
233+
) {
234+
const { keepGlobalFieldRules = false } = options;
204235
log.debug(`Starting field rules update for content type: ${contentType.uid}`);
205236

206237
const fieldDataTypeMap: { [key: string]: string } = {};
@@ -217,6 +248,18 @@ export const updateFieldRules = function (contentType: any) {
217248

218249
// Looping backwards as we need to delete elements as we move.
219250
for (let i = len - 1; i >= 0; i--) {
251+
// Global field rules reference embedded global field sub-fields via dotted paths
252+
// (e.g. `global_field.reference`), which cannot be validated while the embedded global field
253+
// schema is still incomplete and would fail the whole content type update with
254+
// "Invalid field UID". Dropped during the content type update; re-applied later (see
255+
// updateGFFieldRules) with keepGlobalFieldRules once all global fields are complete.
256+
if (!keepGlobalFieldRules && isGlobalFieldRule(fieldRules[i])) {
257+
log.debug(`Skipping global field rule from content type update`);
258+
fieldRules.splice(i, 1);
259+
removedRules++;
260+
continue;
261+
}
262+
220263
const conditions = fieldRules[i].conditions;
221264
let isReference = false;
222265

@@ -235,6 +278,6 @@ export const updateFieldRules = function (contentType: any) {
235278
}
236279
}
237280

238-
log.debug(`Field rules update completed. Removed ${removedRules} rules with reference conditions`);
281+
log.debug(`Field rules update completed. Removed ${removedRules} rules`);
239282
return fieldRules;
240283
};

packages/contentstack-import/test/unit/utils/content-type-helper.test.ts

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { expect } from 'chai';
22
import sinon from 'sinon';
3-
import { schemaTemplate, suppressSchemaReference, removeReferenceFields, updateFieldRules } from '../../../src/utils/content-type-helper';
3+
import { schemaTemplate, suppressSchemaReference, removeReferenceFields, updateFieldRules, isGlobalFieldRule } from '../../../src/utils/content-type-helper';
44

55
describe('Content Type Helper', () => {
66
let sandbox: sinon.SinonSandbox;
@@ -752,5 +752,107 @@ describe('Content Type Helper', () => {
752752
expect(result).to.be.an('array');
753753
expect(result).to.have.length(1); // Rule should remain as field type is unknown
754754
});
755+
756+
it('should drop global field rules by default', () => {
757+
const contentType = {
758+
uid: 'test_fvr',
759+
schema: [
760+
{ uid: 'title', data_type: 'text' },
761+
{ uid: 'global_field', data_type: 'global_field' }
762+
],
763+
field_rules: [
764+
{ conditions: [{ operand_field: 'title' }] },
765+
{
766+
is_global_field_rule: true,
767+
conditions: [{ operand_field: 'global_field.multi_line' }],
768+
actions: [{ action: 'show', target_field: 'global_field.reference' }]
769+
}
770+
]
771+
};
772+
773+
const result = updateFieldRules(contentType);
774+
775+
expect(result).to.have.length(1); // global field rule dropped
776+
expect(result[0].conditions[0].operand_field).to.equal('title');
777+
expect(result.some((r: any) => r.is_global_field_rule)).to.be.false;
778+
});
779+
780+
it('should drop BOTH global field rules and reference-condition rules by default', () => {
781+
const contentType = {
782+
uid: 'test_fvr',
783+
schema: [
784+
{ uid: 'title', data_type: 'text' },
785+
{ uid: 'reference_field', data_type: 'reference' },
786+
{ uid: 'global_field', data_type: 'global_field' }
787+
],
788+
field_rules: [
789+
{ conditions: [{ operand_field: 'title' }] }, // keep
790+
{ conditions: [{ operand_field: 'reference_field' }] }, // drop (reference)
791+
{ is_global_field_rule: true, conditions: [{ operand_field: 'global_field.multi_line' }] } // drop (GF)
792+
]
793+
};
794+
795+
const result = updateFieldRules(contentType);
796+
797+
expect(result).to.have.length(1);
798+
expect(result[0].conditions[0].operand_field).to.equal('title');
799+
});
800+
801+
it('should KEEP global field rules but still DROP reference-condition rules with keepGlobalFieldRules (P0 regression)', () => {
802+
const contentType = {
803+
uid: 'test_fvr',
804+
schema: [
805+
{ uid: 'title', data_type: 'text' },
806+
{ uid: 'reference_field', data_type: 'reference' },
807+
{ uid: 'global_field', data_type: 'global_field' }
808+
],
809+
field_rules: [
810+
{ conditions: [{ operand_field: 'title' }] }, // keep
811+
{ conditions: [{ operand_field: 'reference_field' }] }, // still dropped
812+
{ is_global_field_rule: true, conditions: [{ operand_field: 'global_field.multi_line' }] } // kept now
813+
]
814+
};
815+
816+
const result = updateFieldRules(contentType, { keepGlobalFieldRules: true });
817+
818+
expect(result).to.have.length(2);
819+
// the global field rule survives
820+
expect(result.some((r: any) => r.is_global_field_rule)).to.be.true;
821+
// the reference-condition rule is NOT resurrected (owned by the entries stage)
822+
expect(result.some((r: any) => r.conditions[0].operand_field === 'reference_field')).to.be.false;
823+
// the plain rule survives
824+
expect(result.some((r: any) => r.conditions[0].operand_field === 'title')).to.be.true;
825+
});
826+
827+
it('should not mutate the original field_rules array', () => {
828+
const contentType = {
829+
uid: 'test_fvr',
830+
schema: [{ uid: 'global_field', data_type: 'global_field' }],
831+
field_rules: [
832+
{ is_global_field_rule: true, conditions: [{ operand_field: 'global_field.x' }] }
833+
]
834+
};
835+
836+
updateFieldRules(contentType);
837+
838+
expect(contentType.field_rules).to.have.length(1); // source untouched
839+
});
840+
});
841+
842+
describe('isGlobalFieldRule', () => {
843+
it('should be a function', () => {
844+
expect(isGlobalFieldRule).to.be.a('function');
845+
});
846+
847+
it('should return true when is_global_field_rule is true', () => {
848+
expect(isGlobalFieldRule({ is_global_field_rule: true })).to.be.true;
849+
});
850+
851+
it('should return false when the flag is missing, false, or the rule is nullish', () => {
852+
expect(isGlobalFieldRule({ conditions: [] })).to.be.false;
853+
expect(isGlobalFieldRule({ is_global_field_rule: false })).to.be.false;
854+
expect(isGlobalFieldRule(null)).to.be.false;
855+
expect(isGlobalFieldRule(undefined)).to.be.false;
856+
});
755857
});
756858
});

0 commit comments

Comments
 (0)