зеркало из https://github.com/mozilla/pontoon.git
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:
Родитель
505023de36
Коммит
d43c73c479
|
@ -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';
|
||||
|
|
Загрузка…
Ссылка в новой задаче