localization, assign data-base, tenant domain, plan and subscription and data-base connection screen added
This commit is contained in:
+62
@@ -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>
|
||||
+329
@@ -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>;
|
||||
}
|
||||
+46
@@ -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()"
|
||||
/>
|
||||
+1
@@ -0,0 +1 @@
|
||||
/* Custom styles for Translation List */
|
||||
+180
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user