fixing UXD review issue (#529)

* fixing UXD review issue

fixing: #509

* added detail expandable sections
This commit is contained in:
Erik Jan de Wit 2021-04-20 14:28:21 +02:00 committed by GitHub
parent b86db32ba8
commit 9e5104b668
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 187 additions and 50 deletions

View file

@ -1,4 +1,4 @@
import React, { ReactNode, useEffect, useState } from "react"; import React, { isValidElement, ReactNode, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useErrorHandler } from "react-error-boundary"; import { useErrorHandler } from "react-error-boundary";
import { import {
@ -20,21 +20,33 @@ import { asyncStateFetch } from "../../context/auth/AdminClient";
import { ListEmptyState } from "../list-empty-state/ListEmptyState"; import { ListEmptyState } from "../list-empty-state/ListEmptyState";
import { SVGIconProps } from "@patternfly/react-icons/dist/js/createIcon"; import { SVGIconProps } from "@patternfly/react-icons/dist/js/createIcon";
type Row<T> = { type TitleCell = { title: JSX.Element };
type Cell<T> = keyof T | JSX.Element | TitleCell;
type BaseRow<T> = {
data: T; data: T;
cells: Cell<T>[];
};
type Row<T> = BaseRow<T> & {
selected: boolean; selected: boolean;
isOpen: boolean;
disableSelection: boolean; disableSelection: boolean;
disableActions: boolean; disableActions: boolean;
cells: (keyof T | JSX.Element)[]; };
type SubRow<T> = BaseRow<T> & {
parent: number;
}; };
type DataTableProps<T> = { type DataTableProps<T> = {
ariaLabelKey: string; ariaLabelKey: string;
columns: Field<T>[]; columns: Field<T>[];
rows: Row<T>[]; rows: (Row<T> | SubRow<T>)[];
actions?: IActions; actions?: IActions;
actionResolver?: IActionsResolver; actionResolver?: IActionsResolver;
onSelect?: (isSelected: boolean, rowIndex: number) => void; onSelect?: (isSelected: boolean, rowIndex: number) => void;
onCollapse?: (isOpen: boolean, rowIndex: number) => void;
canSelectAll: boolean; canSelectAll: boolean;
}; };
@ -45,6 +57,7 @@ function DataTable<T>({
actionResolver, actionResolver,
ariaLabelKey, ariaLabelKey,
onSelect, onSelect,
onCollapse,
canSelectAll, canSelectAll,
...props ...props
}: DataTableProps<T>) { }: DataTableProps<T>) {
@ -58,6 +71,11 @@ function DataTable<T>({
? (_, isSelected, rowIndex) => onSelect(isSelected, rowIndex) ? (_, isSelected, rowIndex) => onSelect(isSelected, rowIndex)
: undefined : undefined
} }
onCollapse={
onCollapse
? (_, rowIndex, isOpen) => onCollapse(isOpen, rowIndex)
: undefined
}
canSelectAll={canSelectAll} canSelectAll={canSelectAll}
cells={columns.map((column) => { cells={columns.map((column) => {
return { ...column, title: t(column.displayKey || column.name) }; return { ...column, title: t(column.displayKey || column.name) };
@ -81,6 +99,12 @@ export type Field<T> = {
cellRenderer?: (row: T) => ReactNode; cellRenderer?: (row: T) => ReactNode;
}; };
export type DetailField<T> = {
name: string;
enabled?: (row: T) => boolean;
cellRenderer?: (row: T) => ReactNode;
};
export type Action<T> = IAction & { export type Action<T> = IAction & {
onRowClick?: (row: T) => Promise<boolean> | void; onRowClick?: (row: T) => Promise<boolean> | void;
}; };
@ -89,6 +113,7 @@ export type DataListProps<T> = {
loader: (first?: number, max?: number, search?: string) => Promise<T[]>; loader: (first?: number, max?: number, search?: string) => Promise<T[]>;
onSelect?: (value: T[]) => void; onSelect?: (value: T[]) => void;
canSelectAll?: boolean; canSelectAll?: boolean;
detailColumns?: DetailField<T>[];
isRowDisabled?: (value: T) => boolean; isRowDisabled?: (value: T) => boolean;
isPaginated?: boolean; isPaginated?: boolean;
ariaLabelKey: string; ariaLabelKey: string;
@ -119,6 +144,7 @@ export type DataListProps<T> = {
* @param {boolean} props.isPaginated - if true the the loader will be called with first, max and search and a pager will be added in the header * @param {boolean} props.isPaginated - if true the the loader will be called with first, max and search and a pager will be added in the header
* @param {(first?: number, max?: number, search?: string) => Promise<T[]>} props.loader - loader function that will fetch the data to display first, max and search are only applicable when isPaginated = true * @param {(first?: number, max?: number, search?: string) => Promise<T[]>} props.loader - loader function that will fetch the data to display first, max and search are only applicable when isPaginated = true
* @param {Field<T>} props.columns - definition of the columns * @param {Field<T>} props.columns - definition of the columns
* @param {Field<T>} props.detailColumns - definition of the columns expandable columns
* @param {Action[]} props.actions - the actions that appear on the row * @param {Action[]} props.actions - the actions that appear on the row
* @param {IActionsResolver} props.actionResolver Resolver for the given action * @param {IActionsResolver} props.actionResolver Resolver for the given action
* @param {ReactNode} props.toolbarItem - Toolbar items that appear on the top of the table {@link ToolbarItem} * @param {ReactNode} props.toolbarItem - Toolbar items that appear on the top of the table {@link ToolbarItem}
@ -130,6 +156,7 @@ export function KeycloakDataTable<T>({
isPaginated = false, isPaginated = false,
onSelect, onSelect,
canSelectAll = false, canSelectAll = false,
detailColumns,
isRowDisabled, isRowDisabled,
loader, loader,
columns, columns,
@ -143,9 +170,9 @@ export function KeycloakDataTable<T>({
}: DataListProps<T>) { }: DataListProps<T>) {
const { t } = useTranslation(); const { t } = useTranslation();
const [selected, setSelected] = useState<T[]>([]); const [selected, setSelected] = useState<T[]>([]);
const [rows, setRows] = useState<Row<T>[]>(); const [rows, setRows] = useState<(Row<T> | SubRow<T>)[]>();
const [unPaginatedData, setUnPaginatedData] = useState<T[]>(); const [unPaginatedData, setUnPaginatedData] = useState<T[]>();
const [filteredData, setFilteredData] = useState<Row<T>[]>(); const [filteredData, setFilteredData] = useState<(Row<T> | SubRow<T>)[]>();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [max, setMax] = useState(10); const [max, setMax] = useState(10);
@ -194,7 +221,7 @@ export function KeycloakDataTable<T>({
); );
}, [key, first, max, search]); }, [key, first, max, search]);
const getNodeText = (node: keyof T | JSX.Element): string => { const getNodeText = (node: Cell<T>): string => {
if (["string", "number"].includes(typeof node)) { if (["string", "number"].includes(typeof node)) {
return node!.toString(); return node!.toString();
} }
@ -202,27 +229,55 @@ export function KeycloakDataTable<T>({
return node.map(getNodeText).join(""); return node.map(getNodeText).join("");
} }
if (typeof node === "object" && node) { if (typeof node === "object" && node) {
return getNodeText(node.props.children); return getNodeText(
isValidElement((node as TitleCell).title)
? (node as TitleCell).title.props.children
: (node as JSX.Element).props.children
);
} }
return ""; return "";
}; };
const convertToColumns = (data: T[]) => { const convertToColumns = (data: T[]): (Row<T> | SubRow<T>)[] => {
return data!.map((value) => { return data!
.map((value, index) => {
const disabledRow = isRowDisabled ? isRowDisabled(value) : false; const disabledRow = isRowDisabled ? isRowDisabled(value) : false;
return { const row: (Row<T> | SubRow<T>)[] = [
{
data: value, data: value,
disableSelection: disabledRow, disableSelection: disabledRow,
disableActions: disabledRow, disableActions: disabledRow,
selected: !!selected.find((v) => (v as any).id === (value as any).id), selected: !!selected.find(
(v) => _.get(v, "id") === _.get(value, "id")
),
isOpen: false,
cells: columns.map((col) => { cells: columns.map((col) => {
if (col.cellRenderer) { if (col.cellRenderer) {
return col.cellRenderer(value); return { title: col.cellRenderer(value) };
} }
return _.get(value, col.name); return _.get(value, col.name);
}), }),
}; },
}); ];
if (
detailColumns &&
detailColumns[0] &&
detailColumns[0].enabled &&
detailColumns[0].enabled(value)
) {
row.push({
parent: index * 2,
cells: detailColumns!.map((col) => {
if (col.cellRenderer) {
return { title: col.cellRenderer(value) };
}
return _.get(value, col.name);
}),
} as SubRow<T>);
}
return row;
})
.flat();
}; };
const filter = (search: string) => { const filter = (search: string) => {
@ -247,6 +302,9 @@ export function KeycloakDataTable<T>({
(filteredData || rows)![rowIndex].data (filteredData || rows)![rowIndex].data
); );
if (result) { if (result) {
if (!isPaginated) {
setFilteredData(undefined);
}
refresh(); refresh();
} }
}; };
@ -264,12 +322,12 @@ export function KeycloakDataTable<T>({
if (rowIndex === -1) { if (rowIndex === -1) {
setRows( setRows(
data!.map((row) => { data!.map((row) => {
row.selected = isSelected; (row as Row<T>).selected = isSelected;
return row; return row;
}) })
); );
} else { } else {
data![rowIndex].selected = isSelected; (data![rowIndex] as Row<T>).selected = isSelected;
setRows([...rows!]); setRows([...rows!]);
} }
@ -284,13 +342,19 @@ export function KeycloakDataTable<T>({
// Selected rows are any rows previously selected from a different page, plus current page selections // Selected rows are any rows previously selected from a different page, plus current page selections
const selectedRows = [ const selectedRows = [
...difference, ...difference,
...data!.filter((row) => row.selected).map((row) => row.data), ...data!.filter((row) => (row as Row<T>).selected).map((row) => row.data),
]; ];
setSelected(selectedRows); setSelected(selectedRows);
onSelect!(selectedRows); onSelect!(selectedRows);
}; };
const onCollapse = (isOpen: boolean, rowIndex: number) => {
const data = filteredData || rows;
(data![rowIndex] as Row<T>).isOpen = isOpen;
setRows([...data!]);
};
return ( return (
<> <>
{rows && ( {rows && (
@ -319,6 +383,7 @@ export function KeycloakDataTable<T>({
{...props} {...props}
canSelectAll={canSelectAll} canSelectAll={canSelectAll}
onSelect={onSelect ? _onSelect : undefined} onSelect={onSelect ? _onSelect : undefined}
onCollapse={detailColumns ? onCollapse : undefined}
actions={convertAction()} actions={convertAction()}
actionResolver={actionResolver} actionResolver={actionResolver}
rows={filteredData || rows} rows={filteredData || rows}

View file

@ -1,4 +1,4 @@
import React, { ReactElement, useContext, useState } from "react"; import React, { ReactElement, ReactNode, useContext, useState } from "react";
import { import {
Text, Text,
PageSection, PageSection,
@ -27,7 +27,7 @@ export type ViewHeaderProps = {
badge?: string; badge?: string;
badgeId?: string; badgeId?: string;
badgeIsRead?: boolean; badgeIsRead?: boolean;
subKey: string; subKey: string | ReactNode;
actionsDropdownId?: string; actionsDropdownId?: string;
subKeyLinkProps?: FormattedLinkProps; subKeyLinkProps?: FormattedLinkProps;
dropdownItems?: ReactElement[]; dropdownItems?: ReactElement[];
@ -133,7 +133,7 @@ export const ViewHeader = ({
{enabled && ( {enabled && (
<TextContent id="view-header-subkey"> <TextContent id="view-header-subkey">
<Text> <Text>
{t(subKey)} {React.isValidElement(subKey) ? subKey : t(subKey as string)}
{subKeyLinkProps && ( {subKeyLinkProps && (
<FormattedLink <FormattedLink
{...subKeyLinkProps} {...subKeyLinkProps}

View file

@ -160,7 +160,7 @@ export const AdminEvents = () => {
{ {
name: "time", name: "time",
displayKey: "events:time", displayKey: "events:time",
cellRenderer: (row) => moment(row.time).fromNow(), cellRenderer: (row) => moment(row.time).format("LLL"),
}, },
{ {
name: "resourcePath", name: "resourcePath",

View file

@ -1,32 +1,40 @@
import React, { useContext, useState } from "react"; import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import moment from "moment";
import { import {
Button, Button,
Label, DescriptionList,
DescriptionListDescription,
DescriptionListGroup,
DescriptionListTerm,
PageSection, PageSection,
Tab, Tab,
TabTitleText, TabTitleText,
ToolbarItem, ToolbarItem,
Tooltip,
} from "@patternfly/react-core"; } from "@patternfly/react-core";
import moment from "moment"; import { cellWidth, expandable } from "@patternfly/react-table";
import { CheckCircleIcon, WarningTriangleIcon } from "@patternfly/react-icons";
import EventRepresentation from "keycloak-admin/lib/defs/eventRepresentation"; import EventRepresentation from "keycloak-admin/lib/defs/eventRepresentation";
import { useAdminClient } from "../context/auth/AdminClient"; import { useAdminClient } from "../context/auth/AdminClient";
import { ViewHeader } from "../components/view-header/ViewHeader"; import { ViewHeader } from "../components/view-header/ViewHeader";
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable"; import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import { RealmContext } from "../context/realm-context/RealmContext"; import { RealmContext } from "../context/realm-context/RealmContext";
import { InfoCircleIcon } from "@patternfly/react-icons";
import { AdminEvents } from "./AdminEvents"; import { AdminEvents } from "./AdminEvents";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState"; import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
import { KeycloakTabs } from "../components/keycloak-tabs/KeycloakTabs"; import { KeycloakTabs } from "../components/keycloak-tabs/KeycloakTabs";
import "./events-section.css";
export const EventsSection = () => { export const EventsSection = () => {
const { t } = useTranslation("events"); const { t } = useTranslation("events");
const adminClient = useAdminClient(); const adminClient = useAdminClient();
const { realm } = useContext(RealmContext); const { realm } = useContext(RealmContext);
const [key, setKey] = useState(""); const [key, setKey] = useState(0);
const refresh = () => setKey(`${new Date().getTime()}`); const refresh = () => setKey(new Date().getTime());
const loader = async (first?: number, max?: number, search?: string) => { const loader = async (first?: number, max?: number, search?: string) => {
const params = { const params = {
@ -40,21 +48,72 @@ export const EventsSection = () => {
return await adminClient.realms.findEvents({ ...params }); return await adminClient.realms.findEvents({ ...params });
}; };
const StatusRow = (event: EventRepresentation) => { const StatusRow = (event: EventRepresentation) => (
return (
<> <>
<Label color="red" icon={<InfoCircleIcon />}> {!event.error && (
<span key={`status-${event.time}-${event.type}`}>
<CheckCircleIcon
color="green"
key={`circle-${event.time}-${event.type}`}
/>{" "}
{event.type} {event.type}
</Label> </span>
)}
{event.error && (
<Tooltip
content={event.error}
key={`tooltip-${event.time}-${event.type}`}
>
<span key={`label-${event.time}-${event.type}`}>
<WarningTriangleIcon
color="orange"
key={`triangle-${event.time}-${event.type}`}
/>{" "}
{event.type}
</span>
</Tooltip>
)}
</>
);
const UserDetailLink = (event: EventRepresentation) => (
<>
<Link
key={`link-${event.time}-${event.type}`}
to={`/${realm}/users/${event.userId}/details`}
>
{event.userId}
</Link>
</>
);
const DetailCell = (event: EventRepresentation) => (
<>
<DescriptionList isHorizontal className="keycloak_eventsection_details">
{Object.keys(event.details!).map((k) => (
<DescriptionListGroup key={`detail-${event.time}-${event.type}`}>
<DescriptionListTerm>{k}</DescriptionListTerm>
<DescriptionListDescription>
{event.details![k]}
</DescriptionListDescription>
</DescriptionListGroup>
))}
</DescriptionList>
</> </>
); );
};
return ( return (
<> <>
<ViewHeader <ViewHeader
titleKey="events:title" titleKey="events:title"
subKey="events:eventExplain" subKey={
<Trans i18nKey="events:eventExplain">
If you want to configure user events, Admin events or Event
listeners, please enter
<Link to={`/${realm}/`}>{t("eventConfig")}</Link>
page realm settings to configure.
</Trans>
}
divider={false} divider={false}
/> />
<PageSection variant="light" className="pf-u-p-0"> <PageSection variant="light" className="pf-u-p-0">
@ -66,6 +125,13 @@ export const EventsSection = () => {
<KeycloakDataTable <KeycloakDataTable
key={key} key={key}
loader={loader} loader={loader}
detailColumns={[
{
name: "details",
enabled: (event) => event.details !== undefined,
cellRenderer: DetailCell,
},
]}
isPaginated isPaginated
ariaLabelKey="events:title" ariaLabelKey="events:title"
searchPlaceholderKey="events:searchForEvent" searchPlaceholderKey="events:searchForEvent"
@ -80,11 +146,13 @@ export const EventsSection = () => {
{ {
name: "time", name: "time",
displayKey: "events:time", displayKey: "events:time",
cellRenderer: (row) => moment(row.time).fromNow(), cellRenderer: (row) => moment(row.time).format("LLL"),
cellFormatters: [expandable],
}, },
{ {
name: "userId", name: "userId",
displayKey: "events:user", displayKey: "events:user",
cellRenderer: UserDetailLink,
}, },
{ {
name: "type", name: "type",
@ -94,6 +162,7 @@ export const EventsSection = () => {
{ {
name: "ipAddress", name: "ipAddress",
displayKey: "events:ipAddress", displayKey: "events:ipAddress",
transforms: [cellWidth(10)],
}, },
{ {
name: "clientId", name: "clientId",

View file

@ -0,0 +1,4 @@
.keycloak_eventsection_details {
--pf-c-description-list--m-horizontal__term--width: 15ch;
}

View file

@ -1,7 +1,8 @@
{ {
"events": { "events": {
"title": "Events", "title": "Events",
"eventExplain": "If you want to configure user events, Admin events or Event listeners, please enter Event configs page realm settings to configure.", "eventExplain": "If you want to configure user events, Admin events or Event listeners, please enter <1>Event configs</1> page realm settings to configure.",
"eventConfigs": "Event configs",
"userEvents": "User events", "userEvents": "User events",
"adminEvents": "Admin events", "adminEvents": "Admin events",
"searchForEvent": "Search user event", "searchForEvent": "Search user event",

View file

@ -7,7 +7,6 @@ import { useAdminClient } from "../context/auth/AdminClient";
import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation"; import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation";
import { ListEmptyState } from "../components/list-empty-state/ListEmptyState"; import { ListEmptyState } from "../components/list-empty-state/ListEmptyState";
import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable"; import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable";
import { formattedLinkTableCell } from "../components/external-link/FormattedLink";
import { useAlerts } from "../components/alert/Alerts"; import { useAlerts } from "../components/alert/Alerts";
import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog";
import { emptyFormatter, boolFormatter } from "../util"; import { emptyFormatter, boolFormatter } from "../util";
@ -108,7 +107,6 @@ export const RolesList = ({
name: "name", name: "name",
displayKey: "roles:roleName", displayKey: "roles:roleName",
cellRenderer: RoleDetailLink, cellRenderer: RoleDetailLink,
cellFormatters: [formattedLinkTableCell(), emptyFormatter()],
}, },
{ {
name: "composite", name: "composite",