Skip to content

Commit 3ea67a4

Browse files
committed
feat: custom email in Plan migration form
1 parent c7e53b2 commit 3ea67a4

9 files changed

Lines changed: 139 additions & 62 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package io.tolgee.component.email.customTemplate
2+
3+
import io.tolgee.component.email.customTemplate.placeholder.EmailPlaceholdersExtractor
4+
import org.springframework.stereotype.Component
5+
import java.text.MessageFormat
6+
import java.util.Locale
7+
import kotlin.reflect.KClass
8+
9+
@Component
10+
class EmailTemplateRenderer(
11+
private val placeholderExtractor: EmailPlaceholdersExtractor,
12+
) {
13+
14+
fun render(
15+
template: String,
16+
variables: EmailTemplateVariables,
17+
): String {
18+
@Suppress("UNCHECKED_CAST")
19+
val entries =
20+
placeholderExtractor.getEntries(
21+
variables::class as KClass<EmailTemplateVariables>,
22+
)
23+
24+
val parameters =
25+
entries.map { entry ->
26+
entry.accessor(variables) ?: ""
27+
}.toTypedArray()
28+
29+
return MessageFormat(template, Locale.ENGLISH).format(parameters)
30+
}
31+
}
32+
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package io.tolgee.component.email.customTemplate
2+
3+
/**
4+
* Marker interface for classes that describe email template variables via [io.tolgee.component.email.customTemplate.placeholder.EmailPlaceholder] annotations.
5+
*/
6+
interface EmailTemplateVariables
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package io.tolgee.component.email.customTemplate.placeholder
2+
3+
@Target(AnnotationTarget.PROPERTY)
4+
@Retention(AnnotationRetention.RUNTIME)
5+
annotation class EmailPlaceholder(
6+
val position: Int,
7+
val placeholder: String,
8+
val description: String,
9+
val exampleValue: String,
10+
)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package io.tolgee.component.email.customTemplate.placeholder
2+
3+
import io.tolgee.component.email.customTemplate.EmailTemplateVariables
4+
5+
data class EmailPlaceholderDefinition(
6+
val position: Int,
7+
val placeholder: String,
8+
val description: String,
9+
val exampleValue: String,
10+
)
11+
12+
data class EmailPlaceholderEntry<T : EmailTemplateVariables>(
13+
val definition: EmailPlaceholderDefinition,
14+
val accessor: (T) -> String?,
15+
)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package io.tolgee.component.email.customTemplate.placeholder
2+
3+
import io.tolgee.component.email.customTemplate.EmailTemplateVariables
4+
import org.springframework.stereotype.Component
5+
import java.util.concurrent.ConcurrentHashMap
6+
import kotlin.collections.set
7+
import kotlin.reflect.KClass
8+
import kotlin.reflect.full.findAnnotation
9+
import kotlin.reflect.full.memberProperties
10+
11+
@Component
12+
class EmailPlaceholdersExtractor {
13+
14+
private val cache =
15+
ConcurrentHashMap<KClass<*>, List<EmailPlaceholderEntry<*>>>()
16+
17+
fun <T : EmailTemplateVariables> getEntries(kClass: KClass<T>): List<EmailPlaceholderEntry<T>> {
18+
val existing = cache[kClass]
19+
if (existing != null) {
20+
@Suppress("UNCHECKED_CAST")
21+
return existing as List<EmailPlaceholderEntry<T>>
22+
}
23+
24+
val extracted = extract(kClass)
25+
cache[kClass] = extracted
26+
return extracted
27+
}
28+
29+
fun <T : EmailTemplateVariables> getDefinitions(kClass: KClass<T>): List<EmailPlaceholderDefinition> {
30+
return getEntries(kClass).map { it.definition }
31+
}
32+
33+
private fun <T : EmailTemplateVariables> extract(kClass: KClass<T>): List<EmailPlaceholderEntry<T>> {
34+
return kClass.memberProperties.mapNotNull { property ->
35+
val annotation = property.findAnnotation<EmailPlaceholder>() ?: return@mapNotNull null
36+
37+
EmailPlaceholderEntry(
38+
definition = EmailPlaceholderDefinition(
39+
position = annotation.position,
40+
placeholder = annotation.placeholder,
41+
description = annotation.description,
42+
exampleValue = annotation.exampleValue,
43+
),
44+
accessor = { instance: T ->
45+
property.get(instance)?.toString()
46+
}
47+
)
48+
}.sortedBy { it.definition.position }
49+
}
50+
}

