diff --git a/awx/ui/.eslintignore b/awx/ui/.eslintignore index 1de8506d67..f290f33893 100644 --- a/awx/ui/.eslintignore +++ b/awx/ui/.eslintignore @@ -17,3 +17,4 @@ test !client/lib/models/**/*.js !client/lib/services/**/*.js !client/features/**/*.js +!client/src/app.start.js diff --git a/awx/ui/.eslintrc.js b/awx/ui/.eslintrc.js index 5cb4504be4..c624bf962a 100644 --- a/awx/ui/.eslintrc.js +++ b/awx/ui/.eslintrc.js @@ -20,7 +20,7 @@ module.exports = { node: true }, globals: { - angular: true, + angular: true, d3: true, $: true, _: true, @@ -43,6 +43,7 @@ module.exports = { 'no-param-reassign': 'off', 'no-plusplus': 'off', 'no-underscore-dangle': 'off', + 'no-use-before-define': 'off', 'object-curly-newline': 'off', 'space-before-function-paren': ['error', 'always'] } diff --git a/awx/ui/build/webpack.base.js b/awx/ui/build/webpack.base.js index e0b6998974..ae4c3a856d 100644 --- a/awx/ui/build/webpack.base.js +++ b/awx/ui/build/webpack.base.js @@ -109,7 +109,7 @@ const base = { jsonlint: 'codemirror.jsonlint' }), new ExtractTextPlugin('css/[name].[chunkhash].css'), - new CleanWebpackPlugin([STATIC_PATH, COVERAGE_PATH, LANGUAGES_PATH], { + new CleanWebpackPlugin([STATIC_PATH, COVERAGE_PATH], { root: UI_PATH, verbose: false }), diff --git a/awx/ui/build/webpack.development.js b/awx/ui/build/webpack.development.js index 5e936c0a7e..a9b2db20d8 100644 --- a/awx/ui/build/webpack.development.js +++ b/awx/ui/build/webpack.development.js @@ -1,5 +1,3 @@ -const path = require('path'); - const _ = require('lodash'); const base = require('./webpack.base'); diff --git a/awx/ui/build/webpack.test.js b/awx/ui/build/webpack.test.js index 0ebcac286b..8fb4e4e1c7 100644 --- a/awx/ui/build/webpack.test.js +++ b/awx/ui/build/webpack.test.js @@ -1,13 +1,12 @@ -const path = require('path'); - const _ = require('lodash'); const webpack = require('webpack'); const STATIC_URL = '/static/'; -const development = require('./webpack.development'); +const development = require('./webpack.base'); const test = { + devtool: 'cheap-source-map', plugins: [ new webpack.DefinePlugin({ $basePath: STATIC_URL diff --git a/awx/ui/build/webpack.watch.js b/awx/ui/build/webpack.watch.js index 1c2f9e5270..5030bdf4d4 100644 --- a/awx/ui/build/webpack.watch.js +++ b/awx/ui/build/webpack.watch.js @@ -2,8 +2,8 @@ const path = require('path'); const _ = require('lodash'); const webpack = require('webpack'); -const HtmlWebpackHarddiskPlugin = require('html-webpack-harddisk-plugin'); const HardSourceWebpackPlugin = require('hard-source-webpack-plugin'); +const HtmlWebpackHarddiskPlugin = require('html-webpack-harddisk-plugin'); const TARGET_PORT = _.get(process.env, 'npm_package_config_django_port', 8043); const TARGET_HOST = _.get(process.env, 'npm_package_config_django_host', 'https://localhost'); @@ -29,6 +29,7 @@ const watch = { ] }, plugins: [ + new HtmlWebpackHarddiskPlugin(), new HardSourceWebpackPlugin({ cacheDirectory: 'node_modules/.cache/hard-source/[confighash]', recordsPath: 'node_modules/.cache/hard-source/[confighash]/records.json', @@ -41,7 +42,6 @@ const watch = { files: ['package.json'] } }), - new HtmlWebpackHarddiskPlugin(), new webpack.HotModuleReplacementPlugin() ], devServer: { diff --git a/awx/ui/client/features/credentials/index.js b/awx/ui/client/features/credentials/index.js index 5800793287..5a20d290aa 100644 --- a/awx/ui/client/features/credentials/index.js +++ b/awx/ui/client/features/credentials/index.js @@ -3,6 +3,8 @@ import AddController from './add-credentials.controller'; import EditController from './edit-credentials.controller'; import CredentialsStrings from './credentials.strings'; +const MODULE_NAME = 'at.features.credentials'; + const addEditTemplate = require('~features/credentials/add-edit-credentials.view.html'); function CredentialsResolve ($q, $stateParams, Me, Credential, CredentialType, Organization) { @@ -51,12 +53,8 @@ CredentialsResolve.$inject = [ 'OrganizationModel' ]; -function CredentialsConfig ($stateExtenderProvider, legacyProvider, stringProvider) { - const stateExtender = $stateExtenderProvider.$get(); - const legacy = legacyProvider.$get(); - const strings = stringProvider.$get(); - - stateExtender.addState({ +function CredentialsRun ($stateExtender, legacy, strings) { + $stateExtender.addState({ name: 'credentials.add', route: '/add', ncyBreadcrumb: { @@ -78,7 +76,7 @@ function CredentialsConfig ($stateExtenderProvider, legacyProvider, stringProvid } }); - stateExtender.addState({ + $stateExtender.addState({ name: 'credentials.edit', route: '/:credential_id', ncyBreadcrumb: { @@ -101,25 +99,27 @@ function CredentialsConfig ($stateExtenderProvider, legacyProvider, stringProvid } }); - stateExtender.addState(legacy.getStateConfiguration('list')); - stateExtender.addState(legacy.getStateConfiguration('edit-permissions')); - stateExtender.addState(legacy.getStateConfiguration('add-permissions')); - stateExtender.addState(legacy.getStateConfiguration('add-organization')); - stateExtender.addState(legacy.getStateConfiguration('edit-organization')); - stateExtender.addState(legacy.getStateConfiguration('add-credential-type')); - stateExtender.addState(legacy.getStateConfiguration('edit-credential-type')); + $stateExtender.addState(legacy.getStateConfiguration('list')); + $stateExtender.addState(legacy.getStateConfiguration('edit-permissions')); + $stateExtender.addState(legacy.getStateConfiguration('add-permissions')); + $stateExtender.addState(legacy.getStateConfiguration('add-organization')); + $stateExtender.addState(legacy.getStateConfiguration('edit-organization')); + $stateExtender.addState(legacy.getStateConfiguration('add-credential-type')); + $stateExtender.addState(legacy.getStateConfiguration('edit-credential-type')); } -CredentialsConfig.$inject = [ - '$stateExtenderProvider', - 'LegacyCredentialsServiceProvider', - 'CredentialsStringsProvider' +CredentialsRun.$inject = [ + '$stateExtender', + 'LegacyCredentialsService', + 'CredentialsStrings' ]; angular - .module('at.features.credentials', []) - .config(CredentialsConfig) + .module(MODULE_NAME, []) .controller('AddController', AddController) .controller('EditController', EditController) .service('LegacyCredentialsService', LegacyCredentials) - .service('CredentialsStrings', CredentialsStrings); + .service('CredentialsStrings', CredentialsStrings) + .run(CredentialsRun); + +export default MODULE_NAME; diff --git a/awx/ui/client/features/index.js b/awx/ui/client/features/index.js index f5756de731..60598ed192 100644 --- a/awx/ui/client/features/index.js +++ b/awx/ui/client/features/index.js @@ -1,5 +1,16 @@ -import '~features/credentials'; +import atLibServices from '~services'; +import atLibComponents from '~components'; +import atLibModels from '~models'; -angular.module('at.features', [ - 'at.features.credentials' +import atFeaturesCredentials from '~features/credentials'; + +const MODULE_NAME = 'at.features'; + +angular.module(MODULE_NAME, [ + atLibServices, + atLibComponents, + atLibModels, + atFeaturesCredentials ]); + +export default MODULE_NAME; diff --git a/awx/ui/client/index.template.ejs b/awx/ui/client/index.template.ejs index 7d7973088e..4e47d3c8cb 100644 --- a/awx/ui/client/index.template.ejs +++ b/awx/ui/client/index.template.ejs @@ -1,5 +1,5 @@ - + diff --git a/awx/ui/client/installing.template.ejs b/awx/ui/client/installing.template.ejs index f9005def42..42aa1ffc07 100644 --- a/awx/ui/client/installing.template.ejs +++ b/awx/ui/client/installing.template.ejs @@ -1,5 +1,5 @@ - + {% load staticfiles %} diff --git a/awx/ui/client/lib/components/index.js b/awx/ui/client/lib/components/index.js index 9d6a2a1ac2..426b9244cd 100644 --- a/awx/ui/client/lib/components/index.js +++ b/awx/ui/client/lib/components/index.js @@ -1,3 +1,5 @@ +import atLibServices from '~services'; + import actionGroup from '~components/action/action-group.directive'; import divider from '~components/utility/divider.directive'; import form from '~components/form/form.directive'; @@ -28,8 +30,12 @@ import truncate from '~components/truncate/truncate.directive'; import BaseInputController from '~components/input/base.controller'; import ComponentsStrings from '~components/components.strings'; +const MODULE_NAME = 'at.lib.components'; + angular - .module('at.lib.components', []) + .module(MODULE_NAME, [ + atLibServices + ]) .directive('atActionGroup', actionGroup) .directive('atDivider', divider) .directive('atForm', form) @@ -58,3 +64,5 @@ angular .directive('atTruncate', truncate) .service('BaseInputController', BaseInputController) .service('ComponentsStrings', ComponentsStrings); + +export default MODULE_NAME; diff --git a/awx/ui/client/lib/models/index.js b/awx/ui/client/lib/models/index.js index e56782441a..1213463e57 100644 --- a/awx/ui/client/lib/models/index.js +++ b/awx/ui/client/lib/models/index.js @@ -1,3 +1,5 @@ +import atLibServices from '~services'; + import Base from '~models/Base'; import Config from '~models/Config'; import Credential from '~models/Credential'; @@ -5,11 +7,17 @@ import CredentialType from '~models/CredentialType'; import Me from '~models/Me'; import Organization from '~models/Organization'; +const MODULE_NAME = 'at.lib.models'; + angular - .module('at.lib.models', []) + .module(MODULE_NAME, [ + atLibServices + ]) .service('BaseModel', Base) .service('ConfigModel', Config) .service('CredentialModel', Credential) .service('CredentialTypeModel', CredentialType) .service('MeModel', Me) .service('OrganizationModel', Organization); + +export default MODULE_NAME; diff --git a/awx/ui/client/lib/services/index.js b/awx/ui/client/lib/services/index.js index 89b5f1d73b..39ed1d8299 100644 --- a/awx/ui/client/lib/services/index.js +++ b/awx/ui/client/lib/services/index.js @@ -1,11 +1,17 @@ +import AppStrings from '~services/app.strings'; +import BaseStringService from '~services/base-string.service'; import CacheService from '~services/cache.service'; import EventService from '~services/event.service'; -import BaseStringService from '~services/base-string.service'; -import AppStrings from '~services/app.strings'; + +const MODULE_NAME = 'at.lib.services'; angular - .module('at.lib.services', []) + .module(MODULE_NAME, [ + 'I18N' + ]) .service('AppStrings', AppStrings) .service('BaseStringService', BaseStringService) .service('CacheService', CacheService) .service('EventService', EventService); + +export default MODULE_NAME; diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index fb5e4a1f75..1a068de835 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -1,7 +1,7 @@ // Configuration dependencies global.$AnsibleConfig = null; // Provided via Webpack DefinePlugin in webpack.config.js -global.$ENV = {} ; +global.$ENV = {}; // ui-router debugging if ($ENV['route-debug']){ let trace = angular.module('ui.router').trace; @@ -14,7 +14,8 @@ if ($basePath) { urlPrefix = `${$basePath}`; } -// Modules +import start from './app.start'; + import portalMode from './portal-mode/main'; import systemTracking from './system-tracking/main'; import inventoriesHosts from './inventories-hosts/main'; @@ -46,71 +47,76 @@ import access from './access/main'; import scheduler from './scheduler/main'; import instanceGroups from './instance-groups/main'; -import '../lib/components'; -import '../lib/models'; -import '../lib/services'; -import '../features'; +import atFeatures from '~features'; +import atLibComponents from '~components'; +import atLibModels from '~models'; +import atLibServices from '~services'; -angular.module('awApp', [ - 'I18N', - 'AngularCodeMirrorModule', - 'angular-duration-format', - 'angularMoment', - 'AngularScheduler', - 'angular-md5', - 'dndLists', - 'ncy-angular-breadcrumb', - 'ngSanitize', - 'ngCookies', - 'ngToast', - 'gettext', - 'Timezones', - 'ui.router', - 'ui.router.state.events', - 'lrInfiniteScroll', +start.bootstrap(() => { + angular.bootstrap(document.body, ['awApp']); +}); - about.name, - access.name, - license.name, - RestServices.name, - browserData.name, - configuration.name, - systemTracking.name, - inventoriesHosts.name, - inventoryScripts.name, - credentials.name, - credentialTypes.name, - organizations.name, - managementJobs.name, - breadCrumb.name, - home.name, - login.name, - activityStream.name, - workflowResults.name, - jobResults.name, - jobSubmission.name, - notifications.name, - standardOut.name, - Templates.name, - portalMode.name, - jobs.name, - teams.name, - users.name, - projects.name, - scheduler.name, - instanceGroups.name, +angular + .module('awApp', [ + 'I18N', + 'AngularCodeMirrorModule', + 'angular-duration-format', + 'angularMoment', + 'AngularScheduler', + 'angular-md5', + 'dndLists', + 'ncy-angular-breadcrumb', + 'ngSanitize', + 'ngCookies', + 'ngToast', + 'gettext', + 'Timezones', + 'ui.router', + 'ui.router.state.events', + 'lrInfiniteScroll', - 'Utilities', - 'templates', - 'PromptDialog', - 'AWDirectives', - 'features', + about.name, + access.name, + license.name, + RestServices.name, + browserData.name, + configuration.name, + systemTracking.name, + inventoriesHosts.name, + inventoryScripts.name, + credentials.name, + credentialTypes.name, + organizations.name, + managementJobs.name, + breadCrumb.name, + home.name, + login.name, + activityStream.name, + workflowResults.name, + jobResults.name, + jobSubmission.name, + notifications.name, + standardOut.name, + Templates.name, + portalMode.name, + jobs.name, + teams.name, + users.name, + projects.name, + scheduler.name, + instanceGroups.name, - 'at.lib.components', - 'at.lib.models', - 'at.lib.services', - 'at.features', -]) + 'Utilities', + 'templates', + 'PromptDialog', + 'AWDirectives', + 'features', + + atFeatures, + atLibComponents, + atLibModels, + atLibServices + ]) .constant('AngularScheduler.partials', urlPrefix + 'lib/angular-scheduler/lib/') .constant('AngularScheduler.useTimezone', true) .constant('AngularScheduler.showUTCField', true) @@ -171,13 +177,13 @@ angular.module('awApp', [ 'CheckLicense', '$location', 'Authorization', 'LoadBasePaths', 'Timer', 'LoadConfig', 'Store', 'pendoService', 'Prompt', 'Rest', 'Wait', 'ProcessErrors', '$state', 'GetBasePath', 'ConfigService', - 'FeaturesService', '$filter', 'SocketService', 'AppStrings', 'I18NInit', + 'FeaturesService', '$filter', 'SocketService', 'AppStrings', function($stateExtender, $q, $compile, $cookies, $rootScope, $log, $stateParams, CheckLicense, $location, Authorization, LoadBasePaths, Timer, LoadConfig, Store, pendoService, Prompt, Rest, Wait, ProcessErrors, $state, GetBasePath, ConfigService, FeaturesService, - $filter, SocketService, AppStrings, I18NInit) { - I18NInit(); + $filter, SocketService, AppStrings) { + $rootScope.$state = $state; $rootScope.$state.matches = function(stateName) { return $state.current.name.search(stateName) > 0; diff --git a/awx/ui/client/src/app.start.js b/awx/ui/client/src/app.start.js new file mode 100644 index 0000000000..f715f14082 --- /dev/null +++ b/awx/ui/client/src/app.start.js @@ -0,0 +1,72 @@ +const SUPPORTED_LOCALES = ['en', 'es', 'fr', 'ja', 'nl']; +const DEFAULT_LOCALE = 'en'; +const BASE_PATH = global.$basePath ? `${global.$basePath}languages/` : '/static/languages/'; + +/** + * The Angular app is manually initialized in order to complete some + * asynchronous work up front. This function returns a callback so app.js can + * call `angular.bootstrap` when the work is complete. + * + * @argument {function} - Callback. + */ +function bootstrap (callback) { + fetchLocaleStrings((locale) => { + if (locale) { + angular.module('I18N').constant('LOCALE', locale); + } + + angular.element(document).ready(() => callback()); + }); +} + +/** + * GET the localized JSON strings file or fall back to the default language + * if the locale isn't supported or if the request fails. + * + * @argument {function} - Callback. + * + * @returns {object=} - Locale data if it exists. + */ +function fetchLocaleStrings (callback) { + const code = normalizeLocaleCode(navigator.language || navigator.userLanguage); + + if (isDefaultLocale(code) || !isSupportedLocale(code)) { + callback({ code }); + + return; + } + + const request = $.ajax(`${BASE_PATH}${code}.json`); + + request.done(res => { + if (res[code]) { + callback({ code, strings: res[code] }); + } else { + callback({ code: DEFAULT_LOCALE }); + } + }); + + request.fail(() => callback({ code: DEFAULT_LOCALE })); +} + +function normalizeLocaleCode (code) { + try { + code = code.split('-')[0].toLowerCase(); + } catch (error) { + code = DEFAULT_LOCALE; + } + + return code; +} + +function isSupportedLocale (code) { + return SUPPORTED_LOCALES.includes(code); +} + +function isDefaultLocale (code) { + return code === DEFAULT_LOCALE; +} + +export default { + bootstrap +}; diff --git a/awx/ui/client/src/i18n.js b/awx/ui/client/src/i18n.js index 685c7e77df..04ee4ebcf7 100644 --- a/awx/ui/client/src/i18n.js +++ b/awx/ui/client/src/i18n.js @@ -1,52 +1,36 @@ -/* jshint ignore:start */ +import { sprintf } from 'sprintf-js'; -var sprintf = require('sprintf-js').sprintf; -let defaultLanguage = 'en_US'; +function I18n (gettextCatalog) { + return { + N_, + sprintf, + _: s => gettextCatalog.getString(s), + translate: (singular, context) => gettextCatalog.getString(singular, context), + translatePlural: (count, singular, plural, context) => { + return gettextCatalog.getPlural(count, singular, plural, context); + }, + hasTranslation: () => gettextCatalog.strings[gettextCatalog.currentLanguage] !== undefined + }; +} + +I18n.$inject = ['gettextCatalog']; + +function run (LOCALE, gettextCatalog) { + if (LOCALE.code && LOCALE.strings) { + gettextCatalog.setCurrentLanguage(LOCALE.code); + gettextCatalog.setStrings(LOCALE.code, LOCALE.strings); + } +} + +run.$inject = ['LOCALE', 'gettextCatalog']; -/** - * @ngdoc method - * @name function:i18n#N_ - * @methodOf function:N_ - * @description this function marks the translatable string with N_ - * for 'grunt nggettext_extract' - * -*/ export function N_(s) { return s; } -export default - angular.module('I18N', []) - .factory('I18NInit', ['$window', 'gettextCatalog', - function ($window, gettextCatalog) { - return function() { - var langInfo = ($window.navigator.languages || [])[0] || - $window.navigator.language || - $window.navigator.userLanguage || - ''; - var langUrl = langInfo.replace('-', '_'); - - if (langUrl === defaultLanguage) { - return; - } - - // gettextCatalog.debug = true; - gettextCatalog.setCurrentLanguage(langInfo); - gettextCatalog.loadRemote('/static/languages/' + langUrl + '.json'); - }; - }]) - .factory('i18n', ['gettextCatalog', - function (gettextCatalog) { - return { - _: function (s) { return gettextCatalog.getString (s); }, - N_: N_, - translate: (singular, context) => gettextCatalog.getString(singular, context), - translatePlural: (count, singular, plural, context) => { - return gettextCatalog.getPlural(count, singular, plural, context); - }, - sprintf: sprintf, - hasTranslation: function () { - return gettextCatalog.strings[gettextCatalog.currentLanguage] !== undefined; - } - }; - }]); +export default angular + .module('I18N', [ + 'gettext' + ]) + .factory('i18n', I18n) + .run(run); diff --git a/awx/ui/karma.conf.js b/awx/ui/karma.conf.js index f35dea0200..165ca318d2 100644 --- a/awx/ui/karma.conf.js +++ b/awx/ui/karma.conf.js @@ -16,16 +16,16 @@ module.exports = function(config) { reporters: ['progress', 'coverage', 'junit'], files:[ './client/src/vendor.js', - './client/src/app.js', './node_modules/angular-mocks/angular-mocks.js', - { pattern: './tests/**/*-test.js' }, - 'client/src/**/*.html' + './client/src/app.js', + './tests/**/*-test.js', + './client/src/**/*.html' ], preprocessors: { - './client/src/vendor.js': ['webpack', 'sourcemap'], - './client/src/app.js': ['webpack', 'sourcemap'], - './tests/**/*-test.js': ['webpack', 'sourcemap'], - 'client/src/**/*.html': ['html2js'] + './client/src/vendor.js': ['webpack'], + './client/src/app.js': ['webpack'], + './tests/**/*-test.js': ['webpack'], + './client/src/**/*.html': ['html2js'] }, webpack: webpackTestConfig, webpackMiddleware: {