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){
-
- } @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
-
-
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;