diff --git a/cms-oss-changelog/src/changelog/entries/2026/09/8908.SUP-20206.enhancement b/cms-oss-changelog/src/changelog/entries/2026/09/8908.SUP-20206.enhancement new file mode 100644 index 0000000000..e4fc2e5813 --- /dev/null +++ b/cms-oss-changelog/src/changelog/entries/2026/09/8908.SUP-20206.enhancement @@ -0,0 +1,3 @@ +Form Translations Custom Tool: The scope tabs now state how many placeholders are still open instead of a +combined placeholder-times-language figure, and every language column header shows how many entries are +still empty in that language. diff --git a/cms-ui/apps/ct-form-translations/public/i18n/de.json b/cms-ui/apps/ct-form-translations/public/i18n/de.json index 66404ac7f8..f0998ee1d9 100644 --- a/cms-ui/apps/ct-form-translations/public/i18n/de.json +++ b/cms-ui/apps/ct-form-translations/public/i18n/de.json @@ -10,6 +10,9 @@ "filter_type_all": "Alle", "filter_type_incomplete": "Unvollständig", "placeholder_display_count": "{{shown}} von {{total}} Platzhalter", + "scope_tab_open": "{{open}} von {{total}} offen", + "scope_tab_open_hint": "Bei {{open}} von {{total}} Platzhaltern fehlt mindestens eine Sprache", + "language_open_cells": "{{count}} unvollständige Zellen in dieser Sprache", "legend_saved": "gespeichert", "legend_dirty": "geändert", "legend_empty": "leer", diff --git a/cms-ui/apps/ct-form-translations/public/i18n/en.json b/cms-ui/apps/ct-form-translations/public/i18n/en.json index 52e6d69083..babef8ea5b 100644 --- a/cms-ui/apps/ct-form-translations/public/i18n/en.json +++ b/cms-ui/apps/ct-form-translations/public/i18n/en.json @@ -10,6 +10,9 @@ "filter_type_all": "All", "filter_type_incomplete": "Incomplete", "placeholder_display_count": "{{shown}} of {{total}} placeholders", + "scope_tab_open": "{{open}} of {{total}} open", + "scope_tab_open_hint": "{{open}} of {{total}} placeholders are missing at least one language", + "language_open_cells": "{{count}} incomplete cells in this language", "legend_saved": "saved", "legend_dirty": "changed", "legend_empty": "empty", diff --git a/cms-ui/apps/ct-form-translations/src/app/app.component.html b/cms-ui/apps/ct-form-translations/src/app/app.component.html index 4ce3519e99..4020be61c2 100644 --- a/cms-ui/apps/ct-form-translations/src/app/app.component.html +++ b/cms-ui/apps/ct-form-translations/src/app/app.component.html @@ -50,6 +50,7 @@

