e370859690
* WIP modal * modal WIP add modal place modal in separate file format wip implementation getCompositeRoles with Jeff add associated roles tab WIP addComposites function WIP fix post call additional roles fetch big rebase WIP refresh resolve conflicts with Erik latest -> fixes role creation cypress tests, bump react-hook-form to remove console warnings delete add refresh with Jeff, update cypress tests, select additional roles tab on add make dropdownId optional format add additionalRolesModal to associated roles tab add toolbar items add toolbaritems to associated role tab, matches mock rebase add descriptions to alert add badge fix badge logic fix URL when associate roles are deleted, format update cypress test format add associated roles refresh, PR feedback from Erik add associated roles refresh, PR feedback from Erik lint WIP modal with client roles add clients to modal WIP label labels WIP promises wip wip add clients to modal with labels modal with Clients format * rebase * fix check types * PR feedback from Erik
310 lines
9.1 KiB
TypeScript
310 lines
9.1 KiB
TypeScript
import React, { ReactNode, useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useErrorHandler } from "react-error-boundary";
|
|
import {
|
|
IAction,
|
|
IActions,
|
|
IActionsResolver,
|
|
IFormatter,
|
|
Table,
|
|
TableBody,
|
|
TableHeader,
|
|
TableVariant,
|
|
} from "@patternfly/react-table";
|
|
import { Spinner } from "@patternfly/react-core";
|
|
import _ from "lodash";
|
|
|
|
import { PaginatingTableToolbar } from "./PaginatingTableToolbar";
|
|
import { TableToolbar } from "./TableToolbar";
|
|
import { asyncStateFetch } from "../../context/auth/AdminClient";
|
|
|
|
type Row<T> = {
|
|
data: T;
|
|
selected: boolean;
|
|
cells: (keyof T | JSX.Element)[];
|
|
};
|
|
|
|
type DataTableProps<T> = {
|
|
ariaLabelKey: string;
|
|
columns: Field<T>[];
|
|
rows: Row<T>[];
|
|
actions?: IActions;
|
|
actionResolver?: IActionsResolver;
|
|
onSelect?: (isSelected: boolean, rowIndex: number) => void;
|
|
canSelectAll: boolean;
|
|
};
|
|
|
|
function DataTable<T>({
|
|
columns,
|
|
rows,
|
|
actions,
|
|
actionResolver,
|
|
ariaLabelKey,
|
|
onSelect,
|
|
canSelectAll,
|
|
}: DataTableProps<T>) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<Table
|
|
variant={TableVariant.compact}
|
|
onSelect={
|
|
onSelect
|
|
? (_, isSelected, rowIndex) => onSelect(isSelected, rowIndex)
|
|
: undefined
|
|
}
|
|
canSelectAll={canSelectAll}
|
|
cells={columns.map((column) => {
|
|
return { ...column, title: t(column.displayKey || column.name) };
|
|
})}
|
|
rows={rows}
|
|
actions={actions}
|
|
actionResolver={actionResolver}
|
|
aria-label={t(ariaLabelKey)}
|
|
>
|
|
<TableHeader />
|
|
<TableBody />
|
|
</Table>
|
|
);
|
|
}
|
|
|
|
export type Field<T> = {
|
|
name: string;
|
|
displayKey?: string;
|
|
cellFormatters?: IFormatter[];
|
|
cellRenderer?: (row: T) => ReactNode;
|
|
};
|
|
|
|
export type Action<T> = IAction & {
|
|
onRowClick?: (row: T) => Promise<boolean> | void;
|
|
};
|
|
|
|
export type DataListProps<T> = {
|
|
loader: (first?: number, max?: number, search?: string) => Promise<T[]>;
|
|
onSelect?: (value: T[]) => void;
|
|
canSelectAll?: boolean;
|
|
isPaginated?: boolean;
|
|
ariaLabelKey: string;
|
|
searchPlaceholderKey: string;
|
|
setRefresher?: (refresher: () => void) => void;
|
|
columns: Field<T>[];
|
|
actions?: Action<T>[];
|
|
actionResolver?: IActionsResolver;
|
|
searchTypeComponent?: ReactNode;
|
|
toolbarItem?: ReactNode;
|
|
emptyState?: ReactNode;
|
|
};
|
|
|
|
/**
|
|
* A generic component that can be used to show the initial list most sections have. Takes care of the loading of the date and filtering.
|
|
* All you have to define is how the columns are displayed.
|
|
* @example
|
|
* <KeycloakDataTable columns={[
|
|
* {
|
|
* name: "clientId", //name of the field from the array of object the loader returns to display in this column
|
|
* displayKey: "clients:clientID", //i18n key to use to lookup the name of the column header
|
|
* cellRenderer: ClientDetailLink, //optionally you can use a component to render the column when you don't want just the content of the field, the whole row / entire object is passed in.
|
|
* }
|
|
* ]}
|
|
* @param {DataListProps} props - The properties.
|
|
* @param {string} props.ariaLabelKey - The aria label key i18n key to lookup the label
|
|
* @param {string} props.searchPlaceholderKey - The i18n key to lookup the placeholder for the search box
|
|
* @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 {Field<T>} props.columns - definition of the 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}
|
|
* @param {ReactNode} props.emptyState - ReactNode show when the list is empty could be any component but best to use {@link ListEmptyState}
|
|
*/
|
|
export function KeycloakDataTable<T>({
|
|
ariaLabelKey,
|
|
searchPlaceholderKey,
|
|
isPaginated = false,
|
|
onSelect,
|
|
setRefresher,
|
|
canSelectAll = false,
|
|
loader,
|
|
columns,
|
|
actions,
|
|
actionResolver,
|
|
searchTypeComponent,
|
|
toolbarItem,
|
|
emptyState,
|
|
}: DataListProps<T>) {
|
|
const { t } = useTranslation();
|
|
const [rows, setRows] = useState<Row<T>[]>();
|
|
const [filteredData, setFilteredData] = useState<Row<T>[]>();
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const [max, setMax] = useState(10);
|
|
const [first, setFirst] = useState(0);
|
|
const [search, setSearch] = useState("");
|
|
|
|
const [key, setKey] = useState(0);
|
|
const refresh = () => setKey(new Date().getTime());
|
|
const handleError = useErrorHandler();
|
|
|
|
useEffect(() => {
|
|
setRefresher && setRefresher(refresh);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
return asyncStateFetch(
|
|
async () => {
|
|
setLoading(true);
|
|
const data = await loader(first, max, search);
|
|
|
|
const result = data!.map((value) => {
|
|
return {
|
|
data: value,
|
|
selected: false,
|
|
cells: columns.map((col) => {
|
|
if (col.cellRenderer) {
|
|
return col.cellRenderer(value);
|
|
}
|
|
return _.get(value, col.name);
|
|
}),
|
|
};
|
|
});
|
|
return result;
|
|
},
|
|
(result) => {
|
|
setRows(result);
|
|
setFilteredData(result);
|
|
setLoading(false);
|
|
},
|
|
handleError
|
|
);
|
|
}, [key, first, max]);
|
|
|
|
const getNodeText = (node: keyof T | JSX.Element): string => {
|
|
if (["string", "number"].includes(typeof node)) {
|
|
return node!.toString();
|
|
}
|
|
if (node instanceof Array) {
|
|
return node.map(getNodeText).join("");
|
|
}
|
|
if (typeof node === "object" && node) {
|
|
return getNodeText(node.props.children);
|
|
}
|
|
return "";
|
|
};
|
|
|
|
const filter = (search: string) => {
|
|
setFilteredData(
|
|
rows!.filter((row) =>
|
|
row.cells.some(
|
|
(cell) =>
|
|
cell &&
|
|
getNodeText(cell).toLowerCase().includes(search.toLowerCase())
|
|
)
|
|
)
|
|
);
|
|
};
|
|
|
|
const convertAction = () =>
|
|
actions &&
|
|
_.cloneDeep(actions).map((action: Action<T>, index: number) => {
|
|
delete action.onRowClick;
|
|
action.onClick = async (_, rowIndex) => {
|
|
const result = await actions[index].onRowClick!(
|
|
(filteredData || rows)![rowIndex].data
|
|
);
|
|
if (result) {
|
|
refresh();
|
|
}
|
|
};
|
|
return action;
|
|
});
|
|
|
|
const searchOnChange = (value: string) => {
|
|
if (isPaginated) {
|
|
setSearch(value);
|
|
} else {
|
|
filter(value);
|
|
}
|
|
};
|
|
|
|
const Loading = () => (
|
|
<div className="pf-u-text-align-center">
|
|
<Spinner />
|
|
</div>
|
|
);
|
|
|
|
const _onSelect = (isSelected: boolean, rowIndex: number) => {
|
|
if (rowIndex === -1) {
|
|
setRows(
|
|
rows!.map((row) => {
|
|
row.selected = isSelected;
|
|
return row;
|
|
})
|
|
);
|
|
} else {
|
|
rows![rowIndex].selected = isSelected;
|
|
setRows([...rows!]);
|
|
}
|
|
onSelect!(rows!.filter((row) => row.selected).map((row) => row.data));
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{!rows && <Loading />}
|
|
{rows && isPaginated && (
|
|
<PaginatingTableToolbar
|
|
count={rows.length}
|
|
first={first}
|
|
max={max}
|
|
onNextClick={setFirst}
|
|
onPreviousClick={setFirst}
|
|
onPerPageSelect={(first, max) => {
|
|
setFirst(first);
|
|
setMax(max);
|
|
}}
|
|
inputGroupName={`${ariaLabelKey}input`}
|
|
inputGroupOnChange={searchOnChange}
|
|
inputGroupOnClick={refresh}
|
|
inputGroupPlaceholder={t(searchPlaceholderKey)}
|
|
searchTypeComponent={searchTypeComponent}
|
|
toolbarItem={toolbarItem}
|
|
>
|
|
{!loading && (emptyState === undefined || rows.length !== 0) && (
|
|
<DataTable
|
|
canSelectAll={canSelectAll}
|
|
onSelect={onSelect ? _onSelect : undefined}
|
|
actions={convertAction()}
|
|
actionResolver={actionResolver}
|
|
rows={rows}
|
|
columns={columns}
|
|
ariaLabelKey={ariaLabelKey}
|
|
/>
|
|
)}
|
|
{!loading && rows.length === 0 && emptyState}
|
|
{loading && <Loading />}
|
|
</PaginatingTableToolbar>
|
|
)}
|
|
{rows && !isPaginated && (
|
|
<TableToolbar
|
|
inputGroupName={`${ariaLabelKey}input`}
|
|
inputGroupOnChange={searchOnChange}
|
|
inputGroupOnClick={() => {}}
|
|
inputGroupPlaceholder={t(searchPlaceholderKey)}
|
|
toolbarItem={toolbarItem}
|
|
searchTypeComponent={searchTypeComponent}
|
|
>
|
|
{(emptyState === undefined || rows.length !== 0) && (
|
|
<DataTable
|
|
canSelectAll={canSelectAll}
|
|
onSelect={onSelect ? _onSelect : undefined}
|
|
actions={convertAction()}
|
|
actionResolver={actionResolver}
|
|
rows={filteredData || rows}
|
|
columns={columns}
|
|
ariaLabelKey={ariaLabelKey}
|
|
/>
|
|
)}
|
|
{rows.length === 0 && emptyState}
|
|
</TableToolbar>
|
|
)}
|
|
</>
|
|
);
|
|
}
|