diff --git a/admin/src/js/controllers/edit-language.js b/admin/src/js/controllers/edit-language.js index 8932e00814b..44aac26b47e 100644 --- a/admin/src/js/controllers/edit-language.js +++ b/admin/src/js/controllers/edit-language.js @@ -30,6 +30,7 @@ angular.module('controllers').controller('EditLanguageCtrl', $scope.language = _.clone($scope.model) || { enabled: true, + rtl: false, generic: {}, custom: {}, type: 'translations' diff --git a/admin/src/templates/display_languages.html b/admin/src/templates/display_languages.html index 4392e08b50d..9df875a43f2 100644 --- a/admin/src/templates/display_languages.html +++ b/admin/src/templates/display_languages.html @@ -56,7 +56,7 @@

diff --git a/admin/src/templates/edit_language.html b/admin/src/templates/edit_language.html index c17ce5ceeef..554aaaf8c7b 100644 --- a/admin/src/templates/edit_language.html +++ b/admin/src/templates/edit_language.html @@ -27,5 +27,12 @@ Language name help
+
+ + + {{errors.rtl}} + Language RTL help +
+ diff --git a/api/resources/translations/messages-en.properties b/api/resources/translations/messages-en.properties index 3bc93546cdc..d6be7f100a0 100644 --- a/api/resources/translations/messages-en.properties +++ b/api/resources/translations/messages-en.properties @@ -138,6 +138,7 @@ Language\ For\ Outgoing\ Messages = Language for outgoing messages Language\ code = Language code Language\ code\ help = The 2 or 3 digit code for the language following the Language\ name\ help = The display name for the language. +Language\ RTL\ help = Whether the language should enable Right-To-Left writing UI. Language\ to\ edit = Language to edit Languages = Languages Last\ Appointment = Last appointment @@ -654,8 +655,8 @@ enketo.geopicker.altitude = altitude (m) enketo.geopicker.closepolygon = close polygon enketo.geopicker.kmlcoords = KML coordinates enketo.geopicker.kmlpaste = paste KML coordinates here -enketo.geopicker.latitude = latitude (x.y °) -enketo.geopicker.longitude = longitude (x.y °) +enketo.geopicker.latitude = latitude (x.y �) +enketo.geopicker.longitude = longitude (x.y �) enketo.geopicker.points = points enketo.geopicker.searchPlaceholder = search for place or address enketo.geopicker.removePoint = This will completely remove the current geopoint from the list of geopoints and cannot be undone. Are you sure you want to do this? diff --git a/webapp/src/css/inbox.less b/webapp/src/css/inbox.less index 8e324ff468a..c5e0c53394a 100644 --- a/webapp/src/css/inbox.less +++ b/webapp/src/css/inbox.less @@ -21,6 +21,7 @@ @import 'error-log'; @import 'tooltip'; @import 'old-nav'; +@import 'rtl'; .container-fluid { overflow: hidden; @@ -1918,4 +1919,4 @@ mm-sidebar-menu .mat-sidenav-container { .desktop-only { display: none; } -} \ No newline at end of file +} diff --git a/webapp/src/css/rtl.less b/webapp/src/css/rtl.less new file mode 100644 index 00000000000..5dfe996debb --- /dev/null +++ b/webapp/src/css/rtl.less @@ -0,0 +1,103 @@ +@import "variables"; + +.app-root:not(.old-nav).rtl { + direction: rtl; + * { + direction: rtl; + } + + mm-header { + right: 0; + left: auto; + } + + .content .page { + padding-right: 80px; + padding-left: 0; + } + + .left-pane { + float: right; + } + + .fast-action-trigger:not(.embed-fast-action) { + top: 60px; + left: 5px; + right: auto; + } + + .content-row { + border-left: 0; + border-right: 5px solid transparent; + + a { + padding: 10px 8px 10px 20px; + .icon { + margin: 5px 0 5px 7px; + } + } + + input[type="checkbox"] { + float: right; + margin: 20px 10px 20px 7px; + } + } + + &.reports .content-row:hover { + border-right-color: @reports-color; + } + + .select-mode-available .select-all .select-all-label { + margin-right: 15px; + } + + .item-summary .icon { + float: right; + margin-left: 4px; + margin-right: 0; + } + + .sidebar-filter .sidebar-main .sidebar-header .sidebar-reset { + margin-right: 0; + margin-left: 20px; + } + + .sidebar-filter .sidebar-main .sidebar-body mat-expansion-panel mat-expansion-panel-header .chip { + margin-right: 10px; + } + + mm-search-bar .mm-search-bar-container .btn.open-filter .open-filter-label { + margin-left: 6px; + } + + .item-content .deselect { + float: left; + clear: left; + margin-right: -10px; + } + + &.contacts .action-header { + h3 { + float: right; + } + .table-filter { + float: left; + } + } + + .targets .target .heading .icon { + margin-left: 10px; + margin-right: 0; + } + + .enketo .or h2, .enketo .or h3, .enketo .or h4 { + text-align: right; + } +} + +[dir="rtl"] { + .fast-action-body .fast-action-item .fast-action-item-icon { + margin-left: 20px; + margin-right: 0; + } +} diff --git a/webapp/src/ts/actions/global.ts b/webapp/src/ts/actions/global.ts index 0e1472723e5..bed39248d0b 100644 --- a/webapp/src/ts/actions/global.ts +++ b/webapp/src/ts/actions/global.ts @@ -40,6 +40,7 @@ export const Actions = { setSearchBar: createSingleValueAction('SET_SEARCH_BAR', 'searchBar'), setTrainingCard: createSingleValueAction('SET_TRAINING_CARD', 'trainingCard'), clearTrainingCards: createAction('CLEAR_TRAINING_CARDS'), + setLanguage: createSingleValueAction('SET_LANGUAGE', 'language'), }; export class GlobalActions { @@ -244,4 +245,8 @@ export class GlobalActions { return this.store.dispatch(Actions.closeSidebarMenu()); } + setLanguage(language) { + return this.store.dispatch(Actions.setLanguage(language)); + } + } diff --git a/webapp/src/ts/app.component.html b/webapp/src/ts/app.component.html index 924eaca01c6..8eeb2adf4eb 100644 --- a/webapp/src/ts/app.component.html +++ b/webapp/src/ts/app.component.html @@ -3,7 +3,10 @@ [class.select-mode]="selectMode" [class.sidebar-filter-active]="isSidebarFilterOpen" [class.search-bar-active]="openSearch" - [class.old-nav]="hasOldNav"> + [class.old-nav]="hasOldNav" + [class.rtl]="direction === 'rtl'" + [dir]="direction" +>
diff --git a/webapp/src/ts/app.component.ts b/webapp/src/ts/app.component.ts index a0079893d52..f7d23bdfd63 100644 --- a/webapp/src/ts/app.component.ts +++ b/webapp/src/ts/app.component.ts @@ -50,6 +50,7 @@ import { BrowserCompatibilityComponent } from '@mm-modals/browser-compatibility/ import { PerformanceService } from '@mm-services/performance.service'; import { UserSettings, UserSettingsService } from '@mm-services/user-settings.service'; import { OLD_NAV_PERMISSION } from '@mm-components/header/header.component'; +import { Directionality } from '@angular/cdk/bidi'; const SYNC_STATUS = { inProgress: { @@ -95,6 +96,7 @@ export class AppComponent implements OnInit, AfterViewInit { androidAppVersion; hasOldNav = false; initialisationComplete = false; + direction; private readonly SVG_ICONS = new Map([ ['icon-close', './img/icon-close.svg'], ['icon-filter', './img/icon-filter.svg'], @@ -463,18 +465,21 @@ export class AppComponent implements OnInit, AfterViewInit { this.store.select(Selectors.getCurrentTab), this.store.select(Selectors.getSelectMode), this.store.select(Selectors.getSearchBar), + this.store.select(Selectors.getDirection), ]).subscribe(([ replicationStatus, androidAppVersion, currentTab, selectMode, searchBar, + direction, ]) => { this.replicationStatus = replicationStatus; this.androidAppVersion = androidAppVersion; this.currentTab = currentTab || ''; this.selectMode = selectMode; this.openSearch = !!searchBar?.isOpen; + this.direction = direction; }); combineLatest([ diff --git a/webapp/src/ts/app.module.ts b/webapp/src/ts/app.module.ts index a49334e2c34..b8dc59d3b0d 100644 --- a/webapp/src/ts/app.module.ts +++ b/webapp/src/ts/app.module.ts @@ -41,6 +41,7 @@ import { GlobalEffects } from '@mm-effects/global.effects'; import { ReportsEffects } from '@mm-effects/reports.effects'; import { ContactsEffects } from '@mm-effects/contacts.effects'; import { reducers } from '@mm-reducers/index'; +import { LanguageService } from '@mm-services/language.service'; const logger = reducer => { // default, no options @@ -74,8 +75,9 @@ export class MissingTranslationHandlerLog implements MissingTranslationHandler { TranslateModule.forRoot({ loader: { provide: TranslateLoader, - useFactory: (db: DbService) => new TranslationLoaderProvider(db), - deps: [DbService], + useFactory: + (db: DbService, languageService: LanguageService) => new TranslationLoaderProvider(db, languageService), + deps: [DbService, LanguageService], }, missingTranslationHandler: { provide: MissingTranslationHandler, diff --git a/webapp/src/ts/components/fast-action-button/fast-action-button.component.ts b/webapp/src/ts/components/fast-action-button/fast-action-button.component.ts index 05da87c9f15..538a225488b 100644 --- a/webapp/src/ts/components/fast-action-button/fast-action-button.component.ts +++ b/webapp/src/ts/components/fast-action-button/fast-action-button.component.ts @@ -28,6 +28,7 @@ export class FastActionButtonComponent implements OnInit, OnDestroy { iconTypeResource = IconType.RESOURCE; iconTypeFontAwesome = IconType.FONT_AWESOME; buttonTypeFlat = ButtonType.FLAT; + direction; constructor( private store: Store, @@ -35,7 +36,11 @@ export class FastActionButtonComponent implements OnInit, OnDestroy { private responsiveService: ResponsiveService, private matBottomSheet: MatBottomSheet, private matDialog: MatDialog, - ) { } + ) { + this.store.select(Selectors.getDirection).subscribe(direction => { + this.direction = direction; + }); + } ngOnInit() { this.subscribeToStore(); @@ -87,6 +92,7 @@ export class FastActionButtonComponent implements OnInit, OnDestroy { autoFocus: false, minWidth: 300, minHeight: 150, + direction: this.direction, }); } diff --git a/webapp/src/ts/modules/reports/reports.component.ts b/webapp/src/ts/modules/reports/reports.component.ts index da6786dce96..50990c54930 100644 --- a/webapp/src/ts/modules/reports/reports.component.ts +++ b/webapp/src/ts/modules/reports/reports.component.ts @@ -25,6 +25,7 @@ import { XmlFormsService } from '@mm-services/xml-forms.service'; import { PerformanceService } from '@mm-services/performance.service'; import { ExtractLineageService } from '@mm-services/extract-lineage.service'; import { ButtonType } from '@mm-components/fast-action-button/fast-action-button.component'; +import { Directionality } from '@angular/cdk/bidi'; const PAGE_SIZE = 50; const CAN_DEFAULT_FACILITY_FILTER = 'can_default_facility_filter'; @@ -65,6 +66,7 @@ export class ReportsComponent implements OnInit, AfterViewInit, OnDestroy { userParentPlace; fastActionList?: FastAction[]; userLineageLevel; + isRtl; LIMIT_SELECT_ALL_REPORTS = 500; @@ -88,9 +90,11 @@ export class ReportsComponent implements OnInit, AfterViewInit, OnDestroy { private xmlFormsService:XmlFormsService, private performanceService: PerformanceService, private extractLineageService: ExtractLineageService, + dir: Directionality, ) { this.globalActions = new GlobalActions(store); this.reportsActions = new ReportsActions(store); + this.isRtl = dir.value === 'rtl'; } ngOnInit() { diff --git a/webapp/src/ts/providers/translation-loader.provider.ts b/webapp/src/ts/providers/translation-loader.provider.ts index dc21cb6aaf6..1d8846dc067 100644 --- a/webapp/src/ts/providers/translation-loader.provider.ts +++ b/webapp/src/ts/providers/translation-loader.provider.ts @@ -5,10 +5,14 @@ import { from } from 'rxjs'; import { DbService } from '@mm-services/db.service'; import * as translationUtils from '@medic/translation-utils'; import { TranslationDocsMatcherProvider } from '@mm-providers/translation-docs-matcher.provider'; +import { LanguageService } from '@mm-services/language.service'; @Injectable() export class TranslationLoaderProvider implements TranslateLoader { - constructor(private db:DbService) {} + constructor( + private db: DbService, + private languageService: LanguageService + ) {} private loadingPromises = {}; @@ -38,11 +42,12 @@ export class TranslationLoaderProvider implements TranslateLoader { const promise = this.db .get() .get(translationsDocId) - .then(doc => { + .then((doc:{ generic:{}; custom:{}; rtl: boolean }) => { const values = Object.assign(doc.generic || {}, doc.custom || {}); if (testing) { mapTesting(values); } + doc.rtl && this.languageService.setRtlLanguage(locale); return translationUtils.loadTranslations(values); }) .catch(err => { diff --git a/webapp/src/ts/reducers/global.ts b/webapp/src/ts/reducers/global.ts index c94c7124b9c..e17f17c447d 100644 --- a/webapp/src/ts/reducers/global.ts +++ b/webapp/src/ts/reducers/global.ts @@ -42,6 +42,7 @@ const initialState: GlobalState = { translationsLoaded: false, userFacilityIds: [], userContactId: null, + language: null, }; const setShowContent = (state, showContent) => { @@ -177,6 +178,9 @@ const _globalReducer = createReducer( on(Actions.setTrainingCard, (state, { payload: { trainingCard } }) => { return { ...state, trainingCard: { ...state.trainingCard, ...trainingCard } }; }), + on(Actions.setLanguage, (state, { payload: { language } }) => { + return { ...state, language }; + }), ); export const globalReducer = (state, action) => { @@ -211,6 +215,7 @@ export interface GlobalState { translationsLoaded: boolean; userFacilityIds: null | string[]; userContactId: null | string; + language: null | Record; } interface SidebarMenuState { diff --git a/webapp/src/ts/selectors/index.ts b/webapp/src/ts/selectors/index.ts index 95c5fc52eb1..0e91cabe916 100644 --- a/webapp/src/ts/selectors/index.ts +++ b/webapp/src/ts/selectors/index.ts @@ -38,6 +38,8 @@ export const Selectors = { getUserContactId: createSelector(getGlobalState, (globalState) => globalState.userContactId), getTrainingCardFormId: createSelector(getGlobalState, (globalState) => globalState.trainingCard?.formId), getTrainingCard: createSelector(getGlobalState, (globalState) => globalState.trainingCard), + getLanguage: createSelector(getGlobalState, (globalState) => globalState.language), + getDirection: createSelector(getGlobalState, (globalState) => globalState.language?.rtl ? 'rtl' : 'ltr'), // enketo getEnketoStatus: createSelector(getGlobalState, (globalState) => globalState.enketoStatus), diff --git a/webapp/src/ts/services/language.service.ts b/webapp/src/ts/services/language.service.ts index f465d8b9f88..69dbc5e852c 100644 --- a/webapp/src/ts/services/language.service.ts +++ b/webapp/src/ts/services/language.service.ts @@ -6,6 +6,9 @@ import { TranslateService as NgxTranslateService } from '@ngx-translate/core'; import { SettingsService } from '@mm-services/settings.service'; import { FormatDateService } from '@mm-services/format-date.service'; import { TelemetryService } from '@mm-services/telemetry.service'; +import { Store } from '@ngrx/store'; +import { ServicesActions } from '@mm-actions/services'; +import { GlobalActions } from '@mm-actions/global'; @Injectable({ providedIn: 'root' @@ -46,12 +49,17 @@ export class LanguageCookieService { providedIn: 'root' }) export class SetLanguageService { + private globalActions; + constructor( private ngxTranslateService:NgxTranslateService, private telemetryService:TelemetryService, private languageCookieService:LanguageCookieService, private formatDateService:FormatDateService, + private languageService:LanguageService, + private store:Store, ) { + this.globalActions = new GlobalActions(this.store); } private setDatepickerLanguage(language) { @@ -71,6 +79,7 @@ export class SetLanguageService { // formatDateService depends on the cookie, so also wait for the cookie to be updated await this.formatDateService.init(); + this.globalActions.setLanguage({ code, rtl: this.languageService.useRtl(code) }); this.telemetryService.record(`user_settings:language:${code}`); } } @@ -89,11 +98,17 @@ export class LanguageService { private readonly DEFAULT_LOCALE = 'en'; private readonly NEPALI_LOCALE = 'ne'; + private rtlLanguages:string[] = []; + private async fetchLocale() { const settings = await this.settingsService.get(); return settings.locale || this.DEFAULT_LOCALE; } + setRtlLanguage(code:string) { + this.rtlLanguages.push(code); + } + async get() { const cookieVal = this.languageCookieService.get(); if (cookieVal) { @@ -108,4 +123,8 @@ export class LanguageService { const language = this.languageCookieService.get(); return language === this.NEPALI_LOCALE; } + + useRtl(code:string) { + return this.rtlLanguages.includes(code); + } } diff --git a/webapp/src/ts/services/modal.service.ts b/webapp/src/ts/services/modal.service.ts index 0e124425bf1..4ea9b2648de 100644 --- a/webapp/src/ts/services/modal.service.ts +++ b/webapp/src/ts/services/modal.service.ts @@ -5,6 +5,7 @@ import { Store } from '@ngrx/store'; import { ResponsiveService } from '@mm-services/responsive.service'; import { GlobalActions } from '@mm-actions/global'; +import { Selectors } from '@mm-selectors/index'; @Injectable({ providedIn: 'root' @@ -12,6 +13,7 @@ import { GlobalActions } from '@mm-actions/global'; export class ModalService { private globalActions: GlobalActions; + private direction; constructor( private matDialog: MatDialog, @@ -19,6 +21,9 @@ export class ModalService { private responsiveService: ResponsiveService, ) { this.globalActions = new GlobalActions(this.store); + this.store.select(Selectors.getDirection).subscribe(direction => { + this.direction = direction; + }); } show(component: ComponentType, config?: Record): MatDialogRef { @@ -42,6 +47,7 @@ export class ModalService { width: '600px', maxWidth: '90vw', minHeight: '100px', + direction: this.direction, ...config, }); } diff --git a/webapp/src/ts/services/select2-search.service.ts b/webapp/src/ts/services/select2-search.service.ts index 9723b428645..8000f0f60e4 100644 --- a/webapp/src/ts/services/select2-search.service.ts +++ b/webapp/src/ts/services/select2-search.service.ts @@ -11,11 +11,15 @@ import { SettingsService } from '@mm-services/settings.service'; import { ContactMutedService } from '@mm-services/contact-muted.service'; import { TranslateService } from '@mm-services/translate.service'; import { SearchTelemetryService } from '@mm-services/search-telemetry.service'; +import { Store } from '@ngrx/store'; +import { Selectors } from '@mm-selectors/index'; @Injectable({ providedIn: 'root' }) export class Select2SearchService { + private direction; + constructor( private readonly route: ActivatedRoute, private readonly formatProvider: FormatProvider, @@ -26,7 +30,12 @@ export class Select2SearchService { private readonly settingsService: SettingsService, private readonly contactMutedService: ContactMutedService, private readonly searchTelemetryService: SearchTelemetryService, - ) { } + private store: Store, + ) { + this.store.select(Selectors.getDirection).subscribe(direction => { + this.direction = direction; + }); + } private defaultTemplateResult(row) { if (!row.doc) { @@ -154,7 +163,8 @@ export class Select2SearchService { templateResult: options.templateResult, templateSelection: options.templateSelection, width: '100%', - minimumInputLength: this.sessionService.isOnlineOnly() ? 3 : 0 + minimumInputLength: this.sessionService.isOnlineOnly() ? 3 : 0, + dir: this.direction, }); if (options.allowNew) {