Skip to content

Commit 8e99f77

Browse files
refactor(email-theming): remove classReference, use sectionId as CssJs key, rename normalization to backwards-compatibility
- Remove classReference from PanelGroup and PanelInputProperty types - Use group id (PanelSectionId) as CssJs key instead of per-input classReference - Merge typography section into body (fontSize) and container (lineHeight) - Rename section IDs to camelCase: code-block → codeBlock, inline-code → inlineCode - Remove typography from PanelSectionId - Rename normalization module to backwards-compatibility - normalizeThemePanelStyles → applyPanelStylesBackwardsCompat - normalizePanelInputs → backfillPanelInputs - Add PersistedPanelGroup type for backward-compatible legacy data handling - Strip classReference from inputs during normalization - Filter out unresolvable groups instead of passing them through - Update all tests to reflect new structure Syncs changes from resend/resend PR #9330 (PRs #3113 and #3114 equivalent). Co-authored-by: Gabriel Miranda <gabrielmfern@outlook.com>
1 parent 53c7fc5 commit 8e99f77

10 files changed

Lines changed: 398 additions & 358 deletions

File tree

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
type PersistedPanelGroup,
4+
applyPanelStylesBackwardsCompat,
5+
inferThemeFromPanelStyles,
6+
} from './backwards-compatibility';
7+
8+
describe('applyPanelStylesBackwardsCompat', () => {
9+
it('maps legacy global panel groups to the current section ids', () => {
10+
const legacy: PersistedPanelGroup[] = [
11+
{ title: 'Body', classReference: 'body', inputs: [] },
12+
{ title: 'Container', classReference: 'container', inputs: [] },
13+
{ title: 'Typography', classReference: 'body', inputs: [] },
14+
{ title: 'Code Block', classReference: 'codeBlock', inputs: [] },
15+
];
16+
17+
const result = applyPanelStylesBackwardsCompat('minimal', legacy);
18+
19+
expect(result).toEqual([
20+
expect.objectContaining({
21+
id: 'body',
22+
title: 'Background',
23+
}),
24+
expect.objectContaining({
25+
id: 'container',
26+
title: 'Body',
27+
}),
28+
// Legacy 'typography' with classReference 'body' maps to 'body'
29+
expect.objectContaining({
30+
id: 'body',
31+
title: 'Background',
32+
}),
33+
expect.objectContaining({
34+
id: 'codeBlock',
35+
title: 'Code Block',
36+
}),
37+
]);
38+
39+
// classReference should be stripped from all groups
40+
for (const group of result!) {
41+
expect(group).not.toHaveProperty('classReference');
42+
}
43+
});
44+
45+
it('maps legacy kebab-case section ids to camelCase', () => {
46+
const legacy: PersistedPanelGroup[] = [
47+
{ id: 'code-block', title: 'Code Block', inputs: [] },
48+
{ id: 'inline-code', title: 'Inline Code', inputs: [] },
49+
];
50+
51+
const result = applyPanelStylesBackwardsCompat('minimal', legacy);
52+
53+
expect(result).toEqual([
54+
expect.objectContaining({ id: 'codeBlock' }),
55+
expect.objectContaining({ id: 'inlineCode' }),
56+
]);
57+
});
58+
59+
it('strips classReference from inputs during normalization', () => {
60+
const legacy: PersistedPanelGroup[] = [
61+
{
62+
id: 'button',
63+
title: 'Button',
64+
inputs: [
65+
{
66+
label: 'Background',
67+
type: 'color',
68+
value: '#000000',
69+
prop: 'backgroundColor',
70+
classReference: 'button',
71+
},
72+
],
73+
},
74+
];
75+
76+
const result = applyPanelStylesBackwardsCompat('basic', legacy);
77+
78+
expect(result![0].inputs[0]).not.toHaveProperty('classReference');
79+
expect(result![0].inputs[0]).toMatchObject({
80+
prop: 'backgroundColor',
81+
value: '#000000',
82+
});
83+
});
84+
});
85+
86+
describe('inferThemeFromPanelStyles', () => {
87+
it('infers the minimal theme from legacy empty panel groups', () => {
88+
expect(
89+
inferThemeFromPanelStyles([
90+
{ title: 'Body', inputs: [] },
91+
{ title: 'Container', inputs: [] },
92+
]),
93+
).toBe('minimal');
94+
});
95+
});
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { EDITOR_THEMES } from './themes';
2+
import type {
3+
EditorTheme,
4+
KnownThemeComponents,
5+
PanelGroup,
6+
PanelSectionId,
7+
} from './types';
8+
9+
/**
10+
* Represents the shape of a PanelGroup as it may exist in persisted data.
11+
* Legacy data can include `classReference` and old-style section IDs
12+
* (e.g. `'code-block'`, `'typography'`) that no longer exist in the
13+
* current `PanelSectionId` type.
14+
*/
15+
export interface PersistedPanelGroup {
16+
id?: string;
17+
title: string;
18+
classReference?: KnownThemeComponents;
19+
inputs: Array<
20+
PanelGroup['inputs'][number] & { classReference?: KnownThemeComponents }
21+
>;
22+
}
23+
24+
const PANEL_SECTION_IDS = new Set<PanelSectionId>([
25+
'body',
26+
'container',
27+
'link',
28+
'image',
29+
'button',
30+
'codeBlock',
31+
'inlineCode',
32+
]);
33+
34+
const PANEL_SECTION_IDS_BY_TITLE: Record<string, PanelSectionId> = {
35+
background: 'body',
36+
body: 'body',
37+
content: 'container',
38+
container: 'container',
39+
link: 'link',
40+
image: 'image',
41+
button: 'button',
42+
'code block': 'codeBlock',
43+
'inline code': 'inlineCode',
44+
};
45+
46+
/**
47+
* Maps legacy section IDs and classReference values to current PanelSectionId.
48+
* Handles backward compatibility for persisted data.
49+
*/
50+
const LEGACY_ID_MAP: Record<string, PanelSectionId> = {
51+
'code-block': 'codeBlock',
52+
'inline-code': 'inlineCode',
53+
};
54+
55+
/**
56+
* Maps legacy classReference values to PanelSectionId for data
57+
* that predates the id field.
58+
*/
59+
const LEGACY_CLASS_REFERENCE_MAP: Record<string, PanelSectionId> = {
60+
body: 'body',
61+
container: 'container',
62+
link: 'link',
63+
image: 'image',
64+
button: 'button',
65+
codeBlock: 'codeBlock',
66+
inlineCode: 'inlineCode',
67+
};
68+
69+
function isPanelSectionId(value: unknown): value is PanelSectionId {
70+
return (
71+
typeof value === 'string' && PANEL_SECTION_IDS.has(value as PanelSectionId)
72+
);
73+
}
74+
75+
function normalizeTitle(title: string | undefined): string {
76+
return title?.trim().toLowerCase().replace(/\s+/g, ' ') ?? '';
77+
}
78+
79+
function resolvePanelSectionId(
80+
group: PersistedPanelGroup,
81+
): PanelSectionId | null {
82+
if (isPanelSectionId(group.id)) {
83+
return group.id;
84+
}
85+
86+
// Handle legacy IDs like 'code-block' → 'codeBlock'
87+
if (group.id && LEGACY_ID_MAP[group.id]) {
88+
return LEGACY_ID_MAP[group.id]!;
89+
}
90+
91+
// Handle legacy classReference-based resolution
92+
if (group.classReference && LEGACY_CLASS_REFERENCE_MAP[group.classReference]) {
93+
const normalizedTitle = normalizeTitle(group.title);
94+
// Special case: legacy 'typography' section with classReference 'body'
95+
// should be merged into 'body' (its inputs have been redistributed)
96+
if (group.classReference === 'body' && normalizedTitle === 'typography') {
97+
return 'body';
98+
}
99+
return LEGACY_CLASS_REFERENCE_MAP[group.classReference]!;
100+
}
101+
102+
return PANEL_SECTION_IDS_BY_TITLE[normalizeTitle(group.title)] ?? null;
103+
}
104+
105+
function backfillPanelInputs(
106+
inputs: PersistedPanelGroup['inputs'],
107+
defaultInputs: PanelGroup['inputs'],
108+
): PanelGroup['inputs'] {
109+
if (!Array.isArray(inputs)) {
110+
return [];
111+
}
112+
113+
return inputs.map((input) => {
114+
const defaultInput = defaultInputs.find(
115+
(candidate) => candidate.prop === input.prop,
116+
);
117+
118+
const { classReference: _, ...inputWithoutClassRef } = input;
119+
120+
return {
121+
...defaultInput,
122+
...inputWithoutClassRef,
123+
};
124+
});
125+
}
126+
127+
export function inferThemeFromPanelStyles(
128+
panelStyles: PersistedPanelGroup[] | null | undefined,
129+
): EditorTheme | null {
130+
if (!Array.isArray(panelStyles) || panelStyles.length === 0) {
131+
return null;
132+
}
133+
134+
let finalTheme: EditorTheme | null = null;
135+
for (const group of panelStyles) {
136+
if (!Array.isArray(group?.inputs)) {
137+
finalTheme = null;
138+
break;
139+
}
140+
141+
if (group.inputs.length !== 0) {
142+
finalTheme = 'basic';
143+
break;
144+
}
145+
146+
finalTheme = 'minimal';
147+
}
148+
149+
return finalTheme;
150+
}
151+
152+
export function applyPanelStylesBackwardsCompat(
153+
theme: EditorTheme,
154+
panelStyles: PersistedPanelGroup[] | null | undefined,
155+
): PanelGroup[] | null {
156+
if (!Array.isArray(panelStyles)) {
157+
return null;
158+
}
159+
160+
return panelStyles
161+
.map((group) => {
162+
const panelId = resolvePanelSectionId(group);
163+
164+
if (!panelId) {
165+
return null;
166+
}
167+
168+
const defaultGroup = EDITOR_THEMES[theme].find(
169+
(candidate) => candidate.id === panelId,
170+
);
171+
172+
if (!defaultGroup) {
173+
return null;
174+
}
175+
176+
const normalized: PanelGroup = {
177+
id: panelId,
178+
title: defaultGroup.title,
179+
inputs: backfillPanelInputs(group.inputs, defaultGroup.inputs),
180+
};
181+
182+
return normalized;
183+
})
184+
.filter((group): group is PanelGroup => group !== null);
185+
}

0 commit comments

Comments
 (0)