diff --git a/package-lock.json b/package-lock.json index bac51c85c5..e7a41a4271 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "file-saver": "^2.0.5", "formik": "2.4.6", "frontend-components-tinymce-advanced-plugins": "^1.0.3", + "iso-639-1": "^3.1.5", "jszip": "^3.10.1", "lodash": "4.17.21", "meilisearch": "^0.41.0", @@ -14149,6 +14150,15 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/iso-639-1": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/iso-639-1/-/iso-639-1-3.1.5.tgz", + "integrity": "sha512-gXkz5+KN7HrG0Q5UGqSMO2qB9AsbEeyLP54kF1YrMsIxmu+g4BdB7rflReZTSTZGpfj8wywu6pfPBCylPIzGQA==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, "node_modules/isobject": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", diff --git a/package.json b/package.json index db3a39dde7..31bb3f8aa1 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "start": "fedx-scripts webpack-dev-server --progress", "start:with-theme": "paragon install-theme && npm start && npm install", "dev": "PUBLIC_PATH=/authoring/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io", - "test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests", + "test": "TZ=UTC fedx-scripts jest --passWithNoTests", "test:ci": "TZ=UTC fedx-scripts jest --silent --coverage --passWithNoTests", "types": "tsc --noEmit" }, @@ -73,6 +73,7 @@ "file-saver": "^2.0.5", "formik": "2.4.6", "frontend-components-tinymce-advanced-plugins": "^1.0.3", + "iso-639-1": "^3.1.5", "jszip": "^3.10.1", "lodash": "4.17.21", "meilisearch": "^0.41.0", diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.jsx index ad441c562d..5164a6fdfb 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.jsx +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.jsx @@ -12,8 +12,8 @@ import { Check } from '@openedx/paragon/icons'; import { connect, useDispatch } from 'react-redux'; import { useIntl } from '@edx/frontend-platform/i18n'; import { thunkActions, selectors } from '../../../../../../data/redux'; -import { videoTranscriptLanguages } from '../../../../../../data/constants/video'; import { FileInput, fileInput } from '../../../../../../sharedComponents/FileInput'; +import { getLanguageName } from '../../../../../../data/constants/video'; import messages from './messages'; export const hooks = { @@ -45,27 +45,27 @@ export const hooks = { }; const LanguageSelector = ({ - index, // For a unique id for the form control + index, language, - // Redux - openLanguages, // Only allow those languages not already associated with a transcript to be selected + openLanguages, }) => { const intl = useIntl(); + const dispatch = useDispatch(); + const [localLang, setLocalLang] = React.useState(language); - const input = fileInput({ onAddFile: hooks.addFileCallback({ dispatch: useDispatch(), localLang }) }); + const input = fileInput({ onAddFile: hooks.addFileCallback({ dispatch, localLang }) }); const onLanguageChange = hooks.onSelectLanguage({ - dispatch: useDispatch(), languageBeforeChange: localLang, setLocalLang, triggerupload: input.click, + dispatch, languageBeforeChange: localLang, setLocalLang, triggerupload: input.click, }); const getTitle = () => { - if (Object.prototype.hasOwnProperty.call(videoTranscriptLanguages, language)) { + if (language) { return ( - {videoTranscriptLanguages[language]} + {getLanguageName(language)} - ); } return ( @@ -78,10 +78,7 @@ const LanguageSelector = ({ return ( <> - - + - {Object.entries(videoTranscriptLanguages).map(([lang, text]) => { + {openLanguages.map(lang => { + const name = getLanguageName(lang); + if (language === lang) { - return ({text}); - } - if (openLanguages.some(row => row.includes(lang))) { - return ( onLanguageChange({ newLang: lang })}>{text}); + return ( + + {name} + + + ); } - return ({text}); + return ( + onLanguageChange({ newLang: lang })} + > + {name} + + ); })} diff --git a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.test.jsx b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.test.jsx index 3fc95017b7..0adef88aac 100644 --- a/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.test.jsx +++ b/src/editors/containers/VideoEditor/components/VideoSettingsModal/components/TranscriptWidget/LanguageSelector.test.jsx @@ -1,6 +1,6 @@ import React from 'react'; import { - render, screen, initializeMocks, fireEvent, + render, screen, initializeMocks, } from '@src/testUtils'; import LanguageSelector from './LanguageSelector'; import { selectors } from '../../../../../../data/redux'; @@ -13,11 +13,14 @@ const lang3 = 'sImLisH'; const lang3Code = 'sl'; jest.mock('../../../../../../data/constants/video', () => ({ - videoTranscriptLanguages: { - [lang1Code]: lang1, - [lang2Code]: lang2, - [lang3Code]: lang3, - }, + getLanguageName: jest.fn((code) => { + const mockMap = { + kl: lang1, + el: lang2, + sl: lang3, + }; + return mockMap[code] || code; + }), })); describe('LanguageSelector', () => { @@ -25,7 +28,7 @@ describe('LanguageSelector', () => { onSelect: jest.fn().mockName('props.OnSelect'), index: 1, language: lang1Code, - openLanguages: [[lang2Code, lang2], [lang3Code, lang3]], + openLanguages: [lang2Code, lang3Code, lang1Code], }; beforeEach(() => { initializeMocks(); @@ -46,13 +49,4 @@ describe('LanguageSelector', () => { render(); expect(screen.getByText('Select Language')).toBeInTheDocument(); }); - - test('transcripts no Open Languages, all dropdown items should be disabled', () => { - const { video } = selectors; - jest.spyOn(video, 'openLanguages').mockReturnValue([]); - const { container } = render(); - fireEvent.click(screen.getByRole('button', { name: 'Languages' })); - const disabledItems = container.querySelectorAll('.disabled.dropdown-item'); - expect(disabledItems.length).toBe(3); - }); }); diff --git a/src/editors/data/constants/video.js b/src/editors/data/constants/video.js index f5a24ed1ca..90b843ab2c 100644 --- a/src/editors/data/constants/video.js +++ b/src/editors/data/constants/video.js @@ -1,209 +1,49 @@ +import ISO6391 from 'iso-639-1'; import { StrictDict } from '../../utils'; -export const videoTranscriptLanguages = StrictDict({ - placeholder: '', - aa: 'Afar', - ab: 'Abkhazian', - af: 'Afrikaans', - ak: 'Akan', - sq: 'Albanian', - am: 'Amharic', - ar: 'Arabic', - an: 'Aragonese', - hy: 'Armenian', - as: 'Assamese', - av: 'Avaric', - ae: 'Avestan', - ay: 'Aymara', - az: 'Azerbaijani', - ba: 'Bashkir', - bm: 'Bambara', - eu: 'Basque', - be: 'Belarusian', - bn: 'Bengali', - bh: 'Bihari languages', - bi: 'Bislama', - bs: 'Bosnian', - br: 'Breton', - bg: 'Bulgarian', - my: 'Burmese', - ca: 'Catalan', - ch: 'Chamorro', - ce: 'Chechen', - zh: 'Chinese', - zh_HANS: 'Simplified Chinese', - zh_HANT: 'Traditional Chinese', - cu: 'Church Slavic', - cv: 'Chuvash', - kw: 'Cornish', - co: 'Corsican', - cr: 'Cree', - cs: 'Czech', - da: 'Danish', - dv: 'Divehi', - nl: 'Dutch', - dz: 'Dzongkha', - en: 'English', - eo: 'Esperanto', - et: 'Estonian', - ee: 'Ewe', - fo: 'Faroese', - fj: 'Fijian', - fi: 'Finnish', - fr: 'French', - fy: 'Western Frisian', - ff: 'Fulah', - ka: 'Georgian', - de: 'German', - gd: 'Gaelic', - ga: 'Irish', - gl: 'Galician', - gv: 'Manx', - el: 'Greek', - gn: 'Guarani', - gu: 'Gujarati', - ht: 'Haitian', - ha: 'Hausa', - he: 'Hebrew', - hz: 'Herero', - hi: 'Hindi', - ho: 'Hiri Motu', - hr: 'Croatian', - hu: 'Hungarian', - ig: 'Igbo', - is: 'Icelandic', - io: 'Ido', - ii: 'Sichuan Yi', - iu: 'Inuktitut', - ie: 'Interlingue', - ia: 'Interlingua', - id: 'Indonesian', - ik: 'Inupiaq', - it: 'Italian', - jv: 'Javanese', - ja: 'Japanese', - kl: 'Kalaallisut', - kn: 'Kannada', - ks: 'Kashmiri', - kr: 'Kanuri', - kk: 'Kazakh', - km: 'Central Khmer', - ki: 'Kikuyu', - rw: 'Kinyarwanda', - ky: 'Kirghiz', - kv: 'Komi', - kg: 'Kongo', - ko: 'Korean', - kj: 'Kuanyama', - ku: 'Kurdish', - lo: 'Lao', - la: 'Latin', - lv: 'Latvian', - li: 'Limburgan', - ln: 'Lingala', - lt: 'Lithuanian', - lb: 'Luxembourgish', - lu: 'Luba-Katanga', - lg: 'Ganda', - mk: 'Macedonian', - mh: 'Marshallese', - ml: 'Malayalam', - mi: 'Maori', - mr: 'Marathi', - ms: 'Malay', - mg: 'Malagasy', - mt: 'Maltese', - mn: 'Mongolian', - na: 'Nauru', - nv: 'Navajo', - nr: 'Ndebele: South', - nd: 'Ndebele: North', - ng: 'Ndonga', - ne: 'Nepali', - nn: 'Norwegian Nynorsk', - nb: 'Bokmål: Norwegian', - no: 'Norwegian', - ny: 'Chichewa', - oc: 'Occitan', - oj: 'Ojibwa', - or: 'Oriya', - om: 'Oromo', - os: 'Ossetian', - pa: 'Panjabi', - fa: 'Persian', - pi: 'Pali', - pl: 'Polish', - pt: 'Portuguese', - 'pt-BR': 'Portuguese (Brazil)', - ps: 'Pushto', - qu: 'Quechua', - rm: 'Romansh', - ro: 'Romanian', - rn: 'Rundi', - ru: 'Russian', - sg: 'Sango', - sa: 'Sanskrit', - si: 'Sinhala', - sk: 'Slovak', - sl: 'Slovenian', - se: 'Northern Sami', - sm: 'Samoan', - sn: 'Shona', - sd: 'Sindhi', - so: 'Somali', - st: 'Sotho: Southern', - es: 'Spanish', - sc: 'Sardinian', - sr: 'Serbian', - ss: 'Swati', - su: 'Sundanese', - sw: 'Swahili', - sv: 'Swedish', - ty: 'Tahitian', - ta: 'Tamil', - tt: 'Tatar', - te: 'Telugu', - tg: 'Tajik', - tl: 'Tagalog', - th: 'Thai', - bo: 'Tibetan', - ti: 'Tigrinya', - to: 'Tonga (Tonga Islands)', - tn: 'Tswana', - ts: 'Tsonga', - tk: 'Turkmen', - tr: 'Turkish', - tw: 'Twi', - ug: 'Uighur', - uk: 'Ukrainian', - ur: 'Urdu', - uz: 'Uzbek', - ve: 'Venda', - vi: 'Vietnamese', - vo: 'Volapük', - cy: 'Welsh', - wa: 'Walloon', - wo: 'Wolof', - xh: 'Xhosa', - yi: 'Yiddish', - yo: 'Yoruba', - za: 'Zhuang', - zu: 'Zulu', -}); +export const getLanguageName = (langCode, locales = ['en']) => { + const code = langCode?.toLowerCase(); + if (!code) { return ''; } + + for (const locale of locales) { + try { + const dn = new Intl.DisplayNames([locale], { type: 'language' }); + const name = dn.of(code); + if (name && name !== code) { + return name; + } + } catch { + // Fallback to ISO6391 if Intl.DisplayNames fails + } + } + const isoName = ISO6391.getName(code); + if (isoName) { return isoName; } + + return code; +}; + +// Zero-maintenance: generate an object mapping code → name from iso-639-1 package +export const openLanguagesDataSet = ISO6391.getAllCodes().reduce((acc, code) => { + acc[code] = ISO6391.getName(code); + return acc; +}, {}); export const in8lTranscriptLanguages = (intl) => { const messageLookup = {}; - // for tests and non-internationlized setups, return en + + // For tests and non-internationalized setups, return raw dataset if (!intl?.formatMessage) { - return videoTranscriptLanguages; + return openLanguagesDataSet; } - Object.keys(videoTranscriptLanguages).forEach((code) => { + + Object.keys(openLanguagesDataSet).forEach((code) => { messageLookup[code] = intl.formatMessage({ id: `authoring.videoeditor.transcripts.language.${code}`, - defaultMessage: videoTranscriptLanguages[code], - description: `Name of Language called in English ${videoTranscriptLanguages[code]}`, + defaultMessage: openLanguagesDataSet[code], + description: `Name of Language called in English ${openLanguagesDataSet[code]}`, }); }); + return messageLookup; }; @@ -214,5 +54,4 @@ export const timeKeys = StrictDict({ export default { timeKeys, - videoTranscriptLanguages, }; diff --git a/src/editors/data/constants/video.test.js b/src/editors/data/constants/video.test.js new file mode 100644 index 0000000000..cec43e1c31 --- /dev/null +++ b/src/editors/data/constants/video.test.js @@ -0,0 +1,65 @@ +import ISO6391 from 'iso-639-1'; +import { getLanguageName } from './video'; + +describe('getLanguageName', () => { + let displayNamesSpy; + + beforeEach(() => { + jest.restoreAllMocks(); + }); + + it('returns language name from Intl.DisplayNames for given locale', () => { + displayNamesSpy = jest.spyOn(global.Intl, 'DisplayNames').mockImplementation(() => ({ + of: jest.fn().mockReturnValue('English'), + })); + + expect(getLanguageName('en')).toBe('English'); + expect(displayNamesSpy).toHaveBeenCalledWith(['en'], { type: 'language' }); + }); + + it('falls back to ISO6391 when Intl.DisplayNames throws', () => { + jest.spyOn(global.Intl, 'DisplayNames').mockImplementation(() => { + throw new Error('Intl not supported'); + }); + jest.spyOn(ISO6391, 'getName').mockReturnValue('English'); + + expect(getLanguageName('en')).toBe('English'); + }); + + it('falls back to code when both Intl.DisplayNames and ISO6391 fail', () => { + jest.spyOn(global.Intl, 'DisplayNames').mockImplementation(() => { + throw new Error('Intl not supported'); + }); + jest.spyOn(ISO6391, 'getName').mockReturnValue(''); + + expect(getLanguageName('xx')).toBe('xx'); + }); + + it('returns empty string when langCode is missing', () => { + expect(getLanguageName('')).toBe(''); + expect(getLanguageName(null)).toBe(''); + expect(getLanguageName(undefined)).toBe(''); + }); + + it('is case-insensitive for language codes', () => { + displayNamesSpy = jest.spyOn(global.Intl, 'DisplayNames').mockImplementation(() => ({ + of: jest.fn().mockReturnValue('English'), + })); + + expect(getLanguageName('EN')).toBe('English'); + expect(getLanguageName('En')).toBe('English'); + }); + + it('tries multiple locales in order', () => { + const mockOf = jest.fn() + .mockReturnValueOnce(null) // first locale returns null + .mockReturnValueOnce('Inglés'); // second locale works + + displayNamesSpy = jest.spyOn(global.Intl, 'DisplayNames').mockImplementation(() => ({ + of: mockOf, + })); + + expect(getLanguageName('en', ['fr', 'es'])).toBe('Inglés'); + expect(mockOf).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/editors/data/redux/video/selectors.js b/src/editors/data/redux/video/selectors.js index 4829321835..d9b4c104c3 100644 --- a/src/editors/data/redux/video/selectors.js +++ b/src/editors/data/redux/video/selectors.js @@ -1,7 +1,7 @@ import { createSelector } from 'reselect'; import { keyStore } from '../../../utils'; -import { videoTranscriptLanguages } from '../../constants/video'; +import { openLanguagesDataSet } from '../../constants/video'; import { initialState } from './reducer'; // This 'module' self-import hack enables mocking during tests. @@ -50,10 +50,10 @@ export const openLanguages = createSelector( [module.simpleSelectors.transcripts], (transcripts) => { if (!transcripts) { - return videoTranscriptLanguages; + return Object.keys(openLanguagesDataSet); } - const open = Object.keys(videoTranscriptLanguages).filter( - (lang) => !transcripts.includes(lang), + const open = Object.keys(openLanguagesDataSet).filter( + (code) => !transcripts.includes(code), ); return open; }, diff --git a/src/editors/data/redux/video/selectors.test.js b/src/editors/data/redux/video/selectors.test.js new file mode 100644 index 0000000000..e5551ae47c --- /dev/null +++ b/src/editors/data/redux/video/selectors.test.js @@ -0,0 +1,29 @@ +import { openLanguages } from './selectors'; +import { openLanguagesDataSet } from '../../constants/video'; + +describe('openLanguages selector', () => { + const allLangCodes = Object.keys(openLanguagesDataSet); + + it('should return all languages when transcripts is undefined', () => { + const state = { transcripts: undefined }; + const result = openLanguages.resultFunc(state.transcripts); + expect(result).toEqual(allLangCodes); + }); + + it('should return only languages not present in transcripts', () => { + const transcripts = [allLangCodes[0], allLangCodes[1]]; + const state = { transcripts }; + const result = openLanguages.resultFunc(state.transcripts); + const expected = allLangCodes.filter( + code => !transcripts.includes(code), + ); + expect(result).toEqual(expected); + }); + + it('should return empty array if all languages are in transcripts', () => { + const transcripts = [...allLangCodes]; + const state = { transcripts }; + const result = openLanguages.resultFunc(state.transcripts); + expect(result).toEqual([]); + }); +});