Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions cms-ui/apps/ct-form-translations/public/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions cms-ui/apps/ct-form-translations/public/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ <h3>
[languages]="languages()"
[saved]="savedTranslations()[activeScopeId()]"
[draft]="draft()"
[missingByLanguage]="activeCompletion().missingByLanguage"
(cellEdit)="onCellEdit($event)">
</gtx-translations-table>
</div>
Expand Down
81 changes: 68 additions & 13 deletions cms-ui/apps/ct-form-translations/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>;
}

@Component({
selector: 'gtx-app',
templateUrl: './app.component.html',
Expand Down Expand Up @@ -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<ScopeCompletion>(() => {
return computeCompletion(
merge(this.activeTranslations(), this.draft()),
this.languages(),
);
});

public readonly scopeTabs = computed<ScopeTabInfo[]>(() => {
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,
};
});
Expand Down Expand Up @@ -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<string, number> = {};
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)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,35 @@
<nav class="scope-tabs" role="tablist" data-region="scope-tabs">
@for (tab of tabs; track tab.scope.id) {
<button
type="button"
type="button"
role="tab"
class="scope-tabs__tab"
[class.scope-tabs__tab--active]="tab.scope.id === activeScopeId"
[attr.aria-selected]="tab.scope.id === activeScopeId"
[attr.data-id]="tab.scope.id"
[attr.data-active]="tab.scope.id === activeScopeId"
[attr.data-dirty]="tab.hasDirty"
[attr.data-open]="tab.open"
[attr.data-total]="tab.total"
[title]="hint(tab)"
data-action="select-scope"
(click)="onClick(tab.scope.id)"
>
<span class="scope-tabs__label" data-name="label">{{ tab.scope.label }}</span>
<span class="scope-tabs__count" data-name="count">{{ tab.translatedCount }}/{{ tab.totalCount }}</span>
@if (tab.hasDirty) {
<span
class="pm-dirty-indicator"
data-name="dirty-indicator"
[title]="'common.unsaved_changes' | gtxI18n"
></span>
}
<span class="scope-tabs__head">
<span class="scope-tabs__label" data-name="label">{{ tab.scope.label }}</span>
@if (tab.hasDirty) {
<span
class="pm-dirty-indicator"
data-name="dirty-indicator"
[title]="'common.unsaved_changes' | gtxI18n"
></span>
}
</span>

<!-- Same unit for every scope: placeholders still open. -->
<span class="scope-tabs__stat" data-name="stat">
{{ 'tool.scope_tab_open' | gtxI18n:{ open: tab.open, total: tab.total } }}
</span>
</button>
}
</nav>
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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); }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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<ScopeId>();
Expand All @@ -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,
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,26 @@
<tr>
<th class="col-key">{{ 'tool.table_column_placeholder' | gtxI18n }}</th>
@for (lang of languages; track lang.code) {
<th class="col-lang" [attr.data-lang]="lang.code">
{{ lang.name }}
<span class="lang-meta">({{ lang.code }})</span>
@let missing = missingFor(lang.code);
<th
class="col-lang"
[attr.data-lang]="lang.code"
[attr.data-missing]="missing"
>
<span class="lang-head">
<span class="lang-name">{{ displayName(lang) }}</span>

<!-- Incomplete cells in this column, sitting right
above the column they are about. -->
@if (missing != null) {
<span
class="lang-open"
[class.lang-open--done]="missing === 0"
data-name="lang-open"
[title]="'tool.language_open_cells' | gtxI18n:{ count: missing }"
>{{ missing }}</span>
}
</span>
</th>
}
</tr>
Expand All @@ -19,12 +36,13 @@
<span class="placeholder-key" data-name="key">{{ key }}</span>
</td>
@for (lang of languages; track lang.code) {
@let state = getCellState(key, lang.code);
<td [attr.data-lang]="lang.code" data-name="cell">
<div
class="cell-input-wrapper"
[attr.data-state]="getCellState(key, lang.code)"
[class.is-dirty]="getCellState(key, lang.code) === 'dirty'"
[class.is-empty]="getCellState(key, lang.code) === 'empty'"
[attr.data-state]="state"
[class.is-dirty]="state === 'dirty'"
[class.is-empty]="state === 'empty'"
>
<input
type="text"
Expand All @@ -37,7 +55,7 @@
/>

<icon class="cell-indicator" aria-hidden="true">
@switch (getCellState(key, lang.code)) {
@switch (state) {
@case ('dirty') {circle}
@case ('empty') {remove}
}
Expand Down
Loading