From ac755dd3e909d834fbbc7224b9445ef3c15ae2ea Mon Sep 17 00:00:00 2001 From: Norman Niati Date: Fri, 15 May 2026 16:07:31 +0200 Subject: [PATCH 1/3] feat(plugin-id): add Company auto-suggest on UserEditView (edit + new user) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the plain v-text-field for form.company in UserEditView's create and edit dialog with a Vuetify v-autocomplete that queries rest/service/id/company as the user types (300ms debounced, no client-side filter). Displays the company name as title and the scope + member count chip as subtitle. Covers 2 entries from Fabrice's todo (Identity → Edit user → Company and Identity → New user → Company) in a single commit since the same view handles both routes. Includes a small temporary fallback in searchCompanies() that surfaces a static demo list of 3 companies (Ligoj, AcmeCorp, TechSolutions) when the API returns an empty array. This is needed to validate the autocomplete pattern visually while the local dev environment runs in "Demo mode" without an IAM provider configured. The fallback only triggers when results are empty — once a real LDAP node is attached in Admin → Nodes, the API will return real data and the fallback becomes inert. To remove cleanly once LDAP is wired up. Pattern Vuetify v4 compliant: the #item slot uses item.name / item.scope / item.count directly (NOT item.raw.foo which is dead in v4 — see SystemPluginView.vue:53 for a still-broken case). Includes the standard non-scoped CSS workaround on .v-autocomplete__content for the ligojLight custom theme that defaults --v-theme-on-surface-variant to a near-white grey, making v-list-item titles invisible inside teleported v-menu content. --- ui/src/views/UserEditView.vue | 137 +++++++++++++++++++++++++++++++++- 1 file changed, 136 insertions(+), 1 deletion(-) diff --git a/ui/src/views/UserEditView.vue b/ui/src/views/UserEditView.vue index 81430b3..00a862c 100644 --- a/ui/src/views/UserEditView.vue +++ b/ui/src/views/UserEditView.vue @@ -13,7 +13,46 @@ class="mb-2" /> - + + + + + @@ -109,6 +148,12 @@ const actionDialog = ref(false) const actionType = ref('') const actionLoading = ref(false) +// --- Company auto-suggest state --- +const companySearchQuery = ref('') +const companyResults = ref([]) +const companyLoading = ref(false) +let companyDebounce = null + const isEdit = computed(() => !!route.params.id) const groupsDisplay = computed(() => groups.value.map(g => g.name || g).join(', ') || '-') @@ -126,6 +171,65 @@ const rules = { required: v => !!v || t('common.required'), } +// --- Company auto-suggest logic --- + +/** Called on every keystroke in the autocomplete. Debounced 300 ms. */ +function onCompanySearch(query) { + companySearchQuery.value = query || '' + clearTimeout(companyDebounce) + companyDebounce = setTimeout(() => searchCompanies(query), 300) +} + +async function searchCompanies(query) { + if (!query || query.length < 1) { + companyResults.value = [] + return + } + companyLoading.value = true + try { + // Direct URL with un-encoded brackets — the legacy DataTables backend + // expects `search[value]=...` literally. + const url = `rest/service/id/company?search[value]=${encodeURIComponent(query)}&rows=20&page=1&sidx=name&sord=asc` + const resp = await api.get(url) + // Defensive: api.get may return the wrapper { data: [...] } or the + // array directly depending on the endpoint's content-type handling. + companyResults.value = Array.isArray(resp) ? resp : (Array.isArray(resp?.data) ? resp.data : []) + // Fallback : if no IAM provider is configured (Demo mode), the + // backend returns no companies. Surface a small demo list so the + // pattern can be visually validated. The full backend integration + // will work once a real LDAP node is configured in Admin → Nodes. + if (companyResults.value.length === 0 && query) { + const DEMO = [ + { name: 'Ligoj', scope: 'Company', count: 4 }, + { name: 'AcmeCorp', scope: 'Company', count: 2 }, + { name: 'TechSolutions', scope: 'Company', count: 2 }, + ] + const q = query.toLowerCase() + companyResults.value = DEMO.filter(c => c.name.toLowerCase().includes(q)) + } + } catch (err) { + console.error('Company search failed:', err) + companyResults.value = [] + } finally { + companyLoading.value = false + } +} + +/** When editing an existing user, the company is already set but the + * autocomplete's item list is empty — pre-seed with the current value + * so v-autocomplete can render its label correctly on open. */ +async function ensureCurrentCompanyInResults(name) { + if (!name) return + try { + const url = `rest/service/id/company?search[value]=${encodeURIComponent(name)}&rows=5&page=1&sidx=name&sord=asc` + const resp = await api.get(url) + const items = Array.isArray(resp) ? resp : (Array.isArray(resp?.data) ? resp.data : []) + companyResults.value = items.length ? items : [{ name }] + } catch { + companyResults.value = [{ name }] + } +} + // Demo users matching UserListView const DEMO_USERS = [ { id: 'admin', firstName: 'Admin', lastName: 'User', company: 'Ligoj', mails: ['admin@ligoj.org'], groups: [{ name: 'Engineering' }, { name: 'Management' }] }, @@ -165,11 +269,18 @@ onMounted(async () => { groups.value = data.groups || [] locked.value = !!data.locked isolated.value = !!data.isolated + // Pre-seed the company suggest with the current value so the input + // displays it correctly without an explicit search. + await ensureCurrentCompanyInResults(form.value.company) } else { // API unavailable — use demo data demoMode.value = true errorStore.clear() loadDemoUser(route.params.id) + // Pre-seed in demo mode too, with a stub object. + if (form.value.company) { + companyResults.value = [{ name: form.value.company }] + } } loading.value = false appStore.setBreadcrumbs([ @@ -280,3 +391,27 @@ async function confirmAction() { } } + + From 51068258ff36cf396a75d5883a14b599a9b55719 Mon Sep 17 00:00:00 2001 From: Norman Niati Date: Fri, 15 May 2026 17:01:15 +0200 Subject: [PATCH 2/3] feat(plugin-id): add Group multi-select auto-suggest on UserEditView (edit only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the read-only v-text-field that used to display groupsDisplay on UserEditView with a Vuetify v-autocomplete (multiple + chips + closable-chips) that queries rest/service/id/group as the user types (300ms debounced, no client-side filter). Visible only in edit mode (v-if="isEdit") since Fabrice's todo didn't include groups on New User (and he said in the call: "les groupes éventuellement aussi mais peut-être pas"). Covers chantier #3 of Fabrice's onboarding todo: "Identity → Edit user → Groupes : Group doit être un suggest avec liste dépliante (potentiellement des centaines de milliers d'entrées)." Key implementation choices: - groups is now normalised to an Array of names everywhere (load, demo, payload). The legacy g.name || g defensive map is kept to absorb any object-shaped value that may slip through. - onGroupModelUpdate resets searchQuery + results after pick/remove so the dropdown reverts to its no-data hint instead of keeping the last search "ghost" — Vuetify v4 doesn't clear the inline query automatically in multi-select mode. - ensureCurrentGroupsInResults pre-seeds groupResults with stubs (just {name}) at mount so chips render immediately, no extra API roundtrip required. - Same fallback strategy as the Company autocomplete: when the API returns an empty array (Demo mode without IAM provider), surface a small demo list (Engineering, Management, DevOps, Marketing, Sales) so the pattern can be validated visually. Inert once a real LDAP node is attached. - Save payload adds groups: Array only on edit. Not exercised in demo mode (save() early-returns) — to be validated end-to-end once LDAP is wired up. Side improvement on the existing Company autocomplete in the same view (single commit since same fix surface area): - hint+persistent-hint replaced by a placeholder for visual cleanliness - the no-data string aligned to the same formal wording as Groups Pattern Vuetify 4 compliant: the #item slot uses item.name directly (NOT item.raw.name which is dead in v4). The non-scoped style block adopting .v-autocomplete__content as a workaround for the ligojLight theme remains in place and covers both autocompletes in this view. Known follow-ups (out of scope of this PR, tracked in memory.md): - Move the four UI strings (placeholder, no-data, etc.) to i18n - useFormGuard does not track groups (separate ref), so editing only groups without other field changes won't trigger the "unsaved changes" guard dialog. To unify in a follow-up. - SystemPluginView.vue:53 (plugin-ui repo) still uses item.raw.foo pattern — silently broken since Vuetify v4. Separate PR. --- ui/src/views/UserEditView.vue | 122 ++++++++++++++++++++++++++++++++-- 1 file changed, 115 insertions(+), 7 deletions(-) diff --git a/ui/src/views/UserEditView.vue b/ui/src/views/UserEditView.vue index 00a862c..355fd26 100644 --- a/ui/src/views/UserEditView.vue +++ b/ui/src/views/UserEditView.vue @@ -24,8 +24,7 @@ item-title="name" item-value="name" :label="t('user.company')" - hint="Tape quelques lettres pour chercher une entité" - persistent-hint + placeholder="Rechercher une entité…" variant="outlined" class="mb-2" no-filter @@ -48,13 +47,47 @@ - + + + + + @@ -154,8 +187,13 @@ const companyResults = ref([]) const companyLoading = ref(false) let companyDebounce = null +// --- Group auto-suggest state (multi-select) --- +const groupSearchQuery = ref('') +const groupResults = ref([]) +const groupLoading = ref(false) +let groupDebounce = null + const isEdit = computed(() => !!route.params.id) -const groupsDisplay = computed(() => groups.value.map(g => g.name || g).join(', ') || '-') const form = ref({ id: '', @@ -230,6 +268,64 @@ async function ensureCurrentCompanyInResults(name) { } } +// --- Group auto-suggest logic (multi-select) --- + +/** Called on every keystroke in the group autocomplete. Debounced 300 ms. */ +function onGroupSearch(query) { + groupSearchQuery.value = query || '' + clearTimeout(groupDebounce) + groupDebounce = setTimeout(() => searchGroups(query), 300) +} + +/** After picking or removing a chip, reset the search field so the user + * can immediately type the next group name. Vuetify v4 doesn't clear + * the inline query automatically in multi-select mode. */ +function onGroupModelUpdate() { + groupSearchQuery.value = '' + groupResults.value = [] +} + +async function searchGroups(query) { + if (!query || query.length < 1) { + groupResults.value = [] + return + } + groupLoading.value = true + try { + const url = `rest/service/id/group?search[value]=${encodeURIComponent(query)}&rows=20&page=1&sidx=name&sord=asc` + const resp = await api.get(url) + groupResults.value = Array.isArray(resp) ? resp : (Array.isArray(resp?.data) ? resp.data : []) + // Fallback (same pattern as company) — Demo mode often returns + // an empty array; surface a small demo list so multi-select can + // be exercised visually. Replaced by real backend data once an + // LDAP node is configured. + if (groupResults.value.length === 0 && query) { + const DEMO = [ + { name: 'Engineering' }, + { name: 'Management' }, + { name: 'DevOps' }, + { name: 'Marketing' }, + { name: 'Sales' }, + ] + const q = query.toLowerCase() + groupResults.value = DEMO.filter(g => g.name.toLowerCase().includes(q)) + } + } catch (err) { + console.error('Group search failed:', err) + groupResults.value = [] + } finally { + groupLoading.value = false + } +} + +/** Pre-seed groupResults with the user's existing groups so Vuetify can + * render their chips on edit without an explicit search. Takes an array + * of group **names** (strings). */ +function ensureCurrentGroupsInResults(names) { + if (!Array.isArray(names) || !names.length) return + groupResults.value = names.map(n => ({ name: n })) +} + // Demo users matching UserListView const DEMO_USERS = [ { id: 'admin', firstName: 'Admin', lastName: 'User', company: 'Ligoj', mails: ['admin@ligoj.org'], groups: [{ name: 'Engineering' }, { name: 'Management' }] }, @@ -250,7 +346,10 @@ function loadDemoUser(id) { form.value.lastName = user.lastName form.value.company = user.company form.value.mail = user.mails?.[0] || '' - groups.value = user.groups || [] + // Normalize groups to an array of names (strings) so v-autocomplete + // with item-value="name" can roundtrip them through v-model. + groups.value = (user.groups || []).map(g => g.name || g) + ensureCurrentGroupsInResults(groups.value) locked.value = !!user.locked isolated.value = !!user.isolated } @@ -266,12 +365,17 @@ onMounted(async () => { form.value.lastName = data.lastName || '' form.value.company = data.company || '' form.value.mail = data.mails?.[0] || '' - groups.value = data.groups || [] + // Normalize groups to an array of names (strings) so v-autocomplete + // with item-value="name" can roundtrip them through v-model. + groups.value = (data.groups || []).map(g => g.name || g) locked.value = !!data.locked isolated.value = !!data.isolated // Pre-seed the company suggest with the current value so the input // displays it correctly without an explicit search. await ensureCurrentCompanyInResults(form.value.company) + // Pre-seed groupResults with stubs so v-autocomplete renders the + // existing chips immediately (no API roundtrip needed). + ensureCurrentGroupsInResults(groups.value) } else { // API unavailable — use demo data demoMode.value = true @@ -322,6 +426,10 @@ async function save() { lastName: form.value.lastName, company: form.value.company, mail: form.value.mail, + // groups is an array of names (strings). Defensive `.map(g => g.name || g)` + // in case any legacy object slipped through. Only sent on edit since the + // groups field is hidden on New User for this PR. + ...(isEdit.value ? { groups: groups.value.map(g => g.name || g) } : {}), } if (isEdit.value) { From ad0b9e210dcdba2d679baa0f686b61128b38155f Mon Sep 17 00:00:00 2001 From: Norman Niati Date: Mon, 18 May 2026 10:10:46 +0200 Subject: [PATCH 3/3] fix(ui): gate demo fallback behind import.meta.env.DEV (PR #20 review) Per Fabrice's review: demo data must never leak to production. import.meta.env.DEV is true in 'vite dev', false in 'vite build', so the fallback only kicks in during local development. --- ui/src/views/UserEditView.vue | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/ui/src/views/UserEditView.vue b/ui/src/views/UserEditView.vue index 355fd26..48424ec 100644 --- a/ui/src/views/UserEditView.vue +++ b/ui/src/views/UserEditView.vue @@ -232,11 +232,12 @@ async function searchCompanies(query) { // Defensive: api.get may return the wrapper { data: [...] } or the // array directly depending on the endpoint's content-type handling. companyResults.value = Array.isArray(resp) ? resp : (Array.isArray(resp?.data) ? resp.data : []) - // Fallback : if no IAM provider is configured (Demo mode), the - // backend returns no companies. Surface a small demo list so the - // pattern can be visually validated. The full backend integration - // will work once a real LDAP node is configured in Admin → Nodes. - if (companyResults.value.length === 0 && query) { + // Dev-only fallback: gated behind import.meta.env.DEV so demo + // data NEVER leaks to production. When LDAP isn't configured in + // dev, surface a small demo list so the autosuggest can be + // visually validated. In prod, an empty backend response stays + // empty — the real LDAP integration will populate it. + if (import.meta.env.DEV && companyResults.value.length === 0 && query) { const DEMO = [ { name: 'Ligoj', scope: 'Company', count: 4 }, { name: 'AcmeCorp', scope: 'Company', count: 2 }, @@ -295,11 +296,10 @@ async function searchGroups(query) { const url = `rest/service/id/group?search[value]=${encodeURIComponent(query)}&rows=20&page=1&sidx=name&sord=asc` const resp = await api.get(url) groupResults.value = Array.isArray(resp) ? resp : (Array.isArray(resp?.data) ? resp.data : []) - // Fallback (same pattern as company) — Demo mode often returns - // an empty array; surface a small demo list so multi-select can - // be exercised visually. Replaced by real backend data once an - // LDAP node is configured. - if (groupResults.value.length === 0 && query) { + // Dev-only fallback (same gating as company) — never leaks to + // production. import.meta.env.DEV is true in `vite dev`, false + // in `vite build`. + if (import.meta.env.DEV && groupResults.value.length === 0 && query) { const DEMO = [ { name: 'Engineering' }, { name: 'Management' },