Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Modify language switch on project edit page #4481

Merged
merged 2 commits into from
Apr 13, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions frontend/src/components/projectEdit/descriptionForm.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React, { useContext } from 'react';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import { FormattedMessage } from 'react-intl';

import messages from './messages';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import { StateContext, styleClasses } from '../../views/projectEdit';
import { InputLocale } from './inputLocale';

Expand Down
196 changes: 119 additions & 77 deletions frontend/src/components/projectEdit/inputLocale.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useLayoutEffect, useCallback, useContext } from 'react';
import React, { useState, useEffect, useLayoutEffect, useCallback, useContext } from 'react';
import { useDropzone } from 'react-dropzone';
import { FormattedMessage } from 'react-intl';

Expand All @@ -8,102 +8,145 @@ import { htmlFromMarkdown } from '../../utils/htmlFromMarkdown';
import { StateContext, styleClasses } from '../../views/projectEdit';
import FileRejections from '../comments/fileRejections';
import DropzoneUploadStatus from '../comments/uploadStatus';
import { LocaleOption } from './localeOption';
import { DROPZONE_SETTINGS } from '../../config';

export const InputLocale = (props) => {
const { projectInfo, setProjectInfo, success, setSuccess, error, setError } = useContext(
StateContext,
);
const [language, setLanguage] = useState(null);
const [value, setValue] = useState('');
const [preview, setPreview] = useState(null);
const appendImgToComment = (url) => setValue(`${value}\n![image](${url})\n`);
const [uploadError, uploading, onDrop] = useOnDrop(appendImgToComment);
const { fileRejections, getRootProps, getInputProps } = useDropzone({
onDrop,
...DROPZONE_SETTINGS,
});

export const InputLocale = ({ children, name, type, maxLength, languages }) => {
const { projectInfo, setProjectInfo, setSuccess, setError } = useContext(StateContext);
const [activeLocale, setActiveLocale] = useState(null);
const locales = projectInfo.projectInfoLocales;
const translatedLocales = locales
.filter((l) => l.locale !== projectInfo.defaultLocale)
.filter((l) => l[name])
.map((l) => l.locale);

const getDefaultLocaleLabel = useCallback(() => {
const filteredLanguages = languages.filter((l) => l.code === projectInfo.defaultLocale);
if (filteredLanguages.length) return filteredLanguages[0].language;
}, [languages, projectInfo.defaultLocale]);

const updateState = (e) => {
const updateState = (name, value, language) => {
let selected = locales.filter((f) => f.locale === language);
let data = null;
if (selected.length === 0) {
data = { locale: language, [e.target.name]: e.target.value };
data = { locale: language, [name]: value };
} else {
data = selected[0];
data[e.target.name] = e.target.value;
data[name] = value;
}
// create element with new locale.
let newLocales = locales.filter((f) => f.locale !== language);
newLocales.push(data);
setProjectInfo({ ...projectInfo, projectInfoLocales: newLocales });
};

const handleChange = (e) => {
setValue(e.target.value);
if (success !== false) setSuccess(false);
if (error !== null) setError(null);
if (!preview) setPreview(true);
};
// start the component with one of the available translations active
useEffect(() => {
if (activeLocale === null && translatedLocales && translatedLocales.length) {
setActiveLocale(translatedLocales[0]);
}
}, [activeLocale, translatedLocales]);

// Resets preview when language changes.
// Reset success and error when language changes.
useLayoutEffect(() => {
setPreview(false);
setSuccess(false);
setError(null);
}, [language, setSuccess, setError]);
}, [activeLocale, setSuccess, setError]);

return (
<div>
{children}
<div className="cf db mb0 pt1">
<label className="cf db w-100 mb1 blue-grey fw5">
<FormattedMessage {...messages.language} /> - {getDefaultLocaleLabel()}
</label>
<LocalizedInputField
type={type}
name={name}
maxLength={maxLength}
updateContext={updateState}
locale={projectInfo.defaultLocale}
/>
</div>
<p className="cf db w-100 mv0 blue-grey fw5">
<FormattedMessage {...messages.translations} />
</p>
<ul className="list mb2 mt2 pa0 w-100 flex flex-wrap ttu">
{languages &&
languages
.filter((l) => l.code !== projectInfo.defaultLocale)
.map((l) => (
<LocaleOption
key={l.code}
localeCode={l.code}
name={l.language}
isActive={l.code === activeLocale}
hasValue={translatedLocales.includes(l.code)}
onClick={setActiveLocale}
/>
))}
</ul>
{activeLocale ? (
<LocalizedInputField
type={type}
name={name}
maxLength={maxLength}
updateContext={updateState}
locale={activeLocale}
/>
) : (
<span className="pt2">
<FormattedMessage {...messages.selectLanguage} />
</span>
)}
</div>
);
};

