Complete partial import functionality. (#1644)

Co-authored-by: Jon Koops <jonkoops@gmail.com>
This commit is contained in:
Stan Silvert 2021-12-10 05:53:21 -05:00 committed by GitHub
parent 242c1d8445
commit 6250ccdaef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 423 additions and 263 deletions

View file

@ -74,8 +74,6 @@
} }
], ],
"client": { "client": {
"database-service": [],
"admin-client": [],
"realm-management": [ "realm-management": [
{ {
"id": "3b939f75-d013-4096-8462-48aa39261293", "id": "3b939f75-d013-4096-8462-48aa39261293",
@ -101,7 +99,7 @@
"secret": "password" "secret": "password"
}, },
{ {
"clientId": "customer-portal", "clientId": "customer-portal2",
"enabled": true, "enabled": true,
"adminUrl": "/customer-portal", "adminUrl": "/customer-portal",
"baseUrl": "/customer-portal", "baseUrl": "/customer-portal",

View file

@ -59,12 +59,12 @@ describe("Partial import test", () => {
modal.importButton().should("be.disabled"); modal.importButton().should("be.disabled");
// verify resource counts // verify resource counts
modal.userCount().contains("1 users"); modal.userCount().contains("1 Users");
modal.groupCount().contains("1 groups"); modal.groupCount().contains("1 Groups");
modal.clientCount().contains("1 clients"); modal.clientCount().contains("1 Clients");
modal.idpCount().contains("1 identity providers"); modal.idpCount().contains("1 Identity providers");
modal.realmRolesCount().contains("2 realm roles"); modal.realmRolesCount().contains("2 Realm roles");
modal.clientRolesCount().contains("1 client roles"); modal.clientRolesCount().contains("1 Client roles");
// import button should disable when switching realms // import button should disable when switching realms
modal.usersCheckbox().click(); modal.usersCheckbox().click();
@ -72,23 +72,36 @@ describe("Partial import test", () => {
modal.selectRealm("realm2"); modal.selectRealm("realm2");
modal.importButton().should("be.disabled"); modal.importButton().should("be.disabled");
modal.clientCount().contains("2 clients"); modal.clientCount().contains("2 Clients");
modal.clientsCheckbox().click();
modal.importButton().click();
cy.contains("2 records added");
cy.contains("customer-portal");
cy.contains("customer-portal2");
}); });
it("Displays user options after realmless import", () => { it("Displays user options after realmless import and does the import", () => {
modal.open(); modal.open();
modal.typeResourceFile("client-only.json"); modal.typeResourceFile("client-only.json");
modal.realmSelector().should("not.exist"); modal.realmSelector().should("not.exist");
modal.clientCount().contains("1 clients"); modal.clientCount().contains("1 Clients");
modal.userCount().should("not.exist"); modal.usersCheckbox().should("not.exist");
modal.groupCount().should("not.exist"); modal.groupsCheckbox().should("not.exist");
modal.idpCount().should("not.exist"); modal.idpCheckbox().should("not.exist");
modal.realmRolesCount().should("not.exist"); modal.realmRolesCheckbox().should("not.exist");
modal.clientRolesCount().should("not.exist"); modal.clientRolesCheckbox().should("not.exist");
modal.clientsCheckbox().click();
modal.importButton().click();
cy.contains("One record added");
cy.contains("customer-portal");
}); });
// Unfortunately, the PatternFly FileUpload component does not create an id for the clear button. So we can't easily test that function right now. // Unfortunately, the PatternFly FileUpload component does not create an id for the clear button. So we can't easily test that function right now.

View file

@ -30,36 +30,52 @@ export default class GroupModal {
return cy.findByTestId("cancel-button"); return cy.findByTestId("cancel-button");
} }
groupsCheckbox() {
return cy.findByTestId("groups-checkbox");
}
usersCheckbox() { usersCheckbox() {
return cy.findByTestId("users-checkbox"); return cy.findByTestId("users-checkbox");
} }
clientsCheckbox() {
return cy.findByTestId("clients-checkbox");
}
groupsCheckbox() {
return cy.findByTestId("groups-checkbox");
}
idpCheckbox() {
return cy.findByTestId("identityProviders-checkbox");
}
realmRolesCheckbox() {
return cy.findByTestId("realmRoles-checkbox");
}
clientRolesCheckbox() {
return cy.findByTestId("clientRoles-checkbox");
}
userCount() { userCount() {
return cy.findByTestId("users-count"); return this.usersCheckbox().get("label");
} }
clientCount() { clientCount() {
return cy.findByTestId("clients-count"); return this.clientsCheckbox().get("label");
} }
groupCount() { groupCount() {
return cy.findByTestId("groups-count"); return this.groupsCheckbox().get("label");
} }
idpCount() { idpCount() {
return cy.findByTestId("identityProviders-count"); return this.idpCheckbox().get("label");
} }
realmRolesCount() { realmRolesCount() {
return cy.findByTestId("realmRoles-count"); return this.realmRolesCheckbox().get("label");
} }
clientRolesCount() { clientRolesCount() {
return cy.findByTestId("clientRoles-count"); return this.clientRolesCheckbox().get("label");
} }
realmSelector() { realmSelector() {

14
package-lock.json generated
View file

@ -7,7 +7,7 @@
"name": "keycloak-admin-ui", "name": "keycloak-admin-ui",
"license": "Apache", "license": "Apache",
"dependencies": { "dependencies": {
"@keycloak/keycloak-admin-client": "^16.0.0-dev.59", "@keycloak/keycloak-admin-client": "^16.0.0-dev.61",
"@patternfly/patternfly": "^4.159.1", "@patternfly/patternfly": "^4.159.1",
"@patternfly/react-code-editor": "^4.16.4", "@patternfly/react-code-editor": "^4.16.4",
"@patternfly/react-core": "^4.175.4", "@patternfly/react-core": "^4.175.4",
@ -3407,9 +3407,9 @@
} }
}, },
"node_modules/@keycloak/keycloak-admin-client": { "node_modules/@keycloak/keycloak-admin-client": {
"version": "16.0.0-dev.59", "version": "16.0.0-dev.61",
"resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-16.0.0-dev.59.tgz", "resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-16.0.0-dev.61.tgz",
"integrity": "sha512-ygDXfVh7MRGbWNA/8zloWh5ULqhukZ+dhptGKuLmN1kxirzsc0P9//96/EYI3FX9rf+xiuF575dkOsR6sQx5Eg==", "integrity": "sha512-xeJTOQevOeHe8bQfu3/6Y9Lsort3Ep/VgzieUKmBHdR25kkMyQEUtZGX/7nQKYofjONG2m/KWivNwf0OZp+rhg==",
"dependencies": { "dependencies": {
"axios": "^0.24.0", "axios": "^0.24.0",
"camelize-ts": "^1.0.8", "camelize-ts": "^1.0.8",
@ -24119,9 +24119,9 @@
} }
}, },
"@keycloak/keycloak-admin-client": { "@keycloak/keycloak-admin-client": {
"version": "16.0.0-dev.59", "version": "16.0.0-dev.61",
"resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-16.0.0-dev.59.tgz", "resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-16.0.0-dev.61.tgz",
"integrity": "sha512-ygDXfVh7MRGbWNA/8zloWh5ULqhukZ+dhptGKuLmN1kxirzsc0P9//96/EYI3FX9rf+xiuF575dkOsR6sQx5Eg==", "integrity": "sha512-xeJTOQevOeHe8bQfu3/6Y9Lsort3Ep/VgzieUKmBHdR25kkMyQEUtZGX/7nQKYofjONG2m/KWivNwf0OZp+rhg==",
"requires": { "requires": {
"axios": "^0.24.0", "axios": "^0.24.0",
"camelize-ts": "^1.0.8", "camelize-ts": "^1.0.8",

View file

@ -23,7 +23,7 @@
"prepare": "husky install" "prepare": "husky install"
}, },
"dependencies": { "dependencies": {
"@keycloak/keycloak-admin-client": "^16.0.0-dev.59", "@keycloak/keycloak-admin-client": "^16.0.0-dev.61",
"@patternfly/patternfly": "^4.159.1", "@patternfly/patternfly": "^4.159.1",
"@patternfly/react-code-editor": "^4.16.4", "@patternfly/react-code-editor": "^4.16.4",
"@patternfly/react-core": "^4.175.4", "@patternfly/react-core": "^4.175.4",

View file

@ -81,6 +81,7 @@ export default {
clients: "Clients", clients: "Clients",
clientScopes: "Client scopes", clientScopes: "Client scopes",
realmRoles: "Realm roles", realmRoles: "Realm roles",
clientRoles: "Client roles",
users: "Users", users: "Users",
groups: "Groups", groups: "Groups",
sessions: "Sessions", sessions: "Sessions",

View file

@ -1,16 +1,17 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import _ from "lodash";
import { import {
Alert,
Button, Button,
ButtonVariant, ButtonVariant,
Checkbox,
DataList, DataList,
DataListCell, DataListCell,
DataListItem, DataListItem,
DataListItemCells, DataListItemCells,
DataListItemRow, DataListItemRow,
DataListCheck,
Divider, Divider,
Label,
Modal, Modal,
ModalVariant, ModalVariant,
Select, Select,
@ -22,7 +23,20 @@ import {
TextContent, TextContent,
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import { useAlerts } from "../components/alert/Alerts";
import { JsonFileUpload } from "../components/json-file-upload/JsonFileUpload"; import { JsonFileUpload } from "../components/json-file-upload/JsonFileUpload";
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import { useAdminClient } from "../context/auth/AdminClient";
import { useRealm } from "../context/realm-context/RealmContext";
import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import type {
PartialImportRealmRepresentation,
PartialImportResponse,
PartialImportResult,
} from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation";
import type RoleRepresentation from "@keycloak/keycloak-admin-client/lib/defs/roleRepresentation";
export type PartialImportProps = { export type PartialImportProps = {
open: boolean; open: boolean;
@ -31,22 +45,7 @@ export type PartialImportProps = {
// An imported JSON file can either be an array of realm objects // An imported JSON file can either be an array of realm objects
// or a single realm object. // or a single realm object.
type ImportedMultiRealm = [ImportedRealm?] | ImportedRealm; type ImportedMultiRealm = RealmRepresentation | RealmRepresentation[];
// Realms in imported json can have a lot more properties,
// but these are the ones we care about.
type ImportedRealm = {
id?: string;
realm?: string;
users?: [];
clients?: [];
groups?: [];
identityProviders?: [];
roles?: {
realm?: [];
client?: { [index: string]: [] };
};
};
type NonRoleResource = "users" | "clients" | "groups" | "identityProviders"; type NonRoleResource = "users" | "clients" | "groups" | "identityProviders";
type RoleResource = "realmRoles" | "clientRoles"; type RoleResource = "realmRoles" | "clientRoles";
@ -56,42 +55,43 @@ type CollisionOption = "FAIL" | "SKIP" | "OVERWRITE";
type ResourceChecked = { [k in Resource]: boolean }; type ResourceChecked = { [k in Resource]: boolean };
const INITIAL_RESOURCES: Readonly<ResourceChecked> = {
users: false,
clients: false,
groups: false,
identityProviders: false,
realmRoles: false,
clientRoles: false,
};
export const PartialImportDialog = (props: PartialImportProps) => { export const PartialImportDialog = (props: PartialImportProps) => {
const tRealm = useTranslation("realm-settings").t; const tRealm = useTranslation("realm-settings").t;
const { t } = useTranslation("partial-import"); const { t } = useTranslation("partial-import");
const adminClient = useAdminClient();
const { realm } = useRealm();
const [isFileSelected, setIsFileSelected] = useState(false); const [importedFile, setImportedFile] = useState<ImportedMultiRealm>();
const [isMultiRealm, setIsMultiRealm] = useState(false); const isFileSelected = !!importedFile;
const [importedFile, setImportedFile] = useState<ImportedMultiRealm>([]);
const [isRealmSelectOpen, setIsRealmSelectOpen] = useState(false); const [isRealmSelectOpen, setIsRealmSelectOpen] = useState(false);
const [isCollisionSelectOpen, setIsCollisionSelectOpen] = useState(false); const [isCollisionSelectOpen, setIsCollisionSelectOpen] = useState(false);
const [importInProgress, setImportInProgress] = useState(false);
const [collisionOption, setCollisionOption] = const [collisionOption, setCollisionOption] =
useState<CollisionOption>("FAIL"); useState<CollisionOption>("FAIL");
const [targetRealm, setTargetRealm] = useState<ImportedRealm>({}); const [targetRealm, setTargetRealm] = useState<RealmRepresentation>({});
const [importResponse, setImportResponse] = useState<PartialImportResponse>();
const { addError } = useAlerts();
const allResourcesUnChecked: Readonly<ResourceChecked> = { const [resourcesToImport, setResourcesToImport] = useState(INITIAL_RESOURCES);
users: false, const isAnyResourceChecked = Object.values(resourcesToImport).some(
clients: false, (checked) => checked
groups: false,
identityProviders: false,
realmRoles: false,
clientRoles: false,
};
const [resourcesToImport, setResourcesToImport] = useState<ResourceChecked>(
_.cloneDeep(allResourcesUnChecked)
); );
const [isAnyResourceChecked, setIsAnyResourceChecked] = useState(false);
const resetResourcesToImport = () => { const resetResourcesToImport = () => {
setResourcesToImport(_.cloneDeep(allResourcesUnChecked)); setResourcesToImport(INITIAL_RESOURCES);
setIsAnyResourceChecked(false);
}; };
const resetInputState = () => { const resetInputState = () => {
setIsMultiRealm(false); setImportedFile(undefined);
setImportedFile([]);
setTargetRealm({}); setTargetRealm({});
setCollisionOption("FAIL"); setCollisionOption("FAIL");
resetResourcesToImport(); resetResourcesToImport();
@ -99,30 +99,24 @@ export const PartialImportDialog = (props: PartialImportProps) => {
// when dialog opens or closes, clear state // when dialog opens or closes, clear state
useEffect(() => { useEffect(() => {
setIsFileSelected(false); setImportInProgress(false);
setImportResponse(undefined);
resetInputState(); resetInputState();
}, [props.open]); }, [props.open]);
const handleFileChange = (value: object) => { const handleFileChange = (value: ImportedMultiRealm) => {
setIsFileSelected(!!value);
resetInputState(); resetInputState();
setImportedFile(value); setImportedFile(value);
if (value instanceof Array && value.length > 0) { if (!Array.isArray(value)) {
setIsMultiRealm(value.length > 1); setTargetRealm(value);
setTargetRealm(value[0] || {}); } else if (value.length > 0) {
} else { setTargetRealm(value[0]);
setIsMultiRealm(false);
setTargetRealm((value as ImportedRealm) || {});
} }
}; };
const handleRealmSelect = ( const handleRealmSelect = (realm: string | SelectOptionObject) => {
event: React.ChangeEvent<Element> | React.MouseEvent<Element, MouseEvent>, setTargetRealm(realm as RealmRepresentation);
realm: string | SelectOptionObject
) => {
setTargetRealm(realm as ImportedRealm);
setIsRealmSelectOpen(false); setIsRealmSelectOpen(false);
resetResourcesToImport(); resetResourcesToImport();
}; };
@ -131,30 +125,24 @@ export const PartialImportDialog = (props: PartialImportProps) => {
checked: boolean, checked: boolean,
event: React.FormEvent<HTMLInputElement> event: React.FormEvent<HTMLInputElement>
) => { ) => {
const resource: Resource = event.currentTarget.name as Resource; const resource = event.currentTarget.name as Resource;
const copyOfResourcesToImport = _.cloneDeep(resourcesToImport);
copyOfResourcesToImport[resource] = checked; setResourcesToImport({
setResourcesToImport(copyOfResourcesToImport); ...resourcesToImport,
setIsAnyResourceChecked(resourcesChecked(copyOfResourcesToImport)); [resource]: checked,
});
}; };
const realmSelectOptions = () => { const realmSelectOptions = (realms: RealmRepresentation[]) =>
if (!isMultiRealm) return []; realms.map((realm) => (
<SelectOption
const mapper = (realm: ImportedRealm) => { key={realm.id}
return ( value={realm}
<SelectOption data-testid={realm.id + "-select-option"}
key={realm.id} >
value={realm} {realm.realm || realm.id}
data-testid={realm.id + "-select-option"} </SelectOption>
> ));
{realm.realm || realm.id}
</SelectOption>
);
};
return (importedFile as [ImportedRealm]).map(mapper);
};
const handleCollisionSelect = ( const handleCollisionSelect = (
event: React.ChangeEvent<Element> | React.MouseEvent<Element, MouseEvent>, event: React.ChangeEvent<Element> | React.MouseEvent<Element, MouseEvent>,
@ -190,66 +178,41 @@ export const PartialImportDialog = (props: PartialImportProps) => {
}; };
const targetHasResource = (resource: NonRoleResource) => { const targetHasResource = (resource: NonRoleResource) => {
return ( const value = targetRealm[resource];
targetRealm && return value !== undefined && value.length > 0;
targetRealm[resource] instanceof Array &&
targetRealm[resource]!.length > 0
);
};
const targetHasRoles = () => {
return (
targetRealm && Object.prototype.hasOwnProperty.call(targetRealm, "roles")
);
}; };
const targetHasRealmRoles = () => { const targetHasRealmRoles = () => {
return ( const value = targetRealm.roles?.realm;
targetHasRoles() && return value !== undefined && value.length > 0;
targetRealm.roles!.realm instanceof Array &&
targetRealm.roles!.realm.length > 0
);
}; };
const targetHasClientRoles = () => { const targetHasClientRoles = () => {
return ( const value = targetRealm.roles?.client;
targetHasRoles() && return value !== undefined && Object.keys(value).length > 0;
Object.prototype.hasOwnProperty.call(targetRealm.roles, "client") &&
Object.keys(targetRealm.roles!.client!).length > 0
);
}; };
const itemCount = (resource: Resource) => { const itemCount = (resource: Resource) => {
if (!isFileSelected) return 0; if (!isFileSelected) return 0;
if (targetHasRealmRoles() && resource === "realmRoles") if (resource === "realmRoles") {
return targetRealm.roles!.realm!.length; return targetRealm.roles?.realm?.length ?? 0;
if (targetHasClientRoles() && resource == "clientRoles")
return clientRolesCount(targetRealm.roles!.client!);
if (!targetRealm[resource as NonRoleResource]) return 0;
return targetRealm[resource as NonRoleResource]!.length;
};
const clientRolesCount = (clientRoles: { [index: string]: [] }) => {
let total = 0;
for (const clientName in clientRoles) {
total += clientRoles[clientName].length;
}
return total;
};
const resourcesChecked = (resources: ResourceChecked) => {
let resource: Resource;
for (resource in resources) {
if (resources[resource]) return true;
} }
return false; if (resource === "clientRoles") {
return targetHasClientRoles()
? clientRolesCount(targetRealm.roles!.client!)
: 0;
}
return targetRealm[resource]?.length ?? 0;
}; };
const clientRolesCount = (
clientRoles: Record<string, RoleRepresentation[]>
) =>
Object.values(clientRoles).reduce((total, role) => total + role.length, 0);
const resourceDataListItem = ( const resourceDataListItem = (
resource: Resource, resource: Resource,
resourceDisplayName: string resourceDisplayName: string
@ -257,19 +220,18 @@ export const PartialImportDialog = (props: PartialImportProps) => {
return ( return (
<DataListItem aria-labelledby={`${resource}-list-item`}> <DataListItem aria-labelledby={`${resource}-list-item`}>
<DataListItemRow> <DataListItemRow>
<DataListCheck
aria-labelledby={`${resource}-checkbox`}
name={resource}
isChecked={resourcesToImport[resource]}
onChange={handleResourceCheckBox}
data-testid={resource + "-checkbox"}
/>
<DataListItemCells <DataListItemCells
dataListCells={[ dataListCells={[
<DataListCell key={resource}> <DataListCell key={resource}>
<span data-testid={resource + "-count"}> <Checkbox
{itemCount(resource)} {resourceDisplayName} id={`${resource}-checkbox`}
</span> label={`${itemCount(resource)} ${resourceDisplayName}`}
aria-labelledby={`${resource}-checkbox`}
name={resource}
isChecked={resourcesToImport[resource]}
onChange={handleResourceCheckBox}
data-testid={resource + "-checkbox"}
/>
</DataListCell>, </DataListCell>,
]} ]}
/> />
@ -278,107 +240,263 @@ export const PartialImportDialog = (props: PartialImportProps) => {
); );
}; };
return ( const jsonForImport = () => {
<Modal const jsonToImport: PartialImportRealmRepresentation = {
variant={ModalVariant.medium} ifResourceExists: collisionOption,
title={tRealm("partialImport")} id: targetRealm.id,
isOpen={props.open} realm: targetRealm.realm,
onClose={props.toggleDialog} };
actions={[
<Button
id="modal-import"
data-testid="import-button"
key="import"
isDisabled={!isAnyResourceChecked}
onClick={() => {
props.toggleDialog();
}}
>
{t("import")}
</Button>,
<Button
id="modal-cancel"
data-testid="cancel-button"
key="cancel"
variant={ButtonVariant.link}
onClick={() => {
props.toggleDialog();
}}
>
{t("common:cancel")}
</Button>,
]}
>
<Stack hasGutter>
<StackItem>
<TextContent>
<Text>{t("partialImportHeaderText")}</Text>
</TextContent>
</StackItem>
<StackItem>
<JsonFileUpload
id="partial-import-file"
allowEditingUploadedText
onChange={handleFileChange}
/>
</StackItem>
{isFileSelected && targetHasResources() && ( if (resourcesToImport["users"]) jsonToImport.users = targetRealm.users;
<> if (resourcesToImport["groups"]) jsonToImport.groups = targetRealm.groups;
<StackItem> if (resourcesToImport["identityProviders"])
<Divider /> jsonToImport.identityProviders = targetRealm.identityProviders;
</StackItem> if (resourcesToImport["clients"])
{isMultiRealm && ( jsonToImport.clients = targetRealm.clients;
if (resourcesToImport["realmRoles"] || resourcesToImport["clientRoles"]) {
jsonToImport.roles = targetRealm.roles;
if (!resourcesToImport["realmRoles"]) delete jsonToImport.roles?.realm;
if (!resourcesToImport["clientRoles"]) delete jsonToImport.roles?.client;
}
return jsonToImport;
};
async function doImport() {
if (importInProgress) return;
setImportInProgress(true);
try {
const importResults = await adminClient.realms.partialImport({
realm,
rep: jsonForImport(),
});
setImportResponse(importResults);
} catch (error) {
addError("partial-import:importFail", error);
}
setImportInProgress(false);
}
const importModal = () => {
return (
<Modal
variant={ModalVariant.medium}
title={tRealm("partialImport")}
isOpen={props.open}
onClose={props.toggleDialog}
actions={[
<Button
id="modal-import"
data-testid="import-button"
key="import"
isDisabled={!isAnyResourceChecked}
onClick={() => {
doImport();
}}
>
{t("import")}
</Button>,
<Button
id="modal-cancel"
data-testid="cancel-button"
key="cancel"
variant={ButtonVariant.link}
onClick={() => {
props.toggleDialog();
}}
>
{t("common:cancel")}
</Button>,
]}
>
<Stack hasGutter>
<StackItem>
<TextContent>
<Text>{t("partialImportHeaderText")}</Text>
</TextContent>
</StackItem>
<StackItem>
<JsonFileUpload
id="partial-import-file"
allowEditingUploadedText
onChange={handleFileChange}
/>
</StackItem>
{isFileSelected && targetHasResources() && (
<>
<StackItem> <StackItem>
<Text>{t("selectRealm")}:</Text> <Divider />
</StackItem>
{Array.isArray(importedFile) && importedFile.length > 1 && (
<StackItem>
<Text>{t("selectRealm")}:</Text>
<Select
toggleId="realm-selector"
isOpen={isRealmSelectOpen}
onToggle={() => setIsRealmSelectOpen(!isRealmSelectOpen)}
onSelect={(_, value) => handleRealmSelect(value)}
placeholderText={targetRealm.realm || targetRealm.id}
>
{realmSelectOptions(importedFile)}
</Select>
</StackItem>
)}
<StackItem>
<Text>{t("chooseResources")}:</Text>
<DataList aria-label={t("resourcesToImport")} isCompact>
{targetHasResource("users") &&
resourceDataListItem("users", t("common:users"))}
{targetHasResource("groups") &&
resourceDataListItem("groups", t("common:groups"))}
{targetHasResource("clients") &&
resourceDataListItem("clients", t("common:clients"))}
{targetHasResource("identityProviders") &&
resourceDataListItem(
"identityProviders",
t("common:identityProviders")
)}
{targetHasRealmRoles() &&
resourceDataListItem("realmRoles", t("common:realmRoles"))}
{targetHasClientRoles() &&
resourceDataListItem(
"clientRoles",
t("common:clientRoles")
)}
</DataList>
</StackItem>
<StackItem>
<Text>{t("selectIfResourceExists")}:</Text>
<Select <Select
toggleId="realm-selector" isOpen={isCollisionSelectOpen}
isOpen={isRealmSelectOpen} direction="up"
onToggle={() => setIsRealmSelectOpen(!isRealmSelectOpen)} onToggle={() => {
onSelect={handleRealmSelect} setIsCollisionSelectOpen(!isCollisionSelectOpen);
placeholderText={targetRealm.realm || targetRealm.id} }}
onSelect={handleCollisionSelect}
placeholderText={t(collisionOption)}
> >
{realmSelectOptions()} {collisionOptions()}
</Select> </Select>
</StackItem> </StackItem>
)} </>
<StackItem> )}
<Text>{t("chooseResources")}:</Text> </Stack>
<DataList aria-label="Resources to import" isCompact> </Modal>
{targetHasResource("users") && );
resourceDataListItem("users", "users")} };
{targetHasResource("groups") &&
resourceDataListItem("groups", "groups")} const importCompleteMessage = () => {
{targetHasResource("clients") && return `${t("importAdded", {
resourceDataListItem("clients", "clients")} count: importResponse?.added,
{targetHasResource("identityProviders") && })} ${t("importSkipped", {
resourceDataListItem( count: importResponse?.skipped,
"identityProviders", })} ${t("importOverwritten", {
"identity providers" count: importResponse?.overwritten,
)} })}`;
{targetHasRealmRoles() && };
resourceDataListItem("realmRoles", "realm roles")}
{targetHasClientRoles() && const loader = async (first = 0, max = 15) => {
resourceDataListItem("clientRoles", "client roles")} if (!importResponse) {
</DataList> return [];
</StackItem> }
<StackItem>
<Text>{t("selectIfResourceExists")}:</Text> const last = Math.min(first + max, importResponse.results.length);
<Select
isOpen={isCollisionSelectOpen} return importResponse.results.slice(first, last);
direction="up" };
onToggle={() => {
setIsCollisionSelectOpen(!isCollisionSelectOpen); const ActionLabel = (importRecord: PartialImportResult) => {
}} switch (importRecord.action) {
onSelect={handleCollisionSelect} case "ADDED":
placeholderText={t(collisionOption)} return (
> <Label key={importRecord.id} color="green">
{collisionOptions()} {t("added")}
</Select> </Label>
</StackItem> );
</> case "SKIPPED":
)} return (
</Stack> <Label key={importRecord.id} color="orange">
</Modal> {t("skipped")}
); </Label>
);
case "OVERWRITTEN":
return (
<Label key={importRecord.id} color="purple">
{t("overwritten")}
</Label>
);
}
};
const TypeRenderer = (importRecord: PartialImportResult) => {
const typeMap = new Map([
["CLIENT", t("common:clients")],
["REALM_ROLE", t("common:realmRoles")],
["USER", t("common:users")],
["CLIENT_ROLE", t("common:clientRoles")],
["IDP", t("common:identityProviders")],
]);
return <span>{typeMap.get(importRecord.resourceType)}</span>;
};
const importCompletedModal = () => {
return (
<Modal
variant={ModalVariant.medium}
title={tRealm("partialImport")}
isOpen={props.open}
onClose={props.toggleDialog}
actions={[
<Button
id="modal-close"
data-testid="close-button"
key="close"
variant={ButtonVariant.primary}
onClick={() => {
props.toggleDialog();
}}
>
{t("common:close")}
</Button>,
]}
>
<Alert variant="success" isInline title={importCompleteMessage()} />
<KeycloakDataTable
loader={loader}
isPaginated
ariaLabelKey="realm-settings:partialImport"
columns={[
{
name: "action",
displayKey: "common:action",
cellRenderer: ActionLabel,
},
{
name: "resourceType",
displayKey: "common:type",
cellRenderer: TypeRenderer,
},
{
name: "resourceName",
displayKey: "common:name",
},
{
name: "id",
displayKey: "common:id",
},
]}
/>
</Modal>
);
};
if (!importResponse) {
return importModal();
}
return importCompletedModal();
}; };

View file

@ -743,9 +743,23 @@ export default {
selectIfResourceExists: selectIfResourceExists:
"If a resource already exists, specify what should be done", "If a resource already exists, specify what should be done",
import: "Import", import: "Import",
resourcesToImport: "Resources to import",
importFail: "Import failed: {{error}}",
FAIL: "Fail import", FAIL: "Fail import",
SKIP: "Skip", SKIP: "Skip",
OVERWRITE: "Overwrite", OVERWRITE: "Overwrite",
added: "Added",
skipped: "Skipped",
overwritten: "Overwritten",
importAdded_zero: "No records added.",
importAdded_one: "One record added.",
importAdded_other: "{{count}} records added.",
importOverwritten_zero: "No records overwritten.",
importOverwritten_one: "One record overwritten.",
importOverwritten_other: "{{count}} records overwritten.",
importSkipped_zero: "No records skipped.",
importSkipped_one: "One record skipped.",
importSkipped_other: "{{count}} records skipped.",
}, },
"partial-export": { "partial-export": {
partialExportHeaderText: partialExportHeaderText: