feat(admin): separate language package lifecycle
This commit is contained in:
@@ -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
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user