webapp/src/component/common/form/HtmlTemplateEditor.tsx

Lines changed: 24 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
ToggleButtonGroup,
1212
Tooltip,
1313
Typography,
14-
useTheme,
1514
} from '@mui/material';
1615
import { T, useTranslate } from '@tolgee/react';
1716
import {
@@ -29,7 +28,6 @@ export type TemplatePlaceholder =
2928
type Props = {
3029
value: string;
3130
onChange: (value: string) => void;
32-
label?: string;
3331
disabled?: boolean;
3432
readOnly?: boolean;
3533
placeholders?: TemplatePlaceholder[];
@@ -40,19 +38,15 @@ type Mode = 'html' | 'preview';
4038
export const HtmlTemplateEditor: React.FC<Props> = ({
4139
value,
4240
onChange,
43-
label,
4441
disabled,
4542
readOnly,
4643
placeholders = [],
4744
}) => {
4845
const { t } = useTranslate();
49-
const theme = useTheme();
5046
const [mode, setMode] = useState<Mode>('html');
5147
const textareaRef = useRef<HTMLTextAreaElement>(null);
5248
const previewRef = useRef<HTMLDivElement>(null);
5349

54-
const isDark = theme.palette.mode === 'dark';
55-
5650
useEffect(() => {
5751
if (
5852
mode === 'preview' &&
@@ -169,11 +163,29 @@ export const HtmlTemplateEditor: React.FC<Props> = ({
169163
<Card variant="outlined">
170164
<Box display="grid" gap={1.5} px={2} pb={2}>
171165
<Box display="flex" justifyContent="space-between" alignItems="center">
172-
{label && (
173-
<Typography variant="subtitle1" fontWeight={600}>
174-
{label}
175-
</Typography>
176-
)}
166+
<ToggleButtonGroup size="small" exclusive>
167+
<ToggleButton
168+
value="bold"
169+
onClick={() => wrapOrUnwrapSelection('b')}
170+
disabled={disabled || readOnly}
171+
>
172+
<Bold01 width={16} height={16} />
173+
</ToggleButton>
174+
<ToggleButton
175+
value="italic"
176+
onClick={() => wrapOrUnwrapSelection('i')}
177+
disabled={disabled || readOnly}
178+
>
179+
<Italic01 width={16} height={16} />
180+
</ToggleButton>
181+
<ToggleButton
182+
value="underline"
183+
onClick={() => wrapOrUnwrapSelection('u')}
184+
disabled={disabled || readOnly}
185+
>
186+
<Underline01 width={16} height={16} />
187+
</ToggleButton>
188+
</ToggleButtonGroup>
177189
<Tabs
178190
value={mode}
179191
onChange={handleTabChange}
@@ -202,30 +214,6 @@ export const HtmlTemplateEditor: React.FC<Props> = ({
202214
</Tabs>
203215
</Box>
204216

205-
<ToggleButtonGroup size="small" exclusive>
206-
<ToggleButton
207-
value="bold"
208-
onClick={() => wrapOrUnwrapSelection('b')}
209-
disabled={disabled || readOnly}
210-
>
211-
<Bold01 width={16} height={16} />
212-
</ToggleButton>
213-
<ToggleButton
214-
value="italic"
215-
onClick={() => wrapOrUnwrapSelection('i')}
216-
disabled={disabled || readOnly}
217-
>
218-
<Italic01 width={16} height={16} />
219-
</ToggleButton>
220-
<ToggleButton
221-
value="underline"
222-
onClick={() => wrapOrUnwrapSelection('u')}
223-
disabled={disabled || readOnly}
224-
>
225-
<Underline01 width={16} height={16} />
226-
</ToggleButton>
227-
</ToggleButtonGroup>
228-
229217
{mode === 'html' ? (
230218
<TextField
231219
inputRef={textareaRef}
@@ -237,31 +225,19 @@ export const HtmlTemplateEditor: React.FC<Props> = ({
237225
disabled={disabled}
238226
InputProps={{
239227
readOnly,
240-
sx: {
241-
fontFamily: 'Source Code Pro, monospace',
242-
backgroundColor: isDark
243-
? 'rgba(255,255,255,0.05)'
244-
: 'background.paper',
245-
color: isDark ? '#fff' : 'inherit',
246-
},
247228
}}
248229
/>
249230
) : (
250231
<Box
251232
ref={previewRef}
252233
contentEditable={!readOnly && !disabled}
253234
suppressContentEditableWarning
235+
p={1}
254236
sx={{
255237
minHeight: 150,
256238
border: 1,
257239
borderColor: 'divider',
258240
borderRadius: 1,
259-
padding: 1,
260-
fontFamily: 'inherit',
261-
backgroundColor: isDark
262-
? 'rgba(255,255,255,0.05)'
263-
: 'background.paper',
264-
color: isDark ? '#fff' : 'inherit',
265241
'&:focus': { outline: 'none' },
266242
}}
267243
onInput={() => {

webapp/src/ee/billing/administration/subscriptionPlans/components/migration/CreatePlanMigrationForm.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import {
22
CreatePlanMigrationFormData,
3-
EmailTemplateData,
43
PlanMigrationForm,
54
} from './PlanMigrationForm';
65
import { PlanType } from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/types';
@@ -18,7 +17,6 @@ type Props = {
1817
onSubmit: (values: CreatePlanMigrationFormData) => void;
1918
loading?: boolean;
2019
planType?: PlanType;
21-
emailTemplate?: EmailTemplateData;
2220
};
2321

2422
export const CreatePlanMigrationForm: React.FC<Props> = (props) => {

webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationEmailSection.tsx

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
} from 'tg.ee.module/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm';
88
import { Box, Typography } from '@mui/material';
99
import { HtmlTemplateEditor } from 'tg.component/common/form/HtmlTemplateEditor';
10-
import { useEffect } from 'react';
1110

1211
type EmailSectionProps = {
1312
template?: EmailTemplateData;
@@ -19,12 +18,6 @@ export const PlanMigrationEmailSection = ({ template }: EmailSectionProps) => {
1918
CreatePlanMigrationFormData | PlanMigrationFormData
2019
>();
2120

22-
useEffect(() => {
23-
if (values.customEmailBody == null && template?.body) {
24-
setFieldValue('customEmailBody', null);
25-
}
26-
}, [setFieldValue, template?.body, values.customEmailBody]);
27-
2821
return (
2922
<Box mt={1} display="grid" gap={1}>
3023
<Typography>
@@ -33,8 +26,6 @@ export const PlanMigrationEmailSection = ({ template }: EmailSectionProps) => {
3326
<HtmlTemplateEditor
3427
value={values.customEmailBody ?? template?.body ?? ''}
3528
onChange={(val) => setFieldValue('customEmailBody', val)}
36-
label={t('administration_plan_migration_custom_email_label')}
37-
readOnly={false}
3829
disabled={!template}
3930
placeholders={template?.placeholders ?? []}
4031
/>

webapp/src/ee/billing/administration/subscriptionPlans/components/migration/PlanMigrationForm.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,10 @@ const normalizeCustomBody = (
3030
body: string | null | undefined,
3131
template?: EmailTemplateData
3232
): string | null | undefined => {
33-
const trimmed = body?.trim();
34-
if (!trimmed) {
33+
if (!body || !body.trim()) {
3534
return null;
3635
}
37-
if (template && trimmed === template.body) {
36+
if (template && body === template.body) {
3837
return null;
3938
}
4039
return body;

0 commit comments

Comments
 (0)