src/app/core/config/configure-translations-popup/configure-translations-popup.component.ts

Description

Edit the translations of one configurable text.

Works on the raw value and returns a raw value, never resolving anything, so no language can be lost. A plain string with no language added stays a string.

Example

Metadata

Relationships

Used by

No results matching.

Depends on

Index

Properties
Methods

Methods

onCancel
onCancel()
Returns : void
onSave
onSave()
Returns : void

Properties

Readonly data
Type : unknown
Default value : inject<ConfigureTranslationsDialogData>(MAT_DIALOG_DATA)
Readonly disabledCount
Type : unknown
Default value : this.rows.filter((r) => !r.enabled).length

number of rows of disabled languages (hidden until expanded)

rows
Type : TranslationRow[]
Default value : this.buildRows()
Readonly showDisabled
Type : unknown
Default value : signal(false)

whether rows of disabled languages are shown; they are hidden by default

import {
  ChangeDetectionStrategy,
  Component,
  inject,
  signal,
} from "@angular/core";
import {
  MAT_DIALOG_DATA,
  MatDialogModule,
  MatDialogRef,
} from "@angular/material/dialog";
import { MatFormFieldModule } from "@angular/material/form-field";
import { MatInputModule } from "@angular/material/input";
import { MatButtonModule } from "@angular/material/button";
import { FormsModule } from "@angular/forms";
import { DialogCloseComponent } from "../../common-components/dialog-close/dialog-close.component";
import { availableLocales } from "../../language/languages";
import { SiteSettingsService } from "../../site-settings/site-settings.service";
import { ConfigurableEnumValue } from "../../basic-datatypes/configurable-enum/configurable-enum.types";
import {
  getActiveLocale,
  isLocaleTranslationMap,
} from "../../language/active-locale";
import { pickForLocale, TranslatableText } from "../multi-lingual-config";

export interface ConfigureTranslationsResult {
  /** the new value, or undefined when every language was cleared */
  value: TranslatableText | undefined;
}

export interface ConfigureTranslationsDialogData {
  /** the raw configured value: a plain string or a per-language map */
  value?: TranslatableText;
  /** name of the setting being translated, shown in the dialog */
  fieldLabel?: string;
  /** edit each language in a multi-line textarea, so line breaks are kept */
  multiline?: boolean;
  /** number of visible lines of a multi-line text */
  rows?: number;
}

interface TranslationRow {
  locale: string;
  localeLabel: string;
  /** false if this language is disabled for the system, and only keeps existing text */
  enabled: boolean;
  text: string;
}

/**
 * Edit the translations of one configurable text.
 *
 * Works on the *raw* value and returns a raw value, never resolving anything, so
 * no language can be lost. A plain string with no language added stays a string.
 */
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: "app-configure-translations-popup",
  templateUrl: "./configure-translations-popup.component.html",
  styleUrls: ["./configure-translations-popup.component.scss"],
  imports: [
    MatDialogModule,
    MatFormFieldModule,
    MatInputModule,
    MatButtonModule,
    FormsModule,
    DialogCloseComponent,
  ],
})
export class ConfigureTranslationsPopupComponent {
  private readonly siteSettings = inject(SiteSettingsService);
  readonly data = inject<ConfigureTranslationsDialogData>(MAT_DIALOG_DATA);
  private readonly dialogRef =
    inject<MatDialogRef<ConfigureTranslationsPopupComponent>>(MatDialogRef);

  rows: TranslationRow[] = this.buildRows();

  /** number of rows of disabled languages (hidden until expanded) */
  readonly disabledCount = this.rows.filter((r) => !r.enabled).length;

  /** whether rows of disabled languages are shown; they are hidden by default */
  readonly showDisabled = signal(false);

