From 0f5d4c313d42bb5d0641f0b355f4c3df6d078aeb Mon Sep 17 00:00:00 2001 From: naman-contentstack Date: Mon, 15 Jun 2026 15:31:00 +0530 Subject: [PATCH 1/2] fix: add global fields FVRs in export --- .talismanrc | 2 ++ packages/contentstack-export/src/config/index.ts | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.talismanrc b/.talismanrc index 71ea6de83..28e81a162 100644 --- a/.talismanrc +++ b/.talismanrc @@ -1,4 +1,6 @@ fileignoreconfig: - filename: pnpm-lock.yaml checksum: 07642e8dd04d580185a459e5b088d8a1bb4e91be4e04f4842bf4fe4775205bf6 + - filename: packages/contentstack-export/src/config/index.ts + checksum: 6fa4bba2174bbf33f5611098f49a02bf2fc789f59634e99be58de7e370f5fcd3 version: '1.0' diff --git a/packages/contentstack-export/src/config/index.ts b/packages/contentstack-export/src/config/index.ts index e3c4d12a2..556764767 100644 --- a/packages/contentstack-export/src/config/index.ts +++ b/packages/contentstack-export/src/config/index.ts @@ -95,12 +95,12 @@ const config: DefaultConfig = { globalfields: { dirName: 'global_fields', fileName: 'globalfields.json', - validKeys: ['title', 'uid', 'schema', 'options', 'singleton', 'description'], + validKeys: ['title', 'uid', 'field_rules', 'schema', 'options', 'singleton', 'description'], }, 'global-fields': { dirName: 'global_fields', fileName: 'globalfields.json', - validKeys: ['title', 'uid', 'schema', 'options', 'singleton', 'description'], + validKeys: ['title', 'uid', 'field_rules', 'schema', 'options', 'singleton', 'description'], }, assets: { dirName: 'assets', From 8ab39bd90b14aae6710374688984dd1805cd326b Mon Sep 17 00:00:00 2001 From: naman-contentstack Date: Tue, 30 Jun 2026 17:51:55 +0530 Subject: [PATCH 2/2] feat: add global field rule handling to content type --- .../src/import/modules/content-types.ts | 102 ++++++++++++++++- .../src/utils/content-type-helper.ts | 47 +++++++- .../unit/utils/content-type-helper.test.ts | 104 +++++++++++++++++- 3 files changed, 244 insertions(+), 9 deletions(-) diff --git a/packages/contentstack-import/src/import/modules/content-types.ts b/packages/contentstack-import/src/import/modules/content-types.ts index c905ddbaf..03c5278d2 100644 --- a/packages/contentstack-import/src/import/modules/content-types.ts +++ b/packages/contentstack-import/src/import/modules/content-types.ts @@ -11,7 +11,7 @@ import { sanitizePath, log, handleAndLogError } from '@contentstack/cli-utilitie import { fsUtil, schemaTemplate, lookupExtension, lookUpTaxonomy, fileHelper } from '../../utils'; import { ImportConfig, ModuleClassParams } from '../../types'; import BaseClass, { ApiOptions } from './base-class'; -import { updateFieldRules } from '../../utils/content-type-helper'; +import { updateFieldRules, isGlobalFieldRule } from '../../utils/content-type-helper'; export default class ContentTypesImport extends BaseClass { private cTsMapperPath: string; @@ -34,7 +34,7 @@ export default class ContentTypesImport extends BaseClass { private reqConcurrency: number; private ignoredFilesInContentTypesFolder: Map; private titleToUIdMap: Map; - private fieldRules: Array>; + private fieldRules: string[]; private installedExtensions: Record; private cTsConfig: { dirName: string; @@ -206,13 +206,103 @@ export default class ContentTypesImport extends BaseClass { this.pendingGFs = fsUtil.readFile(this.gFsPendingPath) as any; if (!this.pendingGFs || isEmpty(this.pendingGFs)) { log.info('No pending global fields found to update.', this.importConfig.context); - return; + } else { + await this.updatePendingGFs().catch((error) => { + handleAndLogError(error, { ...this.importConfig.context }); + }); + log.success('Updated pending global fields with content type with references', this.importConfig.context); } - await this.updatePendingGFs().catch((error) => { + + // Global field rules were skipped during the content type update (see updateFieldRules) because + // the embedded global field schema was not yet complete on the stack. By this point every global + // field is complete — deferred ones via updatePendingGFs above, non-deferred ones already applied + // in the global-fields module, and pre-existing ones already on the stack for module-only imports. + // So re-apply the global field rules now. This runs UNCONDITIONALLY (outside the pending check): + // non-deferred and module-only imports have no pending global fields but still need their rules. + const failedGFFieldRuleCTs = await this.updateGFFieldRules().catch((error) => { handleAndLogError(error, { ...this.importConfig.context }); + return [] as string[]; }); - log.success('Updated pending global fields with content type with references', this.importConfig.context); - log.success('Content types have been imported successfully!', this.importConfig.context); + + if (failedGFFieldRuleCTs.length) { + // Surface the partial failure instead of claiming an unqualified success. + log.error( + `Content types imported, but failed to apply global field rules for: ${failedGFFieldRuleCTs.join(', ')}`, + this.importConfig.context, + ); + } else { + log.success('Content types have been imported successfully!', this.importConfig.context); + } + } + + /** + * Applies the global field rules that were skipped during the content type update (updateFieldRules + * strips rules flagged is_global_field_rule, because their paths reference an embedded global field + * whose schema is not yet complete when the content type is first updated). By the time this runs, + * every embedded global field is complete, so the rules validate. Runs for deferred, non-deferred + * and module-only imports alike. + * @returns the uids of content types whose global field rule update failed. + */ + async updateGFFieldRules(): Promise { + const failedCTs: string[] = []; + + if (!this.fieldRules?.length) { + log.debug('No content types with field rules; skipping global field rules update.', this.importConfig.context); + return failedCTs; + } + + const cTs = (fsUtil.readFile(path.join(this.cTsFolderPath, 'schema.json')) || []) as Record[]; + + for (const cTUid of this.fieldRules) { + const contentType: any = find(cTs, { uid: cTUid }); + if (!contentType?.field_rules?.length) { + continue; + } + + // Only content types carrying a global field rule need re-applying; the rest were fully + // updated (schema + their own rules) in updateCTs. + const hasGFFieldRule = contentType.field_rules.some((rule: any) => isGlobalFieldRule(rule)); + if (!hasGFFieldRule) { + continue; + } + + log.info(`Re-applying global field rules for content type: ${contentType.uid}`, this.importConfig.context); + + const contentTypeResponse: any = await this.stack + .contentType(contentType.uid) + .fetch() + .catch((error: unknown) => { + handleAndLogError(error, { ...this.importConfig.context, uid: contentType.uid }); + }); + if (!contentTypeResponse) { + log.debug( + `Skipping global field rules update for ${contentType.uid} - content type not found`, + this.importConfig.context, + ); + failedCTs.push(contentType.uid); + continue; + } + + // Send the global field rules together with the content type's own non-reference rules, + // NOT the raw on-disk set. updateFieldRules(..., { keepGlobalFieldRules: true }) keeps the + // now-valid global field rules while still dropping reference-condition rules, which are + // owned by the entries module (it remaps their entry-uid values post entry-import). Sending + // the raw set here would resurrect those reference rules prematurely with stale uids. + // NOTE: field_rules is a whole-array PUT — if any single rule is invalid the API rejects the + // entire array, so a malformed rule would take the global field rules down with it. + contentTypeResponse.field_rules = updateFieldRules(contentType, { keepGlobalFieldRules: true }); + await contentTypeResponse + .update() + .then(() => { + log.success(`Re-applied global field rules for content type: ${contentType.uid}`, this.importConfig.context); + }) + .catch((error: Error) => { + handleAndLogError(error, { ...this.importConfig.context, uid: contentType.uid }); + failedCTs.push(contentType.uid); + }); + } + + return failedCTs; } async seedCTs(): Promise { diff --git a/packages/contentstack-import/src/utils/content-type-helper.ts b/packages/contentstack-import/src/utils/content-type-helper.ts index a1d3c3789..1599e106f 100644 --- a/packages/contentstack-import/src/utils/content-type-helper.ts +++ b/packages/contentstack-import/src/utils/content-type-helper.ts @@ -200,7 +200,38 @@ export const removeReferenceFields = async function ( log.debug('Reference field removal process completed'); }; -export const updateFieldRules = function (contentType: any) { +/** + * A global field rule is a field rule whose conditions/actions reference fields of an embedded + * global field via dotted paths (e.g. `global_field.reference`). Such rules cannot be validated + * while the embedded global field schema is still incomplete on the stack, so they are skipped + * during the content type update and re-applied once all global fields are fully created. + * This predicate is the single source of truth for identifying them. + */ +export const isGlobalFieldRule = (rule: any): boolean => Boolean(rule?.is_global_field_rule); + +/** + * Returns the content type's field rules filtered to those safe to apply at the current import + * stage. Two kinds of rules are dropped: + * + * 1. Reference-condition rules — a condition whose operand is a `reference`-type field. Their + * `value` holds entry uids that do not exist until entries are imported, so they are always + * deferred to the entries module (entries.updateFieldRules), which re-applies them with the + * entry-uid mapping. These are dropped in every mode. + * 2. Global field rules (`is_global_field_rule`) — their operand/target are dotted paths into an + * embedded global field (e.g. `global_field.reference`) that cannot be validated until that + * global field's schema is complete on the stack. Dropped during the content type update; once + * the global fields are complete they are re-applied via `keepGlobalFieldRules: true`. + * + * @param contentType the content type whose `field_rules` to filter + * @param options.keepGlobalFieldRules when true, global field rules are retained (reference-condition + * rules are still dropped). Used after global fields are complete to apply the GF rules without + * prematurely resurrecting the reference-condition rules that entries owns. + */ +export const updateFieldRules = function ( + contentType: any, + options: { keepGlobalFieldRules?: boolean } = {}, +) { + const { keepGlobalFieldRules = false } = options; log.debug(`Starting field rules update for content type: ${contentType.uid}`); const fieldDataTypeMap: { [key: string]: string } = {}; @@ -217,6 +248,18 @@ export const updateFieldRules = function (contentType: any) { // Looping backwards as we need to delete elements as we move. for (let i = len - 1; i >= 0; i--) { + // Global field rules reference embedded global field sub-fields via dotted paths + // (e.g. `global_field.reference`), which cannot be validated while the embedded global field + // schema is still incomplete and would fail the whole content type update with + // "Invalid field UID". Dropped during the content type update; re-applied later (see + // updateGFFieldRules) with keepGlobalFieldRules once all global fields are complete. + if (!keepGlobalFieldRules && isGlobalFieldRule(fieldRules[i])) { + log.debug(`Skipping global field rule from content type update`); + fieldRules.splice(i, 1); + removedRules++; + continue; + } + const conditions = fieldRules[i].conditions; let isReference = false; @@ -235,6 +278,6 @@ export const updateFieldRules = function (contentType: any) { } } - log.debug(`Field rules update completed. Removed ${removedRules} rules with reference conditions`); + log.debug(`Field rules update completed. Removed ${removedRules} rules`); return fieldRules; }; diff --git a/packages/contentstack-import/test/unit/utils/content-type-helper.test.ts b/packages/contentstack-import/test/unit/utils/content-type-helper.test.ts index 233d607d9..7dc6cc90d 100644 --- a/packages/contentstack-import/test/unit/utils/content-type-helper.test.ts +++ b/packages/contentstack-import/test/unit/utils/content-type-helper.test.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; import sinon from 'sinon'; -import { schemaTemplate, suppressSchemaReference, removeReferenceFields, updateFieldRules } from '../../../src/utils/content-type-helper'; +import { schemaTemplate, suppressSchemaReference, removeReferenceFields, updateFieldRules, isGlobalFieldRule } from '../../../src/utils/content-type-helper'; describe('Content Type Helper', () => { let sandbox: sinon.SinonSandbox; @@ -752,5 +752,107 @@ describe('Content Type Helper', () => { expect(result).to.be.an('array'); expect(result).to.have.length(1); // Rule should remain as field type is unknown }); + + it('should drop global field rules by default', () => { + const contentType = { + uid: 'test_fvr', + schema: [ + { uid: 'title', data_type: 'text' }, + { uid: 'global_field', data_type: 'global_field' } + ], + field_rules: [ + { conditions: [{ operand_field: 'title' }] }, + { + is_global_field_rule: true, + conditions: [{ operand_field: 'global_field.multi_line' }], + actions: [{ action: 'show', target_field: 'global_field.reference' }] + } + ] + }; + + const result = updateFieldRules(contentType); + + expect(result).to.have.length(1); // global field rule dropped + expect(result[0].conditions[0].operand_field).to.equal('title'); + expect(result.some((r: any) => r.is_global_field_rule)).to.be.false; + }); + + it('should drop BOTH global field rules and reference-condition rules by default', () => { + const contentType = { + uid: 'test_fvr', + schema: [ + { uid: 'title', data_type: 'text' }, + { uid: 'reference_field', data_type: 'reference' }, + { uid: 'global_field', data_type: 'global_field' } + ], + field_rules: [ + { conditions: [{ operand_field: 'title' }] }, // keep + { conditions: [{ operand_field: 'reference_field' }] }, // drop (reference) + { is_global_field_rule: true, conditions: [{ operand_field: 'global_field.multi_line' }] } // drop (GF) + ] + }; + + const result = updateFieldRules(contentType); + + expect(result).to.have.length(1); + expect(result[0].conditions[0].operand_field).to.equal('title'); + }); + + it('should KEEP global field rules but still DROP reference-condition rules with keepGlobalFieldRules (P0 regression)', () => { + const contentType = { + uid: 'test_fvr', + schema: [ + { uid: 'title', data_type: 'text' }, + { uid: 'reference_field', data_type: 'reference' }, + { uid: 'global_field', data_type: 'global_field' } + ], + field_rules: [ + { conditions: [{ operand_field: 'title' }] }, // keep + { conditions: [{ operand_field: 'reference_field' }] }, // still dropped + { is_global_field_rule: true, conditions: [{ operand_field: 'global_field.multi_line' }] } // kept now + ] + }; + + const result = updateFieldRules(contentType, { keepGlobalFieldRules: true }); + + expect(result).to.have.length(2); + // the global field rule survives + expect(result.some((r: any) => r.is_global_field_rule)).to.be.true; + // the reference-condition rule is NOT resurrected (owned by the entries stage) + expect(result.some((r: any) => r.conditions[0].operand_field === 'reference_field')).to.be.false; + // the plain rule survives + expect(result.some((r: any) => r.conditions[0].operand_field === 'title')).to.be.true; + }); + + it('should not mutate the original field_rules array', () => { + const contentType = { + uid: 'test_fvr', + schema: [{ uid: 'global_field', data_type: 'global_field' }], + field_rules: [ + { is_global_field_rule: true, conditions: [{ operand_field: 'global_field.x' }] } + ] + }; + + updateFieldRules(contentType); + + expect(contentType.field_rules).to.have.length(1); // source untouched + }); + }); + + describe('isGlobalFieldRule', () => { + it('should be a function', () => { + expect(isGlobalFieldRule).to.be.a('function'); + }); + + it('should return true when is_global_field_rule is true', () => { + expect(isGlobalFieldRule({ is_global_field_rule: true })).to.be.true; + }); + + it('should return false when the flag is missing, false, or the rule is nullish', () => { + expect(isGlobalFieldRule({ conditions: [] })).to.be.false; + expect(isGlobalFieldRule({ is_global_field_rule: false })).to.be.false; + expect(isGlobalFieldRule(null)).to.be.false; + expect(isGlobalFieldRule(undefined)).to.be.false; + }); }); });