diff --git a/apps/picsa-apps/dashboard/src/app/material.module.ts b/apps/picsa-apps/dashboard/src/app/material.module.ts index 6b35f985..7ac7d9b6 100644 --- a/apps/picsa-apps/dashboard/src/app/material.module.ts +++ b/apps/picsa-apps/dashboard/src/app/material.module.ts @@ -1,5 +1,6 @@ import { NgModule } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatChipsModule } from '@angular/material/chips'; import { MatDialogModule } from '@angular/material/dialog'; import { MatExpansionModule } from '@angular/material/expansion'; @@ -7,6 +8,7 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule, MatIconRegistry } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatListModule } from '@angular/material/list'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatRadioModule } from '@angular/material/radio'; import { MatSelectModule } from '@angular/material/select'; import { MatSidenavModule } from '@angular/material/sidenav'; @@ -19,6 +21,7 @@ import { DomSanitizer } from '@angular/platform-browser'; const matModules = [ MatButtonModule, + MatCheckboxModule, MatChipsModule, MatDialogModule, MatExpansionModule, @@ -26,6 +29,7 @@ const matModules = [ MatIconModule, MatInputModule, MatListModule, + MatProgressBarModule, MatRadioModule, MatSelectModule, MatSidenavModule, diff --git a/apps/picsa-apps/dashboard/src/app/modules/translations/pages/edit/translations-edit.component.html b/apps/picsa-apps/dashboard/src/app/modules/translations/pages/edit/translations-edit.component.html index e3728e73..8d6ba3ea 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/translations/pages/edit/translations-edit.component.html +++ b/apps/picsa-apps/dashboard/src/app/modules/translations/pages/edit/translations-edit.component.html @@ -1,41 +1,21 @@ -
-

Edit Translations

- @if(translationRow){ -
-
- -
{{ translationRow.created_at | date: 'mediumDate' }}
-
+ + + @if(text.length<20){ + + {{ text }} + + -
- - -
- -
- - -
- -
- - -
-
- - -
-
- - -
- -
- } @else if(dataLoadError) { -
{{ dataLoadError }}
} @else { -
Loading...
- } @if(editActionFeedbackMessage){ -
{{ editActionFeedbackMessage }}
+ + +

{{ text }}

+ +
} -
+ + + + + + diff --git a/apps/picsa-apps/dashboard/src/app/modules/translations/pages/edit/translations-edit.component.scss b/apps/picsa-apps/dashboard/src/app/modules/translations/pages/edit/translations-edit.component.scss index 7ae0bed7..e69de29b 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/translations/pages/edit/translations-edit.component.scss +++ b/apps/picsa-apps/dashboard/src/app/modules/translations/pages/edit/translations-edit.component.scss @@ -1,27 +0,0 @@ -.form-content { - display: flex; - flex-direction: column; - gap: 1.4rem; -} -.submitButton { - width: 7rem; - margin-bottom: 1rem; -} -.deleteButton { - width: 15rem; -} -.form-data { - display: flex; - flex-direction: column; - gap: 0.5rem; -} -input { - padding: 8px; - color: var(--color-primary); - outline-color: var(--color-primary); - max-width: 300px; - display: block; - font-size: 0.9rem; - white-space: pre-wrap; - word-wrap: break-word; -} diff --git a/apps/picsa-apps/dashboard/src/app/modules/translations/pages/edit/translations-edit.component.ts b/apps/picsa-apps/dashboard/src/app/modules/translations/pages/edit/translations-edit.component.ts index 967b015d..26c2b80d 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/translations/pages/edit/translations-edit.component.ts +++ b/apps/picsa-apps/dashboard/src/app/modules/translations/pages/edit/translations-edit.component.ts @@ -1,15 +1,12 @@ import { CommonModule } from '@angular/common'; -import { Component } from '@angular/core'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { ActivatedRoute, Router } from '@angular/router'; +import { Component, Inject } from '@angular/core'; +import { FormBuilder, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MAT_DIALOG_DATA } from '@angular/material/dialog'; import { RouterModule } from '@angular/router'; -// eslint-disable-next-line @nx/enforce-module-boundaries -import type { Database } from '@picsa/server-types'; +import { ILocaleCode } from '@picsa/data'; import { DashboardMaterialModule } from '../../../../material.module'; -import { TranslationDashboardService } from '../../translations.service'; - -type ITranslationEntry = Database['public']['Tables']['translations']['Row']; +import { ITranslationRow, TranslationDashboardService } from '../../translations.service'; @Component({ selector: 'dashboard-translations-edit', @@ -19,36 +16,28 @@ type ITranslationEntry = Database['public']['Tables']['translations']['Row']; styleUrls: ['./translations-edit.component.scss'], }) export class TranslationsEditComponent { - translationRow: ITranslationEntry; - dataLoadError: string; - editActionFeedbackMessage: string; - constructor(private service: TranslationDashboardService, private route: ActivatedRoute, private router: Router) { - this.service.ready(); - this.route.params.subscribe((params) => { - const id = params['id']; - this.service - .getTranslationById(id) - .then((data) => { - this.translationRow = data; - }) - .catch((error) => { - console.error('Error fetching translation:', error); - this.dataLoadError = 'Failed to fetch translation.'; - }); - }); + public text: string; + + public form = this.fb.group({ value: [''] }); + + constructor( + private service: TranslationDashboardService, + @Inject(MAT_DIALOG_DATA) public data: { row: ITranslationRow; locale: ILocaleCode }, + private fb: FormBuilder + ) { + // populate source text + this.text = data.row.text; + // populate saved translation value + const value = data.row[data.locale]; + if (value) { + this.form.patchValue({ value }); + } } - submitForm() { - this.service - .updateTranslationById(this.translationRow.id, this.translationRow) - .then((data) => { - if (data === 'Updated successfully') { - this.editActionFeedbackMessage = 'Updated successfully'; - } - }) - .catch((error) => { - console.error('Error editing translation:', error); - this.editActionFeedbackMessage = 'Failed to edit translation.'; - }); + async save() { + await this.service.ready(); + const { id } = this.data.row; + const { value } = this.form.value; + await this.service.updateTranslationById(id, { [this.data.locale]: value }); } } diff --git a/apps/picsa-apps/dashboard/src/app/modules/translations/pages/home/translations.page.html b/apps/picsa-apps/dashboard/src/app/modules/translations/pages/home/translations.page.html index b22027df..87877bca 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/translations/pages/home/translations.page.html +++ b/apps/picsa-apps/dashboard/src/app/modules/translations/pages/home/translations.page.html @@ -1,6 +1,24 @@
- @if(tableOptions && tableData().length>0){ - + + + Language + + @for(locale of localeOptions(); track locale.id){ + {{locale.language_label}} + } + + + + +
+ +
{{countTranslated}} / {{countTotal}}
+ Show All +
+ + + @if(tableOptions() && tableData().length>0){ + {{value | date: 'mediumDate' }} } @else { diff --git a/apps/picsa-apps/dashboard/src/app/modules/translations/pages/home/translations.page.ts b/apps/picsa-apps/dashboard/src/app/modules/translations/pages/home/translations.page.ts index 395694f5..5a7705ee 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/translations/pages/home/translations.page.ts +++ b/apps/picsa-apps/dashboard/src/app/modules/translations/pages/home/translations.page.ts @@ -1,7 +1,8 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, effect, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatDialog } from '@angular/material/dialog'; import { RouterModule } from '@angular/router'; -import { Router } from '@angular/router'; import { COUNTRIES_DATA_HASHMAP, ILocaleDataEntry, LOCALES_DATA, LOCALES_DATA_HASHMAP } from '@picsa/data'; // eslint-disable-next-line @nx/enforce-module-boundaries import { Database } from '@picsa/server-types'; @@ -11,72 +12,114 @@ import { capitalise } from '@picsa/utils'; import { DashboardMaterialModule } from '../../../../material.module'; import { DeploymentDashboardService } from '../../../deployment/deployment.service'; -import { IDeploymentRow } from '../../../deployment/types'; import { TranslationDashboardService } from '../../translations.service'; +import { TranslationsEditComponent } from '../edit/translations-edit.component'; export type ITranslationRow = Database['public']['Tables']['translations']['Row']; @Component({ selector: 'dashboard-translations-page', standalone: true, - imports: [CommonModule, DashboardMaterialModule, PicsaDataTableComponent, PicsaLoadingComponent, RouterModule], + imports: [ + CommonModule, + FormsModule, + DashboardMaterialModule, + PicsaDataTableComponent, + PicsaLoadingComponent, + RouterModule, + ], templateUrl: './translations.page.html', styleUrls: ['./translations.page.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) export class TranslationsPageComponent { /** Table options specific to active deployment (display columns vary depending on country) */ - public tableOptions: IDataTableOptions; + public tableOptions = computed(() => { + const locale = this.locale(); + return this.generateTableOptions(locale); + }); + + /** Specify whether to show all translations or just missing */ + public includeTranslated = signal(false); - public languageHashmap = LOCALES_DATA_HASHMAP; + /** List of available locales for deployment country */ + public localeOptions = signal([]); + /** ID of currently selected locale */ + public locale = signal(LOCALES_DATA_HASHMAP.global_en.id); + + /** Generated list of table entries */ public tableData = computed(() => { - return this.service.translations.filter((entry) => !entry.archived); + const translations = this.service.translations(); + const locale = this.locale(); + const data = this.generateTableData(locale, translations, this.includeTranslated()); + return data; + }); + + /** List of entries pending translation */ + public pendingEntries = computed(() => { + const locale = this.locale(); + return this.service.translations().filter((entry) => !entry[locale]); }); - /** Common table options used independent of deployment selected */ - private tableOptionsBase: IDataTableOptions = { - displayColumns: [], - exportFilename: 'translations', - formatHeader: (v) => { - const languageData: ILocaleDataEntry = LOCALES_DATA_HASHMAP[v]; - if (languageData) { - const { language_label, country_code } = languageData; - const { label: country_label } = COUNTRIES_DATA_HASHMAP[country_code]; - if (country_code === 'global') return capitalise(language_label); - return capitalise(country_label) + ' - ' + capitalise(language_label); - } - return formatHeaderDefault(v); - }, - paginatorSizes: [50, 100, 250], - handleRowClick: (row) => this.goToRecord(row), - }; + public translationProgress = computed(() => (100 * this.countTranslated) / this.countTotal); + + public get countTotal() { + return this.service.translations().length; + } + public get countPending() { + return this.pendingEntries().length; + } + public get countTranslated() { + return this.countTotal - this.countPending; + } + + /** */ + private generateTableData(localeId: string, entries: ITranslationRow[], includeTranslated = false) { + // HACK - ignore list when default translations set + if (localeId === LOCALES_DATA_HASHMAP.global_en.id) return []; + // Filter entries to only include those not already translated or archived + return entries + .filter((entry) => { + if (entry.archived) return false; + if (!includeTranslated) { + return entry[localeId] ? false : true; + } + return true; + }) + .sort((a, b) => (a.id > b.id ? 1 : -1)); + } /** Track active country code to avoid refreshing list when toggling between different country versions */ private activeCountryCode: string; constructor( public service: TranslationDashboardService, - private router: Router, - cdr: ChangeDetectorRef, + public dialog: MatDialog, deploymentService: DeploymentDashboardService ) { - effect(async () => { - const deployment = deploymentService.activeDeployment(); - if (deployment) { - await this.loadTranslations(deployment); - cdr.markForCheck(); - } - }); + effect( + async () => { + const deployment = deploymentService.activeDeployment(); + if (deployment) { + const { country_code } = deployment; + this.activeCountryCode = country_code; + await this.loadTranslationMeta(country_code); + await this.refreshTranslations(); + } + }, + { allowSignalWrites: true } + ); } - private async loadTranslations(deployment: IDeploymentRow) { - const { country_code } = deployment; - if (country_code && country_code !== this.activeCountryCode) { - this.activeCountryCode = country_code; - const languages = this.getTargetTranslationLanguages(country_code); - this.tableOptions = this.generateTableOptions(languages.map((l) => l.id)); - await this.refreshTranslations(); - } + public showEditDialog(row: ITranslationRow) { + this.dialog.open(TranslationsEditComponent, { data: { row, locale: this.locale() } }); + } + + private async loadTranslationMeta(country_code: string) { + // List all locales available for current language + const locales = this.getTargetTranslationLanguages(country_code); + this.localeOptions.set(locales); + this.locale.set(locales[0].id); } private getTargetTranslationLanguages(country_code: string) { @@ -86,21 +129,27 @@ export class TranslationsPageComponent { return LOCALES_DATA.filter((o) => o.country_code === country_code); } - private generateTableOptions(languageCodes: string[]): IDataTableOptions { + private generateTableOptions(locale: string): IDataTableOptions { return { - ...this.tableOptionsBase, - displayColumns: ['tool', 'context', 'text', ...languageCodes, 'created_at'], + exportFilename: 'translations', + formatHeader: (v) => { + const languageData: ILocaleDataEntry = LOCALES_DATA_HASHMAP[v]; + if (languageData) { + const { language_label, country_code } = languageData; + const { label: country_label } = COUNTRIES_DATA_HASHMAP[country_code]; + if (country_code === 'global') return capitalise(language_label); + return capitalise(country_label) + ' - ' + capitalise(language_label); + } + return formatHeaderDefault(v); + }, + paginatorSizes: [10, 50, 100], + handleRowClick: (row) => this.showEditDialog(row), + displayColumns: ['tool', 'context', 'text', locale, 'created_at'], }; } - goToRecord(row: ITranslationRow) { - this.router.navigate([`/translations`, row.id]); - } - - async refreshTranslations() { + private async refreshTranslations() { await this.service.ready(); - this.service.listTranslations().catch((error) => { - console.error('Error fetching translations:', error); - }); + this.service.listTranslations(); } } diff --git a/apps/picsa-apps/dashboard/src/app/modules/translations/pages/import/translations-import.component.html b/apps/picsa-apps/dashboard/src/app/modules/translations/pages/import/translations-import.component.html index 95a5239a..747205b0 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/translations/pages/import/translations-import.component.html +++ b/apps/picsa-apps/dashboard/src/app/modules/translations/pages/import/translations-import.component.html @@ -1,29 +1,40 @@
-

Import Translations

-
- +
+ + @if (importTotal()===-1) { +
+ } @else { + + @for(table of summaryTables(); track table.key){ + + + + } + } - +
+ +
+

CSV Translations

+

Coming soon...

+
- - @if (importTotal()===-1) { -
- } @else { - - @for(table of summaryTables(); track table.key){ - - - - } - - } diff --git a/apps/picsa-apps/dashboard/src/app/modules/translations/pages/import/translations-import.component.ts b/apps/picsa-apps/dashboard/src/app/modules/translations/pages/import/translations-import.component.ts index 3e78e595..c0fbf419 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/translations/pages/import/translations-import.component.ts +++ b/apps/picsa-apps/dashboard/src/app/modules/translations/pages/import/translations-import.component.ts @@ -114,6 +114,7 @@ export class TranslationsImportComponent { private async prepareSourceActions(entries: ITranslationEntry[]) { if (!Array.isArray(entries)) { this.uppy.cancelAll(); + console.error(entries); throw new Error('Data is not formatted correctly'); } const localHashmap: Record = {}; @@ -122,7 +123,7 @@ export class TranslationsImportComponent { localHashmap[id] = entry; } await this.service.ready(); - const serverHashmap = arrayToHashmap(this.service.translations, 'id'); + const serverHashmap = arrayToHashmap(this.service.translations(), 'id'); let summary = this.generateSourceSummary(localHashmap, serverHashmap); // When running locally allow one-time migration of existing translations from i18n folder if (!ENVIRONMENT.production) { diff --git a/apps/picsa-apps/dashboard/src/app/modules/translations/translations.service.ts b/apps/picsa-apps/dashboard/src/app/modules/translations/translations.service.ts index 5eacdd48..108c406e 100644 --- a/apps/picsa-apps/dashboard/src/app/modules/translations/translations.service.ts +++ b/apps/picsa-apps/dashboard/src/app/modules/translations/translations.service.ts @@ -1,15 +1,19 @@ -import { Injectable } from '@angular/core'; +import { Injectable, signal } from '@angular/core'; // eslint-disable-next-line @nx/enforce-module-boundaries import { Database } from '@picsa/server-types'; import { PicsaAsyncService } from '@picsa/shared/services/asyncService.service'; // import { PicsaNotificationService } from '@picsa/shared/services/core/notification.service'; import { SupabaseService } from '@picsa/shared/services/core/supabase'; +import { arrayToHashmap } from '@picsa/utils'; export type ITranslationRow = Database['public']['Tables']['translations']['Row']; @Injectable({ providedIn: 'root' }) export class TranslationDashboardService extends PicsaAsyncService { - public translations: ITranslationRow[] = []; + public translations = signal([]); + + /** Track a list of translations by id for lookup and local update */ + private translationsHashmap: Record = {}; public get table() { return this.supabaseService.db.table('translations'); @@ -29,15 +33,23 @@ export class TranslationDashboardService extends PicsaAsyncService { if (error) { throw error; } - this.translations = data || []; + this.translations.set(data || []); + this.translationsHashmap = arrayToHashmap(data, 'id'); } // update a translation record by ID - public async updateTranslationById(id: string, updatedData: Partial): Promise { - const { data, error } = await this.supabaseService.db.table('translations').update(updatedData).eq('id', id); - if (error) { - throw error; - } - return 'Updated successfully'; + public async updateTranslationById(id: string, updatedData: Partial) { + // Save to DB + const { error, data } = await this.supabaseService.db + .table('translations') + .update(updatedData) + .eq('id', id) + .select<'*', ITranslationRow>('*') + .single(); + if (error) throw new Error(error.message); + // Update current list + this.translationsHashmap[id] = data; + this.translations.set(Object.values(this.translationsHashmap)); + return data; } // Fetch a translation record by ID diff --git a/libs/data/deployments/locales.ts b/libs/data/deployments/locales.ts index 3d7849c3..5b347d4a 100644 --- a/libs/data/deployments/locales.ts +++ b/libs/data/deployments/locales.ts @@ -23,7 +23,7 @@ const LOCALES_BASE: { global_en: { language_code: 'en', language_label: 'English', country_code: 'global' }, mw_ny: { language_code: 'ny', language_label: 'Chichewa', country_code: 'mw' }, mw_tum: { language_code: 'tum', language_label: 'Tumbuka', country_code: 'mw' }, - zm_ny: { language_code: 'ny', language_label: 'Chichewa', country_code: 'zm' }, + zm_ny: { language_code: 'ny', language_label: 'Chewa', country_code: 'zm' }, tj_tg: { language_code: 'tg', language_label: 'Тоҷикӣ', country_code: 'tj' }, } as const;