  /**
   * One row per enabled language (the default language first, then in their order), followed by every
   * other language this text already holds text for. {@link buildValue} rebuilds
   * the value from the rows, so leaving one out would delete that text on save.
   * Rows of disabled languages are only hidden in the template
   * (see {@link showDisabled}), never left out of the rows.
   *
   * Existing text means: a key of a translation map (a base-language key like `en`
   * counts for `en-US`), or, for a plain string, the active language, which
   * {@link initialTextFor} files it under (so both cases are one check). A disabled language that holds
   * no text gets no row, so admins are not invited to write texts in it.
   */
  private buildRows(): TranslationRow[] {
    const defaultId = this.siteSettings.defaultLocale()?.id;
    // the default language always comes first, the others keep their order
    const enabled = [...this.siteSettings.enabledLocales()].sort(
      (a, b) => Number(b.id === defaultId) - Number(a.id === defaultId),
    );
    const isEnabled = (locale: ConfigurableEnumValue) =>
      enabled.some(({ id }) => id === locale.id);

    const existing = availableLocales.values.filter(
      (locale) => !isEnabled(locale) && !!this.initialTextFor(locale.id),
    );

    return [
      ...enabled.map((locale) => ({ locale, enabled: true })),
      ...existing.map((locale) => ({ locale, enabled: false })),
    ].map(({ locale, enabled }) => ({
      locale: locale.id,
      localeLabel: locale.label,
      enabled,
      text: this.initialTextFor(locale.id),
    }));
  }

  private initialTextFor(locale: string): string {
    const value = this.data?.value;
    if (isLocaleTranslationMap(value)) {
      // also reads a hand-written base-language key (`en` for `en-US`), so that
      // saving normalises it to the locale id instead of dropping it
      return pickForLocale(value, locale) ?? "";
    }
    // a plain string becomes the translation of the language the admin is reading
    // it in: on a German instance, filing it under English would show the wrong text
    return locale === getActiveLocale() && typeof value === "string"
      ? value
      : "";
  }

  onSave() {
    this.dialogRef.close({ value: this.buildValue() });
  }

  onCancel() {
    // no result at all, so the caller keeps the existing value
    this.dialogRef.close(undefined);
  }

  /**
   * Build the new raw value. Only the active language on its own collapses back to
   * a plain string, and only if the dialog was opened with one: a map saved as a
   * string would look unchanged to the config merge, which would then restore the
   * translations just removed.
   */
  private buildValue(): TranslatableText | undefined {
    const filled = this.rows.filter((row) => !!row.text?.trim());

    if (filled.length === 0) {
      return undefined;
    }
    if (
      filled.length === 1 &&
      filled[0].locale === getActiveLocale() &&
      !isLocaleTranslationMap(this.data?.value)
    ) {
      return filled[0].text.trim();
    }

    const map: Record<string, string> = {};
    for (const row of filled) {
      map[row.locale] = row.text.trim();
    }
    return map;
  }
}
<h1 matDialogTitle i18n="title of the translations popup dialog">
  Configure translations
</h1>
<app-dialog-close (click)="onCancel()"></app-dialog-close>

<mat-dialog-content class="dialog-content">
  @if (data.fieldLabel) {
    <p i18n>Translations for "{{ data.fieldLabel }}"</p>
  }

  @for (row of rows; track row.locale) {
    @if (row.enabled || showDisabled()) {
      <mat-form-field class="full-width">
        <mat-label>
          @if (row.enabled) {
            {{ row.localeLabel }}
          } @else {
            {{ row.localeLabel }}
            <span class="disabled-marker" i18n>(disabled)</span>
          }
        </mat-label>
        @if (data.multiline) {
          <textarea
            matInput
            [(ngModel)]="row.text"
            [name]="row.locale"
            [rows]="data.rows ?? 3"
          ></textarea>
        } @else {
          <input matInput [(ngModel)]="row.text" [name]="row.locale" />
        }
      </mat-form-field>
    }
  }

  @if (disabledCount > 0) {
    <button
      mat-button
      type="button"
      class="toggle-disabled"
      (click)="showDisabled.set(!showDisabled())"
    >
      @if (showDisabled()) {
        <span i18n>{disabledCount, plural,
          =1 {Hide 1 disabled language}
          other {Hide {{ disabledCount }} disabled languages}
        }</span>
      } @else {
        <span i18n>{disabledCount, plural,
          =1 {Show 1 disabled language}
          other {Show {{ disabledCount }} disabled languages}
        }</span>
      }
    </button>
  }

  <p class="hint-text" i18n>
    Languages left empty fall back to the text of the default language.
  </p>
</mat-dialog-content>

<mat-dialog-actions>
  <button mat-raised-button color="accent" (click)="onSave()" i18n>Save</button>
  <button mat-raised-button (click)="onCancel()" i18n>Cancel</button>
</mat-dialog-actions>

./configure-translations-popup.component.scss

@use "variables/colors";

.dialog-content {
  min-width: 350px;
  padding-top: 5px;
}

.hint-text {
  opacity: 0.7;
}

.toggle-disabled {
  margin-bottom: 8px;
}

.disabled-marker {
  color: colors.$warn;
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""