From 0f5d4c313d42bb5d0641f0b355f4c3df6d078aeb Mon Sep 17 00:00:00 2001 From: naman-contentstack Date: Mon, 15 Jun 2026 15:31:00 +0530 Subject: [PATCH 1/3] 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/3] 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; + }); }); }); From 59de86d54e4cb507e3f24b5fd66900b5986e6229 Mon Sep 17 00:00:00 2001 From: naman-contentstack Date: Wed, 1 Jul 2026 15:28:59 +0530 Subject: [PATCH 3/3] feat: enhance field rules audit to include global fields and add corresponding tests --- .../src/audit-base-command.ts | 25 ++++++++-- .../test/unit/modules/field-rules.test.ts | 47 +++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/packages/contentstack-audit/src/audit-base-command.ts b/packages/contentstack-audit/src/audit-base-command.ts index 1c66fbf0f..b78650a31 100644 --- a/packages/contentstack-audit/src/audit-base-command.ts +++ b/packages/contentstack-audit/src/audit-base-command.ts @@ -340,20 +340,37 @@ export abstract class AuditBaseCommand extends BaseCommand = {}; + if (data.gfSchema?.length) { + gfFieldRules = await new FieldRule( + cloneDeep({ ...constructorParam, moduleName: 'global-fields' }), + ).run(); + } + missingFieldRules = { ...ctFieldRules, ...gfFieldRules }; + await this.prepareReport(module, missingFieldRules); - this.getAffectedData('field-rules', dataModuleWise['content-types'], missingFieldRules); + const total = (data.ctSchema?.length || 0) + (data.gfSchema?.length || 0); + this.getAffectedData('field-rules', { Total: total }, missingFieldRules); log.success( `Field-rules audit completed. Found ${Object.keys(missingFieldRules || {}).length} issues`, this.auditContext, ); break; + } case 'composable-studio': log.info('Executing composable-studio audit', this.auditContext); missingRefsInComposableStudio = await new ComposableStudio(cloneDeep(constructorParam)).run(); diff --git a/packages/contentstack-audit/test/unit/modules/field-rules.test.ts b/packages/contentstack-audit/test/unit/modules/field-rules.test.ts index 8a9473731..ebcfb65b3 100644 --- a/packages/contentstack-audit/test/unit/modules/field-rules.test.ts +++ b/packages/contentstack-audit/test/unit/modules/field-rules.test.ts @@ -140,6 +140,53 @@ describe('Field Rules', () => { }); }); + describe('global field field rules', () => { + const gfWithRuleSchema = () => [ + { + uid: 'gf_with_rule', + title: 'GF With Rule', + schema: [{ uid: 'single_line', data_type: 'text', display_name: 'Single Line' }], + field_rules: [ + { + conditions: [{ operand_field: 'single_line', operator: 'equals', value: 'x' }], + actions: [{ action: 'show', target_field: 'missing_field' }], + }, + ], + }, + ]; + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(FieldRule.prototype, 'prepareEntryMetaData', async () => {}) + .stub(FieldRule.prototype, 'prerequisiteData', async () => {}) + .it("scans a global field's own field_rules and flags missing target fields", async () => { + const gfInstance = new FieldRule({ + ...constructorParam, + moduleName: 'global-fields', + gfSchema: gfWithRuleSchema() as any, + }); + const result = await gfInstance.run(); + expect(result).to.have.property('gf_with_rule'); + expect(JSON.stringify(result)).to.include('missing_field'); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(FieldRule.prototype, 'prepareEntryMetaData', async () => {}) + .stub(FieldRule.prototype, 'prerequisiteData', async () => {}) + .it('does not flag a global field whose field_rules reference existing fields', async () => { + const okSchema = gfWithRuleSchema(); + okSchema[0].field_rules[0].actions[0].target_field = 'single_line'; + const gfInstance = new FieldRule({ + ...constructorParam, + moduleName: 'global-fields', + gfSchema: okSchema as any, + }); + const result = await gfInstance.run(); + expect(result).to.not.have.property('gf_with_rule'); + }); + }); + describe('writeFixContent method', () => { fancy .stdout({ print: process.env.PRINT === 'true' || false })