Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions js/apps/admin-ui/test/clients/authorization.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ import {
createResource,
deletePolicy,
fillForm,
getEvaluateResourceKeyInput,
getEvaluateResourceKeyOptions,
goToAuthorizationTab,
goToEvaluateSubTab,
goToExportSubTab,
goToPermissionsSubTab,
goToPoliciesSubTab,
Expand Down Expand Up @@ -352,3 +355,122 @@ test.describe.serial("Client authorization resources pagination", () => {
await expect(page.getByText("Resource-10", { exact: true })).toBeHidden();
});
});

test.describe.serial("Client authorization evaluate resource key", () => {
const clientId = `client-authz-evaluate-${crypto.randomUUID()}`;
const resourceNames = [
"alpha-resource",
"bravo-resource",
"charlie-resource",
];

test.beforeAll(async () => {
await adminClient.createClient({
protocol: "openid-connect",
clientId,
publicClient: false,
authorizationServicesEnabled: true,
serviceAccountsEnabled: true,
standardFlowEnabled: true,
});

for (const name of resourceNames) {
await adminClient.createResource(clientId, { name });
}
});

test.afterAll(async () => {
await adminClient.deleteClient(clientId);
});

test.beforeEach(async ({ page }) => {
await login(page);
await goToClients(page);
await searchItem(page, "Search for client", clientId);
await clickTableRowItem(page, clientId);
await goToAuthorizationTab(page);
await goToEvaluateSubTab(page);
});

test("Should filter the resource key options while typing", async ({
page,
}) => {
const key = getEvaluateResourceKeyInput(page);
const options = getEvaluateResourceKeyOptions(page);

await key.click();
await key.pressSequentially("charlie");

await expect(key).toHaveValue("charlie");
await expect(options).toHaveText(["charlie-resource"]);

await key.press("Enter");
await expect(key).toHaveValue("charlie-resource");
});

test("Should replace an existing resource key selection by typing", async ({
page,
}) => {
const key = getEvaluateResourceKeyInput(page);
const options = getEvaluateResourceKeyOptions(page);

await key.click();
await options.filter({ hasText: "charlie-resource" }).click();
await expect(key).toHaveValue("charlie-resource");

// Typing over an existing selection must show what was typed, not the
// selection it replaces, and must narrow the menu down to it.
await key.click();
await key.press("ControlOrMeta+a");
await key.pressSequentially("alpha");

await expect(key).toHaveValue("alpha");
await expect(options).toHaveText(["alpha-resource"]);

await key.press("Enter");
await expect(key).toHaveValue("alpha-resource");
});

test("Should restore the selection when an edit is abandoned", async ({
page,
}) => {
const key = getEvaluateResourceKeyInput(page);
const options = getEvaluateResourceKeyOptions(page);

await key.click();
await options.filter({ hasText: "bravo-resource" }).click();
await expect(key).toHaveValue("bravo-resource");

await key.click();
await key.press("ControlOrMeta+a");
await key.pressSequentially("zzz");
await key.press("Escape");
await expect(key).toHaveValue("bravo-resource");

// Re-opening must offer the whole list again, not just the last filter.
await key.click();
await expect(options).toHaveText(resourceNames);
});

test("Should not satisfy a required select by clearing the input", async ({
page,
}) => {
await goToPermissionsSubTab(page);
await createPermission(page, "resource", {
name: "clear-input-permission",
});

// Clearing has to reset the consumer's value via onClear. Falling back to
// onSelect("") would store [""], which is non-empty and would let the
// required check pass with nothing actually selected.
const resources = page.locator("#resources").getByRole("combobox");
await resources.click();
await resources.pressSequentially("alpha");
await page.locator("#resources").getByLabel("Clear input value").click();

await clickSaveButton(page);
await expect(
page.getByText("Required field", { exact: true }),
).toBeVisible();
});
});
19 changes: 19 additions & 0 deletions js/apps/admin-ui/test/clients/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,25 @@ export async function selectResource(page: Page, resourceName: string) {
await page.getByRole("option", { name: resourceName }).click();
}

export async function goToEvaluateSubTab(page: Page) {
await page.getByTestId("authorizationEvaluate").click();
}

export function getEvaluateResourceKeyInput(page: Page, rowIndex = 0) {
return page
.locator(`#resources\\.${rowIndex}\\.key`)
.getByRole("combobox", { name: "Select or type a key" });
}

