Skip to content

Commit

Permalink
Allow translating previously untranslated strings in React editor (ZN…
Browse files Browse the repository at this point in the history
…TA-1767) (#227)

* fix(ZNTA-1767): allow adding new translations in React editor

* test(editor utils): increase to 100% unit test coverage of util module

* test(editor utils): increase to 100% coverage for phrase utils module
  • Loading branch information
davidmason committed Mar 9, 2017
1 parent 7983adf commit 0933773
Show file tree
Hide file tree
Showing 5 changed files with 309 additions and 10 deletions.
160 changes: 160 additions & 0 deletions server/zanata-frontend/src/editor/__tests__/utils/UtilTest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
jest.disableAutomock()

import {
parseNPlurals,
prepareDocs,
prepareLocales,
prepareStats
} from '../../app/utils/Util'


describe('parseNPluralsTest', () => {
it('can parse valid Plural-Forms string', () => {
// Valid plural forms from
// https://www.gnu.org/software/gettext/manual/html_node/Plural-forms.html
expect(parseNPlurals('nplurals=2; plural=n == 1 ? 0 : 1;')).toEqual(2)
expect(parseNPlurals('nplurals=1; plural=0;')).toEqual(1)
expect(parseNPlurals('nplurals=2; plural=n != 1;')).toEqual(2)
expect(parseNPlurals('nplurals=2; plural=n>1;')).toEqual(2)
expect(parseNPlurals(
'nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n != 0 ? 1 : 2;'))
.toEqual(3)
expect(parseNPlurals(
'nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 ' +
': n%100>=3 && n%100<=10 ? 3 : n%100>=11 ? 4 : 5;')).toEqual(6)
})

it('returns undefined when plural string is null or empty', () => {
expect(parseNPlurals(undefined)).toBeUndefined()
expect(parseNPlurals(null)).toBeUndefined()
expect(parseNPlurals('')).toBeUndefined()
})

it('returns undefined for strings without valid nplurals', () => {
expect(parseNPlurals('nplurals=x; plural=y;')).toBeUndefined()
expect(parseNPlurals('not even a plural forms string')).toBeUndefined()
expect(parseNPlurals('mplurals=7; the mumber of plurals')).toBeUndefined()
})
})

describe('prepareLocalesTest', () => {
it('Can transform locales to the expected form', () => {
// Values taken from API response.
const unpreparedLocales =
[
{
'localeId': 'de',
'displayName': 'German',
'nativeName': 'Deutsch',
'enabled': true,
'enabledByDefault': true,
'pluralForms': 'nplurals=2; plural=(n != 1)'
}, {
'localeId': 'ja',
'displayName': 'Japanese',
'nativeName': '日本語',
'enabled': true,
'enabledByDefault': true,
'pluralForms': 'nplurals=1; plural=0'
}
]

const preparedLocales = {
de: {
id: 'de',
name: 'German',
nplurals: 2
},
ja: {
id: 'ja',
name: 'Japanese',
nplurals: 1
}
}

expect(prepareLocales(unpreparedLocales)).toEqual(preparedLocales)
})
})


describe('prepareStatsTest', () => {
it('Can can translate statistics to the expected form.', () => {
// Values taken from API response.
const unpreparedStats = [
{
'total': 4592,
'untranslated': 4592,
'needReview': 0,
'translated': 0,
'approved': 0,
'rejected': 0,
'fuzzy': 0,
'unit': 'WORD',
'locale': 'de',
'lastTranslated': null,
'translatedOnly': 0
}, {
'total': 495,
'untranslated': 460,
'needReview': 0,
'translated': 15,
'approved': 5,
'rejected': 0,
'fuzzy': 20,
'unit': 'MESSAGE',
'locale': 'de',
'lastTranslated': null,
'translatedOnly': 10
}
]

// just the MESSAGE stats
const preparedStats = {
'total': 495,
'untranslated': 460,
'approved': 5,
'rejected': 0,
'needswork': 20,
'translated': 10
}

expect(prepareStats(unpreparedStats)).toEqual(preparedStats)
})
})

describe('prepareDocsTest', () => {
it('Can handle a null document list', () => {
expect(prepareDocs([])).toEqual([])
})

it('Can transform a list of documents to the expected structure.', () => {
// Values taken from API response.
const unpreparedDocs = [
{
'name': 'template20161102.pot',
'contentType': 'application/x-gettext',
'lang': 'en-US',
'type': 'FILE',
'revision': 1
}, {
'name': 'flags/template20161102.pot',
'contentType': 'application/x-gettext',
'lang': 'en-US',
'type': 'FILE',
'revision': 1
}, {
'name': 'msgctxt/template20161102.pot',
'contentType': 'application/x-gettext',
'lang': 'en-US',
'type': 'FILE',
'revision': 1
}
]
const preparedDocs = [
'template20161102.pot',
'flags/template20161102.pot',
'msgctxt/template20161102.pot'
]
expect(prepareDocs(unpreparedDocs)).toEqual(preparedDocs)
})
})
101 changes: 101 additions & 0 deletions server/zanata-frontend/src/editor/__tests__/utils/phraseTest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
jest.disableAutomock()

import {
getSaveButtonStatus,
} from '../../app/utils/phrase'
import {
STATUS_UNTRANSLATED,
STATUS_NEEDS_WORK,
STATUS_TRANSLATED
} from '../../app/utils/status'

describe('getSaveButtonStatusTest', () => {
it('Returns UNTRANSLATED when nothing is translated', () => {
const phrase1 = {
newTranslations: []
}

const phrase2 = {
newTranslations: [
'',
''
]
}

expect(getSaveButtonStatus(phrase1)).toEqual(STATUS_UNTRANSLATED)
expect(getSaveButtonStatus(phrase2)).toEqual(STATUS_UNTRANSLATED)
})

it('Returns NEEDS_WORK when some but not all translations are empty', () => {
const phrase1 = {
newTranslations: [
'',
'Hello'
]
}
const phrase2 = {
newTranslations: [
'Hi',
'',
'Hello'
]
}

expect(getSaveButtonStatus(phrase1)).toEqual(STATUS_NEEDS_WORK)
expect(getSaveButtonStatus(phrase2)).toEqual(STATUS_NEEDS_WORK)
})

it('Returns TRANSLATED when all translated and something changed', () => {
const phrase1 = {
translations: [
'Hi',
''
],
newTranslations: [
'Hi',
'Hello'
]
}
const phrase2 = {
translations: [
'Hi',
'Hello'
],
newTranslations: [
'Hi',
'Haldo'
]
}

expect(getSaveButtonStatus(phrase1)).toEqual(STATUS_TRANSLATED)
expect(getSaveButtonStatus(phrase2)).toEqual(STATUS_TRANSLATED)
})

it('Returns the current status when nothing has changed', () => {
const phrase1 = {
status: STATUS_NEEDS_WORK,
translations: [
'Hi',
'Hello'
],
newTranslations: [
'Hi',
'Hello'
]
}
const phrase2 = {
status: STATUS_TRANSLATED,
translations: [
'Hi',
'Hello'
],
newTranslations: [
'Hi',
'Hello'
]
}

expect(getSaveButtonStatus(phrase1)).toEqual(STATUS_NEEDS_WORK)
expect(getSaveButtonStatus(phrase2)).toEqual(STATUS_TRANSLATED)
})
})
24 changes: 18 additions & 6 deletions server/zanata-frontend/src/editor/app/actions/phrases.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { fetchPhraseList, fetchPhraseDetail, savePhrase } from '../api'
import { toggleDropdown } from '.'
import { isUndefined, mapValues, slice } from 'lodash'
import { fill, get, isUndefined, mapValues, slice } from 'lodash'
import {
defaultSaveStatus,
STATUS_NEW,
Expand Down Expand Up @@ -79,7 +79,7 @@ export function phraseListFetchFailed (error) {
export const FETCHING_PHRASE_DETAIL = Symbol('FETCHING_PHRASE_DETAIL')
// API lookup of the detail for a given set of phrases (by id)
export function requestPhraseDetail (localeId, phraseIds) {
return (dispatch) => {
return (dispatch, getState) => {
dispatch({ type: FETCHING_PHRASE_DETAIL })
fetchPhraseDetail(localeId, phraseIds)
.then(response => {
Expand All @@ -91,10 +91,14 @@ export function requestPhraseDetail (localeId, phraseIds) {
return response.json()
})
.then(transUnitDetail => {
const locale = get(getState(),
['headerData', 'context', 'projectVersion', 'locales', localeId],
// default value if locale object cannot be found
{ id: localeId })
dispatch(
phraseDetailFetched(
// phraseDetail
transUnitDetailToPhraseDetail(transUnitDetail, localeId)
transUnitDetailToPhraseDetail(transUnitDetail, locale)
)
)
})
Expand All @@ -105,19 +109,27 @@ export function requestPhraseDetail (localeId, phraseIds) {
* Convert the TransUnit response objects to the Phrase structure that
* is needed for the component tree.
*/
function transUnitDetailToPhraseDetail (transUnitDetail, localeId) {
function transUnitDetailToPhraseDetail (transUnitDetail, locale) {
const localeId = locale.id
const nplurals = locale.nplurals || 1
return mapValues(transUnitDetail, (transUnit, id) => {
const source = transUnit.source
const plural = source.plural
const trans = transUnit[localeId]
const status = transUnitStatusToPhraseStatus(trans && trans.state)
const translations = extractTranslations(trans)
const emptyTranslations = Array(plural ? nplurals : 1)
fill(emptyTranslations, '')
const newTranslations =
status === STATUS_UNTRANSLATED ? emptyTranslations : [...translations]

return {
id: parseInt(id, 10),
plural,
sources: plural ? source.contents : [source.content],
translations,
newTranslations: [...translations],
status: transUnitStatusToPhraseStatus(trans && trans.state),
newTranslations,
status,
revision: trans && trans.revision ? parseInt(trans.revision, 10) : 0,
wordCount: parseInt(source.wordCount, 10)
}
Expand Down
26 changes: 24 additions & 2 deletions server/zanata-frontend/src/editor/app/utils/Util.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,36 @@
import { chain, map } from 'lodash'
import { chain, isNaN, map } from 'lodash'

const npluralRegex = /^nplurals\s*=\s*(\d*)\s*;/

/**
* Extract nplurals value as an integer from a Plural-Forms string.
*
* Given a string in the form 'nplurals=x; plural=(y)', extract x
*/
export const parseNPlurals = (pluralFormsString) => {
const result = npluralRegex.exec(pluralFormsString)
if (result !== null) {
const nplurals = parseInt(result[1], 10)
if (!isNaN(nplurals)) {
return nplurals
}
}
// Could not find and parse a valid nplurals integer
return undefined
}

/* convert from structure used in angular to structure used in react */
// TODO we should change the server response to save us from doing this
// transformation
export const prepareLocales = (locales) => {
return chain(locales || [])
.map(function (locale) {
const nplurals = parseNPlurals(locale.pluralForms)

return {
id: locale.localeId,
name: locale.displayName
name: locale.displayName,
nplurals
}
})
.keyBy('id')
Expand Down
8 changes: 6 additions & 2 deletions server/zanata-frontend/src/editor/app/utils/phrase.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,15 @@ export function getSaveButtonStatus (phrase) {
export function hasTranslationChanged (phrase) {
// on Firefox with input method turned on,
// when hitting tab it seems to turn undefined value into ''
var allSame = every(phrase.translations,

// Iterating newTranslations since those are guaranteed to exist for all
// plural forms. translations can be just an empty array.
const allSame = every(phrase.newTranslations,
function (translation, index) {
return nullToEmpty(translation) ===
nullToEmpty(phrase.newTranslations[index])
nullToEmpty(phrase.translations[index])
})

return !allSame
}

Expand Down

0 comments on commit 0933773

Please sign in to comment.