From 69ab09c993b55f5d1f3d9528ee3dcd1a49e618c3 Mon Sep 17 00:00:00 2001 From: Erik Jan de Wit Date: Thu, 23 Jul 2026 13:31:33 +0200 Subject: [PATCH] make multi value toggle less confusing Align multivalued rendering precedence across user profile UIs Ensure inputType-specific controls (select, multiselect, select-radiobuttons, multiselect-checkboxes, textarea) take precedence over generic multi-input rendering in React, and mirror the same fallback behavior in FreeMarker when inputType is unset. Clarify the Multivalued help text in Admin UI and update the user profile Playwright spec to assert the new precedence rules. fixes: #34763 Signed-off-by: Erik Jan de Wit --- .../admin/messages/messages_en.properties | 3 +- .../test/realm-settings/userprofile.spec.ts | 104 +++++++++++++++++- .../src/user-profile/UserProfileFields.tsx | 20 +++- .../theme/base/login/user-profile-commons.ftl | 3 +- 4 files changed, 120 insertions(+), 10 deletions(-) diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index 556a48293154..15817ffa46df 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -3561,10 +3561,9 @@ bruteForceMode.PermanentAfterTemporaryLockout=Lockout permanently after temporar bruteForceMode=Brute Force Mode error-invalid-multivalued-size=Attribute {{0}} must have at least {{1}} and at most {{2}} value(s). multivalued=Multivalued -multivaluedHelp=If this attribute supports multiple values. This setting is an indicator and does not enable any validation. +multivaluedHelp=If enabled, this attribute can store multiple values. Rendering depends on inputType annotations (for example, multiselect and multiselect-checkboxes use their own controls). Use validators to enforce allowed value counts. defaultValue=Default value defaultValueHelp=Default value when attribute value is not specified. -to the attribute. For that, make sure to use any of the built-in validators to properly validate the size and the values. sendIdTokenOnLogout=Send 'id_token_hint' in logout requests sendIdTokenOnLogoutHelp=If the 'id_token_hint' parameter should be sent in logout requests. sendClientIdOnLogout=Send 'client_id' in logout requests diff --git a/js/apps/admin-ui/test/realm-settings/userprofile.spec.ts b/js/apps/admin-ui/test/realm-settings/userprofile.spec.ts index e58ae6fd37f1..0d044cd7ee23 100644 --- a/js/apps/admin-ui/test/realm-settings/userprofile.spec.ts +++ b/js/apps/admin-ui/test/realm-settings/userprofile.spec.ts @@ -1,7 +1,12 @@ import { expect, test } from "@playwright/test"; import { v4 as uuid } from "uuid"; import adminClient from "../utils/AdminClient.ts"; -import { assertFieldError, selectItem, switchOn } from "../utils/form.ts"; +import { + assertFieldError, + selectItem, + switchOff, + switchOn, +} from "../utils/form.ts"; import { login } from "../utils/login.ts"; import { assertNotificationMessage } from "../utils/masthead.ts"; import { confirmModal } from "../utils/modal.ts"; @@ -32,6 +37,29 @@ test.describe.serial("User profile tabs", () => { const group = `realm-settings-group-${uuid()}`; const realmName = `sessions-realm-user-profile-${uuid()}`; + const userProfilePermissions = { + view: ["admin", "user"], + edit: ["admin", "user"], + }; + + const addMultiselectCheckboxAttribute = async ( + attributeName: string, + optionValues: string[], + multivalued = true, + ) => { + await adminClient.addUserProfile(realmName, { + attributes: [ + { + name: attributeName, + displayName: attributeName, + permissions: userProfilePermissions, + multivalued, + annotations: { inputType: "multiselect-checkboxes" }, + validations: { options: { options: optionValues } }, + }, + ], + }); + }; test.beforeAll(async () => { await adminClient.createRealm(realmName); @@ -259,4 +287,78 @@ test.describe.serial("User profile tabs", () => { await expect(page.getByRole("heading", { name: group })).toBeVisible(); }); + + test("Persists multivalued switch state when editing a configured attribute", async ({ + page, + }) => { + const suffix = uuid().slice(0, 8); + const attributeName = `multivalued-switch-${suffix}`; + + await addMultiselectCheckboxAttribute(attributeName, [ + `option-a-${suffix}`, + `option-b-${suffix}`, + ]); + await goToRealmSettings(page); + await goToUserProfileTab(page); + await goToAttributesTab(page); + await assertRowExists(page, attributeName, true); + + await clickTableRowItem(page, attributeName); + await expect(page.locator("#multivalued")).toBeChecked(); + + await switchOff(page, "#multivalued"); + await clickSaveAttribute(page); + await assertNotificationMessage( + page, + "Success! User Profile configuration has been saved.", + ); + + await clickTableRowItem(page, attributeName); + await expect(page.locator("#multivalued")).not.toBeChecked(); + }); + + test("Uses inputType precedence over multivalued switch for multiselect-checkboxes rendering", async ({ + page, + }) => { + const suffix = uuid().slice(0, 8); + const attributeName = `multivalued-render-${suffix}`; + const optionOne = `option-one-${suffix}`; + const optionTwo = `option-two-${suffix}`; + + await addMultiselectCheckboxAttribute(attributeName, [ + optionOne, + optionTwo, + ]); + await goToRealmSettings(page); + await goToUserProfileTab(page); + await goToAttributesTab(page); + await assertRowExists(page, attributeName, true); + + await goToUsers(page); + await page.getByTestId("no-users-found-empty-action").click(); + await expect(page.getByTestId(optionOne)).toBeVisible(); + await expect(page.getByTestId(optionTwo)).toBeVisible(); + await expect(page.getByTestId(`attributes.${attributeName}0`)).toHaveCount( + 0, + ); + + await goToRealmSettings(page); + await goToUserProfileTab(page); + await goToAttributesTab(page); + await clickTableRowItem(page, attributeName); + await switchOff(page, "#multivalued"); + await clickSaveAttribute(page); + await assertNotificationMessage( + page, + "Success! User Profile configuration has been saved.", + ); + + await goToUsers(page); + await page.getByTestId("no-users-found-empty-action").click(); + await expect(page.getByTestId(optionOne)).toBeVisible(); + await expect(page.getByTestId(optionTwo)).toBeVisible(); + await expect(page.getByTestId(`attributes.${attributeName}0`)).toHaveCount( + 0, + ); + }); }); diff --git a/js/libs/ui-shared/src/user-profile/UserProfileFields.tsx b/js/libs/ui-shared/src/user-profile/UserProfileFields.tsx index 4554ef5d3185..e03c3a80307b 100644 --- a/js/libs/ui-shared/src/user-profile/UserProfileFields.tsx +++ b/js/libs/ui-shared/src/user-profile/UserProfileFields.tsx @@ -187,12 +187,20 @@ const FormField = ({ fieldName(attribute.name) as FieldPath, ); const inputType = useMemo(() => determineInputType(attribute), [attribute]); - - const Component = - attribute.multivalued || - (isMultiValue(value) && attribute.annotations?.inputType === undefined) - ? FIELDS["multi-input"] - : FIELDS[inputType]; + const hasDedicatedInputComponent = + inputType === "textarea" || + inputType === "select" || + inputType === "multiselect" || + inputType === "select-radiobuttons" || + inputType === "multiselect-checkboxes"; + const shouldRenderMultiInput = + !hasDedicatedInputComponent && + (attribute.multivalued || + (isMultiValue(value) && attribute.annotations?.inputType === undefined)); + + const Component = shouldRenderMultiInput + ? FIELDS["multi-input"] + : FIELDS[inputType]; if (attribute.name === "locale") return ( diff --git a/themes/src/main/resources/theme/base/login/user-profile-commons.ftl b/themes/src/main/resources/theme/base/login/user-profile-commons.ftl index 286efb379c82..541c0d483846 100644 --- a/themes/src/main/resources/theme/base/login/user-profile-commons.ftl +++ b/themes/src/main/resources/theme/base/login/user-profile-commons.ftl @@ -83,7 +83,8 @@ <@inputTagSelects attribute=attribute/> <#break> <#default> - <#if attribute.multivalued && attribute.values?has_content> + <#assign inferredMultivalued = !attribute.annotations.inputType?? && (attribute.values?size > 1)> + <#if (attribute.multivalued || inferredMultivalued) && attribute.values?has_content> <#list attribute.values as value> <@inputTag attribute=attribute value=value!''/>