localization, assign data-base, tenant domain, plan and subscription and data-base connection screen added

This commit is contained in:
Gagan7900
2026-08-07 14:01:25 +05:30
parent 0ed581abc9
commit c29d9450ec
88 changed files with 3489 additions and 2469 deletions
@@ -0,0 +1,62 @@
<modal [open]="open()" [title]="modalTitle()" size="md" [submitAction]="isCreateMode() ? 'save' : 'update'"
[submitLabel]="isCreateMode() ? 'Save' : 'Update'" [loadingLabel]="isCreateMode() ? 'Saving...' : 'Updating...'"
[loading]="saving() || modalLoading()" [showSubmitButton]="!isViewMode()"
[cancelLabel]="isViewMode() ? 'Close' : 'Cancel'" (closed)="closeModal()" (submitted)="saveTranslation()">
@if (modalLoading()) {
<div class="flex min-h-32 items-center justify-center">
<span class="ti ti-loader-2 animate-spin text-2xl text-primary"></span>
<span class="ms-2 text-defaulttextcolor">Loading translation details...</span>
</div>
} @else {
<form [formGroup]="translationForm" (ngSubmit)="saveTranslation()" autocomplete="off">
<div class="grid grid-cols-12 gap-x-5 gap-y-5 items-center">
<!-- Translation Key Autocomplete (col-6) -->
<div class="col-span-12 md:col-span-6">
<app-autocomplete formControlName="translationKeyId" inputId="translation-key-id" variant="floating" size="sm"
label="Translation Key" placeholder="Select translation key" [required]="true"
[readonly]="isEditMode() || isViewMode() || !!presetTranslationKey()" [submitAttempted]="submitAttempted()" [searchFn]="keySearchFn"
[valueWith]="keyValueFn" [displayWith]="keyDisplayFn" [selectedItem]="selectedFormTranslationKey()"
[minSearchLength]="0" (itemSelected)="onFormTranslationKeyChanged($event)"
[validationMessages]="{ required: 'Translation key is required.' }" />
</div>
<!-- Language Autocomplete (col-6) -->
<div class="col-span-12 md:col-span-6">
<app-autocomplete formControlName="languageId" inputId="translation-language-id" variant="floating" size="sm"
label="Language" placeholder="Select language" [required]="true" [readonly]="isEditMode() || isViewMode() || !!presetLanguage()"
[submitAttempted]="submitAttempted()" [searchFn]="languageSearchFn" [valueWith]="languageValueFn"
[displayWith]="languageDisplayFn" [selectedItem]="selectedFormLanguage()" [minSearchLength]="0"
(itemSelected)="onFormLanguageChanged($event)" [validationMessages]="{ required: 'Language is required.' }" />
</div>
<!-- Tenant Autocomplete (col-6) -->
<div class="col-span-12 md:col-span-6">
<app-autocomplete formControlName="tenantId" inputId="translation-tenant-id" variant="floating" size="sm"
label="Tenant" placeholder="Select tenant" [required]="false" [readonly]="isEditMode() || isViewMode()"
[submitAttempted]="submitAttempted()" [searchFn]="tenantSearchFn" [valueWith]="tenantValueFn"
[displayWith]="tenantDisplayFn" [selectedItem]="selectedFormTenant()" [minSearchLength]="0"
(itemSelected)="onFormTenantChanged($event)" />
</div>
<!-- Translated Text (col-6) -->
<div class="col-span-12 md:col-span-6">
<app-form-input formControlName="translatedText" inputId="translation-text" variant="floating"
label="Translated Text" placeholder="Enter translated text..." [required]="true" [readonly]="isViewMode()"
[maxLength]="4000" [submitAttempted]="submitAttempted()"
[validationMessages]="{ required: 'Translated text is required.' }" />
</div>
<!-- Approved Status (col-6) -->
<div class="col-span-12 md:col-span-6 flex flex-col justify-center">
<label for="translation-is-approved" class="inline-flex cursor-pointer items-center gap-2" [class.pointer-events-none]="isViewMode()">
<input id="translation-is-approved" type="checkbox" formControlName="isApproved" class="form-check-input" />
<span class="text-sm font-medium text-defaulttextcolor">Is Approved</span>
</label>
<p class="text-xs text-gray-500 mt-1">Approved translations will be active immediately.</p>
</div>
</div>
</form>
}
</modal>
@@ -0,0 +1,329 @@
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
computed,
effect,
inject,
input,
output,
signal
} from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { HttpErrorResponse } from '@angular/common/http';
import { forkJoin, of } from 'rxjs';
import { catchError, finalize, map, switchMap } from 'rxjs/operators';
import { NotificationService } from '../../../../../core/services/common/notification.service';
import {
CreateTranslationRequest,
TranslationDto,
TranslationModalMode,
UpdateTranslationRequest
} from '../../models/translation.model';
import { LanguageLookupDto } from '../../../../global-masters/languages/models/language.model';
import { TenantLookupDto } from '../../../../tenants/models/tenant.model';
import { TranslationKeyLookupDto } from '../../../translation-keys/models/translation-key.model';
import { TranslationService } from '../../data-access/translation.service';
import { LanguageService } from '../../../../global-masters/languages/data-access/language.service';
import { TenantService } from '../../../../tenants/data-access/tenant.service';
import { TranslationKeyService } from '../../../translation-keys/data-access/translation-key.service';
import { Modal } from '../../../../../shared/components/modal/modal';
import { FormInput } from '../../../../../shared/components/form/form-input/form-input';
import { Autocomplete } from '../../../../../shared/components/form/autocomplete/autocomplete';
import {
AutocompleteDisplayFn,
AutocompleteSearchFn,
AutocompleteValueFn
} from '../../../../../shared/components/form/autocomplete/autocomplete.types';
@Component({
selector: 'app-translation-form-modal',
standalone: true,
imports: [Modal, ReactiveFormsModule, FormInput, Autocomplete],
templateUrl: './translation-form-modal.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TranslationFormModalComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly formBuilder = inject(FormBuilder);
private readonly translationApi = inject(TranslationService);
private readonly translationKeyApi = inject(TranslationKeyService);
private readonly languageApi = inject(LanguageService);
private readonly tenantApi = inject(TenantService);
private readonly notification = inject(NotificationService);
readonly open = input<boolean>(false);
readonly mode = input<TranslationModalMode>('create');
readonly translationId = input<string | null>(null);
/** When set for a create, locks the key/language pickers to this pair (used by the matrix grid's per-cell "add" flow). */
readonly presetTranslationKey = input<TranslationKeyLookupDto | null>(null);
readonly presetLanguage = input<LanguageLookupDto | null>(null);
readonly saved = output<void>();
readonly closed = output<void>();
readonly modalLoading = signal(false);
readonly saving = signal(false);
readonly submitAttempted = signal(false);
readonly selectedTranslation = signal<TranslationDto | null>(null);
readonly selectedFormTranslationKey = signal<TranslationKeyLookupDto | null>(null);
readonly selectedFormLanguage = signal<LanguageLookupDto | null>(null);
readonly selectedFormTenant = signal<TenantLookupDto | null>(null);
readonly translationForm = this.formBuilder.nonNullable.group({
translationKeyId: ['', Validators.required],
languageId: ['', Validators.required],
tenantId: this.formBuilder.control<string | null>(null),
translatedText: ['', [Validators.required, Validators.maxLength(4000)]],
isApproved: [true, Validators.required]
});
readonly isEditMode = computed(() => this.mode() === 'edit');
readonly isViewMode = computed(() => this.mode() === 'view');
readonly isCreateMode = computed(() => this.mode() === 'create');
readonly modalTitle = computed(() => {
switch (this.mode()) {
case 'create':
return 'Add Translation';
case 'edit':
return 'Edit Translation';
case 'view':
return 'View Translation';
}
});
readonly keySearchFn: AutocompleteSearchFn<TranslationKeyLookupDto> = (term, page) =>
this.translationKeyApi.autocomplete(term, page);
readonly keyValueFn: AutocompleteValueFn<TranslationKeyLookupDto, string> = key => key.id;
readonly keyDisplayFn: AutocompleteDisplayFn<TranslationKeyLookupDto> = key =>
key.defaultText ? `${key.keyName} (${key.defaultText})` : key.keyName;
readonly languageSearchFn: AutocompleteSearchFn<LanguageLookupDto> = (term, page) =>
this.languageApi.autocomplete(term, page);
readonly languageValueFn: AutocompleteValueFn<LanguageLookupDto, string> = lang => lang.id;
readonly languageDisplayFn: AutocompleteDisplayFn<LanguageLookupDto> = lang =>
`${lang.name} (${lang.code})`;
readonly tenantSearchFn: AutocompleteSearchFn<TenantLookupDto> = (term, page) =>
this.tenantApi.autocomplete(term, page);
readonly tenantValueFn: AutocompleteValueFn<TenantLookupDto, string> = tenant => tenant.id;
readonly tenantDisplayFn: AutocompleteDisplayFn<TenantLookupDto> = tenant => tenant.name;
constructor() {
effect(() => {
if (this.open()) {
this.prepareModal(this.translationId());
}
});
}
onFormTranslationKeyChanged(key: TranslationKeyLookupDto | null): void {
this.selectedFormTranslationKey.set(key);
this.translationForm.controls.translationKeyId.setValue(key ? key.id : '');
}
onFormLanguageChanged(language: LanguageLookupDto | null): void {
this.selectedFormLanguage.set(language);
this.translationForm.controls.languageId.setValue(language ? language.id : '');
}
onFormTenantChanged(tenant: TenantLookupDto | null): void {
this.selectedFormTenant.set(tenant);
this.translationForm.controls.tenantId.setValue(tenant ? tenant.id : null);
}
prepareModal(id: string | null): void {
this.submitAttempted.set(false);
this.translationForm.reset({
translationKeyId: '',
languageId: '',
tenantId: null,
translatedText: '',
isApproved: true
});
this.selectedFormTranslationKey.set(null);
this.selectedFormLanguage.set(null);
this.selectedFormTenant.set(null);
this.translationForm.enable();
if (!id || this.isCreateMode()) {
this.selectedTranslation.set(null);
this.modalLoading.set(false);
const presetKey = this.presetTranslationKey();
const presetLanguage = this.presetLanguage();
if (presetKey) {
this.selectedFormTranslationKey.set(presetKey);
this.translationForm.controls.translationKeyId.setValue(presetKey.id);
}
if (presetLanguage) {
this.selectedFormLanguage.set(presetLanguage);
this.translationForm.controls.languageId.setValue(presetLanguage.id);
}
return;
}
this.modalLoading.set(true);
this.translationApi
.getTranslationById(id)
.pipe(
switchMap(translation => {
this.selectedTranslation.set(translation);
const key$ = this.translationKeyApi.getById(translation.translationKeyId).pipe(catchError(() => of(null)));
const lang$ = this.languageApi.getById(translation.languageId).pipe(catchError(() => of(null)));
const tenant$ = translation.tenantId
? this.tenantApi.getTenantById(translation.tenantId).pipe(catchError(() => of(null)))
: of(null);
return forkJoin({ key: key$, lang: lang$, tenant: tenant$ }).pipe(
map(({ key, lang, tenant }) => ({ translation, lang, key, tenant }))
);
}),
finalize(() => this.modalLoading.set(false)),
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
next: ({ translation, key, lang, tenant }) => {
if (key) {
this.selectedFormTranslationKey.set({
id: key.id,
keyName: key.keyName,
defaultText: key.defaultText,
moduleCode: key.moduleCode,
componentType: key.componentType
});
} else {
this.selectedFormTranslationKey.set({
id: translation.translationKeyId,
keyName: translation.translationKeyId,
defaultText: ''
});
}
if (lang) {
this.selectedFormLanguage.set({
id: lang.id,
code: lang.code,
name: lang.name,
nativeName: lang.nativeName,
isRightToLeft: lang.isRightToLeft
});
} else {
this.selectedFormLanguage.set({
id: translation.languageId,
code: '',
name: 'Selected Language',
nativeName: '',
isRightToLeft: false
});
}
if (tenant) {
this.selectedFormTenant.set({
id: tenant.id,
name: tenant.name,
code: tenant.code
});
} else if (translation.tenantId) {
this.selectedFormTenant.set({
id: translation.tenantId,
name: 'Selected Tenant',
code: ''
});
}
this.translationForm.patchValue({
translationKeyId: translation.translationKeyId,
languageId: translation.languageId,
tenantId: translation.tenantId ?? null,
translatedText: translation.translatedText,
isApproved: translation.isApproved
});
this.translationForm.enable();
},
error: () => {
this.notification.error('Unable to load translation details.');
this.closeModal();
}
});
}
saveTranslation(): void {
if (this.isViewMode()) {
this.closeModal();
return;
}
this.submitAttempted.set(true);
if (this.translationForm.invalid || this.saving()) return;
this.saving.set(true);
if (this.isCreateMode()) {
const request: CreateTranslationRequest = {
translationKeyId: this.translationForm.controls.translationKeyId.value.trim(),
languageId: this.translationForm.controls.languageId.value,
tenantId: this.translationForm.controls.tenantId.value || null,
translatedText: this.translationForm.controls.translatedText.value.trim(),
isApproved: this.translationForm.controls.isApproved.value
};
this.translationApi
.createTranslation(request)
.pipe(
finalize(() => this.saving.set(false)),
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
next: () => {
this.notification.success('Translation created successfully.');
this.saved.emit();
this.closed.emit();
},
error: err => this.handleSaveError(err, 'create')
});
} else {
const id = this.translationId();
if (!id) return;
const request: UpdateTranslationRequest = {
translatedText: this.translationForm.controls.translatedText.value.trim(),
isApproved: this.translationForm.controls.isApproved.value
};
this.translationApi
.updateTranslation(id, request)
.pipe(
finalize(() => this.saving.set(false)),
takeUntilDestroyed(this.destroyRef)
)
.subscribe({
next: () => {
this.notification.success('Translation updated successfully.');
this.saved.emit();
this.closed.emit();
},
error: err => this.handleSaveError(err, 'update')
});
}
}
closeModal(): void {
if (this.saving()) return;
this.closed.emit();
}
private handleSaveError(error: HttpErrorResponse, action: 'create' | 'update'): void {
if (error.status === 409) {
this.notification.error('A translation for this key and language scope already exists.');
return;
}
const message = error.error?.message || error.error?.title || `Unable to ${action} translation.`;
this.notification.error(message);
}
}
@@ -0,0 +1,11 @@
import { buildApiUrl } from '../../../../core/config/api-url.util';
export const TRANSLATION_ENDPOINTS = {
create: buildApiUrl('masterAdmin', '/v1/translations'),
update: (id: string) =>
buildApiUrl('masterAdmin', `/v1/translations/${encodeURIComponent(id)}`),
getById: (id: string) =>
buildApiUrl('masterAdmin', `/v1/translations/${encodeURIComponent(id)}`),
autocomplete: buildApiUrl('masterAdmin', '/v1/translations/autocomplete'),
dataTable: buildApiUrl('masterAdmin', '/v1/translations/datatable')
} as const;
@@ -0,0 +1,111 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { TRANSLATION_ENDPOINTS } from './translation.endpoints';
import {
CreateTranslationRequest,
DataTableResponse,
TranslationDataTableDto,
TranslationDto,
TranslationFilterParams,
TranslationLookupDto,
UpdateTranslationRequest
} from '../models/translation.model';
import {
DataTableQuery,
DataTableResult
} from '../../../../shared/components/data-table/data-table.types';
@Injectable({ providedIn: 'root' })
export class TranslationService {
private readonly http = inject(HttpClient);
createTranslation(request: CreateTranslationRequest): Observable<TranslationDto> {
return this.http.post<TranslationDto>(TRANSLATION_ENDPOINTS.create, request);
}
updateTranslation(id: string, request: UpdateTranslationRequest): Observable<TranslationDto> {
return this.http.put<TranslationDto>(TRANSLATION_ENDPOINTS.update(id), request);
}
getTranslationById(id: string): Observable<TranslationDto> {
return this.http.get<TranslationDto>(TRANSLATION_ENDPOINTS.getById(id));
}
autocomplete(options?: {
translationKeyId?: string | null;
languageId?: string | null;
tenantId?: string | null;
globalOnly?: boolean | null;
term?: string | null;
limit?: number;
}): Observable<readonly TranslationLookupDto[]> {
let params = new HttpParams();
if (options?.translationKeyId) {
params = params.set('translationKeyId', options.translationKeyId);
}
if (options?.languageId) {
params = params.set('languageId', options.languageId);
}
if (options?.tenantId) {
params = params.set('tenantId', options.tenantId);
}
if (options?.globalOnly !== undefined && options?.globalOnly !== null) {
params = params.set('globalOnly', options.globalOnly.toString());
}
if (options?.term) {
params = params.set('term', options.term.trim());
}
if (options?.limit !== undefined && options?.limit !== null) {
params = params.set('limit', options.limit);
} else {
params = params.set('limit', 10);
}
return this.http.get<readonly TranslationLookupDto[]>(
TRANSLATION_ENDPOINTS.autocomplete,
{ params }
);
}
getTranslationDataTable(
query: DataTableQuery,
filters?: TranslationFilterParams
): Observable<DataTableResult<TranslationDataTableDto>> {
let params = new HttpParams();
if (filters?.translationKeyId) {
params = params.set('translationKeyId', filters.translationKeyId);
}
if (filters?.languageId) {
params = params.set('languageId', filters.languageId);
}
if (filters?.tenantId) {
params = params.set('tenantId', filters.tenantId);
}
params = params.set('globalOnly', (filters?.globalOnly ?? false).toString());
if (filters?.isApproved !== undefined && filters?.isApproved !== null) {
params = params.set('isApproved', filters.isApproved.toString());
}
return this.http
.post<DataTableResponse<TranslationDataTableDto>>(
TRANSLATION_ENDPOINTS.dataTable,
query,
{ params }
)
.pipe(
map(response => ({
draw: response.draw ?? query.draw,
total: response.total ?? response.recordsTotal ?? 0,
filtered: response.filtered ?? response.recordsFiltered ?? response.total ?? response.recordsTotal ?? 0,
rows: response.rows ?? response.data ?? []
}))
);
}
}
@@ -0,0 +1,114 @@
import { DataTableRecord } from '../../../../shared/components/data-table/data-table.types';
export interface TranslationDto {
id: string;
translationKeyId: string;
languageId: string;
tenantId?: string | null;
translatedText: string;
isApproved: boolean;
createdOn: string;
updatedOn: string;
}
export interface CreateTranslationRequest {
translationKeyId: string;
languageId: string;
tenantId?: string | null;
translatedText: string;
isApproved: boolean;
}
export interface UpdateTranslationRequest {
translatedText: string;
isApproved: boolean;
}
export interface TranslationLookupDto {
id: string;
translationKeyId: string;
languageId: string;
tenantId?: string | null;
translatedText: string;
isApproved: boolean;
}
export interface TranslationDataTableDto {
id: string;
translationKeyId: string;
keyName: string;
defaultText: string;
languageId: string;
languageCode: string;
languageName: string;
tenantId?: string | null;
tenantName?: string | null;
translatedText: string;
isApproved: boolean;
createdOn: string;
updatedOn: string;
}
export interface DataTableRequest {
draw?: number;
start: number;
length: number;
search?: { value: string; regex: boolean };
order?: Array<{ column: number; dir: 'asc' | 'desc' }>;
columns?: Array<any>;
}
export interface DataTableResponse<T> {
draw?: number;
recordsTotal?: number;
recordsFiltered?: number;
total?: number;
filtered?: number;
data?: T[];
rows?: T[];
}
export type TranslationModalMode = 'create' | 'edit' | 'view';
export interface TranslationFilterParams {
translationKeyId?: string | null;
languageId?: string | null;
tenantId?: string | null;
globalOnly?: boolean | null;
isApproved?: boolean | null;
}
export interface TranslationTableRow extends DataTableRecord {
id: string;
translationKeyId: string;
keyName: string;
defaultText: string;
languageId: string;
languageCode: string;
languageName: string;
tenantId?: string | null;
tenantName?: string | null;
translatedText: string;
isApproved: boolean;
serialNumber: number;
scope: string;
createdOn: string;
updatedOn: string;
}
/** One translation key with its global translations across all languages, used to build the per-language-column matrix grid. */
export interface TranslationKeyWithTranslationsDto {
id: string;
keyName: string;
defaultText: string;
translations: readonly TranslationLookupDto[];
}
/** Matrix grid row: one translation key, with one flat property per language code (set dynamically) plus lookup maps used by cell click handlers. */
export interface TranslationMatrixTableRow extends DataTableRecord {
id: string;
keyName: string;
defaultText: string;
serialNumber: number;
translationIdByLanguageId: Record<string, string>;
}
@@ -0,0 +1,46 @@
<app-data-table
[columns]="columns()"
[rows]="tableStore.rows()"
[totalRecords]="tableStore.totalRecords()"
[pageIndex]="tableStore.queryState.pageIndex()"
[pageSize]="tableStore.queryState.pageSize()"
tableTitle="Translation Manager"
buttonTitle="Add"
[showSearch]="true"
[showAddButton]="true"
[showFilterButton]="false"
searchPlaceholder="Search translations..."
[searchDebounceTime]="300"
toolTip="Add Translation"
(addClicked)="onAddTranslation()"
(searchChanged)="tableStore.onSearch($event)"
(pageChanged)="tableStore.onPageChange($event)"
(sortChanged)="tableStore.onSortChange($event)">
@for (language of languages(); track language.id) {
<ng-template [appDataTableCell]="language.code" let-row let-value="value">
<button
type="button"
class="w-full text-left rounded px-2 py-1 -mx-2 -my-1 hover:bg-primary/10 transition-colors"
[class.text-textmuted]="!value"
[attr.aria-label]="value ? null : 'Add translation'"
(click)="onCellClick(row, language)"
>
@if (value) {
{{ value }}
} @else {
<i class="ti ti-plus text-sm"></i>
}
</button>
</ng-template>
}
</app-data-table>
<app-translation-form-modal
[open]="modalOpen()"
[mode]="modalMode()"
[translationId]="editingTranslationId()"
[presetTranslationKey]="editingKey()"
[presetLanguage]="editingLanguage()"
(saved)="onModalSaved()"
(closed)="onModalClosed()"
/>
@@ -0,0 +1 @@
/* Custom styles for Translation List */
@@ -0,0 +1,180 @@
import { Component, DestroyRef, OnInit, inject, signal } from '@angular/core';
import { NotificationService } from '../../../../../core/services/common/notification.service';
import { extractErrorMessage } from '../../../../../core/utils/error-extractor.util';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Observable, forkJoin, of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import {
TranslationLookupDto,
TranslationMatrixTableRow
} from '../../models/translation.model';
import { TranslationKeyDto, TranslationKeyLookupDto } from '../../../translation-keys/models/translation-key.model';
import { LanguageLookupDto } from '../../../../global-masters/languages/models/language.model';
import { TranslationService } from '../../data-access/translation.service';
import { TranslationKeyService } from '../../../translation-keys/data-access/translation-key.service';
import { LanguageService } from '../../../../global-masters/languages/data-access/language.service';
import { DataTable } from '../../../../../shared/components/data-table/data-table';
import { DataTableCellDirective } from '../../../../../shared/directives/data-table-cell.directive';
import { DataTableStore } from '../../../../../shared/components/data-table/data-table.store';
import { DataTableColumn, DataTableQuery, DataTableResult } from '../../../../../shared/components/data-table/data-table.types';
import { Button } from '../../../../../shared/components/button/button';
import { TranslationFormModalComponent } from '../../components/translation-form-modal/translation-form-modal';
@Component({
selector: 'app-translation-list',
standalone: true,
imports: [
DataTable,
DataTableCellDirective,
Button,
TranslationFormModalComponent
],
providers: [DataTableStore],
templateUrl: './translation-list.html',
styleUrl: './translation-list.scss'
})
export class TranslationList implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly translationApi = inject(TranslationService);
private readonly translationKeyApi = inject(TranslationKeyService);
private readonly languageApi = inject(LanguageService);
private readonly notification = inject(NotificationService);
readonly tableStore = inject(DataTableStore<TranslationKeyDto, TranslationMatrixTableRow>);
readonly languages = signal<readonly LanguageLookupDto[]>([]);
readonly columns = signal<DataTableColumn<TranslationMatrixTableRow>[]>([]);
readonly modalOpen = signal(false);
readonly modalMode = signal<'create' | 'edit'>('create');
readonly editingTranslationId = signal<string | null>(null);
readonly editingKey = signal<TranslationKeyLookupDto | null>(null);
readonly editingLanguage = signal<LanguageLookupDto | null>(null);
ngOnInit(): void {
this.languageApi
.autocomplete('', 100)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: languages => {
this.languages.set(languages);
this.buildColumns(languages);
this.initializeTable();
},
error: err => {
this.notification.error(extractErrorMessage(err, 'Failed to load languages.'));
this.buildColumns([]);
this.initializeTable();
}
});
}
private buildColumns(languages: readonly LanguageLookupDto[]): void {
const languageColumns: DataTableColumn<TranslationMatrixTableRow>[] = languages.map(lang => ({
key: lang.code,
label: lang.name,
header: `${lang.name} (${lang.code})`,
sortable: false,
align: 'left',
headerAlign: 'left'
}));
this.columns.set([
{ key: 'serialNumber', label: 'Sr. No.', header: 'Sr. No.', sortable: false, width: '80px', align: 'center', headerAlign: 'center' },
{ key: 'keyName', label: 'Key / Default Text', header: 'Key / Default Text', sortable: true, align: 'left', headerAlign: 'left' },
...languageColumns
]);
}
private initializeTable(): void {
this.tableStore.initialize({
fetcher: query => this.fetchMatrixPage(query),
mapRow: (key, serialNumber) => this.mapToRow(key, serialNumber),
onError: (err: any) => {
const msg = extractErrorMessage(err, 'Failed to load translations.');
this.notification.error(msg);
}
});
}
/** Currently mapped row's per-key translations, keyed by translation key id, so mapRow can read them without re-fetching. */
private translationsByKeyId = new Map<string, readonly TranslationLookupDto[]>();
private fetchMatrixPage(query: DataTableQuery): Observable<DataTableResult<TranslationKeyDto>> {
return this.translationKeyApi.getTranslationKeyDataTable(query).pipe(
switchMap(page => {
if (page.rows.length === 0) {
this.translationsByKeyId.clear();
return of(page);
}
const lookups$ = page.rows.map(key =>
this.translationApi
.autocomplete({ translationKeyId: key.id, globalOnly: true, limit: 50 })
.pipe(map(translations => ({ keyId: key.id, translations })))
);
return forkJoin(lookups$).pipe(
map(results => {
this.translationsByKeyId.clear();
for (const result of results) {
this.translationsByKeyId.set(result.keyId, result.translations);
}
return page;
})
);
})
);
}
private mapToRow(key: TranslationKeyDto, serialNumber: number): TranslationMatrixTableRow {
const translations = this.translationsByKeyId.get(key.id) ?? [];
const translationIdByLanguageId: Record<string, string> = {};
const row: TranslationMatrixTableRow = {
id: key.id,
keyName: key.keyName,
defaultText: key.defaultText || '',
serialNumber,
translationIdByLanguageId
};
for (const language of this.languages()) {
const match = translations.find(t => t.languageId === language.id);
row[language.code] = match?.translatedText ?? null;
if (match) {
translationIdByLanguageId[language.id] = match.id;
}
}
return row;
}
onAddTranslation(): void {
this.editingKey.set(null);
this.editingLanguage.set(null);
this.editingTranslationId.set(null);
this.modalMode.set('create');
this.modalOpen.set(true);
}
onCellClick(row: TranslationMatrixTableRow, language: LanguageLookupDto): void {
const translationId = row.translationIdByLanguageId[language.id] ?? null;
this.editingKey.set({ id: row.id, keyName: row.keyName, defaultText: row.defaultText });
this.editingLanguage.set(language);
this.editingTranslationId.set(translationId);
this.modalMode.set(translationId ? 'edit' : 'create');
this.modalOpen.set(true);
}
onModalSaved(): void {
this.tableStore.refresh();
this.modalOpen.set(false);
}
onModalClosed(): void {
this.modalOpen.set(false);
}
}