Partial import phase 2 (#702)

* Process the JSON and present user options

* Finish checkboxes.  Refactor.

* Add tests

* Refactor after rebase

* Add more test data for manual testing.

* Fix linting errors

* Put JsonFileUpload back the way it was.

* Clean up comments

* Update src/realm-settings/PartialImport.tsx

Remove comment

Co-authored-by: Jenny <32821331+jenny-s51@users.noreply.github.com>

Co-authored-by: Jenny <32821331+jenny-s51@users.noreply.github.com>
This commit is contained in:
Stan Silvert 2021-06-16 07:03:55 -04:00 committed by GitHub
parent a03c8fc79b
commit 1bf423b505
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 5826 additions and 27 deletions

View file

@ -0,0 +1,13 @@
{
"clients": [
{
"clientId": "customer-portal",
"enabled": true,
"adminUrl": "/customer-portal",
"baseUrl": "/customer-portal",
"redirectUris": [
"/customer-portal/*"
],
"secret": "password"
}]
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,113 @@
[
{
"id": "realm1",
"realm": "realm1",
"clients": [
{
"clientId": "customer-portal",
"enabled": true,
"adminUrl": "/customer-portal",
"baseUrl": "/customer-portal",
"redirectUris": ["/customer-portal/*"],
"secret": "password"
}
],
"users": [
{
"username": "ssilvert",
"enabled": true,
"email": "ssilvert@redhat.com",
"firstName": "Stan",
"lastName": "Silvert",
"credentials": [{ "type": "password", "value": "password" }],
"realmRoles": ["user", "offline_access"],
"clientRoles": {
"account": ["manage-account"]
}
}
],
"groups": [
{
"id": "7a20471c-c695-4778-adb0-4ee4ae88d198",
"name": "group1",
"path": "/group1",
"attributes": {
"foo": ["bar"]
},
"realmRoles": ["create-realm"],
"clientRoles": {},
"subGroups": []
}
],
"identityProviders": [
{
"alias": "keycloak-oidc",
"internalId": "721b5dae-5b98-4284-bcd1-f872ce2ac174",
"providerId": "keycloak-oidc",
"enabled": true,
"updateProfileFirstLoginMode": "on",
"trustEmail": false,
"storeToken": false,
"addReadTokenRoleOnCreate": false,
"authenticateByDefault": false,
"config": {
"clientSecret": "foo",
"clientId": "foo",
"tokenUrl": "https://foo.bar",
"authorizationUrl": "https://foo.bar"
}
}
],
"roles": {
"realm": [
{
"id": "9d2638c8-4c62-4c42-90ea-5f3c836d0cc8",
"name": "offline_access",
"scopeParamRequired": false,
"composite": false
},
{
"id": "9d2638c8-4c62-4c42-90ea-5f3c836d0cc8",
"name": "another",
"scopeParamRequired": false,
"composite": false
}
],
"client": {
"database-service": [],
"admin-client": [],
"realm-management": [
{
"id": "3b939f75-d013-4096-8462-48aa39261293",
"name": "create-client",
"description": "${role_create-client}",
"scopeParamRequired": false,
"composite": false
}
]
}
}
},
{
"id": "realm2",
"realm": "realm2",
"clients": [
{
"clientId": "customer-portal",
"enabled": true,
"adminUrl": "/customer-portal",
"baseUrl": "/customer-portal",
"redirectUris": ["/customer-portal/*"],
"secret": "password"
},
{
"clientId": "customer-portal",
"enabled": true,
"adminUrl": "/customer-portal",
"baseUrl": "/customer-portal",
"redirectUris": ["/customer-portal/*"],
"secret": "password"
}
]
}
]

View file

@ -1,33 +1,94 @@
import CreateRealmPage from "../support/pages/admin_console/CreateRealmPage";
import SidebarPage from "../support/pages/admin_console/SidebarPage"; import SidebarPage from "../support/pages/admin_console/SidebarPage";
import LoginPage from "../support/pages/LoginPage"; import LoginPage from "../support/pages/LoginPage";
import PartialImportModal from "../support/pages/admin_console/configure/realm_settings/PartialImportModal"; import PartialImportModal from "../support/pages/admin_console/configure/realm_settings/PartialImportModal";
import RealmSettings from "../support/pages/admin_console/configure/realm_settings/RealmSettings"; import RealmSettings from "../support/pages/admin_console/configure/realm_settings/RealmSettings";
import { keycloakBefore } from "../support/util/keycloak_before"; import { keycloakBefore } from "../support/util/keycloak_before";
import AdminClient from "../support/util/AdminClient";
describe("Partial import test", () => { describe("Partial import test", () => {
const TEST_REALM = "partial-import-test-realm";
const loginPage = new LoginPage(); const loginPage = new LoginPage();
const sidebarPage = new SidebarPage(); const sidebarPage = new SidebarPage();
const partialImportModal = new PartialImportModal(); const createRealmPage = new CreateRealmPage();
const modal = new PartialImportModal();
const realmSettings = new RealmSettings(); const realmSettings = new RealmSettings();
beforeEach(function () { beforeEach(() => {
keycloakBefore(); keycloakBefore();
loginPage.logIn(); loginPage.logIn();
// doing this from the UI has the added bonus of putting you in the test realm
sidebarPage.goToCreateRealm();
createRealmPage.fillRealmName(TEST_REALM).createRealm();
sidebarPage.goToRealmSettings(); sidebarPage.goToRealmSettings();
realmSettings.clickActionMenu(); realmSettings.clickActionMenu();
}); });
it("Opens and closes partial import dialog", () => { afterEach(async () => {
partialImportModal.open(); const client = new AdminClient();
cy.getId("import-button").should("be.disabled"); await client.deleteRealm(TEST_REALM);
cy.getId("cancel-button").click();
cy.getId("import-button").should("not.exist");
}); });
it("Import button reacts to loaded json", () => { it("Opens and closes partial import dialog", () => {
partialImportModal.open(); modal.open();
modal.importButton().should("be.disabled");
modal.cancelButton().click();
modal.importButton().should("not.exist");
});
it("Import button only enabled if JSON has something to import", () => {
modal.open();
cy.get("#partial-import-file").type("{}"); cy.get("#partial-import-file").type("{}");
cy.getId("import-button").should("be.enabled"); modal.importButton().should("be.disabled");
});
it("Displays user options after multi-realm import", () => {
modal.open();
modal.typeResourceFile("multi-realm.json");
// Import button should be disabled if no checkboxes selected
modal.importButton().should("be.disabled");
modal.usersCheckbox().click();
modal.importButton().should("be.enabled");
modal.groupsCheckbox().click();
modal.importButton().should("be.enabled");
modal.groupsCheckbox().click();
modal.usersCheckbox().click();
modal.importButton().should("be.disabled");
// verify resource counts
modal.userCount().contains("1 users");
modal.groupCount().contains("1 groups");
modal.clientCount().contains("1 clients");
modal.idpCount().contains("1 identity providers");
modal.realmRolesCount().contains("2 realm roles");
modal.clientRolesCount().contains("1 client roles");
// import button should disable when switching realms
modal.usersCheckbox().click();
modal.importButton().should("be.enabled");
modal.selectRealm("realm2");
modal.importButton().should("be.disabled");
modal.clientCount().contains("2 clients");
});
it("Displays user options after realmless import", () => {
modal.open();
modal.typeResourceFile("client-only.json");
modal.realmSelector().should("not.exist");
modal.clientCount().contains("1 clients");
modal.userCount().should("not.exist");
modal.groupCount().should("not.exist");
modal.idpCount().should("not.exist");
modal.realmRolesCount().should("not.exist");
modal.clientRolesCount().should("not.exist");
}); });
// 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

@ -5,4 +5,65 @@ export default class GroupModal {
cy.getId(this.openPartialImport).click(); cy.getId(this.openPartialImport).click();
return this; return this;
} }
typeResourceFile = (filename: string) => {
cy.readFile(
"cypress/integration/partial-import-test-data/" + filename
).then((myJSON) => {
const text = JSON.stringify(myJSON);
cy.get("#partial-import-file").type(text, {
parseSpecialCharSequences: false,
});
});
};
importButton() {
return cy.getId("import-button");
}
cancelButton() {
return cy.getId("cancel-button");
}
groupsCheckbox() {
return cy.getId("groups-checkbox");
}
usersCheckbox() {
return cy.getId("users-checkbox");
}
userCount() {
return cy.getId("users-count");
}
clientCount() {
return cy.getId("clients-count");
}
groupCount() {
return cy.getId("groups-count");
}
idpCount() {
return cy.getId("identityProviders-count");
}
realmRolesCount() {
return cy.getId("realmRoles-count");
}
clientRolesCount() {
return cy.getId("clientRoles-count");
}
realmSelector() {
return cy.get("#realm-selector");
}
selectRealm(realm: string) {
this.realmSelector().click();
cy.getId(realm + "-select-option").click();
}
} }

View file

@ -1,11 +1,21 @@
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 {
Button, Button,
ButtonVariant, ButtonVariant,
DataList,
DataListCell,
DataListItem,
DataListItemCells,
DataListItemRow,
DataListCheck,
Divider, Divider,
Modal, Modal,
ModalVariant, ModalVariant,
Select,
SelectOption,
SelectOptionObject,
Stack, Stack,
StackItem, StackItem,
Text, Text,
@ -19,18 +29,254 @@ export type PartialImportProps = {
toggleDialog: () => void; toggleDialog: () => void;
}; };
// An imported JSON file can either be an array of realm objects
// or a single realm object.
type ImportedMultiRealm = [ImportedRealm?] | ImportedRealm;
// 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 RoleResource = "realmRoles" | "clientRoles";
type Resource = NonRoleResource | RoleResource;
type CollisionOption = "FAIL" | "SKIP" | "OVERWRITE";
type ResourceChecked = { [k in Resource]: boolean };
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 [importEnabled, setImportEnabled] = useState(false);
// when dialog opens or closes, reset importEnabled to false const [isFileSelected, setIsFileSelected] = useState(false);
const [isMultiRealm, setIsMultiRealm] = useState(false);
const [importedFile, setImportedFile] = useState<ImportedMultiRealm>([]);
const [isRealmSelectOpen, setIsRealmSelectOpen] = useState(false);
const [isCollisionSelectOpen, setIsCollisionSelectOpen] = useState(false);
const [collisionOption, setCollisionOption] = useState<CollisionOption>(
"FAIL"
);
const [targetRealm, setTargetRealm] = useState<ImportedRealm>({});
const allResourcesUnChecked: Readonly<ResourceChecked> = {
users: false,
clients: false,
groups: false,
identityProviders: false,
realmRoles: false,
clientRoles: false,
};
const [resourcesToImport, setResourcesToImport] = useState<ResourceChecked>(
_.cloneDeep(allResourcesUnChecked)
);
const [isAnyResourceChecked, setIsAnyResourceChecked] = useState(false);
const resetResourcesToImport = () => {
setResourcesToImport(_.cloneDeep(allResourcesUnChecked));
setIsAnyResourceChecked(false);
};
const resetInputState = () => {
setIsMultiRealm(false);
setImportedFile([]);
setTargetRealm({});
setCollisionOption("FAIL");
resetResourcesToImport();
};
// when dialog opens or closes, clear state
useEffect(() => { useEffect(() => {
setImportEnabled(false); setIsFileSelected(false);
resetInputState();
}, [props.open]); }, [props.open]);
const handleFileChange = (value: object) => { const handleFileChange = (value: object) => {
setImportEnabled(!!value); setIsFileSelected(!!value);
resetInputState();
setImportedFile(value);
if (value instanceof Array && value.length > 0) {
setIsMultiRealm(value.length > 1);
setTargetRealm(value[0] || {});
} else {
setIsMultiRealm(false);
setTargetRealm((value as ImportedRealm) || {});
}
};
const handleRealmSelect = (
event: React.ChangeEvent<Element> | React.MouseEvent<Element, MouseEvent>,
realm: string | SelectOptionObject
) => {
setTargetRealm(realm as ImportedRealm);
setIsRealmSelectOpen(false);
resetResourcesToImport();
};
const handleResourceCheckBox = (
checked: boolean,
event: React.FormEvent<HTMLInputElement>
) => {
const resource: Resource = event.currentTarget.name as Resource;
const copyOfResourcesToImport = _.cloneDeep(resourcesToImport);
copyOfResourcesToImport[resource] = checked;
setResourcesToImport(copyOfResourcesToImport);
setIsAnyResourceChecked(resourcesChecked(copyOfResourcesToImport));
};
const realmSelectOptions = () => {
if (!isMultiRealm) return [];
const mapper = (realm: ImportedRealm) => {
return (
<SelectOption
key={realm.id}
value={realm}
data-testid={realm.id + "-select-option"}
>
{realm.realm || realm.id}
</SelectOption>
);
};
return (importedFile as [ImportedRealm]).map(mapper);
};
const handleCollisionSelect = (
event: React.ChangeEvent<Element> | React.MouseEvent<Element, MouseEvent>,
option: string | SelectOptionObject
) => {
setCollisionOption(option as CollisionOption);
setIsCollisionSelectOpen(false);
};
const collisionOptions = () => {
return [
<SelectOption key="fail" value="FAIL">
{t("FAIL")}
</SelectOption>,
<SelectOption key="skip" value="SKIP">
{t("SKIP")}
</SelectOption>,
<SelectOption key="overwrite" value="OVERWRITE">
{t("OVERWRITE")}
</SelectOption>,
];
};
const targetHasResources = () => {
return (
targetHasResource("users") ||
targetHasResource("groups") ||
targetHasResource("clients") ||
targetHasResource("identityProviders") ||
targetHasRealmRoles() ||
targetHasClientRoles()
);
};
const targetHasResource = (resource: NonRoleResource) => {
return (
targetRealm &&
targetRealm[resource] instanceof Array &&
targetRealm[resource]!.length > 0
);
};
const targetHasRoles = () => {
return (
targetRealm && Object.prototype.hasOwnProperty.call(targetRealm, "roles")
);
};
const targetHasRealmRoles = () => {
return (
targetHasRoles() &&
targetRealm.roles!.realm instanceof Array &&
targetRealm.roles!.realm.length > 0
);
};
const targetHasClientRoles = () => {
return (
targetHasRoles() &&
Object.prototype.hasOwnProperty.call(targetRealm.roles, "client") &&
Object.keys(targetRealm.roles!.client!).length > 0
);
};
const itemCount = (resource: Resource) => {
if (!isFileSelected) return 0;
if (targetHasRealmRoles() && resource === "realmRoles")
return targetRealm.roles!.realm!.length;
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;
};
const resourceDataListItem = (
resource: Resource,
resourceDisplayName: string
) => {
return (
<DataListItem aria-labelledby={`${resource}-list-item`}>
<DataListItemRow>
<DataListCheck
aria-labelledby={`${resource}-checkbox`}
name={resource}
isChecked={resourcesToImport[resource]}
onChange={handleResourceCheckBox}
data-testid={resource + "-checkbox"}
/>
<DataListItemCells
dataListCells={[
<DataListCell key={resource}>
<span data-testid={resource + "-count"}>
{itemCount(resource)} {resourceDisplayName}
</span>
</DataListCell>,
]}
/>
</DataListItemRow>
</DataListItem>
);
}; };
return ( return (
@ -44,7 +290,7 @@ export const PartialImportDialog = (props: PartialImportProps) => {
id="modal-import" id="modal-import"
data-testid="import-button" data-testid="import-button"
key="import" key="import"
isDisabled={!importEnabled} isDisabled={!isAnyResourceChecked}
onClick={() => { onClick={() => {
props.toggleDialog(); props.toggleDialog();
}} }}
@ -76,14 +322,61 @@ export const PartialImportDialog = (props: PartialImportProps) => {
onChange={handleFileChange} onChange={handleFileChange}
/> />
</StackItem> </StackItem>
{importEnabled && (
<StackItem> {isFileSelected && targetHasResources() && (
<Divider /> <>
TODO: This section will include{" "} <StackItem>
<strong>Choose the resources...</strong> and{" "} <Divider />
<strong>If a resource already exists....</strong> </StackItem>
<Divider /> {isMultiRealm && (
</StackItem> <StackItem>
<Text>{t("selectRealm")}:</Text>
<Select
toggleId="realm-selector"
isOpen={isRealmSelectOpen}
onToggle={() => setIsRealmSelectOpen(!isRealmSelectOpen)}
onSelect={handleRealmSelect}
placeholderText={targetRealm.realm || targetRealm.id}
>
{realmSelectOptions()}
</Select>
</StackItem>
)}
<StackItem>
<Text>{t("chooseResources")}:</Text>
<DataList aria-label="Resources to import" isCompact>
{targetHasResource("users") &&
resourceDataListItem("users", "users")}
{targetHasResource("groups") &&
resourceDataListItem("groups", "groups")}
{targetHasResource("clients") &&
resourceDataListItem("clients", "clients")}
{targetHasResource("identityProviders") &&
resourceDataListItem(
"identityProviders",
"identity providers"
)}
{targetHasRealmRoles() &&
resourceDataListItem("realmRoles", "realm roles")}
{targetHasClientRoles() &&
resourceDataListItem("clientRoles", "client roles")}
</DataList>
</StackItem>
<StackItem>
<Text>{t("selectIfResourceExists")}:</Text>
<Select
isOpen={isCollisionSelectOpen}
direction="up"
onToggle={() => {
setIsCollisionSelectOpen(!isCollisionSelectOpen);
}}
onSelect={handleCollisionSelect}
placeholderText={t(collisionOption)}
>
{collisionOptions()}
</Select>
</StackItem>
</>
)} )}
</Stack> </Stack>
</Modal> </Modal>

View file

@ -1,4 +1,3 @@
{ {
"realm-settings": { "realm-settings": {
"partialImport": "Partial import", "partialImport": "Partial import",
@ -477,7 +476,6 @@
"name": "Refresh token error", "name": "Refresh token error",
"description": "Refresh token error" "description": "Refresh token error"
} }
}, },
"emptyEvents": "Nothing to add", "emptyEvents": "Nothing to add",
"emptyEventsInstructions": "There are no more events types left to add", "emptyEventsInstructions": "There are no more events types left to add",
@ -494,8 +492,14 @@
"confirm": "Confirm" "confirm": "Confirm"
}, },
"partial-import": { "partial-import": {
"partialImportHeaderText": "Partial import allows you to import users, clients, and resources from a previously exported json file.", "partialImportHeaderText": "Partial import allows you to import users, clients, and other resources from a previously exported json file.",
"import": "Import" "selectRealm": "Select realm",
"chooseResources": "Choose the resources you want to import",
"selectIfResourceExists": "If a resource already exists, specify what should be done",
"import": "Import",
"FAIL": "Fail import",
"SKIP": "Skip",
"OVERWRITE": "Overwrite"
}, },
"onDragStart": "Dragging started for item {{id}}", "onDragStart": "Dragging started for item {{id}}",
"onDragMove": "Dragging item {{id}}", "onDragMove": "Dragging item {{id}}",