User Credentials -> Draggable user credentials (#2131)

* draggable rows - wip

* draggable rows - wip

* draggable rows - wip

* draggable rows - wip

* draggable rows - wip

* draggable rows - wip

* added tests - wip

* added test

* draggable rows - wip

* feedback

* fixed position of reset credential btn

* fixed position of reset credential btn

* Remove unnecessary key props

Co-authored-by: Agnieszka Gancarczyk <agancarc@redhat.com>
Co-authored-by: Jon Koops <jonkoops@gmail.com>
This commit is contained in:
agagancarczyk 2022-03-07 14:32:34 +00:00 committed by GitHub
parent a84701fe5c
commit 9d376ebcde
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 305 additions and 19 deletions

View file

@ -32,6 +32,7 @@ describe("User creation", () => {
let itemId = "user_crud"; let itemId = "user_crud";
let itemIdWithGroups = "user_with_groups_crud"; let itemIdWithGroups = "user_with_groups_crud";
let itemIdWithCred = "user_crud_cred"; let itemIdWithCred = "user_crud_cred";
const itemCredential = "Password";
before(() => { before(() => {
for (let i = 0; i <= 2; i++) { for (let i = 0; i <= 2; i++) {
@ -235,6 +236,38 @@ describe("User creation", () => {
); );
}); });
it("Edit credential label", () => {
listingPage.goToItemDetails(itemIdWithCred);
credentialsPage
.goToCredentialsTab()
.clickEditCredentialLabelBtn()
.fillEditCredentialForm()
.clickEditConfirmationBtn();
masthead.checkNotificationMessage(
"The user label has been changed successfully."
);
});
it("Show credential data dialog", () => {
listingPage.goToItemDetails(itemIdWithCred);
credentialsPage
.goToCredentialsTab()
.clickShowDataDialogBtn()
.clickCloseDataDialogBtn();
});
it("Delete credential", () => {
listingPage.goToItemDetails(itemIdWithCred);
credentialsPage.goToCredentialsTab();
listingPage.deleteItem(itemCredential);
modalUtils.checkModalTitle("Delete credentials?").confirmModal();
masthead.checkNotificationMessage(
"The credentials has been deleted successfully."
);
});
it("Delete user from search bar test", () => { it("Delete user from search bar test", () => {
// Delete // Delete
sidebarPage.waitForPageLoad(); sidebarPage.waitForPageLoad();

View file

@ -17,6 +17,11 @@ export default class CredentialsPage {
"terms_and_conditions-option", "terms_and_conditions-option",
]; ];
private readonly confirmationButton = "confirm"; private readonly confirmationButton = "confirm";
private readonly editLabelBtn = "editUserLabelBtn";
private readonly labelField = "userLabelFld";
private readonly editConfirmationBtn = "editUserLabelAcceptBtn";
private readonly showDataDialogBtn = "showDataBtn";
private readonly closeDataDialogBtn = '[aria-label^="Close"]';
goToCredentialsTab() { goToCredentialsTab() {
cy.findByTestId(this.credentialsTab).click(); cy.findByTestId(this.credentialsTab).click();
@ -81,4 +86,38 @@ export default class CredentialsPage {
return this; return this;
} }
clickEditCredentialLabelBtn() {
cy.findByTestId(this.editLabelBtn)
.should("be.visible")
.click({ force: true });
return this;
}
fillEditCredentialForm() {
cy.findByTestId(this.labelField).focus().type("test");
return this;
}
clickEditConfirmationBtn() {
cy.findByTestId(this.editConfirmationBtn).click();
return this;
}
clickShowDataDialogBtn() {
cy.findByTestId(this.showDataDialogBtn)
.should("be.visible")
.click({ force: true });
return this;
}
clickCloseDataDialogBtn() {
cy.get(this.closeDataDialogBtn).eq(1).click({ force: true });
return this;
}
} }

View file

@ -1,4 +1,4 @@
import React, { Fragment, useState } from "react"; import React, { Fragment, useMemo, useRef, useState } from "react";
import { import {
AlertVariant, AlertVariant,
Button, Button,
@ -24,8 +24,8 @@ import type CredentialRepresentation from "@keycloak/keycloak-admin-client/lib/d
import { ResetPasswordDialog } from "./user-credentials/ResetPasswordDialog"; import { ResetPasswordDialog } from "./user-credentials/ResetPasswordDialog";
import { ResetCredentialDialog } from "./user-credentials/ResetCredentialDialog"; import { ResetCredentialDialog } from "./user-credentials/ResetCredentialDialog";
import { InlineLabelEdit } from "./user-credentials/InlineLabelEdit"; import { InlineLabelEdit } from "./user-credentials/InlineLabelEdit";
import "./user-credentials.css"; import "./user-credentials.css";
import styles from "@patternfly/react-styles/css/components/Table/table";
import { CredentialRow } from "./user-credentials/CredentialRow"; import { CredentialRow } from "./user-credentials/CredentialRow";
import { toUpperCase } from "../util"; import { toUpperCase } from "../util";
@ -61,6 +61,14 @@ export const UserCredentials = ({ user }: UserCredentialsProps) => {
rowKey: string; rowKey: string;
}>(); }>();
const bodyRef = useRef<HTMLTableSectionElement>(null);
const [state, setState] = useState({
draggedItemId: "",
draggingToItemIndex: -1,
dragging: false,
tempItemOrder: [""],
});
useFetch( useFetch(
() => adminClient.users.getCredentials({ id: user.id! }), () => adminClient.users.getCredentials({ id: user.id! }),
(credentials) => { (credentials) => {
@ -150,6 +158,165 @@ export const UserCredentials = ({ user }: UserCredentialsProps) => {
/> />
</CredentialRow> </CredentialRow>
); );
const itemOrder = useMemo(
() =>
groupedUserCredentials.map(({ value }) =>
value.map(({ id }) => id).toString()
),
[groupedUserCredentials]
);
const onDragStart = (evt: React.DragEvent) => {
evt.dataTransfer.effectAllowed = "move";
evt.dataTransfer.setData("text/plain", evt.currentTarget.id);
const draggedItemId = evt.currentTarget.id;
evt.currentTarget.classList.add(styles.modifiers.ghostRow);
evt.currentTarget.setAttribute("aria-pressed", "true");
setState({ ...state, draggedItemId, dragging: true });
};
const moveItem = (items: string[], targetItem: string, toIndex: number) => {
const fromIndex = items.indexOf(targetItem);
if (fromIndex === toIndex) {
return items;
}
const result = [...items];
result.splice(toIndex, 0, result.splice(fromIndex, 1)[0]);
return result;
};
const move = (itemOrder: string[]) => {
if (!bodyRef.current) return;
const ulNode = bodyRef.current;
const nodes = Array.from(ulNode.children);
if (nodes.every(({ id }, i) => id === itemOrder[i])) {
return;
}
ulNode.replaceChildren();
itemOrder.forEach((itemId) => {
ulNode.appendChild(nodes.find(({ id }) => id === itemId)!);
});
};
const onDragCancel = () => {
if (!bodyRef.current) return;
Array.from(bodyRef.current.children).forEach((el) => {
el.classList.remove(styles.modifiers.ghostRow);
el.setAttribute("aria-pressed", "false");
});
setState({
...state,
draggedItemId: "",
draggingToItemIndex: -1,
dragging: false,
});
};
const onDragLeave = (evt: React.DragEvent) => {
if (!isValidDrop(evt)) {
move(itemOrder);
setState({ ...state, draggingToItemIndex: -1 });
}
};
const isValidDrop = (evt: React.DragEvent) => {
if (!bodyRef.current) return false;
const ulRect = bodyRef.current.getBoundingClientRect();
return (
evt.clientX > ulRect.x &&
evt.clientX < ulRect.x + ulRect.width &&
evt.clientY > ulRect.y &&
evt.clientY < ulRect.y + ulRect.height
);
};
const onDrop = (evt: React.DragEvent) => {
if (isValidDrop(evt)) {
onDragFinish(state.draggedItemId, state.tempItemOrder);
} else {
onDragCancel();
}
};
const onDragOver = (evt: React.DragEvent) => {
evt.preventDefault();
const td = evt.target as HTMLTableCellElement;
const curListItem = td.closest("tr");
if (
!curListItem ||
(bodyRef.current && !bodyRef.current.contains(curListItem)) ||
curListItem.id === state.draggedItemId
) {
return;
} else {
const dragId = curListItem.id;
const draggingToItemIndex = Array.from(
bodyRef.current?.children || []
).findIndex((item) => item.id === dragId);
if (draggingToItemIndex === state.draggingToItemIndex) {
return;
}
const tempItemOrder = moveItem(
itemOrder,
state.draggedItemId,
draggingToItemIndex
);
move(tempItemOrder);
setState({
...state,
draggingToItemIndex,
tempItemOrder,
});
}
};
const onDragEnd = ({ target }: React.DragEvent) => {
if (!(target instanceof HTMLTableRowElement)) {
return;
}
target.classList.remove(styles.modifiers.ghostRow);
target.setAttribute("aria-pressed", "false");
setState({
...state,
draggedItemId: "",
draggingToItemIndex: -1,
dragging: false,
});
};
const onDragFinish = async (dragged: string, newOrder: string[]) => {
dragged = dragged.split(",")[0];
const keys = groupedUserCredentials.map(({ value }) =>
value.map(({ id }) => id)
);
const oldIndex = keys.findIndex((el) => el.join().includes(dragged));
const newIndex = newOrder.findIndex((el) => el.includes(dragged));
const times = newIndex - oldIndex;
try {
for (let index = 0; index < Math.abs(times); index++) {
if (times > 0) {
await adminClient.users.moveCredentialPositionDown({
id: user.id!,
credentialId: dragged,
newPreviousCredentialId: `${keys[newIndex][0]}`,
});
} else {
await adminClient.users.moveCredentialPositionUp({
id: user.id!,
credentialId: dragged,
});
}
}
refresh();
addAlert(t("users:updatedCredentialMoveSuccess"), AlertVariant.success);
} catch (error) {
addError("users:updatedCredentialMoveError", error);
}
};
return ( return (
<> <>
{isOpen && ( {isOpen && (
@ -170,7 +337,6 @@ export const UserCredentials = ({ user }: UserCredentialsProps) => {
{userCredentials.length !== 0 && passwordTypeFinder === undefined && ( {userCredentials.length !== 0 && passwordTypeFinder === undefined && (
<> <>
<Button <Button
key={`confirmSaveBtn-table-${user.id}`}
className="kc-setPasswordBtn-tbl" className="kc-setPasswordBtn-tbl"
data-testid="setPasswordBtn-table" data-testid="setPasswordBtn-table"
variant="primary" variant="primary"
@ -188,7 +354,7 @@ export const UserCredentials = ({ user }: UserCredentialsProps) => {
<> <>
{user.email && ( {user.email && (
<Button <Button
className="resetCredentialBtn-header" className="kc-resetCredentialBtn-header"
variant="primary" variant="primary"
data-testid="credentialResetBtn" data-testid="credentialResetBtn"
onClick={() => setOpenCredentialReset(true)} onClick={() => setOpenCredentialReset(true)}
@ -196,15 +362,19 @@ export const UserCredentials = ({ user }: UserCredentialsProps) => {
{t("credentialResetBtn")} {t("credentialResetBtn")}
</Button> </Button>
)} )}
<TableComposable aria-label="password-data-table" variant={"compact"}> <TableComposable
aria-label="userCredentials-table"
variant={"compact"}
>
<Thead> <Thead>
<Tr> <Tr className="kc-table-header">
<Th> <Th>
<HelpItem <HelpItem
helpText="users:userCredentialsHelpText" helpText="users:userCredentialsHelpText"
fieldLabelId="users:userCredentialsHelpTextLabel" fieldLabelId="users:userCredentialsHelpTextLabel"
/> />
</Th> </Th>
<Th />
<Th>{t("type")}</Th> <Th>{t("type")}</Th>
<Th>{t("userLabel")}</Th> <Th>{t("userLabel")}</Th>
<Th>{t("data")}</Th> <Th>{t("data")}</Th>
@ -212,10 +382,28 @@ export const UserCredentials = ({ user }: UserCredentialsProps) => {
<Th /> <Th />
</Tr> </Tr>
</Thead> </Thead>
<Tbody> <Tbody
ref={bodyRef}
onDragOver={onDragOver}
onDrop={onDragOver}
onDragLeave={onDragLeave}
>
{groupedUserCredentials.map((groupedCredential, rowIndex) => ( {groupedUserCredentials.map((groupedCredential, rowIndex) => (
<Fragment key={`table-${groupedCredential.key}`}> <Fragment key={groupedCredential.key}>
<Tr> <Tr
id={groupedCredential.value.map(({ id }) => id).toString()}
draggable
onDrop={onDrop}
onDragEnd={onDragEnd}
onDragStart={onDragStart}
>
<Td
draggableRow={{
id: `draggable-row-${groupedCredential.value.map(
({ id }) => id
)}`,
}}
/>
{groupedCredential.value.length > 1 ? ( {groupedCredential.value.length > 1 ? (
<Td <Td
className="kc-expandRow-btn" className="kc-expandRow-btn"
@ -240,24 +428,36 @@ export const UserCredentials = ({ user }: UserCredentialsProps) => {
<Td /> <Td />
)} )}
<Td <Td
key={`table-item-${groupedCredential.key}`}
dataLabel={`columns-${groupedCredential.key}`} dataLabel={`columns-${groupedCredential.key}`}
className="kc-notExpandableRow-credentialType" className="kc-notExpandableRow-credentialType"
data-testid="credentialType"
> >
{toUpperCase(groupedCredential.key)} {toUpperCase(groupedCredential.key)}
</Td> </Td>
{groupedCredential.value.length <= 1 && {groupedCredential.value.length <= 1 &&
groupedCredential.value.map((credential) => ( groupedCredential.value.map((credential) => (
<Row <Row key={credential.id} credential={credential} />
key={`subrow-${credential.id}`}
credential={credential}
/>
))} ))}
</Tr> </Tr>
{groupedCredential.isExpanded && {groupedCredential.isExpanded &&
groupedCredential.value.map((credential) => ( groupedCredential.value.map((credential) => (
<Tr key={`child-key-${credential.id}`}> <Tr
key={credential.id}
id={credential.id}
draggable
onDrop={onDrop}
onDragEnd={onDragEnd}
onDragStart={onDragStart}
>
<Td /> <Td />
<Td
className="kc-draggable-dropdown-type-icon"
draggableRow={{
id: `draggable-row-${groupedCredential.value.map(
({ id }) => id
)}`,
}}
/>
<Td <Td
dataLabel={`child-columns-${credential.id}`} dataLabel={`child-columns-${credential.id}`}
className="kc-expandableRow-credentialType" className="kc-expandableRow-credentialType"

View file

@ -144,6 +144,10 @@ export default {
deleteCredentialsSuccess: "The credentials has been deleted successfully.", deleteCredentialsSuccess: "The credentials has been deleted successfully.",
deleteCredentialsError: "Error deleting users credentials: {{error}}", deleteCredentialsError: "Error deleting users credentials: {{error}}",
deleteBtn: "Delete", deleteBtn: "Delete",
updatedCredentialMoveSuccess:
"User Credential configuration has been saved",
updatedCredentialMoveError:
"User Credential configuration hasn't been saved",
resetPasswordFor: "Reset password for {{username}}", resetPasswordFor: "Reset password for {{username}}",
resetPasswordConfirm: "Reset password?", resetPasswordConfirm: "Reset password?",
resetPasswordConfirmText: resetPasswordConfirmText:

View file

@ -67,3 +67,12 @@
.kc-temporaryPassword { .kc-temporaryPassword {
margin: 6px 0 10px 35px; margin: 6px 0 10px 35px;
} }
.kc-resetCredentialBtn-header {
float: right;
margin: 20px 40px 0 0;
}
tr.kc-table-header th {
padding-top: 0px !important;
}

View file

@ -22,6 +22,7 @@ export const CredentialDataDialog = ({
<Modal <Modal
variant={ModalVariant.medium} variant={ModalVariant.medium}
title={t("passwordDataTitle")} title={t("passwordDataTitle")}
data-testid="passwordDataDialog"
isOpen isOpen
onClose={onClose} onClose={onClose}
> >

View file

@ -61,25 +61,25 @@ export const InlineLabelEdit = ({
<> <>
<TextInput <TextInput
name="userLabel" name="userLabel"
data-testid="userLabelFld"
defaultValue={credential.userLabel} defaultValue={credential.userLabel}
ref={register()} ref={register()}
type="text" type="text"
className="kc-userLabel" className="kc-userLabel"
aria-label={t("userLabel")} aria-label={t("userLabel")}
data-testid="user-label-fld"
/> />
<div className="kc-userLabel-actionBtns"> <div className="kc-userLabel-actionBtns">
<Button <Button
data-testid="editUserLabel-acceptBtn" data-testid="editUserLabelAcceptBtn"
variant="link" variant="link"
className="kc-editUserLabel-acceptBtn" className="kc-editUserLabelAcceptBtn"
onClick={() => { onClick={() => {
handleSubmit(saveUserLabel)(); handleSubmit(saveUserLabel)();
}} }}
icon={<CheckIcon />} icon={<CheckIcon />}
/> />
<Button <Button
data-testid="editUserLabel-cancelBtn" data-testid="editUserLabelCancelBtn"
variant="link" variant="link"
className="kc-editUserLabel-cancelBtn" className="kc-editUserLabel-cancelBtn"
onClick={toggle} onClick={toggle}