Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .talismanrc
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
fileignoreconfig:
- filename: pnpm-lock.yaml
checksum: 07642e8dd04d580185a459e5b088d8a1bb4e91be4e04f4842bf4fe4775205bf6
- filename: packages/contentstack-export/src/config/index.ts
checksum: 6fa4bba2174bbf33f5611098f49a02bf2fc789f59634e99be58de7e370f5fcd3
version: '1.0'
4 changes: 2 additions & 2 deletions packages/contentstack-export/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
102 changes: 96 additions & 6 deletions packages/contentstack-import/src/import/modules/content-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,7 +34,7 @@ export default class ContentTypesImport extends BaseClass {
private reqConcurrency: number;
private ignoredFilesInContentTypesFolder: Map<string, string>;
private titleToUIdMap: Map<string, string>;
private fieldRules: Array<Record<string, unknown>>;
private fieldRules: string[];
private installedExtensions: Record<string, unknown>;
private cTsConfig: {
dirName: string;
Expand Down Expand Up @@ -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<string[]> {
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<string, any>[];

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<any> {
Expand Down
47 changes: 45 additions & 2 deletions packages/contentstack-import/src/utils/content-type-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } = {};
Expand All @@ -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;

Expand All @@ -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;
};
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
});
});
});
Loading