Re-introduce CodeMirror editor + delay setting failed checks until editor results have updated (#2917)

* Replace editor `<textarea>` with CodeMirror v6

* refactor: Add EditFieldHandle and EditorResult as interfaces for the editor value

* Add syntax highlighting for placeholders and tags

* Re-enable spellchecker in translation editor (#2884)

* Delay setting failed checks until editor results have updated

* Update tests to account for setTimeout

* Scroll editor settings menu into view on display

---------

Co-authored-by: Matjaž Horvat <matjaz.horvat@gmail.com>
This commit is contained in:
Eemeli Aro 2023-07-26 11:25:21 +03:00 коммит произвёл GitHub
Родитель 505023de36
Коммит d43c73c479
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
36 изменённых файлов: 1291 добавлений и 814 удалений

93
package-lock.json сгенерированный
Просмотреть файл

@ -1912,6 +1912,62 @@
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true
},
"node_modules/@codemirror/autocomplete": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.7.1.tgz",
"integrity": "sha512-hSxf9S0uB+GV+gBsjY1FZNo53e1FFdzPceRfCfD1gWOnV6o21GfB5J5Wg9G/4h76XZMPrF0A6OCK/Rz5+V1egg==",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.6.0",
"@lezer/common": "^1.0.0"
},
"peerDependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.2.4.tgz",
"integrity": "sha512-42lmDqVH0ttfilLShReLXsDfASKLXzfyC36bzwcqzox9PlHulMcsUOfHXNo2X2aFMVNUoQ7j+d4q5bnfseYoOA==",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.2.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.7.0.tgz",
"integrity": "sha512-4SMwe6Fwn57klCUsVN0y4/h/iWT+XIXFEmop2lIHHuWO0ubjCrF3suqSZLyOQlznxkNnNbOOfKe5HQbQGCAmTg==",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/state": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.2.1.tgz",
"integrity": "sha512-RupHSZ8+OjNT38zU9fKH2sv+Dnlr8Eb8sl4NOnnqz95mCFTZUaiRP8Xv5MeeaG0px2b8Bnfe7YGwCV3nsBhbuw=="
},
"node_modules/@codemirror/view": {
"version": "6.12.0",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.12.0.tgz",
"integrity": "sha512-xNHvbJBc2v8JuEcIGOck6EUGShpP+TYGCEMVEVQMYxbFXfMhYnoF3znxB/2GgeKR0nrxBs+nhBupiTYQqCp2kw==",
"dependencies": {
"@codemirror/state": "^6.1.4",
"style-mod": "^4.0.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@emotion/is-prop-valid": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz",
@ -2981,6 +3037,27 @@
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
"integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw=="
},
"node_modules/@lezer/common": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.0.2.tgz",
"integrity": "sha512-SVgiGtMnMnW3ActR8SXgsDhw7a0w0ChHSYAyAUxxrOiJ1OqYWEKk/xJd84tTSPo1mo6DXLObAJALNnd0Hrv7Ng=="
},
"node_modules/@lezer/highlight": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.1.6.tgz",
"integrity": "sha512-cmSJYa2us+r3SePpRCjN5ymCqCPv+zyXmDl0ciWtVaNiORT/MxM7ZgOMQZADD0o51qOaOg24qc/zBViOIwAjJg==",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.3.5.tgz",
"integrity": "sha512-Kye0rxYBi+OdToLUN2tQfeH5VIrpESC6XznuvxmIxbO1lz6M1C90vkjMNYoX1SfbUcuvoPXvLYsBquZ//77zVQ==",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@messageformat/fluent": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@messageformat/fluent/-/fluent-0.4.1.tgz",
@ -11982,6 +12059,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/style-mod": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.0.3.tgz",
"integrity": "sha512-78Jv8kYJdjbvRwwijtCevYADfsI0lGzYJe4mMFdceO8l75DFFDoqBhR1jVDicDRRaX4//g1u9wKeo+ztc2h1Rw=="
},
"node_modules/style-to-js": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.1.tgz",
@ -12606,6 +12688,11 @@
"resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz",
"integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw=="
},
"node_modules/w3c-keyname": {
"version": "2.2.7",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.7.tgz",
"integrity": "sha512-XB8aa62d4rrVfoZYQaYNy3fy+z4nrfy2ooea3/0BnBzXW0tSdZ+lRgjzBZhk0La0H6h8fVyYCxx/qkQcAIuvfg=="
},
"node_modules/w3c-xmlserializer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
@ -12944,10 +13031,16 @@
},
"translate": {
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/commands": "^6.2.4",
"@codemirror/language": "^6.7.0",
"@codemirror/state": "^6.2.1",
"@codemirror/view": "^6.12.0",
"@fluent/bundle": "^0.18.0",
"@fluent/langneg": "^0.7.0",
"@fluent/react": "^0.15.1",
"@fluent/syntax": "^0.19.0",
"@lezer/highlight": "^1.1.6",
"@messageformat/fluent": "^0.4.1",
"@reduxjs/toolkit": "^1.6.1",
"classnames": "^2.3.1",

Просмотреть файл

@ -19,6 +19,7 @@ module.exports = {
'@typescript-eslint/no-empty-function': 0,
'@typescript-eslint/no-explicit-any': 0,
'@typescript-eslint/no-inferrable-types': 0,
'@typescript-eslint/no-unused-vars': ['error', { varsIgnorePattern: '^_' }],
'@typescript-eslint/prefer-as-const': 0,
'import/no-default-export': 'error',
},

Просмотреть файл

@ -2,10 +2,16 @@
"name": "translate",
"private": true,
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/commands": "^6.2.4",
"@codemirror/language": "^6.7.0",
"@codemirror/state": "^6.2.1",
"@codemirror/view": "^6.12.0",
"@fluent/bundle": "^0.18.0",
"@fluent/langneg": "^0.7.0",
"@fluent/react": "^0.15.1",
"@fluent/syntax": "^0.19.0",
"@lezer/highlight": "^1.1.6",
"@messageformat/fluent": "^0.4.1",
"@reduxjs/toolkit": "^1.6.1",
"classnames": "^2.3.1",

Просмотреть файл

@ -10,7 +10,7 @@ import css from 'rollup-plugin-css-only';
/** @type {import('rollup').RollupOptions} */
const config = {
input: 'src/index.tsx',
output: { file: 'dist/translate.js' },
output: { file: 'dist/translate.js', format: 'iife' },
treeshake: 'recommended',

Просмотреть файл

@ -6,7 +6,12 @@ import { act } from 'react-dom/test-utils';
import { createReduxStore, mountComponentWithStore } from '~/test/store';
import { editMessageEntry, parseEntry } from '~/utils/message';
import { EditorActions, EditorData, EditorProvider } from './Editor';
import {
EditorActions,
EditorData,
EditorProvider,
EditorResult,
} from './Editor';
import { EntityView, EntityViewProvider } from './EntityView';
import { Locale } from './Locale';
import { Location, LocationProvider } from './Location';
@ -68,37 +73,64 @@ function mountSpy(Spy, format, translation) {
describe('<EditorProvider>', () => {
it('provides a simple non-Fluent value', () => {
let editor;
let editor, result;
const Spy = () => {
editor = useContext(EditorData);
result = useContext(EditorResult);
return null;
};
mountSpy(Spy, 'simple', 'message');
expect(editor).toMatchObject({
sourceView: false,
initial: [{ id: '', keys: [], labels: [], name: '', value: 'message' }],
value: [{ id: '', keys: [], labels: [], name: '', value: 'message' }],
initial: {
id: 'key',
value: { pattern: { body: [{ type: 'text', value: 'message' }] } },
},
fields: [
{
id: '',
keys: [],
labels: [],
name: '',
handle: { current: { value: 'message' } },
},
],
});
expect(result).toMatchObject([{ name: '', keys: [], value: 'message' }]);
});
it('provides a simple Fluent value', () => {
let editor;
let editor, result;
const Spy = () => {
editor = useContext(EditorData);
result = useContext(EditorResult);
return null;
};
mountSpy(Spy, 'ftl', 'key = message');
expect(editor).toMatchObject({
sourceView: false,
initial: [{ id: '', keys: [], labels: [], name: '', value: 'message' }],
value: [{ id: '', keys: [], labels: [], name: '', value: 'message' }],
initial: {
id: 'key',
value: { pattern: { body: [{ type: 'text', value: 'message' }] } },
},
fields: [
{
id: '',
keys: [],
labels: [],
name: '',
handle: { current: { value: 'message' } },
},
],
});
expect(result).toMatchObject([{ name: '', keys: [], value: 'message' }]);
});
it('provides a rich Fluent value', () => {
let editor;
let editor, result;
const Spy = () => {
editor = useContext(EditorData);
result = useContext(EditorResult);
return null;
};
const source = ftl`
@ -111,14 +143,23 @@ describe('<EditorProvider>', () => {
`;
mountSpy(Spy, 'ftl', source);
const value = editMessageEntry(parseEntry(source));
expect(editor).toMatchObject({ sourceView: false, initial: value, value });
const entry = parseEntry(source);
const fields = editMessageEntry(parseEntry(source)).map((field) => ({
...field,
handle: { current: { value: field.handle.current.value } },
}));
expect(editor).toMatchObject({ sourceView: false, initial: entry, fields });
expect(result).toMatchObject([
{ name: '', keys: [{ type: 'nmtoken', value: 'one' }], value: 'ONE' },
{ name: '', keys: [{ type: '*', value: 'other' }], value: 'OTHER' },
]);
});
it('provides a forced source Fluent value', () => {
let editor;
let editor, result;
const Spy = () => {
editor = useContext(EditorData);
result = useContext(EditorResult);
return null;
};
const source = '## comment\n';
@ -126,19 +167,28 @@ describe('<EditorProvider>', () => {
expect(editor).toMatchObject({
sourceView: true,
initial: [
{ id: '', keys: [], labels: [], name: '', value: '## comment' },
initial: {
id: 'key',
value: { pattern: { body: [{ type: 'text', value: '## comment\n' }] } },
},
fields: [
{
handle: { current: { value: '## comment' } },
id: '',
keys: [],
labels: [],
name: '',
},
],
value: [{ id: '', keys: [], labels: [], name: '', value: '## comment' }],
});
expect(result).toMatchObject([{ name: '', keys: [], value: '## comment' }]);
});
it('updates state on entity and plural form changes', () => {
let editor;
let location;
let entity;
let editor, result, location, entity;
const Spy = () => {
editor = useContext(EditorData);
result = useContext(EditorResult);
location = useContext(Location);
entity = useContext(EntityView);
return null;
@ -149,24 +199,27 @@ describe('<EditorProvider>', () => {
wrapper.update();
expect(editor).toMatchObject({
sourceView: false,
initial: [{ id: '', keys: [], labels: [], name: '', value: 'one' }],
value: [{ id: '', keys: [], labels: [], name: '', value: 'one' }],
initial: {
value: { pattern: { body: [{ type: 'text', value: 'one' }] } },
},
fields: [{ handle: { current: { value: 'one' } } }],
});
expect(result).toMatchObject([{ value: 'one' }]);
act(() => entity.setPluralForm(1));
wrapper.update();
expect(editor).toMatchObject({
sourceView: false,
initial: [{ id: '', keys: [], labels: [], name: '', value: 'other' }],
value: [{ id: '', keys: [], labels: [], name: '', value: 'other' }],
initial: {
value: { pattern: { body: [{ type: 'text', value: 'other' }] } },
},
fields: [{ handle: { current: { value: 'other' } } }],
});
expect(result).toMatchObject([{ value: 'other' }]);
});
it('clears a rich Fluent value', () => {
let editor;
let actions;
let editor, actions;
const Spy = () => {
editor = useContext(EditorData);
actions = useContext(EditorActions);
@ -184,41 +237,36 @@ describe('<EditorProvider>', () => {
wrapper.update();
expect(editor).toMatchObject({
fields: [{ current: null }, { current: null }],
sourceView: false,
value: [
fields: [
{
handle: { current: { value: '' } },
id: '|one',
keys: [{ type: 'nmtoken', value: 'one' }],
labels: [{ label: 'one', plural: true }],
name: '',
value: '',
},
{
handle: { current: { value: '' } },
id: '|other',
keys: [{ type: '*', value: 'other' }],
labels: [{ label: 'other', plural: true }],
name: '',
value: '',
},
],
});
});
it('sets editor from history', () => {
let editor;
let actions;
let editor, result, actions;
const Spy = () => {
editor = useContext(EditorData);
result = useContext(EditorResult);
actions = useContext(EditorActions);
return null;
};
const wrapper = mountSpy(Spy, 'ftl', `key = VALUE\n`);
expect(editor).toMatchObject({
fields: [{ current: null }],
sourceView: false,
value: [{ keys: [], labels: [], name: '', value: 'VALUE' }],
});
const source = ftl`
key =
{ $var ->
@ -230,30 +278,35 @@ describe('<EditorProvider>', () => {
wrapper.update();
expect(editor).toMatchObject({
fields: [{ current: null }, { current: null }],
sourceView: false,
value: [
fields: [
{
handle: { current: { value: 'ONE' } },
id: '|one',
keys: [{ type: 'nmtoken', value: 'one' }],
labels: [{ label: 'one', plural: true }],
name: '',
value: 'ONE',
},
{
handle: { current: { value: 'OTHER' } },
id: '|other',
keys: [{ type: '*', value: 'other' }],
labels: [{ label: 'other', plural: true }],
name: '',
value: 'OTHER',
},
],
});
expect(result).toMatchObject([
{ keys: [{ type: 'nmtoken', value: 'one' }], name: '', value: 'ONE' },
{ keys: [{ type: '*', value: 'other' }], name: '', value: 'OTHER' },
]);
});
it('toggles Fluent source view', () => {
let editor;
let actions;
let editor, result, actions;
const Spy = () => {
editor = useContext(EditorData);
result = useContext(EditorResult);
actions = useContext(EditorActions);
return null;
};
@ -269,31 +322,26 @@ describe('<EditorProvider>', () => {
wrapper.update();
expect(editor).toMatchObject({
fields: [{ current: null }],
sourceView: true,
value: [{ keys: [], labels: [], name: '', value: source }],
fields: [
{
handle: { current: { value: source } },
id: '',
keys: [],
labels: [],
name: '',
},
],
});
expect(result).toMatchObject([{ keys: [], name: '', value: source }]);
act(() => actions.toggleSourceView());
wrapper.update();
expect(editor).toMatchObject({
fields: [{ current: null }, { current: null }],
sourceView: false,
value: [
{
keys: [{ type: 'nmtoken', value: 'one' }],
labels: [{ label: 'one', plural: true }],
name: '',
value: 'ONE',
},
{
keys: [{ type: '*', value: 'other' }],
labels: [{ label: 'other', plural: true }],
name: '',
value: 'OTHER',
},
],
});
expect(editor).toMatchObject({ fields: [{}, {}], sourceView: false });
expect(result).toMatchObject([
{ keys: [{ type: 'nmtoken', value: 'one' }], name: '', value: 'ONE' },
{ keys: [{ type: '*', value: 'other' }], name: '', value: 'OTHER' },
]);
});
});

Просмотреть файл

@ -8,11 +8,12 @@ import React, {
} from 'react';
import type { SourceType } from '~/api/machinery';
import { useTranslationStatus } from '~/modules/entities/useTranslationStatus';
import { useReadonlyEditor } from '~/hooks/useReadonlyEditor';
import { useTranslationStatus } from '~/modules/entities/useTranslationStatus';
import {
buildMessageEntry,
editMessageEntry,
editSource,
requiresSourceView,
getEmptyMessageEntry,
MessageEntry,
@ -25,9 +26,16 @@ import { EntityView, useActiveTranslation } from './EntityView';
import { FailedChecksData } from './FailedChecksData';
import { Locale } from './Locale';
import { MachineryTranslations } from './MachineryTranslations';
import { UnsavedActions, UnsavedChanges } from './UnsavedChanges';
import { UnsavedActions } from './UnsavedChanges';
export type EditorMessage = Array<{
export type EditFieldHandle = {
get value(): string;
focus(): void;
setSelection(text: string): void;
setValue(text: string): void;
};
export type EditorField = {
/** An identifier for this field */
id: string;
@ -39,6 +47,50 @@ export type EditorMessage = Array<{
labels: Array<{ label: string; plural: boolean }>;
handle: React.MutableRefObject<EditFieldHandle>;
};
export type EditorData = Readonly<{
/**
* Should match `useContext(EntityView).pk`.
* If it doesn't, the entity has changed but data isn't updated yet.
*/
pk: number;
/** Is a request to send a new translation running? */
busy: boolean;
/** Used to reconstruct edited messages */
entry: MessageEntry;
/** Input fields for the value being edited */
fields: EditorField[];
/**
* The current or most recent field with focus;
* used as the target of machinery replacements.
*/
focusField: React.MutableRefObject<EditorField | null>;
/** Used for detecting unsaved changes */
initial: MessageEntry;
machinery: {
manual: boolean;
sources: SourceType[];
translation: string;
} | null;
sourceView: boolean;
}>;
export type EditorResult = Array<{
/** Attribute name, or empty for the value */
name: string;
/** Selector keys, or empty array for single-pattern messages */
keys: Variant['keys'];
/**
* A flattened representation of a single message pattern,
* which may contain syntactic representations of placeholders.
@ -46,33 +98,28 @@ export type EditorMessage = Array<{
value: string;
}>;
function editSource(source: string | MessageEntry) {
const value =
typeof source === 'string' ? source : serializeEntry('ftl', source);
return [{ id: '', name: '', keys: [], labels: [], value: value.trim() }];
}
export type EditorActions = {
clearEditor(): void;
/**
* Creates a copy of `base` with an entry matching `id` updated to `value`.
*
* @param id If empty, matches first entry of `base`.
* If set, a path split by `|` characters.
*/
function setEditorMessage(
base: EditorMessage,
id: string | null | undefined,
value: string,
): EditorMessage {
let set = false;
return base.map((field) => {
if (!set && (!id || field.id === id)) {
set = true;
return { ...field, value };
} else {
return field;
}
});
}
setEditorBusy(busy: boolean): void;
/** If `format: 'ftl'`, must be called with the source of a full entry */
setEditorFromHistory(value: string): void;
/** @param manual Set `true` when value set due to direct user action */
setEditorFromHelpers(
value: string,
sources: SourceType[],
manual: boolean,
): void;
setEditorSelection(content: string): void;
/** Set the result value of the active input */
setResultFromInput(idx: number, value: string): void;
toggleSourceView(): void;
};
function parseEntryFromFluentSource(base: MessageEntry, source: string) {
const entry = parseEntry(source);
@ -95,71 +142,15 @@ const createSimpleMessageEntry = (id: string, value: string): MessageEntry => ({
},
});
export type EditorData = Readonly<{
/** Is a request to send a new translation running? */
busy: boolean;
/** Used to reconstruct edited messages */
entry: MessageEntry;
/** Editor input components */
fields: Array<
React.MutableRefObject<HTMLInputElement | HTMLTextAreaElement | null>
>;
/**
* Index in `fields` of the current or most recent field with focus;
* used as the target of machinery replacements.
*/
focusField: React.MutableRefObject<number>;
/** Used for detecting unsaved changes */
initial: EditorMessage;
machinery: {
manual: boolean;
sources: SourceType[];
translation: string;
} | null;
sourceView: boolean;
/** The current value being edited */
value: EditorMessage;
}>;
export type EditorActions = {
clearEditor(): void;
setEditorBusy(busy: boolean): void;
/** If `format: 'ftl'`, must be called with the source of a full entry */
setEditorFromHistory(value: string): void;
/** For `view: 'rich'`, if `value` is a string, sets the value of the active input */
setEditorFromInput(value: string | EditorMessage): void;
/** @param manual Set `true` when value set due to direct user action */
setEditorFromHelpers(
value: string,
sources: SourceType[],
manual: boolean,
): void;
setEditorSelection(content: string): void;
toggleSourceView(): void;
};
const initEditorData: EditorData = {
pk: 0,
busy: false,
entry: { id: '', value: null, attributes: new Map() },
fields: [],
focusField: { current: 0 },
initial: [],
focusField: { current: null },
initial: { id: '', value: null, attributes: new Map() },
machinery: null,
fields: [],
sourceView: false,
value: [],
};
const initEditorActions: EditorActions = {
@ -167,14 +158,22 @@ const initEditorActions: EditorActions = {
setEditorBusy: () => {},
setEditorFromHelpers: () => {},
setEditorFromHistory: () => {},
setEditorFromInput: () => {},
setEditorSelection: () => {},
setResultFromInput: () => {},
toggleSourceView: () => {},
};
export const EditorData = createContext(initEditorData);
export const EditorResult = createContext<EditorResult>([]);
export const EditorActions = createContext(initEditorActions);
const buildResult = (message: EditorField[]): EditorResult =>
message.map(({ handle, keys, name }) => ({
name,
keys,
value: handle.current.value,
}));
export function EditorProvider({ children }: { children: React.ReactElement }) {
const locale = useContext(Locale);
const { entity } = useContext(EntityView);
@ -182,37 +181,44 @@ export function EditorProvider({ children }: { children: React.ReactElement }) {
const readonly = useReadonlyEditor();
const machinery = useContext(MachineryTranslations);
const { setUnsavedChanges } = useContext(UnsavedActions);
const { exist } = useContext(UnsavedChanges);
const { resetFailedChecks } = useContext(FailedChecksData);
const [state, setState] = useState<EditorData>(initEditorData);
const [state, setState] = useState(initEditorData);
const [result, setResult] = useState<EditorResult>([]);
const actions = useMemo<EditorActions>(() => {
if (readonly) {
return initEditorActions;
}
return {
clearEditor: () =>
setState((prev) => {
const empty = prev.value.map((field) => ({ ...field, value: '' }));
return { ...prev, value: empty };
}),
clearEditor() {
setState((state) => {
for (const field of state.fields) {
field.handle.current.setValue('');
}
return state;
});
},
setEditorBusy: (busy) =>
setState((prev) => (busy === prev.busy ? prev : { ...prev, busy })),
setEditorFromHelpers: (str, sources, manual) =>
setState((prev) => {
const { fields, focusField, sourceView, value } = prev;
const input = fields[focusField.current]?.current;
let next = setEditorMessage(value, input?.id, str);
const { fields, focusField, sourceView } = prev;
const field = focusField.current ?? fields[0];
field.handle.current.setValue(str);
let next = fields.slice();
if (sourceView) {
next = editSource(buildMessageEntry(prev.entry, next));
const result = buildResult(next);
next = editSource(buildMessageEntry(prev.entry, result));
focusField.current = next[0];
setResult(result);
}
return {
...prev,
machinery: { manual, translation: str, sources },
value: next,
fields: next,
};
}),
@ -225,80 +231,62 @@ export function EditorProvider({ children }: { children: React.ReactElement }) {
next.entry = entry;
}
if (entry && !requiresSourceView(entry)) {
next.value = prev.sourceView
next.fields = prev.sourceView
? editSource(entry)
: editMessageEntry(entry);
} else {
next.value = editSource(str);
next.fields = editSource(str);
next.sourceView = true;
}
next.fields = next.value.map(() => ({ current: null }));
} else {
next.value = setEditorMessage(prev.initial, null, str);
next.fields = editMessageEntry(prev.initial);
next.fields[0].handle.current.setValue(str);
}
next.focusField.current = next.fields[0];
setResult(buildResult(next.fields));
return next;
}),
setEditorFromInput: (input) =>
setState((prev) => {
if (typeof input === 'string') {
const { fields, focusField, value } = prev;
const field = fields[focusField.current]?.current;
const next = setEditorMessage(value, field?.id, input);
return { ...prev, value: next };
} else {
return { ...prev, value: input };
}
setEditorSelection: (content) =>
setState((state) => {
const { fields, focusField } = state;
const field = focusField.current ?? fields[0];
field.handle.current.setSelection(content);
return state;
}),
setEditorSelection: (content) =>
setState((prev) => {
const { fields, focusField, sourceView, value } = prev;
let next: EditorMessage;
const input = fields[focusField.current]?.current;
if (input) {
input.setRangeText(
content,
input.selectionStart ?? 0, // never actually null for <input type="text"> or <textarea>
input.selectionEnd ?? 0,
'end',
);
next = setEditorMessage(value, input.id, input.value);
} else if (value.length === 1) {
next = setEditorMessage(value, null, value[0].value + content);
if (sourceView) {
next = editSource(buildMessageEntry(prev.entry, next));
}
setResultFromInput: (idx, value) =>
setResult((prev) => {
if (prev.length > idx) {
const res = prev.slice();
res[idx] = { ...res[idx], value };
return res;
} else {
next = setEditorMessage(value, null, content);
return prev;
}
return { ...prev, value: next };
}),
toggleSourceView: () =>
setState((prev) => {
if (prev.sourceView) {
const source = prev.value[0].value;
const source = prev.fields[0].handle.current.value;
const entry = parseEntryFromFluentSource(prev.entry, source);
if (entry && !requiresSourceView(entry)) {
const value = editMessageEntry(entry);
return {
...prev,
entry,
fields: value.map(() => ({ current: null })),
sourceView: false,
value,
};
const fields = editMessageEntry(entry);
prev.focusField.current = fields[0];
setResult(buildResult(fields));
return { ...prev, entry, fields, sourceView: false };
}
} else if (entity.format === 'ftl') {
const entry = buildMessageEntry(prev.entry, prev.value);
const entry = buildMessageEntry(
prev.entry,
buildResult(prev.fields),
);
const source = serializeEntry('ftl', entry);
return {
...prev,
fields: [{ current: null }],
sourceView: true,
value: editSource(source),
};
const fields = editSource(source);
prev.focusField.current = fields[0];
setResult(buildResult(fields));
return { ...prev, fields, sourceView: true };
}
return prev;
}),
@ -339,20 +327,19 @@ export function EditorProvider({ children }: { children: React.ReactElement }) {
entry = createSimpleMessageEntry(entity.key, source);
}
const value: EditorMessage = sourceView
? editSource(source)
: editMessageEntry(entry);
const fields = sourceView ? editSource(source) : editMessageEntry(entry);
setState(() => ({
pk: entity.pk,
busy: false,
entry,
fields: value.map(() => ({ current: null })),
focusField: { current: 0 },
initial: value,
fields,
focusField: { current: fields[0] },
initial: entry,
machinery: null,
sourceView,
value,
}));
setResult(buildResult(fields));
}, [locale, entity, activeTranslation]);
// For missing entries, fill editor initially with a perfect match from
@ -363,8 +350,8 @@ export function EditorProvider({ children }: { children: React.ReactElement }) {
status === 'missing' &&
state.machinery === null &&
!state.sourceView &&
state.value.length === 1 &&
state.value[0].value === ''
state.fields.length === 1 &&
state.fields[0].handle.current.value === ''
) {
const perfect = machinery.translations.find((tx) => tx.quality === 100);
if (perfect) {
@ -378,34 +365,39 @@ export function EditorProvider({ children }: { children: React.ReactElement }) {
}, [state, actions, status, machinery.translations]);
useEffect(() => {
// Changes in `value` need to be reflected in `UnsavedChanges`,
// Dismiss failed checks when the results change.
// Note that if the editor is updated simultaneously,
// setting failed checks needs to be delayed past this.
resetFailedChecks();
// Changes in `result` need to be reflected in `UnsavedChanges`,
// but the latter needs to be defined at a higher level to make it
// available in `EntitiesList`. Therefore, that state is managed here.
setUnsavedChanges(!pojoEquals(state.initial, state.value));
if (exist) {
resetFailedChecks();
}
}, [state.value, state.initial]);
// Let's also avoid the calculation, unless it's actually required.
setUnsavedChanges(() => {
const { entry, initial, sourceView } = state;
const next = sourceView
? parseEntryFromFluentSource(entry, result[0].value)
: buildMessageEntry(entry, result);
return !pojoEquals(initial, next);
});
}, [result]);
return (
<EditorData.Provider value={state}>
<EditorActions.Provider value={actions}>
{children}
</EditorActions.Provider>
<EditorResult.Provider value={result}>
<EditorActions.Provider value={actions}>
{children}
</EditorActions.Provider>
</EditorResult.Provider>
</EditorData.Provider>
);
}
export function useEditorValue(): EditorMessage {
const { value } = useContext(EditorData);
return value;
}
export function useEditorMessageEntry() {
const { entry, sourceView } = useContext(EditorData);
const value = useEditorValue();
const message = useContext(EditorResult);
return sourceView
? parseEntryFromFluentSource(entry, value[0].value)
: buildMessageEntry(entry, value);
? parseEntryFromFluentSource(entry, message[0].value)
: buildMessageEntry(entry, message);
}

Просмотреть файл

@ -1,11 +1,16 @@
import React, { createContext, useMemo, useState } from 'react';
export type UnsavedChanges = Readonly<{
check(): boolean;
onIgnore: (() => void) | null;
exist: boolean;
}>;
export type UnsavedActions = {
/**
* The `callback` is called as `setTimout(callback)`
* to avoid an occasional React complaint about
* updating one component while rendering a different component.
*/
checkUnsavedChanges(callback: () => void): void;
/**
@ -13,11 +18,11 @@ export type UnsavedActions = {
* its callback is triggered.
*/
resetUnsavedChanges(ignore: boolean): void;
setUnsavedChanges(exist: boolean): void;
setUnsavedChanges(check: () => boolean): void;
};
const initUnsavedChanges: UnsavedChanges = {
exist: false,
check: () => false,
onIgnore: null,
};
@ -47,25 +52,21 @@ export function UnsavedChangesProvider({
() => ({
checkUnsavedChanges: (callback: () => void) =>
setState((prev) => {
if (prev.exist) {
return { ...prev, onIgnore: callback };
if (prev.check()) {
return { check: () => true, onIgnore: callback };
} else {
callback();
setTimeout(callback);
return prev;
}
}),
resetUnsavedChanges: (ignore) =>
setState((prev) => {
if (prev.onIgnore) {
const { onIgnore } = prev;
if (onIgnore) {
if (ignore) {
// Needs to happen after the return to avoid an occasional React
// complaint about updating one component while rendering a
// different component.
const { onIgnore } = prev;
setTimeout(onIgnore);
return { ...prev, exist: false, onIgnore: null };
return { check: () => false, onIgnore: null };
}
return { ...prev, onIgnore: null };
} else {
@ -73,12 +74,8 @@ export function UnsavedChangesProvider({
}
}),
setUnsavedChanges: (exist: boolean) =>
setState((prev) =>
prev.exist === exist && !prev.onIgnore
? prev
: { ...prev, exist, onIgnore: null },
),
setUnsavedChanges: (check: () => boolean) =>
setState({ check, onIgnore: null }),
}),
[],
);

Просмотреть файл

@ -5,36 +5,3 @@
flex-direction: column;
position: relative;
}
.editor textarea {
border: none;
box-sizing: border-box;
display: block;
font-size: 14px;
line-height: 22px;
height: calc(100% - 60px);
min-height: 100px;
overflow: auto;
padding: 10px;
width: 100%;
resize: vertical;
}
/* Remove highlight in Chrome */
.editor textarea:focus {
outline: none;
}
.editor .plural-selector ~ textarea {
min-height: auto;
}
.editor input[readonly],
.editor textarea[readonly] {
background: #c7cacf;
cursor: default;
}
.editor textarea[data-script='Arabic'] {
font-size: 15px;
}

Просмотреть файл

@ -11,6 +11,7 @@ import { EditorActions, EditorProvider } from '~/context/Editor';
import { EntityViewProvider } from '~/context/EntityView';
import { LocationProvider } from '~/context/Location';
import { RECEIVE_ENTITIES } from '~/modules/entities/actions';
import { EditField } from '~/modules/translationform/components/EditField';
import { createDefaultUser, createReduxStore } from '~/test/store';
import { MockLocalizationProvider } from '~/test/utils';
@ -128,37 +129,37 @@ describe('<Editor>', () => {
it('renders the simple form when passing a simple string', () => {
const [wrapper] = mountEditor(1);
const input = wrapper.find('TranslationForm textarea');
const input = wrapper.find(EditField);
expect(input).toHaveLength(1);
expect(input.prop('value')).toBe('Salut');
expect(input.prop('defaultValue')).toBe('Salut');
});
it('renders the simple form when passing a simple string with one attribute', () => {
const [wrapper] = mountEditor(2);
const input = wrapper.find('TranslationForm textarea');
const input = wrapper.find(EditField);
expect(input).toHaveLength(1);
expect(input.prop('value')).toBe('Quelque chose de bien');
expect(input.prop('defaultValue')).toBe('Quelque chose de bien');
});
it('renders the rich form when passing a supported rich message', () => {
const [wrapper] = mountEditor(3);
expect(wrapper.find('TranslationForm textarea')).toHaveLength(2);
expect(wrapper.find(EditField)).toHaveLength(2);
});
it('renders the rich form when passing a message with nested selector', () => {
const [wrapper] = mountEditor(5);
expect(wrapper.find('TranslationForm textarea')).toHaveLength(4);
expect(wrapper.find(EditField)).toHaveLength(4);
});
it('renders the source form when passing a broken string', () => {
const [wrapper] = mountEditor(6);
const input = wrapper.find('TranslationForm textarea');
const input = wrapper.find(EditField);
expect(input).toHaveLength(1);
expect(input.prop('value')).toBe(BROKEN_STRING.trim());
expect(input.prop('defaultValue')).toBe(BROKEN_STRING.trim());
});
it('converts translation when switching source mode', () => {
@ -167,9 +168,9 @@ describe('<Editor>', () => {
// Force source mode.
wrapper.find('button.ftl').simulate('click');
const input = wrapper.find('TranslationForm textarea');
const input = wrapper.find(EditField);
expect(input).toHaveLength(1);
expect(input.prop('value')).toBe('my-message = Salut');
expect(input.prop('defaultValue')).toBe('my-message = Salut');
});
it('sets empty initial translation in source mode when untranslated', () => {
@ -178,27 +179,24 @@ describe('<Editor>', () => {
// Force source mode.
wrapper.find('button.ftl').simulate('click');
const input = wrapper.find('TranslationForm textarea');
const input = wrapper.find(EditField);
expect(input).toHaveLength(1);
expect(input.prop('value')).toBe('my-message = { "" }');
expect(input.prop('defaultValue')).toBe('my-message = { "" }');
});
it('changes editor implementation when changing translation syntax', () => {
const [wrapper] = mountEditor(1);
const [wrapper, actions] = mountEditor(1);
// Force source mode.
wrapper.find('button.ftl').simulate('click');
const input = wrapper.find('TranslationForm textarea');
act(() =>
input.prop('onChange')({ currentTarget: { value: RICH_MESSAGE_STRING } }),
);
act(() => actions.setEditorFromHistory(RICH_MESSAGE_STRING));
wrapper.update();
// Switch to rich mode.
wrapper.find('button.ftl').simulate('click');
expect(wrapper.find('TranslationForm textarea')).toHaveLength(2);
expect(wrapper.find(EditField)).toHaveLength(2);
});
it('updates content that comes from an external source', () => {
@ -209,7 +207,7 @@ describe('<Editor>', () => {
wrapper.update();
// The translation has been updated to a simplified preview.
expect(wrapper.find('textarea').text()).toEqual('Coucou');
expect(wrapper.find(EditField).text()).toEqual('Coucou');
});
it('passes a reconstructed translation to sendTranslation', async () => {
@ -218,7 +216,7 @@ describe('<Editor>', () => {
const [wrapper, actions] = mountEditor(1);
// Update the content with new input
act(() => actions.setEditorFromInput('Coucou'));
act(() => actions.setResultFromInput(0, 'Coucou'));
wrapper.update();
await act(() => wrapper.find('.action-suggest').prop('onClick')());

Просмотреть файл

@ -1,5 +1,5 @@
import { Localized } from '@fluent/react';
import React, { useCallback, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { Settings } from '~/modules/user';
import { useOnDiscard } from '~/utils';
@ -23,10 +23,18 @@ export function EditorSettingsDialog({
toggleSetting,
onDiscard,
}: EditorSettingsProps): React.ReactElement<'ul'> {
const ref = useRef(null);
const ref = useRef<HTMLUListElement>(null);
const isTranslator = useTranslator();
useOnDiscard(ref, onDiscard);
useEffect(() => {
const mediaQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)');
ref.current?.scrollIntoView({
behavior: mediaQuery?.matches ? 'auto' : 'smooth',
block: 'nearest',
});
});
return (
<ul ref={ref} className='menu'>
<Localized

Просмотреть файл

@ -2,7 +2,7 @@ import { Localized } from '@fluent/react';
import classNames from 'classnames';
import React, { useCallback, useContext, useMemo } from 'react';
import { EditorActions, EditorData, useEditorValue } from '~/context/Editor';
import { EditorActions, EditorData, EditorResult } from '~/context/Editor';
import { ShowNotification } from '~/context/Notification';
import { FTL_NOT_SUPPORTED_RICH_EDITOR } from '~/modules/notification/messages';
import { USER } from '~/modules/user';
@ -29,17 +29,17 @@ export function FtlSwitch() {
);
const { toggleSourceView } = useContext(EditorActions);
const { sourceView } = useContext(EditorData);
const value = useEditorValue();
const edit = useContext(EditorResult);
const { entity } = useContext(EntityView);
const hasError = useMemo(() => {
if (sourceView) {
const source = value[0].value;
const source = edit[0].value;
return !source || requiresSourceView(parseEntry(source));
} else {
return false;
}
}, [sourceView, value]);
}, [sourceView, edit]);
const handleClick = useCallback(() => {
if (hasError) {

Просмотреть файл

@ -1,20 +1,20 @@
import { Localized } from '@fluent/react';
import React, { useContext } from 'react';
import { EditorData, useEditorValue } from '~/context/Editor';
import { EditorData, EditorResult } from '~/context/Editor';
import './MachinerySourceIndicator.css';
export function MachinerySourceIndicator() {
const { machinery, sourceView } = useContext(EditorData);
const value = useEditorValue();
const edit = useContext(EditorResult);
if (
!machinery ||
machinery.manual ||
sourceView ||
value.length !== 1 ||
machinery.translation !== value[0].value
edit.length !== 1 ||
machinery.translation !== edit[0].value
) {
return null;
}

Просмотреть файл

@ -1,7 +1,7 @@
import React from 'react';
import { shallow } from 'enzyme';
import { EditorData } from '~/context/Editor';
import { EditorData, EditorResult } from '~/context/Editor';
import { EntityView } from '~/context/EntityView';
import { TranslationLength } from './TranslationLength';
@ -18,7 +18,8 @@ describe('<TranslationLength>', () => {
function mountTranslationLength(format, original, value, comment) {
const context = new Map([
[EditorData, { sourceView: false, value: [{ value }] }],
[EditorData, { sourceView: false }],
[EditorResult, [{ value }]],
[EntityView, { entity: { comment, format, original }, pluralForm: 0 }],
]);
React.useContext.callsFake((key) => context.get(key));

Просмотреть файл

@ -1,5 +1,5 @@
import React, { useContext } from 'react';
import { EditorData, useEditorValue } from '~/context/Editor';
import { EditorData, EditorResult } from '~/context/Editor';
import { EntityView, useEntitySource } from '~/context/EntityView';
import { getPlainMessage } from '~/utils/message';
@ -16,13 +16,13 @@ export function TranslationLength(): React.ReactElement<'div'> | null {
const { entity } = useContext(EntityView);
const source = useEntitySource();
const { sourceView } = useContext(EditorData);
const value = useEditorValue();
const edit = useContext(EditorResult);
if (sourceView || value.length !== 1) {
if (sourceView || edit.length !== 1) {
return null;
}
const text = value[0].value;
const text = edit[0].value;
const maxLength =
entity.format === 'lang' && entity.comment.match(/^MAX_LENGTH: (\d+)/);

Просмотреть файл

@ -2,7 +2,7 @@ import { mount } from 'enzyme';
import React from 'react';
import sinon from 'sinon';
import { EditorData } from '~/context/Editor';
import { EditorData, EditorResult } from '~/context/Editor';
import * as Entity from '~/context/EntityView';
import { HistoryData } from '~/context/HistoryData';
import { editMessageEntry, parseEntry } from '~/utils/message';
@ -28,6 +28,12 @@ const HISTORY_FLUENT = {
};
function mountSpy(format, history, editor) {
const result = editor.fields.map(({ handle, keys, name }) => ({
name,
keys,
value: handle.current.value,
}));
let res;
const Spy = () => {
res = useExistingTranslationGetter()();
@ -38,7 +44,9 @@ function mountSpy(format, history, editor) {
<Entity.EntityView.Provider value={{ entity: { format } }}>
<HistoryData.Provider value={history}>
<EditorData.Provider value={editor}>
<Spy />
<EditorResult.Provider value={result}>
<Spy />
</EditorResult.Provider>
</EditorData.Provider>
</HistoryData.Provider>
</Entity.EntityView.Provider>,
@ -57,7 +65,7 @@ const mockMessageEntry = (value) => ({
});
const mockEditorMessage = (value) => [
{ id: 'msg', name: '', keys: [], labels: [], value },
{ id: 'msg', name: '', keys: [], labels: [], handle: { current: { value } } },
];
describe('useExistingTranslation', () => {
@ -70,8 +78,8 @@ describe('useExistingTranslation', () => {
const entry = mockMessageEntry('something');
const res = mountSpy('simple', HISTORY_STRING, {
entry,
initial: editMessageEntry(entry),
value: editMessageEntry(entry),
fields: editMessageEntry(entry),
initial: entry,
});
expect(res).toBe(ACTIVE_TRANSLATION);
@ -81,8 +89,8 @@ describe('useExistingTranslation', () => {
const entry = parseEntry('msg = something');
const res = mountSpy('ftl', HISTORY_FLUENT, {
entry,
initial: editMessageEntry(entry),
value: editMessageEntry(entry),
fields: editMessageEntry(entry),
initial: entry,
});
expect(res).toBe(ACTIVE_TRANSLATION);
@ -92,8 +100,8 @@ describe('useExistingTranslation', () => {
const entry = mockMessageEntry('');
const res = mountSpy('simple', HISTORY_STRING, {
entry,
initial: editMessageEntry(entry),
value: editMessageEntry(entry),
fields: editMessageEntry(entry),
initial: entry,
});
expect(res).toBe(ACTIVE_TRANSLATION);
@ -104,8 +112,8 @@ describe('useExistingTranslation', () => {
const prev0 = HISTORY_STRING.translations[0];
const res0 = mountSpy('simple', HISTORY_STRING, {
entry,
initial: editMessageEntry(entry),
value: mockEditorMessage(prev0.string),
fields: mockEditorMessage(prev0.string),
initial: entry,
});
expect(res0).toBe(prev0);
@ -113,8 +121,8 @@ describe('useExistingTranslation', () => {
const prev1 = HISTORY_STRING.translations[1];
const res1 = mountSpy('simple', HISTORY_STRING, {
entry,
initial: editMessageEntry(entry),
value: mockEditorMessage(prev1.string),
fields: mockEditorMessage(prev1.string),
initial: entry,
});
expect(res1).toBe(prev1);
@ -125,8 +133,8 @@ describe('useExistingTranslation', () => {
const prev0 = HISTORY_FLUENT.translations[0];
const res0 = mountSpy('ftl', HISTORY_FLUENT, {
entry,
initial: editMessageEntry(entry),
value: editMessageEntry(parseEntry(prev0.string)),
fields: editMessageEntry(parseEntry(prev0.string)),
initial: entry,
});
expect(res0).toBe(prev0);
@ -134,8 +142,8 @@ describe('useExistingTranslation', () => {
const prev1 = HISTORY_FLUENT.translations[1];
const res1 = mountSpy('ftl', HISTORY_FLUENT, {
entry,
initial: editMessageEntry(entry),
value: editMessageEntry(parseEntry(prev1.string)),
fields: editMessageEntry(parseEntry(prev1.string)),
initial: entry,
});
expect(res1).toBe(prev1);
@ -145,8 +153,8 @@ describe('useExistingTranslation', () => {
const entry = mockMessageEntry('x');
const res = mountSpy('simple', HISTORY_STRING, {
entry,
initial: editMessageEntry(entry),
value: mockEditorMessage(''),
fields: mockEditorMessage(''),
initial: entry,
});
expect(res).toBe(HISTORY_STRING.translations[2]);
@ -156,8 +164,8 @@ describe('useExistingTranslation', () => {
const entry = parseEntry('msg = something');
const res = mountSpy('ftl', HISTORY_FLUENT, {
entry,
initial: editMessageEntry(entry),
value: mockEditorMessage('Come on Morty!'),
fields: mockEditorMessage('Come on Morty!'),
initial: entry,
});
expect(res).toBe(HISTORY_FLUENT.translations[2]);

Просмотреть файл

@ -1,10 +1,6 @@
import { useContext } from 'react';
import type { HistoryTranslation } from '~/api/translation';
import {
EditorData,
useEditorMessageEntry,
useEditorValue,
} from '~/context/Editor';
import { EditorData, useEditorMessageEntry } from '~/context/Editor';
import { EntityView, useActiveTranslation } from '~/context/EntityView';
import { HistoryData } from '~/context/HistoryData';
import { parseEntry, serializeEntry } from '~/utils/message';
@ -22,11 +18,10 @@ export function useExistingTranslationGetter() {
const { translations } = useContext(HistoryData);
const { entity } = useContext(EntityView);
const { initial } = useContext(EditorData);
const value = useEditorValue();
const entry = useEditorMessageEntry();
return () => {
if (activeTranslation?.pk && pojoEquals(initial, value)) {
if (activeTranslation?.pk && pojoEquals(initial, entry)) {
return activeTranslation;
}

Просмотреть файл

@ -1,153 +0,0 @@
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';
import { SearchData } from '~/context/SearchData';
import { UnsavedActions, UnsavedChanges } from '~/context/UnsavedChanges';
import { useAppSelector } from '~/hooks';
import { useReadonlyEditor } from '~/hooks/useReadonlyEditor';
import { getPlainMessage } from '~/utils/message';
import { useCopyOriginalIntoEditor } from './useCopyOriginalIntoEditor';
import { useExistingTranslationGetter } from './useExistingTranslationGetter';
import { useSendTranslation } from './useSendTranslation';
import { useUpdateTranslationStatus } from './useUpdateTranslationStatus';
/**
* Return a function to handle shortcuts in a translation form.
*/
export function useHandleShortcuts(): (event: React.KeyboardEvent) => void {
const copyOriginalIntoEditor = useCopyOriginalIntoEditor();
const sendTranslation = useSendTranslation();
const updateTranslationStatus = useUpdateTranslationStatus();
const { entity } = useContext(EntityView);
const { resetUnsavedChanges } = useContext(UnsavedActions);
const unsavedChanges = useContext(UnsavedChanges);
const readonly = useReadonlyEditor();
const { clearEditor, setEditorFromHelpers } = useContext(EditorActions);
const getExistingTranslation = useExistingTranslationGetter();
const { errors, source, warnings, resetFailedChecks } =
useContext(FailedChecksData);
const helperSelection = useContext(HelperSelection);
const { translations: machineryTranslations } = useContext(
MachineryTranslations,
);
const { results: concordanceSearchResults } = useContext(SearchData);
const otherLocaleTranslations = useAppSelector(
(state) => state.otherlocales.translations,
);
// Disable keyboard shortcuts when editor is in read only.
if (readonly) {
return () => {};
}
return (ev: React.KeyboardEvent) => {
switch (ev.key) {
// On Enter:
// - If unsaved changes popup is shown, proceed.
// - If failed checks popup is shown after approving a translation, approve it anyway.
// - In other cases, send current translation.
case 'Enter':
if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey) {
ev.preventDefault();
const ignoreWarnings = errors.length + warnings.length > 0;
if (unsavedChanges.onIgnore) {
// There are unsaved changes, proceed.
resetUnsavedChanges(true);
} else if (typeof source === 'number') {
// Approve anyway.
updateTranslationStatus(source, 'approve', ignoreWarnings);
} else {
const existingTranslation = getExistingTranslation();
if (existingTranslation && !existingTranslation.approved) {
updateTranslationStatus(
existingTranslation.pk,
'approve',
ignoreWarnings,
);
} else {
sendTranslation(ignoreWarnings);
}
}
}
break;
// On Esc, close unsaved changes and failed checks popups if open.
case 'Escape':
ev.preventDefault();
if (unsavedChanges.onIgnore) {
// Close unsaved changes popup
resetUnsavedChanges(false);
} else if (errors.length || warnings.length) {
// Close failed checks popup
resetFailedChecks();
}
break;
// On Ctrl + Shift + C, copy the original translation.
case 'C':
if (ev.ctrlKey && ev.shiftKey && !ev.altKey) {
ev.preventDefault();
copyOriginalIntoEditor();
}
break;
// On Ctrl + Shift + Backspace, clear the content.
case 'Backspace':
if (ev.ctrlKey && ev.shiftKey && !ev.altKey) {
ev.preventDefault();
clearEditor();
}
break;
// On Ctrl + Shift + Up/Down, copy next/previous entry from active
// helper tab (Machinery or Locales) into translation.
case 'ArrowDown':
case 'ArrowUp':
if (ev.ctrlKey && ev.shiftKey && !ev.altKey) {
const { tab, element, setElement } = helperSelection;
const isMachinery = tab === 0;
const numTranslations = isMachinery
? machineryTranslations.length + concordanceSearchResults.length
: otherLocaleTranslations.length;
if (numTranslations === 0) {
return;
}
ev.preventDefault();
const nextIdx =
ev.key === 'ArrowDown'
? (element + 1) % numTranslations
: (element - 1 + numTranslations) % numTranslations;
setElement(nextIdx);
if (isMachinery) {
const len = machineryTranslations.length;
const { translation, sources } =
nextIdx < len
? machineryTranslations[nextIdx]
: concordanceSearchResults[nextIdx - len];
setEditorFromHelpers(translation, sources, true);
} else {
const { translation } = otherLocaleTranslations[nextIdx];
setEditorFromHelpers(
getPlainMessage(translation, entity.format),
[],
true,
);
}
}
break;
}
};
}

Просмотреть файл

@ -74,6 +74,7 @@ describe('useUpdateTranslationStatus', () => {
});
it('updates failed checks from response', async () => {
jest.useFakeTimers();
TranslationAPI.setTranslationStatus.returns({
string: 'string',
failedChecks: 'FC',
@ -84,6 +85,7 @@ describe('useUpdateTranslationStatus', () => {
// Let the async code in useUpdateTranslationStatus run
await 1;
jest.runAllTimers();
expect(TranslationAPI.setTranslationStatus.getCalls()).toMatchObject([
{ args: ['approve', 42, 'all', undefined] },

Просмотреть файл

@ -67,8 +67,12 @@ export function useUpdateTranslationStatus(
// Update the UI based on the response.
if (results.failedChecks) {
// Setting the editor value will update the editor result,
// which also resets failed checks.
// So to actually set failed checks,
// we need to do so in the next event cycle with `setTimeout()`.
setEditorFromHistory(results.string);
setFailedChecks(results.failedChecks, translationId);
setTimeout(() => setFailedChecks(results.failedChecks, translationId));
} else {
// Show a notification to explain what happened.
const notif = getNotification(change, !!results.translation);

Просмотреть файл

@ -1,6 +1,5 @@
export { EditorMenu } from './components/EditorMenu';
export { useCopyOriginalIntoEditor } from './hooks/useCopyOriginalIntoEditor';
export { useHandleShortcuts } from './hooks/useHandleShortcuts';
export { useSendTranslation } from './hooks/useSendTranslation';
export { useUpdateTranslationStatus } from './hooks/useUpdateTranslationStatus';

Просмотреть файл

@ -103,7 +103,7 @@ function InnerOriginalString({
if (entry && !requiresSourceView(entry)) {
const msg = editMessageEntry(entry);
if (msg.length === 1) {
source = msg[0].value;
source = msg[0].handle.current.value;
// fallthrough
} else {
return <RichString message={msg} onClick={onClick} terms={terms} />;

Просмотреть файл

@ -1,5 +1,5 @@
import React from 'react';
import type { EditorMessage } from '~/context/Editor';
import type { EditorField } from '~/context/Editor';
import { Highlight } from '~/modules/placeable/components/Highlight';
import type { TermState } from '~/modules/terms';
@ -13,14 +13,14 @@ export function RichString({
onClick,
terms,
}: {
message: EditorMessage;
message: EditorField[];
onClick: (event: React.MouseEvent<HTMLElement>) => void;
terms: TermState;
}): React.ReactElement<'table'> {
return (
<table className='original fluent-rich-string' onClick={onClick}>
<tbody>
{message.map(({ id, labels, value }) => (
{message.map(({ handle, id, labels }) => (
<tr key={id}>
<td>
<label>
@ -32,7 +32,7 @@ export function RichString({
<td>
<span>
<Highlight fluent terms={terms}>
{value}
{handle.current.value}
</Highlight>
</span>
</td>

Просмотреть файл

@ -1,68 +1,145 @@
import React, { useContext } from 'react';
import React, {
forwardRef,
memo,
useCallback,
useContext,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react';
import { EditorActions, useEditorValue } from '~/context/Editor';
import { EditFieldHandle, EditorActions, EditorData } from '~/context/Editor';
import { Locale } from '~/context/Locale';
import { useHandleShortcuts } from '~/modules/editor';
import { useReadonlyEditor } from '~/hooks/useReadonlyEditor';
import { useCopyOriginalIntoEditor } from '~/modules/editor';
import { extractAccessKeyCandidates } from '~/utils/message';
import { useHandleEnter, useHandleEscape } from '../utils/editFieldShortcuts';
import type { EditFieldProps } from './EditField';
export function EditAccesskey({
id,
inputRef,
name,
onFocus,
userInput,
value,
}: EditFieldProps & { name: string }) {
const locale = useContext(Locale);
const { setEditorFromInput } = useContext(EditorActions);
const message = useEditorValue();
function useHandleShortcuts(): (event: React.KeyboardEvent) => void {
const copyOriginalIntoEditor = useCopyOriginalIntoEditor();
const { clearEditor } = useContext(EditorActions);
const handleUpdate = (value: string | null) => {
if (onFocus && typeof value === 'string') {
onFocus({ currentTarget: inputRef.current });
userInput.current = true;
setEditorFromInput(value);
const onEnter = useHandleEnter();
const onEscape = useHandleEscape();
return (ev: React.KeyboardEvent) => {
switch (ev.key) {
case 'Enter':
if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey) {
ev.preventDefault();
onEnter();
}
break;
case 'Escape':
ev.preventDefault();
onEscape();
break;
// On Ctrl + Shift + C, copy the original translation.
case 'C':
if (ev.ctrlKey && ev.shiftKey && !ev.altKey) {
ev.preventDefault();
copyOriginalIntoEditor();
}
break;
// On Ctrl + Shift + Backspace, clear the content.
case 'Backspace':
if (ev.ctrlKey && ev.shiftKey && !ev.altKey) {
ev.preventDefault();
clearEditor();
}
break;
}
};
const handleKeyDown = useHandleShortcuts();
const props = {
className: 'accesskey-input',
value,
dir: locale.direction,
lang: locale.code,
'data-script': locale.script,
};
if (useReadonlyEditor()) {
return <input readOnly {...props} />;
}
const candidates = extractAccessKeyCandidates(message, name);
return (
<>
<input
id={id}
ref={inputRef as React.MutableRefObject<HTMLInputElement>}
maxLength={1}
onChange={(ev) => handleUpdate(ev.currentTarget.value)}
onKeyDown={handleKeyDown}
{...props}
/>
<div className='accesskeys'>
{candidates.map((key) => (
<button
className={`key ${key === value ? 'active' : ''}`}
key={key}
onClick={(ev) => handleUpdate(ev.currentTarget.textContent)}
>
{key}
</button>
))}
</div>
</>
);
}
export const EditAccesskey = memo(
forwardRef<EditFieldHandle, EditFieldProps & { name: string }>(
({ defaultValue, index, name, onFocus }, ref) => {
const locale = useContext(Locale);
const { setResultFromInput } = useContext(EditorActions);
const { fields } = useContext(EditorData);
const domRef = useRef<HTMLInputElement>(null);
const readOnly = useReadonlyEditor();
const [value, setValue_] = useState(defaultValue);
const setValue = useCallback(
(value: string) => {
setValue_(value);
setResultFromInput(index, value);
},
[index],
);
useEffect(() => setValue(defaultValue), [defaultValue]);
useImperativeHandle<EditFieldHandle, EditFieldHandle>(
ref,
() => ({
get value() {
return value;
},
focus() {
const input = domRef.current;
if (input) {
input.focus();
const end = input.value.length;
input.setSelectionRange(end, end);
}
},
setSelection(text) {
if (text.length <= 1) {
setValue(text);
}
},
setValue,
}),
[setValue],
);
const handleUpdate = (value: string) => {
onFocus?.();
setValue(value);
};
const handleKeyDown = useHandleShortcuts();
const candidates = extractAccessKeyCandidates(fields, name);
return (
<>
<input
ref={domRef}
className='accesskey-input'
dir={locale.direction}
lang={locale.code}
maxLength={1}
onChange={(ev) => handleUpdate(ev.currentTarget.value)}
onKeyDown={readOnly ? undefined : handleKeyDown}
readOnly={readOnly}
value={value}
data-script={locale.script}
/>
<div className='accesskeys'>
{candidates.map((key) => (
<button
className={`key ${key === value ? 'active' : ''}`}
disabled={readOnly}
key={key}
onClick={(ev) => {
const value = ev.currentTarget.textContent ?? '';
handleUpdate(value);
}}
>
{key}
</button>
))}
</div>
</>
);
},
),
);

Просмотреть файл

@ -1,63 +1,130 @@
import { EditorState } from '@codemirror/state';
import { EditorView, placeholder } from '@codemirror/view';
import { useLocalization } from '@fluent/react';
import React, { useCallback, useContext } from 'react';
import { EditorActions } from '~/context/Editor';
import React, {
forwardRef,
memo,
useCallback,
useContext,
useEffect,
useImperativeHandle,
useState,
} from 'react';
import { EditFieldHandle, EditorActions } from '~/context/Editor';
import { Locale } from '~/context/Locale';
import { useHandleShortcuts } from '~/modules/editor';
import { useReadonlyEditor } from '~/hooks/useReadonlyEditor';
import { getExtensions, useKeyHandlers } from '../utils/editFieldExtensions';
import { EntityView } from '~/context/EntityView';
export type EditFieldProps = {
id?: string;
inputRef: React.MutableRefObject<
HTMLInputElement | HTMLTextAreaElement | null
>;
onFocus?: (ev: {
currentTarget: HTMLInputElement | HTMLTextAreaElement | null;
}) => void;
index: number;
onFocus?: () => void;
singleField?: boolean;
userInput: React.MutableRefObject<boolean>;
value: string;
defaultValue: string;
};
export function EditField({
id,
inputRef,
onFocus,
singleField,
userInput,
value,
}: EditFieldProps) {
const { l10n } = useLocalization();
const locale = useContext(Locale);
const { setEditorFromInput } = useContext(EditorActions);
/**
* The CodeMirror initialization is only run once,
* so changes in props or context are not reflected in the editor unless handled separately,
* as is done e.g. for `defaultValue`.
*
* Make sure apply an appropriate `key` prop to keep old instances from being reused.
*/
export const EditField = memo(
forwardRef<EditFieldHandle, EditFieldProps>(
({ defaultValue, index, onFocus, singleField }, ref) => {
const { l10n } = useLocalization();
const locale = useContext(Locale);
const readOnly = useReadonlyEditor();
const { entity } = useContext(EntityView);
const { setResultFromInput } = useContext(EditorActions);
const keyHandlers = useKeyHandlers();
const [view, setView] = useState<EditorView | null>(null);
const handleChange: React.ChangeEventHandler<HTMLTextAreaElement> =
useCallback(
(ev) => {
userInput.current = true;
setEditorFromInput(ev.currentTarget.value);
},
[userInput, setEditorFromInput],
);
const handleKeyDown = useHandleShortcuts();
const readOnly = useReadonlyEditor();
const placeholder =
singleField && !readOnly
? l10n.getString('translationform--single-field-placeholder')
: undefined;
const initView = useCallback((parent: HTMLDivElement | null) => {
if (parent) {
const extensions = getExtensions(entity.format, keyHandlers);
if (readOnly) {
extensions.push(
EditorState.readOnly.of(true),
EditorView.editable.of(false),
);
} else {
if (singleField) {
const l10nId = 'translationform--single-field-placeholder';
extensions.push(placeholder(l10n.getString(l10nId)));
}
extensions.push(
EditorView.updateListener.of((update) => {
if (onFocus && update.focusChanged && update.view.hasFocus) {
onFocus();
}
if (update.docChanged) {
setResultFromInput(index, update.state.doc.toString());
}
}),
);
}
return (
<textarea
id={id}
onChange={handleChange}
onFocus={onFocus}
onKeyDown={handleKeyDown}
placeholder={placeholder}
readOnly={readOnly}
ref={inputRef as React.MutableRefObject<HTMLTextAreaElement>}
value={value}
dir={locale.direction}
lang={locale.code}
data-script={locale.script}
/>
);
}
const state = EditorState.create({ doc: defaultValue, extensions });
view?.destroy();
setView(new EditorView({ state, parent }));
} else if (view) {
view.destroy();
setView(null);
}
}, []);
const setValue = useCallback(
(text: string) => {
view?.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: text },
selection: { anchor: text.length },
});
},
[view],
);
useEffect(() => setValue(defaultValue), [defaultValue]);
useImperativeHandle<EditFieldHandle, EditFieldHandle>(
ref,
() => ({
get value() {
return view ? view.state.doc.toString() : defaultValue;
},
focus() {
if (view) {
const range = view.state.selection.main;
const end = view.state.doc.length;
if (range.anchor === 0 && range.empty && end) {
view.dispatch({ selection: { anchor: end } });
}
view.focus();
}
},
setSelection(text) {
if (view) {
view.dispatch(view.state.replaceSelection(text));
view.focus();
}
},
setValue,
}),
[view],
);
return (
<div
className={readOnly ? 'readonly' : undefined}
ref={initView}
data-script={locale.script}
dir={locale.direction}
lang={locale.code}
/>
);
},
),
);

Просмотреть файл

@ -2,7 +2,7 @@ import ftl from '@fluent/dedent';
import React, { useContext } from 'react';
import { act } from 'react-dom/test-utils';
import { EditorActions, EditorProvider } from '~/context/Editor';
import { EditorActions, EditorProvider, EditorResult } from '~/context/Editor';
import { EntityView } from '~/context/EntityView';
import { Locale } from '~/context/Locale';
@ -33,9 +33,10 @@ function mountForm(string) {
translation: [{ string }],
};
let actions;
let actions, result;
const Spy = () => {
actions = useContext(EditorActions);
result = useContext(EditorResult);
return null;
};
@ -55,25 +56,30 @@ function mountForm(string) {
store,
);
return [wrapper, actions];
const views = Array.from(
wrapper.find('.translationform').instance().querySelectorAll('.cm-content'),
).map((el) => el.cmView.view);
return { actions, getResult: () => result, views, wrapper };
}
describe('<TranslationForm> with multiple fields', () => {
it('renders textarea for a value and each attribute', () => {
const [wrapper] = mountForm(ftl`
const { views } = mountForm(ftl`
message = Value
.attr-1 = And
.attr-2 = Attributes
`);
expect(wrapper.find('textarea')).toHaveLength(3);
expect(wrapper.find('textarea').at(0).html()).toContain('Value');
expect(wrapper.find('textarea').at(1).html()).toContain('And');
expect(wrapper.find('textarea').at(2).html()).toContain('Attributes');
expect(views.map((view) => view.state.doc.toString())).toMatchObject([
'Value',
'And',
'Attributes',
]);
});
it('renders select expression properly', () => {
const [wrapper] = mountForm(ftl`
const { views, wrapper } = mountForm(ftl`
my-entry =
{ PLATFORM() ->
[variant] Hello!
@ -81,17 +87,16 @@ describe('<TranslationForm> with multiple fields', () => {
}
`);
expect(wrapper.find('textarea')).toHaveLength(2);
expect(views.map((view) => view.state.doc.toString())).toMatchObject([
'Hello!',
'World!',
]);
expect(wrapper.find('label').at(0).html()).toContain('variant');
expect(wrapper.find('textarea').at(0).html()).toContain('Hello!');
expect(wrapper.find('label').at(1).html()).toContain('another-variant');
expect(wrapper.find('textarea').at(1).html()).toContain('World!');
});
it('renders select expression in attributes properly', () => {
const [wrapper] = mountForm(ftl`
const { views, wrapper } = mountForm(ftl`
my-entry =
.label =
{ PLATFORM() ->
@ -105,18 +110,19 @@ describe('<TranslationForm> with multiple fields', () => {
}
`);
expect(wrapper.find('textarea')).toHaveLength(2);
expect(views.map((view) => view.state.doc.toString())).toMatchObject([
'Preferences',
'Options',
]);
expect(wrapper.find('input')).toHaveLength(2);
const l0 = wrapper.find('label').at(0);
expect(l0.find('span').at(0).html()).toContain('label');
expect(l0.find('span').at(1).html()).toContain('macosx');
expect(wrapper.find('textarea').at(0).html()).toContain('Preferences');
const l1 = wrapper.find('label').at(1);
expect(l1.find('span').at(0).html()).toContain('label');
expect(l1.find('span').at(1).html()).toContain('other');
expect(wrapper.find('textarea').at(1).html()).toContain('Options');
const l2 = wrapper.find('label').at(2);
expect(l2.find('span').at(0).html()).toContain('accesskey');
@ -130,7 +136,7 @@ describe('<TranslationForm> with multiple fields', () => {
});
it('renders plural string properly', () => {
const [wrapper] = mountForm(ftl`
const { views, wrapper } = mountForm(ftl`
my-entry =
{ $num ->
[one] Hello!
@ -138,23 +144,18 @@ describe('<TranslationForm> with multiple fields', () => {
}
`);
expect(wrapper.find('textarea')).toHaveLength(2);
expect(views.map((view) => view.state.doc.toString())).toMatchObject([
'Hello!',
'World!',
]);
expect(wrapper.find('textarea').at(0).html()).toContain('Hello!');
expect(
wrapper.find('#translationform--label-with-example').at(0).prop('vars'),
).toEqual({ label: 'one', example: 1 });
expect(wrapper.find('textarea').at(1).html()).toContain('World!');
expect(
wrapper.find('#translationform--label-with-example').at(1).prop('vars'),
).toEqual({ label: 'other', example: 2 });
const labels = wrapper.find('#translationform--label-with-example');
expect(labels.at(0).prop('vars')).toEqual({ label: 'one', example: 1 });
expect(labels.at(1).prop('vars')).toEqual({ label: 'other', example: 2 });
});
it('renders plural string in attributes properly', () => {
const [wrapper] = mountForm(ftl`
const { views, wrapper } = mountForm(ftl`
my-entry =
.label =
{ $num ->
@ -164,45 +165,43 @@ describe('<TranslationForm> with multiple fields', () => {
.attr = Foo
`);
expect(wrapper.find('textarea')).toHaveLength(3);
expect(wrapper.find('textarea').at(0).html()).toContain('Hello!');
expect(views.map((view) => view.state.doc.toString())).toMatchObject([
'Hello!',
'World!',
'Foo',
]);
expect(wrapper.find('label').at(0).find('span').at(0).html()).toContain(
'label',
);
expect(
wrapper.find('#translationform--label-with-example').at(0).prop('vars'),
).toEqual({ label: 'one', example: 1 });
expect(wrapper.find('textarea').at(1).html()).toContain('World!');
expect(wrapper.find('label').at(1).find('span').at(0).html()).toContain(
'label',
);
expect(
wrapper.find('#translationform--label-with-example').at(1).prop('vars'),
).toEqual({ label: 'other', example: 2 });
const labels = wrapper.find('#translationform--label-with-example');
expect(labels.at(0).prop('vars')).toEqual({ label: 'one', example: 1 });
expect(labels.at(1).prop('vars')).toEqual({ label: 'other', example: 2 });
});
it('renders access keys properly', () => {
const [wrapper] = mountForm(ftl`
const { views, wrapper } = mountForm(ftl`
title = Title
.label = Candidates
.accesskey = C
`);
expect(wrapper.find('textarea')).toHaveLength(2);
expect(wrapper.find('input')).toHaveLength(1);
expect(views.map((view) => view.state.doc.toString())).toMatchObject([
'Title',
'Candidates',
]);
expect(wrapper.find('label').at(1).html()).toContain('label');
expect(wrapper.find('textarea').at(1).prop('value')).toEqual('Candidates');
expect(wrapper.find('label').at(2).html()).toContain('accesskey');
expect(wrapper.find('input').prop('value')).toEqual('C');
expect(wrapper.find('input').prop('maxLength')).toEqual(1);
const input = wrapper.find('input');
expect(input).toHaveLength(1);
expect(input.prop('value')).toEqual('C');
expect(input.prop('maxLength')).toEqual(1);
expect(wrapper.find('.accesskeys')).toHaveLength(1);
expect(wrapper.find('.accesskeys button')).toHaveLength(8);
@ -217,7 +216,7 @@ describe('<TranslationForm> with multiple fields', () => {
});
it('does not render accesskey buttons if no candidates can be generated', () => {
const [wrapper] = mountForm(ftl`
const { wrapper } = mountForm(ftl`
title =
.label = { reference }
.accesskey = C
@ -227,7 +226,7 @@ describe('<TranslationForm> with multiple fields', () => {
});
it('does not render the access key UI if access key is longer than 1 character', () => {
const [wrapper] = mountForm(ftl`
const { wrapper } = mountForm(ftl`
title =
.label = Candidates
.accesskey = { reference }
@ -237,14 +236,15 @@ describe('<TranslationForm> with multiple fields', () => {
});
it('updates the translation when setEditorSelection is passed', async () => {
const [wrapper, actions] = mountForm(ftl`
const { actions, getResult, wrapper } = mountForm(ftl`
title = Value
.label = Something
`);
act(() => actions.setEditorSelection('Add'));
wrapper.update();
expect(wrapper.find('textarea').at(0).prop('value')).toEqual('ValueAdd');
expect(wrapper.find('textarea').at(1).prop('value')).toEqual('Something');
const result = getResult();
expect(result[0].value).toEqual('ValueAdd');
expect(result[1].value).toEqual('Something');
});
});

Просмотреть файл

@ -1,7 +1,7 @@
import React, { useContext } from 'react';
import { act } from 'react-dom/test-utils';
import { EditorActions, EditorProvider } from '~/context/Editor';
import { EditorActions, EditorProvider, EditorResult } from '~/context/Editor';
import { EntityView } from '~/context/EntityView';
import { Locale } from '~/context/Locale';
@ -32,9 +32,10 @@ function mountForm(string) {
translation: [{ string }],
};
let actions;
let actions, result;
const Spy = () => {
actions = useContext(EditorActions);
result = useContext(EditorResult);
return null;
};
@ -54,43 +55,47 @@ function mountForm(string) {
store,
);
return [wrapper, actions];
const view = wrapper
.find('.singlefield')
.instance()
.querySelector('.cm-content').cmView.view;
return { actions, getResult: () => result, view, wrapper };
}
describe('<TranslationForm> with one field', () => {
it('renders a textarea with some content', () => {
const [wrapper] = mountForm('Salut');
expect(wrapper.find('textarea').prop('value')).toBe('Salut');
it('renders an editor with some content', () => {
const { view } = mountForm('Salut');
expect(view.state.doc.toString()).toBe('Salut');
});
it('calls the updateTranslation function on change', () => {
const [wrapper] = mountForm('hello');
const onChange = wrapper.find('textarea').prop('onChange');
act(() => onChange({ currentTarget: { value: 'good bye' } }));
wrapper.update();
expect(wrapper.find('textarea').prop('value')).toBe('good bye');
it('updates the result on change', () => {
const { view, getResult } = mountForm('hello');
act(() =>
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: 'good bye' },
}),
);
expect(getResult()[0].value).toBe('good bye');
});
it('updates the translation when setEditorSelection is passed without focus', async () => {
const [wrapper, actions] = mountForm('Foo');
const { wrapper, actions, getResult } = mountForm('Foo');
act(() => actions.setEditorSelection(', Bar'));
wrapper.update();
expect(wrapper.find('textarea').prop('value')).toBe('Foo, Bar');
expect(getResult()[0].value).toBe('Foo, Bar');
});
it('updates the translation when setEditorSelection is passed with focus', async () => {
const [wrapper, actions] = mountForm('Hello');
const { actions, getResult, view, wrapper } = mountForm('Hello');
act(() => {
const ta = wrapper.find('textarea');
ta.simulate('focus');
ta.getDOMNode().setSelectionRange(5, 5);
view.focus();
view.dispatch({ selection: { anchor: view.state.doc.length } });
actions.setEditorSelection(', World');
});
wrapper.update();
expect(wrapper.find('textarea').prop('value')).toBe('Hello, World');
expect(getResult()[0].value).toBe('Hello, World');
});
});

Просмотреть файл

@ -36,13 +36,6 @@
color: #7bc876;
}
.translationform table tr > td > textarea {
min-height: 0;
height: 24px;
margin: 2px 0;
padding: 0 4px;
}
.translationform .accesskey-input {
border: none;
box-sizing: border-box;
@ -79,6 +72,46 @@
}
.translationform .accesskeys .key.active,
.translationform .accesskeys .key:hover {
.translationform .accesskeys .key:hover:not(:disabled) {
border-color: #7bc876;
}
.translationform .accesskeys .key:disabled {
cursor: default;
}
.cm-editor {
background: white;
color: #444;
font-size: 14px;
padding: 6px 8px 6px 4px;
}
.cm-editor .cm-scroller {
font-family: inherit;
line-height: 22px;
}
.singlefield .cm-content,
.singlefield .cm-gutter {
min-height: calc(100px - 12px);
}
.translationform .cm-editor {
margin: 2px 0;
padding: 0;
}
.translationform .cm-content {
padding: 2px 0;
}
.editor input[readonly],
.editor .readonly .cm-editor {
background: #c7cacf;
cursor: default;
}
[data-script='Arabic'] .cm-editor {
font-size: 15px;
}

Просмотреть файл

@ -1,7 +1,13 @@
import { Localized } from '@fluent/react';
import React, { useCallback, useContext, useLayoutEffect, useRef } from 'react';
import React, {
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { EditorData, useEditorValue } from '~/context/Editor';
import { EditFieldHandle, EditorData } from '~/context/Editor';
import { EntityView } from '~/context/EntityView';
import { Locale } from '~/context/Locale';
import { usePluralExamples } from '~/hooks/usePluralExamples';
@ -9,19 +15,17 @@ import { searchBoxHasFocus } from '~/modules/search/components/SearchBox';
import { CLDR_PLURALS } from '~/utils/constants';
import { EditAccesskey } from './EditAccesskey';
import { EditField, EditFieldProps } from './EditField';
import { EditField } from './EditField';
import './TranslationForm.css';
const Label = ({
getExample,
htmlFor,
labels,
}: {
getExample: (label: string) => number | undefined;
htmlFor: string;
labels: Array<{ label: string; plural: boolean }>;
}) => (
<label htmlFor={htmlFor}>
<label>
{labels.map(({ label, plural }, index) => {
const example = plural && getExample(label);
const key = label + index;
@ -45,13 +49,6 @@ const Label = ({
</label>
);
const EditPattern = (props: EditFieldProps & { name: string }) =>
props.name.endsWith('accesskey') && props.value.length <= 1 ? (
<EditAccesskey {...props} />
) : (
<EditField {...props} />
);
/**
* Rich Editor for supported Fluent strings.
*
@ -59,12 +56,11 @@ const EditPattern = (props: EditFieldProps & { name: string }) =>
* interface to the user. The translation is stored as an AST, and changes
* are made directly to that AST.
*/
export function TranslationForm(): React.ReactElement<'div'> {
export function TranslationForm(): React.ReactElement<'div'> | null {
const { entity } = useContext(EntityView);
const { fields, focusField, machinery } = useContext(EditorData);
const value = useEditorValue();
const userInput = useRef(false);
const { fields, focusField, machinery, pk, sourceView } =
useContext(EditorData);
const [shouldFocus, setShouldFocus] = useState(true);
const locale = useContext(Locale);
const pluralExamples = usePluralExamples(locale);
@ -78,66 +74,72 @@ export function TranslationForm(): React.ReactElement<'div'> {
[pluralExamples],
);
// Reset the currently focused element when the entity changes or when
// Reset the currently focused element when the entity changes or
// the translation changes from an external source.
useLayoutEffect(() => {
if (userInput.current) {
userInput.current = false;
} else {
let input = fields[focusField.current]?.current;
if (!input) {
focusField.current = 0;
input = fields[0]?.current;
}
if (input && !searchBoxHasFocus()) {
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
}
useEffect(() => {
if (!searchBoxHasFocus()) {
setShouldFocus(true);
}
}, [entity, machinery, value]);
}, [entity, machinery, fields]);
const handleFocus = useCallback(
(ev: { currentTarget: HTMLInputElement | HTMLTextAreaElement | null }) => {
for (let i = 0; i < fields.length; ++i) {
if (fields[i].current === ev.currentTarget) {
focusField.current = i;
return;
}
}
focusField.current = -1;
},
[focusField, fields],
const fieldValues = useMemo(
() =>
fields.map((field) => ({
onFocus() {
focusField.current = field;
},
ref(handle: EditFieldHandle | null) {
if (handle) {
field.handle.current = handle;
if (shouldFocus && field.id === focusField.current?.id) {
handle.focus();
setShouldFocus(false);
}
}
},
})),
[fields, focusField, shouldFocus],
);
return value.length === 1 ? (
<EditField
inputRef={fields[0]}
key={entity.pk}
singleField
userInput={userInput}
value={value[0]?.value}
/>
return pk !== entity.pk ? null : fields.length === 1 ? (
<div className='singlefield'>
<EditField
ref={fieldValues[0].ref}
key={sourceView ? 's!' + pk : pk}
defaultValue={fields[0].handle.current.value}
index={0}
singleField
/>
</div>
) : (
<div className='translationform' key={entity.pk}>
<table>
<tbody>
{value.map(({ id, labels, name, value }, i) => (
<tr key={id}>
<td>
<Label getExample={getExample} htmlFor={id} labels={labels} />
</td>
<td>
<EditPattern
id={id}
inputRef={fields[i]}
name={name}
onFocus={handleFocus}
userInput={userInput}
value={value}
/>
</td>
</tr>
))}
{fields.map(({ handle, id, labels, name }, i) => {
const value = handle.current.value;
const EditPattern =
name.endsWith('accesskey') && value.length <= 1
? EditAccesskey
: EditField;
const { onFocus, ref } = fieldValues[i];
return (
<tr key={id}>
<td>
<Label getExample={getExample} labels={labels} />
</td>
<td>
<EditPattern
key={pk + id}
ref={ref}
defaultValue={value}
index={i}
name={name}
onFocus={onFocus}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>

Просмотреть файл

@ -0,0 +1,103 @@
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
import {
history,
historyKeymap,
insertNewlineAndIndent,
standardKeymap,
} from '@codemirror/commands';
import {
HighlightStyle,
StreamLanguage,
bracketMatching,
syntaxHighlighting,
} from '@codemirror/language';
import { Extension } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
import { useContext, useEffect, useRef } from 'react';
import { EditorActions } from '~/context/Editor';
import { useCopyOriginalIntoEditor } from '~/modules/editor';
import {
useHandleCtrlShiftArrow,
useHandleEnter,
useHandleEscape,
} from './editFieldShortcuts';
import { fluentMode, commonMode } from './editFieldModes';
import { tags } from '@lezer/highlight';
/**
* Key handlers depend on application state,
* so rather than updating the keymap continuously,
* let's use a ref for the handlers instead.
*/
export function useKeyHandlers() {
const { clearEditor } = useContext(EditorActions);
const copyOriginalIntoEditor = useCopyOriginalIntoEditor();
const onCtrlShiftArrow = useHandleCtrlShiftArrow();
const onEnter = useHandleEnter();
const onEscape = useHandleEscape();
const handlers = {
onCtrlShiftArrow,
onCtrlShiftBackspace() {
clearEditor();
return true as const;
},
onCtrlShiftC() {
copyOriginalIntoEditor();
return true as const;
},
onEnter,
onEscape,
};
const ref = useRef(handlers);
useEffect(() => {
ref.current = handlers;
});
return ref;
}
const style = HighlightStyle.define([
{ tag: tags.keyword, color: '#872bff', fontFamily: 'monospace' }, // printf
{ tag: tags.tagName, color: '#3e9682', fontFamily: 'monospace' }, // <...>
{ tag: tags.brace, color: '#872bff', fontWeight: 'bold' }, // {...}
{ tag: tags.name, color: '#872bff' }, // {...}
]);
export const getExtensions = (
format: string,
ref: ReturnType<typeof useKeyHandlers>,
): Extension[] => [
history(),
bracketMatching(),
closeBrackets(),
EditorView.lineWrapping,
EditorView.contentAttributes.of({ spellcheck: 'true' }),
StreamLanguage.define<any>(format === 'ftl' ? fluentMode : commonMode),
syntaxHighlighting(style),
keymap.of([
{
key: 'Enter',
run: () => ref.current.onEnter(),
shift: insertNewlineAndIndent,
},
{ key: 'Mod-Enter', run: insertNewlineAndIndent },
{ key: 'Escape', run: () => ref.current.onEscape() },
{
key: 'Shift-Ctrl-ArrowDown',
run: () => ref.current.onCtrlShiftArrow('ArrowDown'),
},
{
key: 'Shift-Ctrl-ArrowUp',
run: () => ref.current.onCtrlShiftArrow('ArrowUp'),
},
{
key: 'Shift-Ctrl-Backspace',
run: () => ref.current.onCtrlShiftBackspace(),
},
{ key: 'Shift-Ctrl-c', run: () => ref.current.onCtrlShiftC() },
...closeBracketsKeymap,
...standardKeymap,
...historyKeymap,
]),
];

Просмотреть файл

@ -0,0 +1,64 @@
import { StreamParser } from '@codemirror/language';
export const fluentMode: StreamParser<{ expression: boolean; tag: boolean }> = {
name: 'fluent',
startState: () => ({ expression: false, tag: false }),
token(stream, state) {
const ch = stream.next();
if (state.expression) {
if (ch === '}') {
state.expression = false;
return 'brace';
}
stream.skipTo('}');
return 'name';
} else {
if (ch === '{') {
state.expression = true;
return 'brace';
}
if (ch === '<') {
state.tag = true;
}
if (state.tag) {
if (ch === '>') {
state.tag = false;
} else {
stream.eatWhile(/[^>{]+/);
}
return 'tagName';
}
stream.eatWhile(/[^<{]+/);
return 'string';
}
},
};
const printf =
/^%(\d\$|\(.*?\))?[-+ 0'#]*[\d*]*(\.[\d*])?(hh?|ll?|[jLtz])?[%@AacdEeFfGginopSsuXx]/;
const pythonFormat = /^{[\w.[\]]*(![rsa])?(:.*?)?}/;
export const commonMode: StreamParser<{ tag: boolean }> = {
name: 'common',
startState: () => ({ tag: false }),
token(stream, state) {
if (stream.match(printf) || stream.match(pythonFormat)) {
return 'keyword';
}
const ch = stream.next();
if (ch === '<') {
state.tag = true;
}
if (state.tag) {
if (ch === '>') {
state.tag = false;
} else {
stream.eatWhile(/[^>%{]+/);
}
return 'tagName';
}
stream.eatWhile(/[^%{<]+/);
return 'string';
},
};

Просмотреть файл

@ -0,0 +1,127 @@
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';
import { SearchData } from '~/context/SearchData';
import { UnsavedActions, UnsavedChanges } from '~/context/UnsavedChanges';
import { useAppSelector } from '~/hooks';
import { getPlainMessage } from '~/utils/message';
import { useExistingTranslationGetter } from '../../editor/hooks/useExistingTranslationGetter';
import { useSendTranslation } from '../../editor/hooks/useSendTranslation';
import { useUpdateTranslationStatus } from '../../editor/hooks/useUpdateTranslationStatus';
/**
* On Enter:
* - If unsaved changes popup is shown, proceed.
* - If failed checks popup is shown after approving a translation, approve it anyway.
* - In other cases, send current translation.
*/
export function useHandleEnter(): () => true {
const sendTranslation = useSendTranslation();
const updateTranslationStatus = useUpdateTranslationStatus();
const { resetUnsavedChanges } = useContext(UnsavedActions);
const unsavedChanges = useContext(UnsavedChanges);
const getExistingTranslation = useExistingTranslationGetter();
const { errors, source, warnings } = useContext(FailedChecksData);
return () => {
const ignoreWarnings = errors.length + warnings.length > 0;
if (unsavedChanges.onIgnore) {
// There are unsaved changes, proceed.
resetUnsavedChanges(true);
} else if (typeof source === 'number') {
// Approve anyway.
updateTranslationStatus(source, 'approve', ignoreWarnings);
} else {
const existingTranslation = getExistingTranslation();
if (existingTranslation && !existingTranslation.approved) {
updateTranslationStatus(
existingTranslation.pk,
'approve',
ignoreWarnings,
);
} else {
sendTranslation(ignoreWarnings);
}
}
return true;
};
}
/** On Esc, close unsaved changes and failed checks popups if open. */
export function useHandleEscape(): () => boolean {
const { resetUnsavedChanges } = useContext(UnsavedActions);
const unsavedChanges = useContext(UnsavedChanges);
const { errors, warnings, resetFailedChecks } = useContext(FailedChecksData);
return () => {
if (unsavedChanges.onIgnore) {
// Close unsaved changes popup
resetUnsavedChanges(false);
return true;
} else if (errors.length || warnings.length) {
// Close failed checks popup
resetFailedChecks();
return true;
}
return false;
};
}
/**
* On Ctrl + Shift + Up/Down, copy next/previous entry from active
* helper tab (Machinery or Locales) into translation.
*/
export function useHandleCtrlShiftArrow(): (
key: 'ArrowDown' | 'ArrowUp',
) => boolean {
const { entity } = useContext(EntityView);
const { setEditorFromHelpers } = useContext(EditorActions);
const helperSelection = useContext(HelperSelection);
const { translations: machineryTranslations } = useContext(
MachineryTranslations,
);
const { results: concordanceSearchResults } = useContext(SearchData);
const otherLocaleTranslations = useAppSelector(
(state) => state.otherlocales.translations,
);
return (key) => {
const { tab, element, setElement } = helperSelection;
const isMachinery = tab === 0;
const numTranslations = isMachinery
? machineryTranslations.length + concordanceSearchResults.length
: otherLocaleTranslations.length;
if (numTranslations === 0) {
return false;
}
const nextIdx =
key === 'ArrowDown'
? (element + 1) % numTranslations
: (element - 1 + numTranslations) % numTranslations;
setElement(nextIdx);
if (isMachinery) {
const len = machineryTranslations.length;
const { translation, sources } =
nextIdx < len
? machineryTranslations[nextIdx]
: concordanceSearchResults[nextIdx - len];
setEditorFromHelpers(translation, sources, true);
} else {
const { translation } = otherLocaleTranslations[nextIdx];
setEditorFromHelpers(
getPlainMessage(translation, entity.format),
[],
true,
);
}
return true;
};
}

Просмотреть файл

@ -1,34 +1,34 @@
import type { Message } from 'messageformat';
import type { EditorMessage } from '~/context/Editor';
import type { EditorResult } from '~/context/Editor';
import { pojoCopy } from '../pojo';
import type { MessageEntry } from '.';
/** Get a `MessageEntry` corresponding to `edit`, based on `base`. */
export function buildMessageEntry(
base: MessageEntry,
edit: EditorMessage,
next: EditorResult,
): MessageEntry {
const res = pojoCopy(base);
setMessage(res.value, '', edit);
setMessage(res.value, '', next);
if (res.attributes) {
for (const [name, msg] of res.attributes) {
setMessage(msg, name, edit);
setMessage(msg, name, next);
}
}
return res;
}
/** Modifies `msg` according to `edit` entries which match `name`. */
function setMessage(msg: Message | null, name: string, edit: EditorMessage) {
function setMessage(msg: Message | null, attrName: string, next: EditorResult) {
switch (msg?.type) {
case 'message':
for (const field of edit) {
if (field.name === name) {
for (const { name, value } of next) {
if (name === attrName) {
const { body } = msg.pattern;
if (body.length === 1 && body[0].type === 'text') {
body[0].value = field.value;
body[0].value = value;
} else {
body.splice(0, body.length, { type: 'text', value: field.value });
body.splice(0, body.length, { type: 'text', value });
}
return;
}
@ -37,11 +37,11 @@ function setMessage(msg: Message | null, name: string, edit: EditorMessage) {
case 'select':
msg.variants = [];
for (const field of edit) {
if (field.name === name) {
for (const { name, keys, value } of next) {
if (name === attrName) {
msg.variants.push({
keys: field.keys,
value: { body: [{ type: 'text', value: field.value }] },
keys,
value: { body: [{ type: 'text', value }] },
});
}
}

Просмотреть файл

@ -1,18 +1,41 @@
import type { Message, Pattern, Variant } from 'messageformat';
import type { EditorMessage } from '~/context/Editor';
import type { EditorField } from '~/context/Editor';
import type { MessageEntry } from '.';
import { findPluralSelectors } from './findPluralSelectors';
import { serializeEntry } from './serializeEntry';
/** Get an `EditorMessage` corresponding to `entry`. */
export function editMessageEntry(entry: MessageEntry): EditorMessage {
const res: EditorMessage = [];
const emptyHandleRef = (value: string) => ({
current: {
value,
focus() {},
setSelection() {},
setValue(text: string) {
this.value = text;
},
},
});
/** Get an `EditorField[]` for the source view */
export function editSource(source: string | MessageEntry): EditorField[] {
const value = (
typeof source === 'string' ? source : serializeEntry('ftl', source)
).trim();
const handle = emptyHandleRef(value);
return [{ id: '', name: '', keys: [], labels: [], handle }];
}
/** Get an `EditorField[]` corresponding to `entry`. */
export function editMessageEntry(entry: MessageEntry): EditorField[] {
const res: EditorField[] = [];
if (entry.value) {
const hasAttributes = !!entry.attributes?.size;
for (const [keys, labels, value] of genPatterns(entry.value)) {
if (hasAttributes) {
labels.unshift({ label: 'Value', plural: false });
}
res.push({ id: getId('', keys), name: '', keys, labels, value });
const handle = emptyHandleRef(value);
const id = getId('', keys);
res.push({ handle, id, name: '', keys, labels });
}
}
if (entry.attributes) {
@ -22,7 +45,9 @@ export function editMessageEntry(entry: MessageEntry): EditorMessage {
if (hasMultiple) {
labels.unshift({ label: name, plural: false });
}
res.push({ id: getId(name, keys), name, keys, labels, value });
const handle = emptyHandleRef(value);
const id = getId(name, keys);
res.push({ handle, id, name, keys, labels });
}
}
}

Просмотреть файл

@ -1,4 +1,4 @@
import type { EditorMessage } from '~/context/Editor';
import type { EditorField } from '~/context/Editor';
/**
* Return a set of potential access key candidates from either the attribute
@ -8,7 +8,7 @@ import type { EditorMessage } from '~/context/Editor';
* @returns A set of access key candidates.
*/
export function extractAccessKeyCandidates(
message: EditorMessage,
fields: EditorField[],
label: string,
): string[] {
let source: string | undefined;
@ -18,16 +18,16 @@ export function extractAccessKeyCandidates(
if (prefix) {
const name = `${prefix}label`;
source = message
source = fields
.filter((field) => field.name === name)
.map((field) => field.value)
.map((field) => field.handle.current.value)
.join('');
} else {
// Generate access key candidates from the 'label' attribute or the message value
for (const name of ['label', '', 'value', 'aria-label']) {
const field = message.filter((field) => field.name === name);
if (field.length) {
source = field.map((field) => field.value).join('');
const match = fields.filter((field) => field.name === name);
if (match.length) {
source = match.map((field) => field.handle.current.value).join('');
break;
}
}

Просмотреть файл

@ -1,6 +1,7 @@
import type {
CatchallKey,
Message,
Pattern,
PatternElement,
PatternMessage,
SelectMessage,
@ -41,6 +42,10 @@ export function getEmptyMessageEntry(
return { id: source.id, value: getEmptyMessage(source.value, locale) };
}
const getEmptyPattern = (): Pattern => ({
body: [{ type: 'text', value: '' }],
});
function getEmptyMessage(
source: Message,
{ code }: Locale,
@ -48,7 +53,7 @@ function getEmptyMessage(
const declarations = pojoCopy(source.declarations);
if (source.type === 'message' || source.type === 'junk') {
return { type: 'message', declarations, pattern: { body: [] } };
return { type: 'message', declarations, pattern: getEmptyPattern() };
}
const plurals = findPluralSelectors(source);
@ -115,9 +120,12 @@ function getEmptyMessage(
}
if (selectors.length === 0) {
return { type: 'message', declarations, pattern: { body: [] } };
return { type: 'message', declarations, pattern: getEmptyPattern() };
}
const variants = variantKeys.map((keys) => ({ keys, value: { body: [] } }));
const variants = variantKeys.map((keys) => ({
keys,
value: getEmptyPattern(),
}));
return { type: 'select', declarations, selectors, variants };
}

Просмотреть файл

@ -17,7 +17,7 @@ export { extractAccessKeyCandidates } from './extractAccessKeyCandidates';
export { getEmptyMessageEntry } from './getEmptyMessage';
export { getPlainMessage } from './getPlainMessage';
export { getSimplePreview } from './getSimplePreview';
export { editMessageEntry } from './editMessageEntry';
export { editMessageEntry, editSource } from './editMessageEntry';
export { findPluralSelectors } from './findPluralSelectors';
export { parseEntry } from './parseEntry';
export { requiresSourceView } from './requiresSourceView';