diff --git a/src/components/table-toolbar/KeycloakDataTable.tsx b/src/components/table-toolbar/KeycloakDataTable.tsx index d47777f57d..d1a25f2419 100644 --- a/src/components/table-toolbar/KeycloakDataTable.tsx +++ b/src/components/table-toolbar/KeycloakDataTable.tsx @@ -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 { useErrorHandler } from "react-error-boundary"; import { @@ -20,21 +20,33 @@ import { asyncStateFetch } from "../../context/auth/AdminClient"; import { ListEmptyState } from "../list-empty-state/ListEmptyState"; import { SVGIconProps } from "@patternfly/react-icons/dist/js/createIcon"; -type Row = { +type TitleCell = { title: JSX.Element }; +type Cell = keyof T | JSX.Element | TitleCell; + +type BaseRow = { data: T; + cells: Cell[]; +}; + +type Row = BaseRow & { selected: boolean; + isOpen: boolean; disableSelection: boolean; disableActions: boolean; - cells: (keyof T | JSX.Element)[]; +}; + +type SubRow = BaseRow & { + parent: number; }; type DataTableProps = { ariaLabelKey: string; columns: Field[]; - rows: Row[]; + rows: (Row | SubRow)[]; actions?: IActions; actionResolver?: IActionsResolver; onSelect?: (isSelected: boolean, rowIndex: number) => void; + onCollapse?: (isOpen: boolean, rowIndex: number) => void; canSelectAll: boolean; }; @@ -45,6 +57,7 @@ function DataTable({ actionResolver, ariaLabelKey, onSelect, + onCollapse, canSelectAll, ...props }: DataTableProps) { @@ -58,6 +71,11 @@ function DataTable({ ? (_, isSelected, rowIndex) => onSelect(isSelected, rowIndex) : undefined } + onCollapse={ + onCollapse + ? (_, rowIndex, isOpen) => onCollapse(isOpen, rowIndex) + : undefined + } canSelectAll={canSelectAll} cells={columns.map((column) => { return { ...column, title: t(column.displayKey || column.name) }; @@ -81,6 +99,12 @@ export type Field = { cellRenderer?: (row: T) => ReactNode; }; +export type DetailField = { + name: string; + enabled?: (row: T) => boolean; + cellRenderer?: (row: T) => ReactNode; +}; + export type Action = IAction & { onRowClick?: (row: T) => Promise | void; }; @@ -89,6 +113,7 @@ export type DataListProps = { loader: (first?: number, max?: number, search?: string) => Promise; onSelect?: (value: T[]) => void; canSelectAll?: boolean; + detailColumns?: DetailField[]; isRowDisabled?: (value: T) => boolean; isPaginated?: boolean; ariaLabelKey: string; @@ -119,6 +144,7 @@ export type DataListProps = { * @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} props.loader - loader function that will fetch the data to display first, max and search are only applicable when isPaginated = true * @param {Field} props.columns - definition of the columns + * @param {Field} props.detailColumns - definition of the columns expandable columns * @param {Action[]} props.actions - the actions that appear on the row * @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} @@ -130,6 +156,7 @@ export function KeycloakDataTable({ isPaginated = false, onSelect, canSelectAll = false, + detailColumns, isRowDisabled, loader, columns, @@ -143,9 +170,9 @@ export function KeycloakDataTable({ }: DataListProps) { const { t } = useTranslation(); const [selected, setSelected] = useState([]); - const [rows, setRows] = useState[]>(); + const [rows, setRows] = useState<(Row | SubRow)[]>(); const [unPaginatedData, setUnPaginatedData] = useState(); - const [filteredData, setFilteredData] = useState[]>(); + const [filteredData, setFilteredData] = useState<(Row | SubRow)[]>(); const [loading, setLoading] = useState(false); const [max, setMax] = useState(10); @@ -194,7 +221,7 @@ export function KeycloakDataTable({ ); }, [key, first, max, search]); - const getNodeText = (node: keyof T | JSX.Element): string => { + const getNodeText = (node: Cell): string => { if (["string", "number"].includes(typeof node)) { return node!.toString(); } @@ -202,27 +229,55 @@ export function KeycloakDataTable({ return node.map(getNodeText).join(""); } 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 ""; }; - const convertToColumns = (data: T[]) => { - return data!.map((value) => { - const disabledRow = isRowDisabled ? isRowDisabled(value) : false; - return { - data: value, - disableSelection: disabledRow, - disableActions: disabledRow, - selected: !!selected.find((v) => (v as any).id === (value as any).id), - cells: columns.map((col) => { - if (col.cellRenderer) { - return col.cellRenderer(value); - } - return _.get(value, col.name); - }), - }; - }); + const convertToColumns = (data: T[]): (Row | SubRow)[] => { + return data! + .map((value, index) => { + const disabledRow = isRowDisabled ? isRowDisabled(value) : false; + const row: (Row | SubRow)[] = [ + { + data: value, + disableSelection: disabledRow, + disableActions: disabledRow, + selected: !!selected.find( + (v) => _.get(v, "id") === _.get(value, "id") + ), + isOpen: false, + cells: columns.map((col) => { + if (col.cellRenderer) { + return { title: col.cellRenderer(value) }; + } + 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); + } + return row; + }) + .flat(); }; const filter = (search: string) => { @@ -247,6 +302,9 @@ export function KeycloakDataTable({ (filteredData || rows)![rowIndex].data ); if (result) { + if (!isPaginated) { + setFilteredData(undefined); + } refresh(); } }; @@ -264,12 +322,12 @@ export function KeycloakDataTable({ if (rowIndex === -1) { setRows( data!.map((row) => { - row.selected = isSelected; + (row as Row).selected = isSelected; return row; }) ); } else { - data![rowIndex].selected = isSelected; + (data![rowIndex] as Row).selected = isSelected; setRows([...rows!]); } @@ -284,13 +342,19 @@ export function KeycloakDataTable({ // Selected rows are any rows previously selected from a different page, plus current page selections const selectedRows = [ ...difference, - ...data!.filter((row) => row.selected).map((row) => row.data), + ...data!.filter((row) => (row as Row).selected).map((row) => row.data), ]; setSelected(selectedRows); onSelect!(selectedRows); }; + const onCollapse = (isOpen: boolean, rowIndex: number) => { + const data = filteredData || rows; + (data![rowIndex] as Row).isOpen = isOpen; + setRows([...data!]); + }; + return ( <> {rows && ( @@ -319,6 +383,7 @@ export function KeycloakDataTable({ {...props} canSelectAll={canSelectAll} onSelect={onSelect ? _onSelect : undefined} + onCollapse={detailColumns ? onCollapse : undefined} actions={convertAction()} actionResolver={actionResolver} rows={filteredData || rows} diff --git a/src/components/view-header/ViewHeader.tsx b/src/components/view-header/ViewHeader.tsx index 6167a86ece..2d06b33fa0 100644 --- a/src/components/view-header/ViewHeader.tsx +++ b/src/components/view-header/ViewHeader.tsx @@ -1,4 +1,4 @@ -import React, { ReactElement, useContext, useState } from "react"; +import React, { ReactElement, ReactNode, useContext, useState } from "react"; import { Text, PageSection, @@ -27,7 +27,7 @@ export type ViewHeaderProps = { badge?: string; badgeId?: string; badgeIsRead?: boolean; - subKey: string; + subKey: string | ReactNode; actionsDropdownId?: string; subKeyLinkProps?: FormattedLinkProps; dropdownItems?: ReactElement[]; @@ -133,7 +133,7 @@ export const ViewHeader = ({ {enabled && ( - {t(subKey)} + {React.isValidElement(subKey) ? subKey : t(subKey as string)} {subKeyLinkProps && ( { { name: "time", displayKey: "events:time", - cellRenderer: (row) => moment(row.time).fromNow(), + cellRenderer: (row) => moment(row.time).format("LLL"), }, { name: "resourcePath", diff --git a/src/events/EventsSection.tsx b/src/events/EventsSection.tsx index 30bd8ee1a7..98797a8c59 100644 --- a/src/events/EventsSection.tsx +++ b/src/events/EventsSection.tsx @@ -1,32 +1,40 @@ 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 { Button, - Label, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, PageSection, Tab, TabTitleText, ToolbarItem, + Tooltip, } 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 { useAdminClient } from "../context/auth/AdminClient"; import { ViewHeader } from "../components/view-header/ViewHeader"; import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable"; import { RealmContext } from "../context/realm-context/RealmContext"; -import { InfoCircleIcon } from "@patternfly/react-icons"; import { AdminEvents } from "./AdminEvents"; import { ListEmptyState } from "../components/list-empty-state/ListEmptyState"; import { KeycloakTabs } from "../components/keycloak-tabs/KeycloakTabs"; +import "./events-section.css"; + export const EventsSection = () => { const { t } = useTranslation("events"); const adminClient = useAdminClient(); const { realm } = useContext(RealmContext); - const [key, setKey] = useState(""); - const refresh = () => setKey(`${new Date().getTime()}`); + const [key, setKey] = useState(0); + const refresh = () => setKey(new Date().getTime()); const loader = async (first?: number, max?: number, search?: string) => { const params = { @@ -40,21 +48,72 @@ export const EventsSection = () => { return await adminClient.realms.findEvents({ ...params }); }; - const StatusRow = (event: EventRepresentation) => { - return ( - <> - - - ); - }; + + )} + {event.error && ( + + + {" "} + {event.type} + + + )} + + ); + + const UserDetailLink = (event: EventRepresentation) => ( + <> + + {event.userId} + + + ); + + const DetailCell = (event: EventRepresentation) => ( + <> + + {Object.keys(event.details!).map((k) => ( + + {k} + + {event.details![k]} + + + ))} + + + ); return ( <> + If you want to configure user events, Admin events or Event + listeners, please enter + {t("eventConfig")} + page realm settings to configure. + + } divider={false} /> @@ -66,6 +125,13 @@ export const EventsSection = () => { event.details !== undefined, + cellRenderer: DetailCell, + }, + ]} isPaginated ariaLabelKey="events:title" searchPlaceholderKey="events:searchForEvent" @@ -80,11 +146,13 @@ export const EventsSection = () => { { name: "time", displayKey: "events:time", - cellRenderer: (row) => moment(row.time).fromNow(), + cellRenderer: (row) => moment(row.time).format("LLL"), + cellFormatters: [expandable], }, { name: "userId", displayKey: "events:user", + cellRenderer: UserDetailLink, }, { name: "type", @@ -94,6 +162,7 @@ export const EventsSection = () => { { name: "ipAddress", displayKey: "events:ipAddress", + transforms: [cellWidth(10)], }, { name: "clientId", diff --git a/src/events/events-section.css b/src/events/events-section.css new file mode 100644 index 0000000000..fffe815c7d --- /dev/null +++ b/src/events/events-section.css @@ -0,0 +1,4 @@ + +.keycloak_eventsection_details { + --pf-c-description-list--m-horizontal__term--width: 15ch; +} \ No newline at end of file diff --git a/src/events/messages.json b/src/events/messages.json index 6e2d3ae11b..0a9519636d 100644 --- a/src/events/messages.json +++ b/src/events/messages.json @@ -1,7 +1,8 @@ { "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 page realm settings to configure.", + "eventConfigs": "Event configs", "userEvents": "User events", "adminEvents": "Admin events", "searchForEvent": "Search user event", diff --git a/src/realm-roles/RolesList.tsx b/src/realm-roles/RolesList.tsx index 91fb744a4f..44ddd45e25 100644 --- a/src/realm-roles/RolesList.tsx +++ b/src/realm-roles/RolesList.tsx @@ -7,7 +7,6 @@ import { useAdminClient } from "../context/auth/AdminClient"; import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation"; import { ListEmptyState } from "../components/list-empty-state/ListEmptyState"; import { KeycloakDataTable } from "../components/table-toolbar/KeycloakDataTable"; -import { formattedLinkTableCell } from "../components/external-link/FormattedLink"; import { useAlerts } from "../components/alert/Alerts"; import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; import { emptyFormatter, boolFormatter } from "../util"; @@ -108,7 +107,6 @@ export const RolesList = ({ name: "name", displayKey: "roles:roleName", cellRenderer: RoleDetailLink, - cellFormatters: [formattedLinkTableCell(), emptyFormatter()], }, { name: "composite",