[languages]="languages()" [saved]="savedTranslations()[activeScopeId()]" [draft]="draft()" + [missingByLanguage]="activeCompletion().missingByLanguage" (cellEdit)="onCellEdit($event)"> diff --git a/cms-ui/apps/ct-form-translations/src/app/app.component.ts b/cms-ui/apps/ct-form-translations/src/app/app.component.ts index 93d9b3287c..6cb54664ea 100644 --- a/cms-ui/apps/ct-form-translations/src/app/app.component.ts +++ b/cms-ui/apps/ct-form-translations/src/app/app.component.ts @@ -26,6 +26,19 @@ import { GCMSRestClientRequestError } from '@gentics/cms-rest-client'; type LoadStatus = 'idle' | 'loading' | 'loaded' | 'no-session' | 'error'; +/** + * How far a scope has come. Local to this component - the tab strip and the + * table each take only the plain numbers they need. + */ +interface ScopeCompletion { + /** Placeholders in the scope. */ + placeholders: number; + /** Placeholders where at least one active language is empty. */ + open: number; + /** Empty placeholders per language code. */ + missingByLanguage: Record; +} + @Component({ selector: 'gtx-app', templateUrl: './app.component.html', @@ -136,30 +149,33 @@ export class AppComponent implements OnInit { return counter; }); + /** Completion of the scope on screen, draft included - feeds the column headers. */ + public readonly activeCompletion = computed(() => { + return computeCompletion( + merge(this.activeTranslations(), this.draft()), + this.languages(), + ); + }); + public readonly scopeTabs = computed(() => { - const langs = this.languages().length; + const languages = this.languages(); const active = this.activeScopeId(); const translations = this.savedTranslations(); const dirtyCount = this.dirtyCount(); return Object.values(this.scopes()).map((scope) => { const isActive = scope.id === active; - const translationData: FormTranslations = (isActive) + /* Only the active scope can hold draft values - the draft is + cleared whenever the scope changes. */ + const data: FormTranslations = isActive ? merge(translations[scope.id], this.draft()) - : translations[scope.id]; - const totalKeys = Object.keys(translationData).length; - - let translatedCount = 0; - for (const row of Object.values(translationData)) { - for (const val of Object.values(row)) { - if (val.trim() !== '') translatedCount++; - } - } + : (translations[scope.id] ?? {}); + const completion = computeCompletion(data, languages); return { scope, - translatedCount, - totalCount: totalKeys * langs, + open: completion.open, + total: completion.placeholders, hasDirty: isActive ? dirtyCount > 0 : false, }; }); @@ -352,6 +368,45 @@ export class AppComponent implements OnInit { * Pure utilities (kept at module level — no side effects) * ===================================================================== */ +/** + * Counts how far a scope has come: placeholders for the tab badge, plus the + * per-language gap for the table's column headers. + * + * Only the *active* languages are inspected. A payload can still carry values + * for a language that has since been removed from the CMS, and counting those + * was what let the old badge exceed its own denominator. + */ +function computeCompletion( + data: FormTranslations, + languages: FormTranslationsLanguage[], +): ScopeCompletion { + const keys = Object.keys(data ?? {}); + const missingByLanguage: Record = {}; + for (const lang of languages) { + missingByLanguage[lang.code] = 0; + } + + let open = 0; + + for (const key of keys) { + const row = data[key] ?? {}; + let rowComplete = true; + + for (const lang of languages) { + if ((row[lang.code] ?? '').trim() === '') { + missingByLanguage[lang.code]++; + rowComplete = false; + } + } + + if (!rowComplete) { + open++; + } + } + + return { placeholders: keys.length, open, missingByLanguage }; +} + function merge(base: FormTranslations, delta: FormTranslations): FormTranslations { const result: FormTranslations = { ...base }; for (const [key, langs] of Object.entries(delta)) { diff --git a/cms-ui/apps/ct-form-translations/src/app/components/scope-tabs/scope-tabs.component.html b/cms-ui/apps/ct-form-translations/src/app/components/scope-tabs/scope-tabs.component.html index b99cb5e8fb..6a12088cf2 100644 --- a/cms-ui/apps/ct-form-translations/src/app/components/scope-tabs/scope-tabs.component.html +++ b/cms-ui/apps/ct-form-translations/src/app/components/scope-tabs/scope-tabs.component.html @@ -1,7 +1,7 @@ diff --git a/cms-ui/apps/ct-form-translations/src/app/components/scope-tabs/scope-tabs.component.scss b/cms-ui/apps/ct-form-translations/src/app/components/scope-tabs/scope-tabs.component.scss index 938ab4cc7a..f2775a9181 100644 --- a/cms-ui/apps/ct-form-translations/src/app/components/scope-tabs/scope-tabs.component.scss +++ b/cms-ui/apps/ct-form-translations/src/app/components/scope-tabs/scope-tabs.component.scss @@ -15,14 +15,18 @@ background: transparent; border: none; border-bottom: 3px solid transparent; - padding: var(--pm-spacing-md); + padding: 10px var(--pm-spacing-md) 8px; font-family: inherit; font-size: var(--pm-font-size-md); color: var(--pm-color-text-secondary); cursor: pointer; - display: inline-flex; - align-items: center; - gap: var(--pm-spacing-sm); + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 2px; + max-width: 260px; + text-align: left; white-space: nowrap; transition: var(--pm-transition); @@ -31,24 +35,42 @@ background: var(--pm-color-primary-subtle); } + &:focus-visible { + outline: 2px solid var(--pm-color-primary); + outline-offset: -2px; + } + &--active { color: var(--pm-color-primary-dark); border-bottom-color: var(--pm-color-primary); background: var(--pm-color-primary-subtle); - font-weight: 600; - .scope-tabs__count { - background: var(--pm-color-primary); - color: #fff; - } + .scope-tabs__label { font-weight: 600; } + .scope-tabs__stat { color: var(--pm-color-primary-dark); } } } -.scope-tabs__count { - background: var(--pm-color-border-light); - color: var(--pm-color-text-secondary); - border-radius: 999px; - padding: 2px 8px 1px; +.scope-tabs__head { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + max-width: 100%; +} + +.scope-tabs__label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.scope-tabs__stat { font-size: var(--pm-font-size-xs); - font-weight: 500; + color: var(--pm-color-text-secondary); + line-height: 1.3; + font-variant-numeric: tabular-nums; +} + +@media (max-width: 700px) { + .scope-tabs { padding: 0 var(--pm-spacing-md); } } diff --git a/cms-ui/apps/ct-form-translations/src/app/components/scope-tabs/scope-tabs.component.ts b/cms-ui/apps/ct-form-translations/src/app/components/scope-tabs/scope-tabs.component.ts index b19d7cd085..3839beaa7a 100644 --- a/cms-ui/apps/ct-form-translations/src/app/components/scope-tabs/scope-tabs.component.ts +++ b/cms-ui/apps/ct-form-translations/src/app/components/scope-tabs/scope-tabs.component.ts @@ -4,13 +4,18 @@ import { EventEmitter, Input, Output, + inject, } from '@angular/core'; +import { I18nService } from '@gentics/cms-components'; import { Scope, ScopeId } from '../../models/translations.model'; +/** What one tab renders. Every scope is measured the same way. */ export interface ScopeTabInfo { scope: Scope; - translatedCount: number; - totalCount: number; + /** Placeholders with at least one empty active language. */ + open: number; + /** Placeholders in the scope. */ + total: number; hasDirty: boolean; } @@ -22,6 +27,9 @@ export interface ScopeTabInfo { styleUrls: ['./scope-tabs.component.scss'], }) export class ScopeTabsComponent { + + private readonly i18n = inject(I18nService); + @Input() tabs: ScopeTabInfo[] = []; @Input() activeScopeId: ScopeId = ''; @Output() readonly scopeSelect = new EventEmitter(); @@ -30,4 +38,11 @@ export class ScopeTabsComponent { if (id !== this.activeScopeId) this.scopeSelect.emit(id); } + /** Spells out the short second line. */ + hint(tab: ScopeTabInfo): string { + return this.i18n.instant('tool.scope_tab_open_hint', { + open: tab.open, + total: tab.total, + }); + } } diff --git a/cms-ui/apps/ct-form-translations/src/app/components/translations-table/translations-table.component.html b/cms-ui/apps/ct-form-translations/src/app/components/translations-table/translations-table.component.html index 36679e2516..a528f511a3 100644 --- a/cms-ui/apps/ct-form-translations/src/app/components/translations-table/translations-table.component.html +++ b/cms-ui/apps/ct-form-translations/src/app/components/translations-table/translations-table.component.html @@ -5,9 +5,26 @@ {{ 'tool.table_column_placeholder' | gtxI18n }} @for (lang of languages; track lang.code) { - - {{ lang.name }} - ({{ lang.code }}) + @let missing = missingFor(lang.code); + + + {{ displayName(lang) }} + + + @if (missing != null) { + {{ missing }} + } + } @@ -19,12 +36,13 @@ {{ key }} @for (lang of languages; track lang.code) { + @let state = getCellState(key, lang.code);