Skip to content
Merged
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
32 changes: 32 additions & 0 deletions docs/3-modelling.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,38 @@ export default buildConfig({
});
```

### Optional: Define Property Groups

Property groups help organize your content type properties in the CMS editor. You can define custom property groups in your config file:

```js
import { buildConfig } from '@optimizely/cms-sdk';

export default buildConfig({
components: ['./src/components/**/*.tsx'],
propertyGroups: [
{
key: 'seo',
displayName: 'SEO',
sortOrder: 1,
},
{
key: 'meta',
displayName: 'Metadata',
sortOrder: 2,
},
],
});
```

Each property group has:

- `key` (required): A unique identifier for the group
- `displayName` (optional): The name shown in the CMS editor. If not provided, it's auto-generated from the key
- `sortOrder` (optional): Controls the display order. If not provided, it's auto-assigned based on array position

You can then reference these groups in your content type properties using the `group` field.

## Step 2. Sync content types to the CMS

Run the following command:
Expand Down
16 changes: 14 additions & 2 deletions packages/optimizely-cms-cli/src/commands/config/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import ora from 'ora';
import { BaseCommand } from '../../baseCommand.js';
import { writeFile } from 'node:fs/promises';
import { createApiClient } from '../../service/cmsRestClient.js';
import { findMetaData, readFromPath } from '../../service/utils.js';
import {
findMetaData,
readFromPath,
normalizePropertyGroups,
} from '../../service/utils.js';
import { mapContentToManifest } from '../../mapper/contentToPackage.js';
import { pathToFileURL } from 'node:url';
import chalk from 'chalk';
Expand Down Expand Up @@ -33,7 +37,9 @@ export default class ConfigPush extends BaseCommand<typeof ConfigPush> {
path.resolve(process.cwd(), args.file)
).href;

const componentPaths = await readFromPath(configPath);
const componentPaths = await readFromPath(configPath, 'components');
const propertyGroups = await readFromPath(configPath, 'propertyGroups');

//the pattern is relative to the config file
const configPathDirectory = path.dirname(configPath);

Expand All @@ -43,9 +49,15 @@ export default class ConfigPush extends BaseCommand<typeof ConfigPush> {
configPathDirectory
);

// Validate and normalize property groups
const normalizedPropertyGroups = propertyGroups
? normalizePropertyGroups(propertyGroups)
: [];

const metaData = {
contentTypes: mapContentToManifest(contentTypes),
displayTemplates,
propertyGroups: normalizedPropertyGroups,
};

const restClient = await createApiClient(flags.host);
Expand Down
91 changes: 87 additions & 4 deletions packages/optimizely-cms-cli/src/service/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
isContentType,
DisplayTemplates,
isDisplayTemplate,
PropertyGroupType,
} from '@optimizely/cms-sdk';
import chalk from 'chalk';
import * as path from 'node:path';
Expand Down Expand Up @@ -116,7 +117,7 @@ async function compileAndImport(
}
}

/** Finds metadata (contentTypes, displayTemplates) in the given paths */
/** Finds metadata (contentTypes, displayTemplates, propertyGroups) in the given paths */
export async function findMetaData(
componentPaths: string[],
cwd: string
Expand Down Expand Up @@ -164,7 +165,7 @@ export async function findMetaData(
function printFilesContnets(
type: string,
path: string,
metaData: AnyContentType | DisplayTemplate
metaData: AnyContentType | DisplayTemplate | PropertyGroupType
) {
console.log(
'%s %s found in %s',
Expand All @@ -174,9 +175,91 @@ function printFilesContnets(
);
}

export async function readFromPath(configPath: string) {
export async function readFromPath(configPath: string, section: string) {
const config = await import(configPath);
return config.default.components;
return config.default[section];
}

/**
* Validates and normalizes property groups from the config file.
* - Validates that each property group has a non-empty key
* - Auto-generates displayName from key (capitalized) if missing
* - Auto-assigns sortOrder based on array position (index + 1) if missing
* - Deduplicates property groups by key, keeping the last occurrence
* @param propertyGroups - The property groups array from the config
* @returns Validated and normalized property groups array
* @throws Error if validation fails (empty or missing key)
*/
export function normalizePropertyGroups(
propertyGroups: any[]
): PropertyGroupType[] {
if (!Array.isArray(propertyGroups)) {
throw new Error('propertyGroups must be an array');
}

const normalizedGroups = propertyGroups.map((group, index) => {
// Validate key is present and not empty
if (
!group.key ||
typeof group.key !== 'string' ||
group.key.trim() === ''
) {
throw new Error(
`Error in property groups: Property group at index ${index} has an empty or missing "key" field`
);
}

// Auto-generate displayName from key if missing (capitalize first letter)
const displayName =
group.displayName &&
typeof group.displayName === 'string' &&
group.displayName.trim() !== ''
? group.displayName
: group.key.charAt(0).toUpperCase() + group.key.slice(1);

// Auto-assign sortOrder based on array position if missing
const sortOrder =
typeof group.sortOrder === 'number' ? group.sortOrder : index + 1;

return {
key: group.key,
displayName,
sortOrder,
};
});

// Deduplicate by key, keeping the last occurrence
const groupMap = new Map<string, PropertyGroupType>();
const duplicates = new Set<string>();

for (const group of normalizedGroups) {
if (groupMap.has(group.key)) {
duplicates.add(group.key);
}
groupMap.set(group.key, group);
}

// Warn about duplicates
if (duplicates.size > 0) {
console.warn(
chalk.yellow(
`Warning: Duplicate property group keys found: ${Array.from(
duplicates
).join(', ')}. Keeping the last occurrence of each.`
)
);
}

const deduplicatedGroups = Array.from(groupMap.values());

// Log found property groups
if (deduplicatedGroups.length > 0) {
const groupKeys = deduplicatedGroups.map((g) => g.displayName).join(', ');
console.log('Property Groups found: %s', chalk.bold.cyan(`[${groupKeys}]`));
}

// Return deduplicated array in the order they were last seen
return deduplicatedGroups;
}

/**
Expand Down
198 changes: 198 additions & 0 deletions packages/optimizely-cms-cli/src/tests/normalizePropertyGroups.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { normalizePropertyGroups } from '../service/utils.js';

describe('normalizePropertyGroups', () => {
let consoleWarnSpy: any;

beforeEach(() => {
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});

afterEach(() => {
consoleWarnSpy.mockRestore();
});

it('should handle duplicate keys by keeping the last occurrence', () => {
const input = [
{
key: 'seo',
displayName: 'SEO',
sortOrder: 1,
},
{
key: 'seo',
displayName: 'SEO Updated',
sortOrder: 5,
},
{
key: 'meta',
displayName: 'Meta',
sortOrder: 2,
},
];

const result = normalizePropertyGroups(input);

expect(result).toHaveLength(2);
expect(result[0]).toEqual({
key: 'seo',
displayName: 'SEO Updated',
sortOrder: 5,
});
expect(result[1]).toEqual({
key: 'meta',
displayName: 'Meta',
sortOrder: 2,
});
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('Duplicate property group keys found: seo')
);
});

it('should handle multiple duplicates correctly', () => {
const input = [
{
key: 'seo',
displayName: 'SEO 1',
sortOrder: 1,
},
{
key: 'meta',
displayName: 'Meta',
sortOrder: 2,
},
{
key: 'seo',
displayName: 'SEO 2',
sortOrder: 3,
},
{
key: 'layout',
displayName: 'Layout',
sortOrder: 4,
},
{
key: 'seo',
displayName: 'SEO Final',
sortOrder: 5,
},
];

const result = normalizePropertyGroups(input);

expect(result).toHaveLength(3);
expect(result.find((g) => g.key === 'seo')).toEqual({
key: 'seo',
displayName: 'SEO Final',
sortOrder: 5,
});
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('Duplicate property group keys found: seo')
);
});

it('should auto-generate displayName from key if missing', () => {
const input = [
{
key: 'seo',
sortOrder: 1,
},
];

const result = normalizePropertyGroups(input);

expect(result[0]).toEqual({
key: 'seo',
displayName: 'Seo',
sortOrder: 1,
});
});

it('should auto-assign sortOrder based on array position if missing', () => {
const input = [
{
key: 'first',
displayName: 'First',
},
{
key: 'second',
displayName: 'Second',
},
{
key: 'third',
displayName: 'Third',
},
];

const result = normalizePropertyGroups(input);

expect(result[0].sortOrder).toBe(1);
expect(result[1].sortOrder).toBe(2);
expect(result[2].sortOrder).toBe(3);
});

it('should throw error for empty key', () => {
const input = [
{
key: '',
displayName: 'Empty',
sortOrder: 1,
},
];

expect(() => normalizePropertyGroups(input)).toThrow(
'Error in property groups: Property group at index 0 has an empty or missing "key" field'
);
});

it('should throw error for missing key', () => {
const input = [
{
displayName: 'No Key',
sortOrder: 1,
},
];

expect(() => normalizePropertyGroups(input)).toThrow(
'Error in property groups: Property group at index 0 has an empty or missing "key" field'
);
});

it('should throw error if propertyGroups is not an array', () => {
expect(() => normalizePropertyGroups({} as any)).toThrow(
'propertyGroups must be an array'
);
});

it('should handle empty array', () => {
const result = normalizePropertyGroups([]);
expect(result).toEqual([]);
});

it('should preserve order for non-duplicate keys', () => {
const input = [
{
key: 'first',
displayName: 'First',
sortOrder: 1,
},
{
key: 'second',
displayName: 'Second',
sortOrder: 2,
},
{
key: 'third',
displayName: 'Third',
sortOrder: 3,
},
];

const result = normalizePropertyGroups(input);

expect(result[0].key).toBe('first');
expect(result[1].key).toBe('second');
expect(result[2].key).toBe('third');
expect(consoleWarnSpy).not.toHaveBeenCalled();
});
});
Loading