Skip to content

Commit 1ef7593

Browse files
authored
Merge pull request #153 from episerver/feature/CMS-46358-add-property-group-support
CMS-46358 Add property group support
2 parents 24c841a + aa7a668 commit 1ef7593

File tree

11 files changed

+385
-7
lines changed

11 files changed

+385
-7
lines changed

docs/3-modelling.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,38 @@ export default buildConfig({
5151
});
5252
```
5353

54+
### Optional: Define Property Groups
55+
56+
Property groups help organize your content type properties in the CMS editor. You can define custom property groups in your config file:
57+
58+
```js
59+
import { buildConfig } from '@optimizely/cms-sdk';
60+
61+
export default buildConfig({
62+
components: ['./src/components/**/*.tsx'],
63+
propertyGroups: [
64+
{
65+
key: 'seo',
66+
displayName: 'SEO',
67+
sortOrder: 1,
68+
},
69+
{
70+
key: 'meta',
71+
displayName: 'Metadata',
72+
sortOrder: 2,
73+
},
74+
],
75+
});
76+
```
77+
78+
Each property group has:
79+
80+
- `key` (required): A unique identifier for the group
81+
- `displayName` (optional): The name shown in the CMS editor. If not provided, it's auto-generated from the key
82+
- `sortOrder` (optional): Controls the display order. If not provided, it's auto-assigned based on array position
83+
84+
You can then reference these groups in your content type properties using the `group` field.
85+
5486
## Step 2. Sync content types to the CMS
5587

5688
Run the following command:

packages/optimizely-cms-cli/src/commands/config/push.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import ora from 'ora';
44
import { BaseCommand } from '../../baseCommand.js';
55
import { writeFile } from 'node:fs/promises';
66
import { createApiClient } from '../../service/cmsRestClient.js';
7-
import { findMetaData, readFromPath } from '../../service/utils.js';
7+
import {
8+
findMetaData,
9+
readFromPath,
10+
normalizePropertyGroups,
11+
} from '../../service/utils.js';
812
import { mapContentToManifest } from '../../mapper/contentToPackage.js';
913
import { pathToFileURL } from 'node:url';
1014
import chalk from 'chalk';
@@ -33,7 +37,9 @@ export default class ConfigPush extends BaseCommand<typeof ConfigPush> {
3337
path.resolve(process.cwd(), args.file)
3438
).href;
3539

36-
const componentPaths = await readFromPath(configPath);
40+
const componentPaths = await readFromPath(configPath, 'components');
41+
const propertyGroups = await readFromPath(configPath, 'propertyGroups');
42+
3743
//the pattern is relative to the config file
3844
const configPathDirectory = path.dirname(configPath);
3945

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

52+
// Validate and normalize property groups
53+
const normalizedPropertyGroups = propertyGroups
54+
? normalizePropertyGroups(propertyGroups)
55+
: [];
56+
4657
const metaData = {
4758
contentTypes: mapContentToManifest(contentTypes),
4859
displayTemplates,
60+
propertyGroups: normalizedPropertyGroups,
4961
};
5062

5163
const restClient = await createApiClient(flags.host);

packages/optimizely-cms-cli/src/service/utils.ts

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
isContentType,
99
DisplayTemplates,
1010
isDisplayTemplate,
11+
PropertyGroupType,
1112
} from '@optimizely/cms-sdk';
1213
import chalk from 'chalk';
1314
import * as path from 'node:path';
@@ -116,7 +117,7 @@ async function compileAndImport(
116117
}
117118
}
118119

119-
/** Finds metadata (contentTypes, displayTemplates) in the given paths */
120+
/** Finds metadata (contentTypes, displayTemplates, propertyGroups) in the given paths */
120121
export async function findMetaData(
121122
componentPaths: string[],
122123
cwd: string
@@ -164,7 +165,7 @@ export async function findMetaData(
164165
function printFilesContnets(
165166
type: string,
166167
path: string,
167-
metaData: AnyContentType | DisplayTemplate
168+
metaData: AnyContentType | DisplayTemplate | PropertyGroupType
168169
) {
169170
console.log(
170171
'%s %s found in %s',
@@ -174,9 +175,91 @@ function printFilesContnets(
174175
);
175176
}
176177

177-
export async function readFromPath(configPath: string) {
178+
export async function readFromPath(configPath: string, section: string) {
178179
const config = await import(configPath);
179-
return config.default.components;
180+
return config.default[section];
181+
}
182+
183+
/**
184+
* Validates and normalizes property groups from the config file.
185+
* - Validates that each property group has a non-empty key
186+
* - Auto-generates displayName from key (capitalized) if missing
187+
* - Auto-assigns sortOrder based on array position (index + 1) if missing
188+
* - Deduplicates property groups by key, keeping the last occurrence
189+
* @param propertyGroups - The property groups array from the config
190+
* @returns Validated and normalized property groups array
191+
* @throws Error if validation fails (empty or missing key)
192+
*/
193+
export function normalizePropertyGroups(
194+
propertyGroups: any[]
195+
): PropertyGroupType[] {
196+
if (!Array.isArray(propertyGroups)) {
197+
throw new Error('propertyGroups must be an array');
198+
}
199+
200+
const normalizedGroups = propertyGroups.map((group, index) => {
201+
// Validate key is present and not empty
202+
if (
203+
!group.key ||
204+
typeof group.key !== 'string' ||
205+
group.key.trim() === ''
206+
) {
207+
throw new Error(
208+
`Error in property groups: Property group at index ${index} has an empty or missing "key" field`
209+
);
210+
}
211+
212+
// Auto-generate displayName from key if missing (capitalize first letter)
213+
const displayName =
214+
group.displayName &&
215+
typeof group.displayName === 'string' &&
216+
group.displayName.trim() !== ''
217+
? group.displayName
218+
: group.key.charAt(0).toUpperCase() + group.key.slice(1);
219+
220+
// Auto-assign sortOrder based on array position if missing
221+
const sortOrder =
222+
typeof group.sortOrder === 'number' ? group.sortOrder : index + 1;
223+
224+
return {
225+
key: group.key,
226+
displayName,
227+
sortOrder,
228+
};
229+
});
230+
231+
// Deduplicate by key, keeping the last occurrence
232+
const groupMap = new Map<string, PropertyGroupType>();
233+
const duplicates = new Set<string>();
234+
235+
for (const group of normalizedGroups) {
236+
if (groupMap.has(group.key)) {
237+
duplicates.add(group.key);
238+
}
239+
groupMap.set(group.key, group);
240+
}
241+
242+
// Warn about duplicates
243+
if (duplicates.size > 0) {
244+
console.warn(
245+
chalk.yellow(
246+
`Warning: Duplicate property group keys found: ${Array.from(
247+
duplicates
248+
).join(', ')}. Keeping the last occurrence of each.`
249+
)
250+
);
251+
}
252+
253+
const deduplicatedGroups = Array.from(groupMap.values());
254+
255+
// Log found property groups
256+
if (deduplicatedGroups.length > 0) {
257+
const groupKeys = deduplicatedGroups.map((g) => g.displayName).join(', ');
258+
console.log('Property Groups found: %s', chalk.bold.cyan(`[${groupKeys}]`));
259+
}
260+
261+
// Return deduplicated array in the order they were last seen
262+
return deduplicatedGroups;
180263
}
181264

182265
/**
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { normalizePropertyGroups } from '../service/utils.js';
3+
4+
describe('normalizePropertyGroups', () => {
5+
let consoleWarnSpy: any;
6+
7+
beforeEach(() => {
8+
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
9+
});
10+
11+
afterEach(() => {
12+
consoleWarnSpy.mockRestore();
13+
});
14+
15+
it('should handle duplicate keys by keeping the last occurrence', () => {
16+
const input = [
17+
{
18+
key: 'seo',
19+
displayName: 'SEO',
20+
sortOrder: 1,
21+
},
22+
{
23+
key: 'seo',
24+
displayName: 'SEO Updated',
25+
sortOrder: 5,
26+
},
27+
{
28+
key: 'meta',
29+
displayName: 'Meta',
30+
sortOrder: 2,
31+
},
32+
];
33+
34+
const result = normalizePropertyGroups(input);
35+
36+
expect(result).toHaveLength(2);
37+
expect(result[0]).toEqual({
38+
key: 'seo',
39+
displayName: 'SEO Updated',
40+
sortOrder: 5,
41+
});
42+
expect(result[1]).toEqual({
43+
key: 'meta',
44+
displayName: 'Meta',
45+
sortOrder: 2,
46+
});
47+
expect(consoleWarnSpy).toHaveBeenCalledWith(
48+
expect.stringContaining('Duplicate property group keys found: seo')
49+
);
50+
});
51+
52+
it('should handle multiple duplicates correctly', () => {
53+
const input = [
54+
{
55+
key: 'seo',
56+
displayName: 'SEO 1',
57+
sortOrder: 1,
58+
},
59+
{
60+
key: 'meta',
61+
displayName: 'Meta',
62+
sortOrder: 2,
63+
},
64+
{
65+
key: 'seo',
66+
displayName: 'SEO 2',
67+
sortOrder: 3,
68+
},
69+
{
70+
key: 'layout',
71+
displayName: 'Layout',
72+
sortOrder: 4,
73+
},
74+
{
75+
key: 'seo',
76+
displayName: 'SEO Final',
77+
sortOrder: 5,
78+
},
79+
];
80+
81+
const result = normalizePropertyGroups(input);
82+
83+
expect(result).toHaveLength(3);
84+
expect(result.find((g) => g.key === 'seo')).toEqual({
85+
key: 'seo',
86+
displayName: 'SEO Final',
87+
sortOrder: 5,
88+
});
89+
expect(consoleWarnSpy).toHaveBeenCalledWith(
90+
expect.stringContaining('Duplicate property group keys found: seo')
91+
);
92+
});
93+
94+
it('should auto-generate displayName from key if missing', () => {
95+
const input = [
96+
{
97+
key: 'seo',
98+
sortOrder: 1,
99+
},
100+
];
101+
102+
const result = normalizePropertyGroups(input);
103+
104+
expect(result[0]).toEqual({
105+
key: 'seo',
106+
displayName: 'Seo',
107+
sortOrder: 1,
108+
});
109+
});
110+
111+
it('should auto-assign sortOrder based on array position if missing', () => {
112+
const input = [
113+
{
114+
key: 'first',
115+
displayName: 'First',
116+
},
117+
{
118+
key: 'second',
119+
displayName: 'Second',
120+
},
121+
{
122+
key: 'third',
123+
displayName: 'Third',
124+
},
125+
];
126+
127+
const result = normalizePropertyGroups(input);
128+
129+
expect(result[0].sortOrder).toBe(1);
130+
expect(result[1].sortOrder).toBe(2);
131+
expect(result[2].sortOrder).toBe(3);
132+
});
133+
134+
it('should throw error for empty key', () => {
135+
const input = [
136+
{
137+
key: '',
138+
displayName: 'Empty',
139+
sortOrder: 1,
140+
},
141+
];
142+
143+
expect(() => normalizePropertyGroups(input)).toThrow(
144+
'Error in property groups: Property group at index 0 has an empty or missing "key" field'
145+
);
146+
});
147+
148+
it('should throw error for missing key', () => {
149+
const input = [
150+
{
151+
displayName: 'No Key',
152+
sortOrder: 1,
153+
},
154+
];
155+
156+
expect(() => normalizePropertyGroups(input)).toThrow(
157+
'Error in property groups: Property group at index 0 has an empty or missing "key" field'
158+
);
159+
});
160+
161+
it('should throw error if propertyGroups is not an array', () => {
162+
expect(() => normalizePropertyGroups({} as any)).toThrow(
163+
'propertyGroups must be an array'
164+
);
165+
});
166+
167+
it('should handle empty array', () => {
168+
const result = normalizePropertyGroups([]);
169+
expect(result).toEqual([]);
170+
});
171+
172+
it('should preserve order for non-duplicate keys', () => {
173+
const input = [
174+
{
175+
key: 'first',
176+
displayName: 'First',
177+
sortOrder: 1,
178+
},
179+
{
180+
key: 'second',
181+
displayName: 'Second',
182+
sortOrder: 2,
183+
},
184+
{
185+
key: 'third',
186+
displayName: 'Third',
187+
sortOrder: 3,
188+
},
189+
];
190+
191+
const result = normalizePropertyGroups(input);
192+
193+
expect(result[0].key).toBe('first');
194+
expect(result[1].key).toBe('second');
195+
expect(result[2].key).toBe('third');
196+
expect(consoleWarnSpy).not.toHaveBeenCalled();
197+
});
198+
});

0 commit comments

Comments
 (0)