// The evaluate form renders several selects, so option queries have to be
// scoped to the key select rather than run against the whole page.
export function getEvaluateResourceKeyOptions(page: Page, rowIndex = 0) {
return page
.locator(".kc-attribute-key-selectable")
.nth(rowIndex)
.getByRole("option");
}

export async function goToExportSubTab(page: Page) {
await page.getByTestId("authorizationExport").click();
}
Expand Down
65 changes: 40 additions & 25 deletions js/libs/ui-shared/src/select/TypeaheadSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const TypeaheadSelect = ({
onSelect,
onToggle,
onFilter,
onClear,
variant,
validated,
placeholderText,
Expand All @@ -42,22 +43,39 @@ export const TypeaheadSelect = ({
...rest
}: KeycloakSelectProps) => {
const [filterValue, setFilterValue] = useState("");
const [isFiltering, setIsFiltering] = useState(false);
const [focusedItemIndex, setFocusedItemIndex] = useState<number>(0);
const textInputRef = useRef<HTMLInputElement>();

const childArray = Children.toArray(
children,
) as React.ReactElement<SelectOptionProps>[];

// Only filter while the user is actually typing, so that re-opening the menu
// after a selection still lists every option.
const visibleChildren =
onFilter || !filterValue
onFilter || !isFiltering || !filterValue
? childArray
: childArray.filter((child) => {
const { children: label, value } = child.props;
const text = typeof label === "string" ? label : String(value ?? "");
return text.toLowerCase().includes(filterValue.toLowerCase());
});

// The single typeahead shows the current selection whenever the user is not
// editing, but their keystrokes must always win over it while they are.
const inputValue =
variant === SelectVariant.typeahead && !isFiltering && selections
? (selections as string)
: filterValue;

const stopFiltering = () => {
setIsFiltering(false);
setFilterValue("");
setFocusedItemIndex(0);
onFilter?.("");
};

const toggle = () => {
onToggle(!rest.isOpen);
};
Expand All @@ -71,25 +89,15 @@ export const TypeaheadSelect = ({
event.preventDefault();
if (!focusedItem) break;

if (variant !== SelectVariant.typeaheadMulti) {
setFilterValue(String(focusedItem.props.value));
} else {
setFilterValue("");
}
onSelect?.(focusedItem.props.value);
onToggle(false);
setFocusedItemIndex(0);
stopFiltering();

break;
}
case "Escape": {
onToggle(false);
break;
}
case "Backspace": {
if (variant === SelectVariant.typeahead) {
onSelect?.("");
}
stopFiltering();
break;
}
case "ArrowUp":
Expand Down Expand Up @@ -125,11 +133,15 @@ export const TypeaheadSelect = ({
<Select
{...rest}
onClick={toggle}
onOpenChange={(isOpen) => onToggle(isOpen)}
onOpenChange={(isOpen) => {
onToggle(isOpen);
if (!isOpen) {
stopFiltering();
}
}}
onSelect={(_, value) => {
onSelect?.(value || "");
onFilter?.("");
setFilterValue("");
stopFiltering();
}}
maxMenuHeight={propertyToString(maxHeight)}
popperProps={{ direction, width: propertyToString(width) }}
Expand All @@ -148,13 +160,10 @@ export const TypeaheadSelect = ({
<TextInputGroup isPlain>
<TextInputGroupMain
placeholder={placeholderText}
value={
variant === SelectVariant.typeahead && selections
? (selections as string)
: filterValue
}
value={inputValue}
onClick={toggle}
onChange={(_, value) => {
setIsFiltering(true);
setFilterValue(value);
setFocusedItemIndex(0);
onFilter?.(value);
Expand Down Expand Up @@ -188,13 +197,19 @@ export const TypeaheadSelect = ({
))}
</TextInputGroupMain>
<TextInputGroupUtilities>
{!!filterValue && (
{!!inputValue && (
<Button
variant="plain"
onClick={() => {
onSelect?.("");
setFilterValue("");
onFilter?.("");
// Consumers that track their own value need to reset it
// themselves: onSelect("") stores an empty entry rather
// than an empty selection.
if (onClear) {
onClear();
} else {
onSelect?.("");
}
stopFiltering();
textInputRef.current?.focus();
}}
aria-label="Clear input value"
Expand Down
Loading