const getValue = useCallback(() => {
const data = locales.filter((l) => l.locale === language);
if (data.length > 0) {
return data[0][props.name];
const LocalizedInputField = ({ type, maxLength, name, locale, updateContext }) => {
const { projectInfo, success, setSuccess, error, setError } = useContext(StateContext);
const [value, setValue] = useState(null);
const [preview, setPreview] = useState(null);
const appendImgToComment = (url) => setValue(`${value}\n![image](${url})\n`);
const [uploadError, uploading, onDrop] = useOnDrop(appendImgToComment);
const { fileRejections, getRootProps, getInputProps } = useDropzone({
onDrop,
...DROPZONE_SETTINGS,
});

const updateValue = useCallback(() => {
const activeLocale = projectInfo.projectInfoLocales.filter((item) => item.locale === locale);
if (activeLocale.length && activeLocale[0][name]) {
setValue(activeLocale[0][name]);
} else {
return '';
setValue('');
setPreview(false);
}
}, [language, locales, props.name]);
}, [locale, name, projectInfo.projectInfoLocales]);

// initialize language using project's defaultLocale
useLayoutEffect(() => {
if (language === null) {
if (projectInfo.defaultLocale) {
setLanguage(projectInfo.defaultLocale);
}
}
}, [projectInfo, language]);
// clean or set a new field value when the locale changes
useEffect(() => updateValue(), [locale, updateValue]);

useLayoutEffect(() => {
const fieldValue = getValue();
setValue(fieldValue);
}, [getValue]);
// hide preview when saved successfully
useEffect(() => {
if (success) setPreview(false);
}, [success]);

const handleChange = (e) => {
setValue(e.target.value);
if (success !== false) setSuccess(false);
if (error !== null) setError(null);
if (!preview) setPreview(true);
};

return (
<div>
{props.children}
<ul className="list mb2 mt3 pa0 w-100 flex flex-wrap ttu">
{props.languages === null
? null
: props.languages.map((l, n) => (
<li
key={n}
onClick={() => setLanguage(l.code)}
className={
(l.code !== language ? 'bg-white blue-dark' : 'bg-blue-dark white') +
' ph2 mb2 pv1 f7 mr2 pointer'
}
title={l.language}
>
{l.code}
</li>
))}
</ul>
{props.type === 'text' ? (
<>
{type === 'text' ? (
<input
type="text"
onBlur={updateState}
onBlur={() => updateContext(name, value, locale)}
className={styleClasses.inputClass}
name={props.name}
name={name}
value={value}
onChange={handleChange}
/>
Expand All @@ -115,28 +158,27 @@ export const InputLocale = (props) => {
style={{ display: 'inline-block' }} // we need to set display, as dropzone makes it none as default
rows={styleClasses.numRows}
type="text"
name={props.name}
name={name}
value={value}
onBlur={updateState}
onBlur={() => updateContext(name, value, locale)}
onChange={handleChange}
maxLength={props.maxLength || null}
maxLength={maxLength || null}
></textarea>
<FileRejections files={fileRejections} />
<DropzoneUploadStatus uploading={uploading} uploadError={uploadError} />
</div>
)}
{props.maxLength && (
{maxLength && (
<div
className={`tr cf fl w-80 f7 ${
value && value.length > 0.9 * props.maxLength ? 'red' : 'blue-light'
value && value.length > 0.9 * maxLength ? 'red' : 'blue-light'
}`}
>
{value ? value.length : 0} / {props.maxLength}
{value ? value.length : 0} / {maxLength}
</div>
)}

{props.type !== 'text' && preview && (
<div className="cf pt1">
{type !== 'text' && preview && (
<div className="cf mb3">
<h3 className="ttu f6 fw6 blue-grey mb1">
<FormattedMessage {...messages.preview} />
</h3>
Expand All @@ -146,6 +188,6 @@ export const InputLocale = (props) => {
/>
</div>
)}
</div>
</>
);
};
18 changes: 18 additions & 0 deletions frontend/src/components/projectEdit/localeOption.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React from 'react';

export const LocaleOption = ({ localeCode, name, isActive, hasValue, onClick }) => {
const additionalClasses = isActive
? 'bg-blue-grey fw6 white'
: hasValue
? 'bg-white fw6 blue-dark'
: 'bg-white blue-grey';
return (
<li
onClick={() => onClick(localeCode)}
className={`${additionalClasses} ba b--grey-light br1 ph2 mb2 pv1 f7 mr2 pointer`}
title={name}
>
{localeCode}
</li>
);
};
8 changes: 8 additions & 0 deletions frontend/src/components/projectEdit/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,14 @@ export default defineMessages({
id: 'projects.formInputs.language',
defaultMessage: 'Default language',
},
translations: {
id: 'projects.formInputs.language.translations',
defaultMessage: 'Translations',
},
selectLanguage: {
id: 'projects.formInputs.language.select',
defaultMessage: 'Select a language above to translate.',
},
mappingEditors: {
id: 'projects.formInputs.mapping_editors',
defaultMessage: 'Editors for mapping',
Expand Down
59 changes: 59 additions & 0 deletions frontend/src/components/projectEdit/tests/localeOption.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';

import { LocaleOption } from '../localeOption';

describe('LocaleOption', () => {
const mockFn = jest.fn();
it('with isActive = true', () => {
render(
<LocaleOption
localeCode={'es'}
name="Español"
isActive={true}
hasValue={true}
onClick={mockFn}
/>,
);
expect(screen.getByText('es').className).toContain(
'ba b--grey-light br1 ph2 mb2 pv1 f7 mr2 pointer',
);
expect(screen.getByText('es').className).toContain('bg-blue-grey fw6 white');
expect(screen.getByText('es').title).toBe('Español');
fireEvent.click(screen.getByText('es'));
expect(mockFn).toHaveBeenCalledWith('es');
});
it('with isActive = false and hasValue = true', () => {
render(
<LocaleOption
localeCode={'pt'}
name="Português"
isActive={false}
hasValue={true}
onClick={mockFn}
/>,
);
expect(screen.getByText('pt').className).toContain(
'ba b--grey-light br1 ph2 mb2 pv1 f7 mr2 pointer',
);
expect(screen.getByText('pt').className).toContain('bg-white fw6 blue-dark');
expect(screen.getByText('pt').title).toBe('Português');
});
it('with isActive = false and hasValue = false', () => {
render(
<LocaleOption
localeCode={'it'}
name="Italiano"
isActive={false}
hasValue={false}
onClick={mockFn}
/>,
);
expect(screen.getByText('it').className).toContain(
'ba b--grey-light br1 ph2 mb2 pv1 f7 mr2 pointer',
);
expect(screen.getByText('it').className).toContain('bg-white blue-grey');
expect(screen.getByText('it').title).toBe('Italiano');
});
});
2 changes: 2 additions & 0 deletions frontend/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,8 @@
"projects.formInputs.priority": "Priority",
"projects.formInputs.license": "Required license",
"projects.formInputs.language": "Default language",
"projects.formInputs.language.translations": "Translations",
"projects.formInputs.language.select": "Select a language above to translate.",
"projects.formInputs.mapping_editors": "Editors for mapping",
"projects.formInputs.validation_editors": "Editors for validation",
"projects.formInputs.editors.options.custom": "Custom editor",
Expand Down
Loading