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
Original file line number Diff line number Diff line change
Expand Up @@ -275,8 +275,9 @@ export class EmailOutputRendererUsecase extends BaseTranslationRendererUsecase {
try {
const contentString = JSON.stringify(mailyContent);
const translatedContent = await this.processStringTranslations(contentString, variables, dbWorkflow, locale);
const escapedContent = this.escapeJsonStringValues(translatedContent);

return JSON.parse(translatedContent);
return JSON.parse(escapedContent);
} catch (error) {
this.logger.error('Maily translation processing failed, falling back to original content', error);

Expand All @@ -301,6 +302,14 @@ export class EmailOutputRendererUsecase extends BaseTranslationRendererUsecase {
}
}

private escapeJsonStringValues(jsonString: string): string {
// Escape literal control characters that break JSON parsing
return jsonString
.replace(/\n/g, '\\n') // newline
.replace(/\r/g, '\\r') // carriage return
.replace(/\t/g, '\\t'); // tab
}

private async parseMailyContentByLiquid(
mailyContent: MailyJSONContent,
variables: FullPayloadForRender
Expand Down
14 changes: 13 additions & 1 deletion apps/api/src/app/shared/helpers/maily-utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { JSONContent as MailyJSONContent } from '@maily-to/render';
import { TRANSLATION_KEY_SINGLE_REGEX } from '@novu/shared';

import { MAILY_FIRST_CITIZEN_VARIABLE_KEY, MailyAttrsEnum, MailyContentTypeEnum } from './maily.types';

Expand Down Expand Up @@ -379,7 +380,18 @@ export const wrapMailyInLiquid = (content: string) => {

return processMailyNodes({
node: mailyJSONContent,
shouldProcessAttr: () => true,
shouldProcessAttr: ({ attrValue, attrKey, attrs }) => {
// Don't process button variable by Liquid if it's a translation key
if (
attrKey === MailyAttrsEnum.TEXT &&
attrs.isTextVariable === true &&
TRANSLATION_KEY_SINGLE_REGEX.test(attrValue)
) {
return false;
}

return true;
},
processAttr: ({ attrValue, attrs }) => {
const { fallback, aliasFor } = attrs;

Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"@codemirror/language": "^6.11.1",
"@hookform/resolvers": "^3.9.0",
"@lezer/highlight": "^1.2.1",
"@maily-to/core": "github:novuhq/maily.to.git#release/v0.2.7-novu.14-core&path:/packages/core",
"@maily-to/core": "github:novuhq/maily.to.git#release/v0.2.7-novu.15-core&path:/packages/core",
"@novu/api": "workspace:*",
"@novu/framework": "workspace:*",
"@novu/js": "workspace:*",
Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/src/api/translations.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { delV2, getV2, postV2 } from './api.client';
import { IEnvironment } from '@novu/shared';
import { LocalizationResourceEnum } from '@novu/dal';
import { LocalizationResourceEnum } from '@/components/translations/types';

export type TranslationsFilter = {
query?: string;
Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/src/components/icons/translate-variable.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';

export const TranslateVariable = (props: React.ComponentPropsWithoutRef<'svg'>) => (
export const TranslateVariableIcon = (props: React.ComponentPropsWithoutRef<'svg'>) => (
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none" {...props}>
<path
d="M10.4125 5.95L12.7225 11.725H11.5911L10.9606 10.15H8.81335L8.18387 11.725H7.05302L9.3625 5.95H10.4125ZM5.95 1.75V2.8H9.1V3.85H8.0668C7.66183 5.06909 7.01547 6.19413 6.1663 7.15803C6.54498 7.49592 6.95573 7.79607 7.3927 8.0542L6.99842 9.04015C6.43432 8.72021 5.90674 8.33979 5.425 7.90563C4.48713 8.75442 3.37647 9.3899 2.16947 9.76833L1.88807 8.7556C2.92224 8.42585 3.87521 7.88165 4.68475 7.15855C4.08556 6.48022 3.58648 5.71967 3.20267 4.9H4.37867C4.67128 5.44015 5.02215 5.94664 5.425 6.41043C6.08131 5.65395 6.59853 4.78728 6.95275 3.85053L1.75 3.85V2.8H4.9V1.75H5.95ZM9.8875 7.46463L9.23282 9.1H10.5411L9.8875 7.46463Z"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { TranslationImportTrigger } from '../translation-import-trigger';
import { ConfirmationModal } from '@/components/confirmation-modal';
import { getLocaleDisplayName } from '../utils';
import { useTranslationFileOperations } from './hooks';
import { TranslationResource } from './types';
import { TranslationResource } from '@/components/translations/types';

type EditorActionsProps = {
selectedLocale: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { loadLanguage } from '@uiw/codemirror-extensions-langs';
import { DATE_FORMAT_OPTIONS, TIME_FORMAT_OPTIONS } from '../constants';
import { formatTranslationDate, formatTranslationTime } from '../utils';
import { EditorActions } from './editor-actions';
import { TranslationResource } from './types';
import { TranslationResource } from '@/components/translations/types';

type JSONEditorProps = {
content: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useRef, useCallback, ReactElement, cloneElement } from 'react';
import { useUploadTranslations } from '@/hooks/use-upload-translations';
import { ACCEPTED_FILE_EXTENSION } from './constants';
import { TranslationResource } from './translation-drawer/types';
import { TranslationResource } from '@/components/translations/types';

type TranslationImportTriggerProps = {
resource: TranslationResource;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { LocalizationResourceEnum } from '@novu/dal';

export type TranslationResource = {
resourceId: string;
resourceType: LocalizationResourceEnum;
Expand All @@ -9,3 +7,7 @@ export type TranslationError = {
message: string;
code?: string;
};

export enum LocalizationResourceEnum {
WORKFLOW = 'workflow',
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,20 @@ import { RiErrorWarningLine } from 'react-icons/ri';
import { Code2 } from '@/components/icons/code-2';
import { DigestVariableIcon } from '@/components/icons/digest-variable-icon';
import { RepeatVariable } from '@/components/icons/repeat-variable';
import { TranslateVariableIcon } from '@/components/icons/translate-variable';
import { REPEAT_BLOCK_ITERABLE_ALIAS } from '@/components/workflow-editor/steps/email/variables/repeat-block-aliases';
import { DIGEST_PREVIEW_MAP } from '@/components/variable/utils/digest-variables';

export const VariableIcon = ({
variableName,
hasError,
isNotInSchema,
context = 'variables',
}: {
variableName: string;
hasError?: boolean;
isNotInSchema?: boolean;
context?: 'variables' | 'translations';
}) => {
if (hasError) {
return <RiErrorWarningLine className="text-error-base size-3.5 min-w-3.5" />;
Expand All @@ -23,6 +26,10 @@ export const VariableIcon = ({
return <RiErrorWarningLine className="text-error-base size-3.5 min-w-3.5" />;
}

if (context === 'translations') {
return <TranslateVariableIcon className="text-feature size-3.5 min-w-3.5" />;
}

if (variableName && variableName in DIGEST_PREVIEW_MAP) {
return <DigestVariableIcon className="text-feature size-3.5 min-w-3.5" />;
}
Expand Down
51 changes: 43 additions & 8 deletions apps/dashboard/src/components/variable/variable-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type VariablesListProps = {
selectedValue?: string;
title: string;
className?: string;
context?: 'variables' | 'translations';
};

export type VariableListRef = {
Expand All @@ -34,9 +35,9 @@ export type VariableListRef = {
};

export const VariableList = React.forwardRef<VariableListRef, VariablesListProps>(
({ options, onSelect, selectedValue, title, className }, ref) => {
({ options, onSelect, selectedValue, title, className, context = 'variables' }, ref) => {
const variablesListRef = useRef<HTMLUListElement>(null);
const [hoveredOptionIndex, setHoveredOptionIndex] = useState(0);
const [hoveredOptionIndex, setHoveredOptionIndex] = useState(options.length > 0 ? 0 : -1);
const maxIndex = options.length - 1;

const scrollToOption = useCallback((index: number) => {
Expand Down Expand Up @@ -125,6 +126,7 @@ export const VariableList = React.forwardRef<VariableListRef, VariablesListProps
hoveredOptionIndex={hoveredOptionIndex}
setHoveredOptionIndex={setHoveredOptionIndex}
onSelect={onSelect}
context={context}
/>
))}
</ul>
Expand All @@ -148,22 +150,43 @@ const VariableListItem = ({
hoveredOptionIndex,
setHoveredOptionIndex,
onSelect,
context = 'variables',
}: {
option: VariablesListProps['options'][number];
index: number;
selectedValue?: string;
hoveredOptionIndex: number;
setHoveredOptionIndex: (index: number) => void;
onSelect: (value: string) => void;
context?: 'variables' | 'translations';
}) => {
const hasPreview = !!option.preview;
const isHovered = hoveredOptionIndex === index;
const timeoutRef = useRef<NodeJS.Timeout | null>(null);

const handleMouseLeave = () => {
// Small delay to allow moving to tooltip
timeoutRef.current = setTimeout(() => {
setHoveredOptionIndex(-1);
}, 150);
};

const handleMouseEnter = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}

setHoveredOptionIndex(index);
};
Comment on lines +167 to +181
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any other way of handling this maybe? Usually those mouseleave things are creating troubles down the read. What is the issue we try to solve with this here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue is that we need to display a tooltip with a link to translation page, therefore user should be able to actually click it.

I also don't like timeouts, there might be a more elegant way but that would require changing / creating new component for that, figuring out how to make it works and then apply it to all places where the component is used etc. I will try to revisit when there are no more higher priority items.


return (
<Tooltip open={hoveredOptionIndex === index && hasPreview} key={option.value}>
<Tooltip open={isHovered && hasPreview} key={option.value}>
<TooltipTrigger asChild>
<li
className={cn(
'text-paragraph-xs font-code text-foreground-950 flex cursor-pointer items-center gap-1 rounded-sm p-1 hover:bg-neutral-100',
hoveredOptionIndex === index ? 'bg-neutral-100' : ''
isHovered ? 'bg-neutral-100' : ''
)}
value={option.value}
onClick={(e) => {
Expand All @@ -172,18 +195,30 @@ const VariableListItem = ({

onSelect(option.value ?? '');
}}
onMouseEnter={() => setHoveredOptionIndex(index)}
onMouseLeave={() => setHoveredOptionIndex(-1)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="flex size-3 items-center justify-center">
<VariableIcon variableName={option.value} />
<VariableIcon variableName={option.value} context={context} />
</div>
<TruncatedText>{option.label}</TruncatedText>
<CheckIcon className={cn('ml-auto size-4', selectedValue === option.value ? 'opacity-50' : 'opacity-0')} />
</li>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent side="right" className="bg-bg-weak border-0 p-0.5" sideOffset={10} hideWhenDetached>
<TooltipContent
side="right"
className="bg-bg-weak border-0 p-0.5"
sideOffset={5}
hideWhenDetached
onMouseEnter={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}}
onMouseLeave={() => setHoveredOptionIndex(-1)}
>
{option.preview}
</TooltipContent>
</TooltipPortal>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,13 +185,17 @@ export const createExtensions = ({
onCreateNewVariable,
isPayloadSchemaEnabled = false,
isTranslationEnabled = false,
translationKeys = [],
onCreateNewTranslationKey,
}: {
handleCalculateVariables: (props: CalculateVariablesProps) => Variables | undefined;
parsedVariables: ParsedVariables;
blocks: BlockGroupItem[];
onCreateNewVariable?: (variableName: string) => Promise<void>;
isPayloadSchemaEnabled?: boolean;
isTranslationEnabled?: boolean;
translationKeys?: { name: string }[];
onCreateNewTranslationKey?: (translationKey: string) => Promise<void>;
}) => {
const extensions = [
RepeatExtension.extend({
Expand Down Expand Up @@ -309,7 +313,7 @@ export const createExtensions = ({
});
},
}),
createTranslationExtension(isTranslationEnabled),
createTranslationExtension(isTranslationEnabled, translationKeys, onCreateNewTranslationKey),
];

extensions.push(
Expand Down
58 changes: 48 additions & 10 deletions apps/dashboard/src/components/workflow-editor/steps/email/maily.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { useWorkflowSchema } from '@/components/workflow-editor/workflow-schema-
import { PayloadSchemaDrawer } from '@/components/workflow-editor/payload-schema-drawer';
import { useCreateVariable } from '@/components/variable/hooks/use-create-variable';
import { useFeatureFlag } from '@/hooks/use-feature-flag';
import { useFetchTranslationKeys } from '@/hooks/use-fetch-translation-keys';
import { useCreateTranslationKey } from '@/hooks/use-create-translation-key';
import { FeatureFlagsKeysEnum } from '@novu/shared';

type MailyProps = HTMLAttributes<HTMLDivElement> & {
Expand Down Expand Up @@ -48,15 +50,6 @@ export const Maily = ({ value, onChange, className, ...rest }: MailyProps) => {

const parsedVariables = useParseVariables(schemaToUse, digestStepBeforeCurrent?.stepId, isPayloadSchemaEnabled);

// Create a key that changes when variables change to force extension recreation
const variablesKey = useMemo(() => {
const variableNames = [...parsedVariables.primitives, ...parsedVariables.arrays, ...parsedVariables.namespaces]
.map((v) => v.name)
.sort()
.join(',');
return `vars-${variableNames.length}-${variableNames.slice(0, 100)}`; // Truncate to avoid overly long keys
}, [parsedVariables.primitives, parsedVariables.arrays, parsedVariables.namespaces]);

const primitives = useMemo(
() => parsedVariables.primitives.map((v) => ({ name: v.name, required: false })),
[parsedVariables.primitives]
Expand Down Expand Up @@ -105,6 +98,46 @@ export const Maily = ({ value, onChange, className, ...rest }: MailyProps) => {

const isTranslationEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_TRANSLATION_ENABLED);

const { translationKeys, isLoading: isTranslationKeysLoading } = useFetchTranslationKeys({
workflowId: workflow?._id || '',
enabled: isTranslationEnabled && !!workflow?._id,
});

const createTranslationKeyMutation = useCreateTranslationKey();

const handleCreateNewTranslationKey = useCallback(
async (translationKey: string) => {
if (!workflow?._id) return;

await createTranslationKeyMutation.mutateAsync({
workflowId: workflow._id,
translationKey,
defaultValue: `[${translationKey}]`, // Placeholder value to indicate missing translation
});
},
[workflow?._id, createTranslationKeyMutation]
);

// Create a key that changes when variables or translation state changes to force extension recreation
const variablesKey = useMemo(() => {
const variableNames = [...parsedVariables.primitives, ...parsedVariables.arrays, ...parsedVariables.namespaces]
.map((v) => v.name)
.sort()
.join(',');

// Include translation state to force re-mount when translation extension becomes ready
const translationState = `translation-${isTranslationEnabled ? 'enabled' : 'disabled'}-${isTranslationKeysLoading ? 'loading' : 'loaded'}-${translationKeys.length}`;

return `vars-${variableNames.length}-${variableNames.slice(0, 100)}-${translationState}`;
}, [
parsedVariables.primitives,
parsedVariables.arrays,
parsedVariables.namespaces,
isTranslationEnabled,
isTranslationKeysLoading,
translationKeys.length,
]);

const extensions = useMemo(
() =>
createExtensions({
Expand All @@ -113,7 +146,9 @@ export const Maily = ({ value, onChange, className, ...rest }: MailyProps) => {
blocks,
onCreateNewVariable: handleCreateNewVariable,
isPayloadSchemaEnabled,
isTranslationEnabled,
isTranslationEnabled: isTranslationEnabled && !isTranslationKeysLoading,
translationKeys,
onCreateNewTranslationKey: handleCreateNewTranslationKey,
}),
[
handleCalculateVariables,
Expand All @@ -122,6 +157,9 @@ export const Maily = ({ value, onChange, className, ...rest }: MailyProps) => {
isPayloadSchemaEnabled,
handleCreateNewVariable,
isTranslationEnabled,
isTranslationKeysLoading,
translationKeys,
handleCreateNewTranslationKey,
]
);

Expand Down
Loading
Loading