From 6f45c7b6a1495538f253185c90ea4fa0e399832b Mon Sep 17 00:00:00 2001 From: Norman Niati Date: Tue, 19 May 2026 15:42:32 +0200 Subject: [PATCH] feat(plugin-id/ui): i18n labels and icons for delegate type selectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Fabrice's review (18 mai 13h30): the type selectors on the Delegate edit view (recipient type and resource type) were showing raw enum values (USER / GROUP / COMPANY / TREE) without translation or visual cue. Add i18n labels (fr + en) and MDI icons. Icons are rendered via: - prepend-inner-icon bound to a computed that maps the v-model value to the icon name (for the field showing the selected value) - the #item slot + #prepend slot (for the dropdown rows) This sidesteps the #selection slot which, combined with object items holding an icon field, triggered "Maximum recursive updates exceeded" inside v-select. TYPE_ICONS lives at module scope (constant, never reactive) so the lookup is stable across renders. Items stay as { value, titleKey } objects; v-model still holds the raw enum value via item-value="value", keeping the save() payload contract unchanged. Plugin-local i18n keys follow the pattern established in PR norman/feat-delete-confirm-bold-name: keys live in plugin-id's ui/src/i18n/{en,fr}.js and are merged into the host store via useI18nStore().merge() in install(). When delete-confirm lands first the two PRs will conflict on the i18n files — trivial textual merge, both extend the same maps. --- ui/src/i18n/en.js | 9 +++++ ui/src/i18n/fr.js | 9 +++++ ui/src/index.js | 7 ++++ ui/src/views/DelegateEditView.vue | 67 ++++++++++++++++++++++++++++--- 4 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 ui/src/i18n/en.js create mode 100644 ui/src/i18n/fr.js diff --git a/ui/src/i18n/en.js b/ui/src/i18n/en.js new file mode 100644 index 0000000..c3bc283 --- /dev/null +++ b/ui/src/i18n/en.js @@ -0,0 +1,9 @@ +// Plugin-local translations merged into the host i18n store at install +// time. Keep keys flat (dot-separated) to match the host's existing +// convention. +export default { + 'delegate.type.user': 'User', + 'delegate.type.group': 'Group', + 'delegate.type.company': 'Company', + 'delegate.type.tree': 'Tree', +} diff --git a/ui/src/i18n/fr.js b/ui/src/i18n/fr.js new file mode 100644 index 0000000..2ba0ce8 --- /dev/null +++ b/ui/src/i18n/fr.js @@ -0,0 +1,9 @@ +// Plugin-local translations merged into the host i18n store at install +// time. Keep keys flat (dot-separated) to match the host's existing +// convention. +export default { + 'delegate.type.user': 'Utilisateur', + 'delegate.type.group': 'Groupe', + 'delegate.type.company': 'Entité', + 'delegate.type.tree': 'Arborescence', +} diff --git a/ui/src/index.js b/ui/src/index.js index ad93610..a10e316 100644 --- a/ui/src/index.js +++ b/ui/src/index.js @@ -33,6 +33,7 @@ if (typeof document !== 'undefined') { * Shared host surface (stores, composables) is imported from `@ligoj/host`, * kept external at build so plugin and host share the same instances. */ +import { useI18nStore } from '@ligoj/host' import IdPlugin from './IdPlugin.vue' import UserListView from './views/UserListView.vue' import UserEditView from './views/UserEditView.vue' @@ -43,6 +44,8 @@ import CompanyEditView from './views/CompanyEditView.vue' import DelegateListView from './views/DelegateListView.vue' import DelegateEditView from './views/DelegateEditView.vue' import ContainerScopeView from './views/ContainerScopeView.vue' +import enMessages from './i18n/en.js' +import frMessages from './i18n/fr.js' import service from './service.js' const features = { @@ -76,6 +79,10 @@ export default { for (const route of routes) { router.addRoute(route) } + // Register plugin-local translations into the host i18n store + const i18n = useI18nStore() + i18n.merge(enMessages, 'en') + i18n.merge(frMessages, 'fr') }, feature(action, ...args) { const fn = features[action] diff --git a/ui/src/views/DelegateEditView.vue b/ui/src/views/DelegateEditView.vue index ae55b6e..9fec6b2 100644 --- a/ui/src/views/DelegateEditView.vue +++ b/ui/src/views/DelegateEditView.vue @@ -6,9 +6,25 @@ - + + + - + + + @@ -73,8 +89,39 @@ const confirmDelete = ref(false) const isEdit = computed(() => !!route.params.id) -const receiverTypes = ['USER', 'GROUP', 'COMPANY'] -const resourceTypes = ['USER', 'GROUP', 'COMPANY', 'TREE'] +// Static icon map keyed by the raw enum value. Const at module scope so +// it is never re-created reactively — referenced from both the +// prepend-inner-icon computed (selected value) and the #item slot +// (dropdown rows). +const TYPE_ICONS = { + USER: 'mdi-account', + GROUP: 'mdi-account-group', + COMPANY: 'mdi-domain', + TREE: 'mdi-file-tree', +} + +// Items as plain objects with the raw enum value + an i18n key. v-model +// still holds the value, item-value="value" wires it back. The earlier +// attempt at adding icons via the #selection slot (with item.raw.icon) +// triggered "Maximum recursive updates exceeded" inside v-select — this +// version keeps only the #item slot and renders the selected icon via +// prepend-inner-icon, which sidesteps that loop. +const receiverTypes = [ + { value: 'USER', titleKey: 'delegate.type.user' }, + { value: 'GROUP', titleKey: 'delegate.type.group' }, + { value: 'COMPANY', titleKey: 'delegate.type.company' }, +] +const resourceTypes = [ + { value: 'USER', titleKey: 'delegate.type.user' }, + { value: 'GROUP', titleKey: 'delegate.type.group' }, + { value: 'COMPANY', titleKey: 'delegate.type.company' }, + { value: 'TREE', titleKey: 'delegate.type.tree' }, +] + +/** v-select item-title callback: resolves the i18n key from the item object. */ +function typeTitle(item) { + return t(item.titleKey) +} const form = ref({ receiver: '', @@ -85,6 +132,10 @@ const form = ref({ canWrite: false, }) +// Icon shown inside the field, driven by the currently selected value. +const receiverIcon = computed(() => TYPE_ICONS[form.value.receiverType] || '') +const typeIcon = computed(() => TYPE_ICONS[form.value.type] || '') + const { showGuardDialog, confirmLeave, cancelLeave, markClean, init: initGuard } = useFormGuard(form) const rules = { @@ -97,9 +148,13 @@ onMounted(async () => { const data = await api.get(`rest/security/delegate/${route.params.id}`) if (data) { form.value.receiver = data.receiver?.id || data.receiver || '' - form.value.receiverType = data.receiverType || 'USER' + // Normalize to the uppercase enum form used by the v-select items. + // The backend stores some delegates with lowercase values ("company", + // "tree", …) and v-model would otherwise mismatch every item, locking + // the select in a "Maximum recursive updates exceeded" loop. + form.value.receiverType = (data.receiverType || 'USER').toUpperCase() form.value.name = data.name || '' - form.value.type = data.type || 'GROUP' + form.value.type = (data.type || 'GROUP').toUpperCase() form.value.canAdmin = !!data.canAdmin form.value.canWrite = !!data.canWrite }