feat(admin): separate language package lifecycle

This commit is contained in:
2026-08-20 05:48:47 +02:00
parent a1d7b649aa
commit 89ba59fcea
10 changed files with 523 additions and 138 deletions
@@ -0,0 +1,330 @@
import { useEffect, useMemo, useState } from "react";
import type { ApiSettings, PlatformWebModule } from "@govoplan/core-webui";
import {
AdminPageLayout,
Button,
Card,
DocumentationHelpLink,
FormField,
FormGrid,
StatusBadge,
adminErrorMessage,
i18nMessage,
useDeltaWatermarks,
usePlatformModules,
useUnsavedDraftGuard
} from "@govoplan/core-webui";
import {
fetchSystemSettingsDelta,
updateSystemSettings,
type LanguagePackage,
type SystemSettingsItem
} from "../../api/admin";
import {
ADMIN_GOVERNANCE_DOCUMENTATION,
ADMIN_INTERFACE_I18N,
mutationDisabledReason
} from "./interfacePatterns";
import {
SYSTEM_SETTINGS_FALLBACK,
applySystemSettingsSections,
languageOptionLabel,
normalizeLanguageCode
} from "./systemSettingsModel";
const DELTA_KEY = "admin:language-packages";
const BUILT_IN_LANGUAGE_CODES = new Set(["de", "en"]);
type LanguageLifecycle = {
code: string;
package: LanguagePackage | null;
catalogCount: number;
installed: boolean;
active: boolean;
uninstallable: boolean;
};
export default function LanguagePackagesPanel({
settings,
canWrite
}: {
settings: ApiSettings;
canWrite: boolean;
}) {
const modules = usePlatformModules();
const [draft, setDraft] = useState<SystemSettingsItem>(SYSTEM_SETTINGS_FALLBACK);
const [savedDraft, setSavedDraft] = useState<SystemSettingsItem>(SYSTEM_SETTINGS_FALLBACK);
const [packageDraft, setPackageDraft] = useState({ code: "", label: "", nativeLabel: "" });
const [selectedCode, setSelectedCode] = useState("de");
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
const dirty = languageDraftKey(draft) !== languageDraftKey(savedDraft);
const lifecycle = useMemo(
() => languageLifecycle(draft, modules),
[draft, modules]
);
const selectedLifecycle = lifecycle.find((item) => item.code === selectedCode) ?? lifecycle[0];
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: () => {
setDraft(savedDraft);
setPackageDraft({ code: "", label: "", nativeLabel: "" });
}
});
async function load() {
setLoading(true);
setError("");
try {
const wasDirty = languageDraftKey(draft) !== languageDraftKey(savedDraft);
const response = await fetchSystemSettingsDelta(settings, {
since: getDeltaWatermark(DELTA_KEY)
});
setDeltaWatermark(DELTA_KEY, response.watermark);
if (response.full && response.item) {
setSavedDraft(response.item);
if (!wasDirty) setDraft(response.item);
} else if (response.changed_sections.length) {
setSavedDraft((current) => applySystemSettingsSections(current, response.sections));
if (!wasDirty) {
setDraft((current) => applySystemSettingsSections(current, response.sections));
}
}
} catch (err) {
setError(adminErrorMessage(err));
} finally {
setLoading(false);
}
}
useEffect(() => {
resetDeltaWatermark(DELTA_KEY);
void load();
}, [settings.accessToken, settings.apiBaseUrl, resetDeltaWatermark]);
useEffect(() => {
if (lifecycle.length && !lifecycle.some((item) => item.code === selectedCode)) {
setSelectedCode(lifecycle[0].code);
}
}, [lifecycle, selectedCode]);
async function save(): Promise<boolean> {
setBusy(true);
setError("");
setSuccess("");
try {
const saved = await updateSystemSettings(settings, {
default_locale: draft.default_locale,
allow_tenant_custom_groups: draft.allow_tenant_custom_groups,
allow_tenant_custom_roles: draft.allow_tenant_custom_roles,
allow_tenant_api_keys: draft.allow_tenant_api_keys,
available_languages: draft.available_languages,
enabled_language_codes: draft.enabled_language_codes
});
setDraft(saved);
setSavedDraft(saved);
resetDeltaWatermark(DELTA_KEY);
setSuccess("i18n:govoplan-admin.language_package_settings_saved.lp001");
return true;
} catch (err) {
setError(adminErrorMessage(err));
return false;
} finally {
setBusy(false);
}
}
function installPackage(code: string, label?: string, nativeLabel?: string) {
const normalized = normalizeLanguageCode(code);
if (!normalized || draft.available_languages.some((item) => item.code === normalized)) return;
const resolvedLabel = label?.trim() || normalized.toUpperCase();
setDraft({
...draft,
available_languages: [
...draft.available_languages,
{
code: normalized,
label: resolvedLabel,
native_label: nativeLabel?.trim() || resolvedLabel
}
]
});
setSelectedCode(normalized);
}
function installDraftPackage() {
const code = normalizeLanguageCode(packageDraft.code);
if (!code || !packageDraft.label.trim()) return;
installPackage(code, packageDraft.label, packageDraft.nativeLabel);
setPackageDraft({ code: "", label: "", nativeLabel: "" });
}
function setPackageActive(code: string, active: boolean) {
const enabled = new Set(draft.enabled_language_codes);
if (active) enabled.add(code);
else enabled.delete(code);
setDraft({
...draft,
enabled_language_codes: draft.available_languages
.map((item) => item.code)
.filter((item) => enabled.has(item))
});
}
function uninstallPackage(code: string) {
if (BUILT_IN_LANGUAGE_CODES.has(code) || code === draft.default_locale) return;
setDraft({
...draft,
available_languages: draft.available_languages.filter((item) => item.code !== code),
enabled_language_codes: draft.enabled_language_codes.filter((item) => item !== code)
});
}
const enabledDefaultOptions = draft.available_languages.filter((item) =>
draft.enabled_language_codes.includes(item.code)
);
return (
<AdminPageLayout
title="i18n:govoplan-admin.language_package_administration.lp001"
description="i18n:govoplan-admin.language_package_administration_description.lp001"
loading={loading}
error={error}
success={success}
actions={<>
<DocumentationHelpLink reference={ADMIN_GOVERNANCE_DOCUMENTATION} />
<Button onClick={() => void load()} disabled={loading || busy} disabledReason={loading ? ADMIN_INTERFACE_I18N.loading : busy ? ADMIN_INTERFACE_I18N.busy : undefined}>i18n:govoplan-admin.reload.cce71553</Button>
<Button variant="primary" onClick={() => void save()} disabledReason={mutationDisabledReason({ busy, permitted: canWrite, changed: dirty })}>{busy ? "i18n:govoplan-admin.saving.56a2285c" : "i18n:govoplan-admin.save_settings.913aba9f"}</Button>
</>}
>
<div className="admin-settings-form" data-language-package-lifecycle>
<Card title="i18n:govoplan-admin.defaults_for_newly_created_tenants.9d0f4ccd">
<FormField label="i18n:govoplan-admin.default_locale.b99d021f" documentation={ADMIN_GOVERNANCE_DOCUMENTATION}>
<select
value={draft.default_locale}
onChange={(event) => setDraft({ ...draft, default_locale: event.target.value })}
disabled={!canWrite || busy || enabledDefaultOptions.length === 0}
>
{enabledDefaultOptions.map((item) => <option key={item.code} value={item.code}>{languageOptionLabel(item)}</option>)}
</select>
</FormField>
<p className="muted small-note">i18n:govoplan-admin.default_language_inheritance_help.lp001</p>
</Card>
<Card title="i18n:govoplan-admin.language_packages">
<div className="module-management-list">
{lifecycle.map((item) => (
<div className="module-management-row" key={item.code}>
<div className="module-management-main">
<div className="module-management-title">
<strong>{item.package ? languageOptionLabel(item.package) : item.code.toUpperCase()}</strong>
<code>{item.code}</code>
</div>
<div className="module-management-meta">
<StatusBadge status={item.installed ? "success" : "info"} label={item.installed ? "i18n:govoplan-admin.installed.7bb4405c" : "i18n:govoplan-admin.available.7c8f1005"} />
{item.installed && <StatusBadge status={item.active ? "active" : "inactive"} label={item.active ? "i18n:govoplan-admin.active.a733b809" : "i18n:govoplan-admin.inactive.09af574c"} />}
<StatusBadge status={item.catalogCount ? "success" : "warning"} label={item.catalogCount ? "i18n:govoplan-admin.compatible.lp001" : "i18n:govoplan-admin.incompatible.lp001"} />
{item.uninstallable && <StatusBadge status="warning" label="i18n:govoplan-admin.uninstallable.lp001" />}
</div>
<div className="module-management-details">
<span>{i18nMessage("i18n:govoplan-admin.module_version_catalogs.lp001", { value0: item.catalogCount })}</span>
</div>
</div>
<div className="module-management-toggle">
{!item.installed && <Button onClick={() => installPackage(item.code)} disabledReason={mutationDisabledReason({ busy, permitted: canWrite })}>i18n:govoplan-admin.install_language_package</Button>}
{item.installed && <Button onClick={() => setPackageActive(item.code, !item.active)} disabledReason={item.active && item.code === draft.default_locale ? "i18n:govoplan-admin.default_language_must_remain_active.lp001" : mutationDisabledReason({ busy, permitted: canWrite })}>{item.active ? "i18n:govoplan-admin.deactivate.lp001" : "i18n:govoplan-admin.activate.lp001"}</Button>}
{item.uninstallable && <Button variant="danger" onClick={() => uninstallPackage(item.code)} disabledReason={mutationDisabledReason({ busy, permitted: canWrite })}>i18n:govoplan-admin.uninstall.a735da1d</Button>}
</div>
</div>
))}
</div>
<FormGrid columns={3} collapseAt="standard" className="">
<FormField label="i18n:govoplan-admin.language_code">
<input value={packageDraft.code} disabled={!canWrite || busy} placeholder="fr" onChange={(event) => setPackageDraft({ ...packageDraft, code: event.target.value })} />
</FormField>
<FormField label="i18n:govoplan-admin.language_name">
<input value={packageDraft.label} disabled={!canWrite || busy} placeholder="i18n:govoplan-admin.language_name_placeholder" onChange={(event) => setPackageDraft({ ...packageDraft, label: event.target.value })} />
</FormField>
<FormField label="i18n:govoplan-admin.native_language_name">
<input value={packageDraft.nativeLabel} disabled={!canWrite || busy} placeholder="i18n:govoplan-admin.native_language_name_placeholder" onChange={(event) => setPackageDraft({ ...packageDraft, nativeLabel: event.target.value })} />
</FormField>
</FormGrid>
<div className="button-row compact-actions">
<Button onClick={installDraftPackage} disabledReason={mutationDisabledReason({ busy, permitted: canWrite, complete: Boolean(normalizeLanguageCode(packageDraft.code) && packageDraft.label.trim()) })}>i18n:govoplan-admin.install_language_package</Button>
</div>
<p className="muted small-note">i18n:govoplan-admin.language_packages_help</p>
</Card>
<Card title="i18n:govoplan-admin.module_version_compatibility.lp001">
{selectedLifecycle && <FormField label="i18n:govoplan-admin.language_packages">
<select value={selectedLifecycle.code} onChange={(event) => setSelectedCode(event.target.value)}>
{lifecycle.map((item) => <option key={item.code} value={item.code}>{item.code.toUpperCase()}</option>)}
</select>
</FormField>}
{selectedLifecycle && modules.length > 0 ? (
<div className="module-management-list">
{modules.map((module) => <ModuleLanguageState key={module.id} module={module} language={selectedLifecycle} />)}
</div>
) : <p className="muted">i18n:govoplan-admin.no_module_language_catalogs.lp001</p>}
</Card>
</div>
</AdminPageLayout>
);
}
function ModuleLanguageState({ module, language }: { module: PlatformWebModule; language: LanguageLifecycle }) {
const compatible = Boolean(module.translations?.[language.code]);
const state = language.installed && compatible
? "i18n:govoplan-admin.installed.7bb4405c"
: compatible
? "i18n:govoplan-admin.available.7c8f1005"
: "i18n:govoplan-admin.incompatible.lp001";
return (
<div className="module-management-row">
<div className="module-management-main">
<div className="module-management-title">
<strong>{module.label}</strong>
<code>{module.id}</code>
<span>v{module.version}</span>
</div>
<div className="module-management-meta">
<StatusBadge status={compatible ? (language.installed ? "success" : "info") : "warning"} label={state} />
{language.installed && compatible && <StatusBadge status={language.active ? "active" : "inactive"} label={language.active ? "i18n:govoplan-admin.active.a733b809" : "i18n:govoplan-admin.inactive.09af574c"} />}
</div>
</div>
</div>
);
}
function languageLifecycle(item: SystemSettingsItem, modules: PlatformWebModule[]): LanguageLifecycle[] {
const installed = new Map(item.available_languages.map((language) => [language.code, language]));
const codes = new Set(installed.keys());
modules.forEach((module) => Object.keys(module.translations ?? {}).forEach((code) => codes.add(code)));
return [...codes].sort().map((code) => {
const languagePackage = installed.get(code) ?? null;
const catalogCount = modules.filter((module) => Boolean(module.translations?.[code])).length;
return {
code,
package: languagePackage,
catalogCount,
installed: Boolean(languagePackage),
active: item.enabled_language_codes.includes(code),
uninstallable: Boolean(
languagePackage && !BUILT_IN_LANGUAGE_CODES.has(code) && code !== item.default_locale
)
};
});
}
function languageDraftKey(item: SystemSettingsItem): string {
return JSON.stringify({
default_locale: item.default_locale,
available_languages: item.available_languages,
enabled_language_codes: item.enabled_language_codes
});
}