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
54 changes: 54 additions & 0 deletions translate/src/context/Editor.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -1070,3 +1070,57 @@ describe('<EditorProvider>', () => {
expect(editor.fields.map((f) => f.handle.current.value)).toEqual(['', '']);
});
});

describe('copying plural variants from another locale', () => {
it('retains target plural fields when copying a plain translation', () => {
let editor, actions;
const Spy = () => {
editor = useContext(EditorData);
actions = useContext(EditorActions);
return null;
};
const source =
'key = { $count ->\n [one] One item\n *[other] Other items\n }';
mountSpy(Spy, 'fluent', undefined, source, {
locale: { code: 'en-US', cldrPlurals: [1, 5] },
});
act(() => actions.setEditorFromHistory('key = Copied text', true));
expect(editor.fields.map((field) => field.labels.at(-1).label)).toEqual([
'one',
'other',
]);
expect(editor.fields.map((field) => field.handle.current.value)).toEqual([
'',
'Copied text',
]);
act(() => actions.setEditorFromHistory('key = History text'));
expect(editor.fields.map((field) => field.handle.current.value)).toEqual([
'History text',
]);
});

it('uses target plural categories', () => {
let editor, actions;
const Spy = () => {
editor = useContext(EditorData);
actions = useContext(EditorActions);
return null;
};
const source =
'key = { $count ->\n [one] One item\n *[other] Other items\n }';
const copied =
'key = { $count ->\n [one] Russian one\n [few] Russian few\n *[other] Russian other\n }';
mountSpy(Spy, 'fluent', undefined, source, {
locale: { code: 'en-US', cldrPlurals: [1, 5] },
});
act(() => actions.setEditorFromHistory(copied, true));
expect(editor.fields.map((field) => field.labels.at(-1).label)).toEqual([
'one',
'other',
]);
expect(editor.fields.map((field) => field.handle.current.value)).toEqual([
'Russian one',
'Russian other',
]);
});
});
10 changes: 7 additions & 3 deletions translate/src/context/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
serializeEntry,
} from '~/utils/message';
import { createMessageEntry } from '~/utils/message/createMessageEntry';
import { copyMessageEntry } from '~/utils/message/copyMessageEntry';
import {
hasOuterWhitespace,
htmlElementEscapes,
Expand Down Expand Up @@ -104,7 +105,7 @@ export type EditorActions = {
setEditorBusy(busy: boolean): void;

/** If `format: 'fluent'`, must be called with the source of a full entry */
setEditorFromHistory(value: string): void;
setEditorFromHistory(value: string, remapPlurals?: boolean): void;

/**
* @param manual Set `true` when value set due to direct user action
Expand Down Expand Up @@ -352,11 +353,14 @@ export function EditorProvider({ children }: { children: React.ReactElement }) {
};
}),

setEditorFromHistory: (str) =>
setEditorFromHistory: (str, remapPlurals = false) =>
setState((prev) => {
const next = { ...prev, autofilled: null };
if (specialFormats.has(format)) {
const entry = parseEntry(format, str);
let entry = parseEntry(format, str);
if (entry && remapPlurals) {
entry = copyMessageEntry(entry, locale, sourceEntry);
}
if (entry) {
includeSourceAttributesAndDeclarations(entry, sourceEntry);
next.base = entry;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,10 @@ const MF2_TRANSLATION = {
locale: LOCALE,
};

function createTranslation(format, translation, setEditorFromHelpers) {
function createTranslation(format, translation, setEditorFromHistory) {
const store = createReduxStore();
const Wrapper = (props) => (
<EditorActions.Provider value={{ setEditorFromHelpers }}>
<EditorActions.Provider value={{ setEditorFromHistory }}>
<HelperSelection.Provider value={{ element: -1, setElement() {} }}>
<OtherLocaleTranslationComponent {...props} />
</HelperSelection.Provider>
Expand Down Expand Up @@ -84,9 +84,7 @@ describe('<OtherLocaleTranslationComponent>', () => {

fireEvent.click(getByRole('listitem'));

expect(spy.mock.calls).toEqual([
['Un cheval, un cheval ! Mon royaume pour un cheval !', [], true],
]);
expect(spy.mock.calls).toEqual([[PLAIN_TRANSLATION.translation, true]]);
});

it('sets editor value for a Fluent translation', () => {
Expand All @@ -95,8 +93,6 @@ describe('<OtherLocaleTranslationComponent>', () => {

fireEvent.click(container.querySelector('li'));

expect(spy.mock.calls).toEqual([
['Un cheval, un cheval ! Mon royaume pour un cheval !', [], true],
]);
expect(spy.mock.calls).toEqual([[FLUENT_TRANSLATION.translation, true]]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export function OtherLocaleTranslationComponent({
parameters: { project, resource, entity },
index,
}: Props): React.ReactElement<React.ElementType> {
const { setEditorFromHelpers } = useContext(EditorActions);
const { setEditorFromHistory } = useContext(EditorActions);
const { element, setElement } = useContext(HelperSelection);
const isSelected = element === index;

Expand All @@ -43,9 +43,9 @@ export function OtherLocaleTranslationComponent({
const copyTranslationIntoEditor = useCallback(() => {
if (window.getSelection()?.isCollapsed !== false) {
setElement(index);
setEditorFromHelpers(plain, [], true);
setEditorFromHistory(translation.translation, true);
}
}, [index, setEditorFromHelpers, plain]);
}, [index, setElement, setEditorFromHistory, translation.translation]);

const className = classNames(
'translation',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { beforeAll, describe, expect, it, vi } from 'vitest';
import { EditorActions, EditorProvider, EditorResult } from '~/context/Editor';
import { EntityView } from '~/context/EntityView';
import { Locale } from '~/context/Locale';
import { HelperSelection } from '~/context/HelperSelection';
import { OtherLocaleTranslationComponent } from '~/modules/otherlocales/components/OtherLocaleTranslation';
import { RECEIVE } from '~/modules/otherlocales/actions';
import { useHandleCtrlShiftArrow } from '../utils/editFieldShortcuts';

import {
createDefaultUser,
Expand All @@ -26,10 +30,18 @@ const DEFAULT_LOCALE = {
cldrPlurals: [1, 5],
};

function mountForm(source, target = null, locale = DEFAULT_LOCALE) {
function mountForm(
source,
target = null,
locale = DEFAULT_LOCALE,
otherLocale,
) {
target ??= source;
const store = createReduxStore();
createDefaultUser(store);
if (otherLocale) {
store.dispatch({ type: RECEIVE, translations: [otherLocale] });
}

const [id, sourceEntry] = fluentParseEntry(source);
const [, targetEntry] = fluentParseEntry(target);
Expand All @@ -53,6 +65,20 @@ function mountForm(source, target = null, locale = DEFAULT_LOCALE) {
result = useContext(EditorResult);
return null;
};
const LocaleHelpers = () => {
const handleArrow = useHandleCtrlShiftArrow();
return (
<>
<OtherLocaleTranslationComponent
entity={entity}
translation={otherLocale}
parameters={{ project: 'p', resource: 'r', entity: '1' }}
index={0}
/>
<button onClick={() => handleArrow('ArrowDown')}>Next locale</button>
</>
);
};

const wrapper = mountComponentWithStore(() => {
const [currentEntity, updateCurrentEntity] = useState(entity);
Expand All @@ -64,6 +90,13 @@ function mountForm(source, target = null, locale = DEFAULT_LOCALE) {
<EditorProvider>
<Spy />
<TranslationForm />
{otherLocale && (
<HelperSelection.Provider
value={{ tab: 1, element: -1, setElement() {} }}
>
<LocaleHelpers />
</HelperSelection.Provider>
)}
</EditorProvider>
</EntityView.Provider>
</MockLocalizationProvider>
Expand All @@ -86,6 +119,81 @@ describe('<TranslationForm> with multiple fields', () => {
vi.useFakeTimers();
});

it.each(['click', 'shortcut'])(
'copies all locale attributes via %s',
(method) => {
const source = 'title =\n .label = Original\n .accesskey = O';
const { getResult, wrapper } = mountForm(source, null, DEFAULT_LOCALE, {
translation: 'title =\n .label = Traduction\n .accesskey = T',
locale: {
code: 'fr',
name: 'French',
direction: 'ltr',
script: 'Latn',
},
});
fireEvent.click(
method === 'click'
? wrapper.getByRole('listitem')
: wrapper.getByRole('button', { name: 'Next locale' }),
);
act(() => {
vi.runAllTimers();
});
expect(getResult().attributes).toEqual(
new Map([
['label', ['Traduction']],
['accesskey', ['T']],
]),
);
},
);

it.each(['click', 'shortcut'])(
'copies every locale variant via %s',
(method) => {
const source = ftl`
title =
{ $count ->
[one] One item
*[other] Many items
}
`;
const translation = ftl`
title =
{ $count ->
[one] Un article
*[other] Plusieurs articles
}
`;
const { wrapper } = mountForm(source, null, DEFAULT_LOCALE, {
translation,
locale: {
code: 'fr',
name: 'French',
direction: 'ltr',
script: 'Latn',
},
});
fireEvent.click(
method === 'click'
? wrapper.getByRole('listitem')
: wrapper.getByRole('button', { name: 'Next locale' }),
);
act(() => {
vi.runAllTimers();
});
const fields = wrapper.container.querySelectorAll(
'.translationform .cm-content',
);
expect(
Array.from(fields, (field) =>
EditorView.findFromDOM(field).state.doc.toString(),
),
).toEqual(['Un article', 'Plusieurs articles']);
},
);

it('renders textarea for a value and each attribute', () => {
const { views } = mountForm(ftl`
message = Value
Expand Down
12 changes: 2 additions & 10 deletions translate/src/modules/translationform/utils/editFieldShortcuts.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useContext } from 'react';

import { EditorActions } from '~/context/Editor';
import { EntityView } from '~/context/EntityView';
import { FailedChecksData } from '~/context/FailedChecksData';
import { HelperSelection } from '~/context/HelperSelection';
import { MachineryTranslations } from '~/context/MachineryTranslations';
Expand All @@ -10,7 +9,6 @@ import { UnsavedActions, UnsavedChanges } from '~/context/UnsavedChanges';
import { useLLMTranslation } from '~/context/TranslationContext';
import { Locale } from '~/context/Locale';
import { useAppSelector } from '~/hooks';
import { getPlainMessage, parseEntry } from '~/utils/message';
import { logUXAction } from '~/api/uxaction';

import { useExistingTranslationGetter } from '../../editor/hooks/useExistingTranslationGetter';
Expand Down Expand Up @@ -83,8 +81,7 @@ export function useHandleEscape(): () => boolean {
export function useHandleCtrlShiftArrow(): (
key: 'ArrowDown' | 'ArrowUp',
) => boolean {
const { entity } = useContext(EntityView);
const { setEditorFromHelpers, setEditorFromComposed } =
const { setEditorFromHelpers, setEditorFromComposed, setEditorFromHistory } =
useContext(EditorActions);
const helperSelection = useContext(HelperSelection);
const { composed, translations: machineryTranslations } = useContext(
Expand Down Expand Up @@ -146,12 +143,7 @@ export function useHandleCtrlShiftArrow(): (
}
} else {
const { translation } = otherLocaleTranslations[nextIdx];
const entry = parseEntry(entity.format, translation);
setEditorFromHelpers(
entry ? getPlainMessage(entry) : translation,
[],
true,
);
setEditorFromHistory(translation, true);
}
return true;
};
Expand Down
Loading