changed to use the keycloak datatable (#432)

* changed to use the keycloak datatable

so it will benifid from a general way tables work in the admin console

* null check

* changed to use any active tab

seems sometimes the tab doesn't get set properly
so instead of "mappers" it's still "settings"
this change makes it work with both
This commit is contained in:
Erik Jan de Wit 2021-03-19 13:43:32 +01:00 committed by GitHub
parent b56788d942
commit dfc4beced4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 345 additions and 465 deletions

View file

@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useHistory, useRouteMatch } from "react-router-dom";
import {
@ -8,12 +8,6 @@ import {
DropdownItem,
DropdownToggle,
} from "@patternfly/react-core";
import {
Table,
TableBody,
TableHeader,
TableVariant,
} from "@patternfly/react-table";
import { CaretDownIcon } from "@patternfly/react-icons";
import ClientScopeRepresentation from "keycloak-admin/lib/defs/clientScopeRepresentation";
@ -21,19 +15,18 @@ import ProtocolMapperRepresentation from "keycloak-admin/lib/defs/protocolMapper
import { ProtocolMapperTypeRepresentation } from "keycloak-admin/lib/defs/serverInfoRepesentation";
import { useServerInfo } from "../../context/server-info/ServerInfoProvider";
import { TableToolbar } from "../../components/table-toolbar/TableToolbar";
import { ListEmptyState } from "../../components/list-empty-state/ListEmptyState";
import { useAlerts } from "../../components/alert/Alerts";
import { AddMapperDialog } from "../add/MapperDialog";
import { useAdminClient } from "../../context/auth/AdminClient";
import { KeycloakDataTable } from "../../components/table-toolbar/KeycloakDataTable";
type MapperListProps = {
clientScope: ClientScopeRepresentation;
refresh: () => void;
};
type Row = {
name: JSX.Element;
type Row = ProtocolMapperRepresentation & {
category: string;
type: string;
priority: number;
@ -46,15 +39,15 @@ export const MapperList = ({ clientScope, refresh }: MapperListProps) => {
const history = useHistory();
const { url } = useRouteMatch();
const [filteredData, setFilteredData] = useState<
{ mapper: ProtocolMapperRepresentation; cells: Row }[]
>();
const [mapperAction, setMapperAction] = useState(false);
const mapperList = clientScope.protocolMappers!;
const mapperTypes = useServerInfo().protocolMapperTypes![
clientScope.protocol!
];
const [key, setKey] = useState(0);
useEffect(() => setKey(new Date().getTime()), [mapperList]);
const [addMapperDialogOpen, setAddMapperDialogOpen] = useState(false);
const [filter, setFilter] = useState(clientScope.protocolMappers);
const toggleAddMapperDialog = (buildIn: boolean) => {
@ -86,7 +79,29 @@ export const MapperList = ({ clientScope, refresh }: MapperListProps) => {
}
};
if (!mapperList) {
const loader = async () =>
Promise.resolve(
(mapperList || [])
.map((mapper) => {
const mapperType = mapperTypes.filter(
(type) => type.id === mapper.protocolMapper
)[0];
return {
...mapper,
category: mapperType.category,
type: mapperType.name,
priority: mapperType.priority,
} as Row;
})
.sort((a, b) => a.priority - b.priority)
);
const MapperLink = (mapper: Row) => (
<>
<Link to={`${url}/${mapper.id}`}>{mapper.name}</Link>
</>
);
return (
<>
<AddMapperDialog
@ -96,57 +111,12 @@ export const MapperList = ({ clientScope, refresh }: MapperListProps) => {
open={addMapperDialogOpen}
toggleDialog={() => setAddMapperDialogOpen(!addMapperDialogOpen)}
/>
<ListEmptyState
message={t("emptyMappers")}
instructions={t("emptyMappersInstructions")}
primaryActionText={t("emptyPrimaryAction")}
onPrimaryAction={() => toggleAddMapperDialog(true)}
secondaryActions={[
{
text: t("emptySecondaryAction"),
onClick: () => toggleAddMapperDialog(false),
type: ButtonVariant.secondary,
},
]}
/>
</>
);
}
const data = mapperList
.map((mapper) => {
const mapperType = mapperTypes.filter(
(type) => type.id === mapper.protocolMapper
)[0];
return {
mapper,
cells: {
name: (
<>
<Link to={`${url}/${mapper.id}`}>{mapper.name}</Link>
</>
),
category: mapperType.category,
type: mapperType.name,
priority: mapperType.priority,
} as Row,
};
})
.sort((a, b) => a.cells.priority - b.cells.priority);
const filterData = (search: string) => {
setFilteredData(
data.filter((column) =>
column.mapper.name!.toLowerCase().includes(search.toLowerCase())
)
);
};
return (
<TableToolbar
inputGroupName="clientsScopeToolbarTextInput"
inputGroupPlaceholder={t("mappersSearchFor")}
inputGroupOnChange={filterData}
<KeycloakDataTable
key={key}
loader={loader}
ariaLabelKey="client-scopes:clientScopeList"
searchPlaceholderKey="client-scopes:mappersSearchFor"
toolbarItem={
<Dropdown
onSelect={() => setMapperAction(false)}
@ -177,50 +147,56 @@ export const MapperList = ({ clientScope, refresh }: MapperListProps) => {
]}
/>
}
>
<AddMapperDialog
protocol={clientScope.protocol!}
filter={filter}
onConfirm={addMappers}
open={addMapperDialogOpen}
toggleDialog={() => setAddMapperDialogOpen(!addMapperDialogOpen)}
/>
<Table
variant={TableVariant.compact}
cells={[
t("common:name"),
t("common:category"),
t("common:type"),
t("common:priority"),
]}
rows={(filteredData || data).map((cell) => {
return { cells: Object.values(cell.cells), mapper: cell.mapper };
})}
aria-label={t("clientScopeList")}
actions={[
{
title: t("common:delete"),
onClick: async (_, rowId) => {
onRowClick: async (mapper) => {
try {
await adminClient.clientScopes.delProtocolMapper({
id: clientScope.id!,
mapperId: data[rowId].mapper.id!,
mapperId: mapper.id!,
});
refresh();
addAlert(t("mappingDeletedSuccess"), AlertVariant.success);
refresh();
} catch (error) {
addAlert(
t("mappingDeletedError", { error }),
AlertVariant.danger
);
}
return true;
},
},
]}
>
<TableHeader />
<TableBody />
</Table>
</TableToolbar>
columns={[
{
name: "name",
cellRenderer: MapperLink,
},
{ name: "category" },
{
name: "type",
},
{
name: "priority",
},
]}
emptyState={
<ListEmptyState
message={t("emptyMappers")}
instructions={t("emptyMappersInstructions")}
primaryActionText={t("emptyPrimaryAction")}
onPrimaryAction={() => toggleAddMapperDialog(true)}
secondaryActions={[
{
text: t("emptySecondaryAction"),
onClick: () => toggleAddMapperDialog(false),
type: ButtonVariant.secondary,
},
]}
/>
}
/>
</>
);
};

View file

@ -1,14 +1,5 @@
import React, { useState, useEffect } from "react";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { useErrorHandler } from "react-error-boundary";
import {
IFormatter,
IFormatterValueType,
Table,
TableBody,
TableHeader,
TableVariant,
} from "@patternfly/react-table";
import {
AlertVariant,
Button,
@ -17,19 +8,14 @@ import {
DropdownToggle,
KebabToggle,
Select,
Spinner,
ToolbarItem,
} from "@patternfly/react-core";
import { FilterIcon } from "@patternfly/react-icons";
import ClientScopeRepresentation from "keycloak-admin/lib/defs/clientScopeRepresentation";
import KeycloakAdminClient from "keycloak-admin";
import {
useAdminClient,
asyncStateFetch,
} from "../../context/auth/AdminClient";
import { useAdminClient } from "../../context/auth/AdminClient";
import { toUpperCase } from "../../util";
import { TableToolbar } from "../../components/table-toolbar/TableToolbar";
import { ListEmptyState } from "../../components/list-empty-state/ListEmptyState";
import { AddScopeDialog } from "./AddScopeDialog";
import {
@ -38,12 +24,18 @@ import {
ClientScope,
} from "./ClientScopeTypes";
import { useAlerts } from "../../components/alert/Alerts";
import { KeycloakDataTable } from "../../components/table-toolbar/KeycloakDataTable";
export type ClientScopesProps = {
clientId: string;
protocol: string;
};
type Row = ClientScopeRepresentation & {
type: ClientScopeType;
description: string;
};
const castAdminClient = (adminClient: KeycloakAdminClient) =>
(adminClient.clients as unknown) as {
[index: string]: Function;
@ -124,7 +116,6 @@ type TableRow = {
export const ClientScopes = ({ clientId, protocol }: ClientScopesProps) => {
const { t } = useTranslation("clients");
const adminClient = useAdminClient();
const handleError = useErrorHandler();
const { addAlert } = useAlerts();
const [searchToggle, setSearchToggle] = useState(false);
@ -133,15 +124,13 @@ export const ClientScopes = ({ clientId, protocol }: ClientScopesProps) => {
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [kebabOpen, setKebabOpen] = useState(false);
const [rows, setRows] = useState<TableRow[]>();
const [rest, setRest] = useState<ClientScopeRepresentation[]>();
const [selectedRows, setSelectedRows] = useState<Row[]>([]);
const [key, setKey] = useState(0);
const refresh = () => setKey(new Date().getTime());
useEffect(() => {
return asyncStateFetch(
async () => {
const loader = async () => {
const defaultClientScopes = await adminClient.clients.listDefaultClientScopes(
{ id: clientId }
);
@ -156,77 +145,52 @@ export const ClientScopes = ({ clientId, protocol }: ClientScopesProps) => {
const optional = optionalClientScopes.map((c) => {
const scope = find(c.id!);
return {
selected: false,
clientScope: c,
...c,
type: ClientScope.optional,
cells: [c.name, c.id, scope.description],
};
description: scope.description,
} as Row;
});
const defaultScopes = defaultClientScopes.map((c) => {
const scope = find(c.id!);
return {
selected: false,
clientScope: c,
...c,
type: ClientScope.default,
cells: [c.name, c.id, scope.description],
};
description: scope.description,
} as Row;
});
const rows = [...optional, ...defaultScopes];
const names = rows.map((row) => row.cells[0]);
const rest = clientScopes
const names = rows.map((row) => row.name);
setRest(
clientScopes
.filter((scope) => !names.includes(scope.name))
.filter((scope) => scope.protocol === protocol);
return { rows, rest };
},
({ rows, rest }) => {
setRows(rows);
setRest(rest);
},
handleError
.filter((scope) => scope.protocol === protocol)
);
}, [key]);
const dropdown = (): IFormatter => (data?: IFormatterValueType) => {
if (!data) {
return <></>;
}
const row = rows?.find((row) => row.clientScope.id === data.toString())!;
return (
return rows;
};
const TypeSelector = (scope: Row) => (
<>
<CellDropdown
clientScope={row.clientScope}
type={row.type}
clientScope={scope}
type={scope.type}
onSelect={async (value) => {
try {
await changeScope(
adminClient,
clientId,
row.clientScope,
row.type,
value
);
await changeScope(adminClient, clientId, scope, scope.type, value);
addAlert(t("clientScopeSuccess"), AlertVariant.success);
await refresh();
refresh();
} catch (error) {
addAlert(t("clientScopeError", { error }), AlertVariant.danger);
}
}}
/>
</>
);
};
const filterData = () => {};
return (
<>
{!rows && (
<div className="pf-u-text-align-center">
<Spinner />
</div>
)}
{rest && (
<AddScopeDialog
clientScopes={rest}
@ -254,8 +218,12 @@ export const ClientScopes = ({ clientId, protocol }: ClientScopesProps) => {
/>
)}
{rows && rows.length > 0 && (
<TableToolbar
<KeycloakDataTable
key={key}
loader={loader}
ariaLabelKey="clients:clientScopeList"
searchPlaceholderKey="clients:searchByName"
onSelect={(rows) => setSelectedRows([...rows])}
searchTypeComponent={
<Dropdown
toggle={
@ -290,9 +258,6 @@ export const ClientScopes = ({ clientId, protocol }: ClientScopesProps) => {
]}
/>
}
inputGroupName="clientsScopeToolbarTextInput"
inputGroupPlaceholder={t("searchByName")}
inputGroupOnChange={filterData}
toolbarItem={
<>
<ToolbarItem>
@ -311,21 +276,18 @@ export const ClientScopes = ({ clientId, protocol }: ClientScopesProps) => {
onSelect={async (_, value) => {
try {
await Promise.all(
rows.map((row) => {
if (row.selected) {
selectedRows.map((row) => {
return changeScope(
adminClient,
clientId,
row.clientScope,
{ ...row },
row.type,
value as ClientScope
);
}
return Promise.resolve();
})
);
setAddToggle(false);
await refresh();
refresh();
addAlert(t("clientScopeSuccess"), AlertVariant.success);
} catch (error) {
addAlert(
@ -349,21 +311,17 @@ export const ClientScopes = ({ clientId, protocol }: ClientScopesProps) => {
dropdownItems={[
<DropdownItem
key="deleteAll"
isDisabled={
rows.filter((row) => row.selected).length === 0
}
isDisabled={selectedRows.length === 0}
onClick={async () => {
try {
await Promise.all(
rows.map(async (row) => {
if (row.selected) {
selectedRows.map(async (row) => {
await removeScope(
adminClient,
clientId,
row.clientScope,
{ ...row },
row.type
);
}
})
);
@ -388,68 +346,26 @@ export const ClientScopes = ({ clientId, protocol }: ClientScopesProps) => {
</ToolbarItem>
</>
}
>
<Table
onSelect={(_, isSelected, rowIndex) => {
if (rowIndex === -1) {
setRows(
rows.map((row) => {
row.selected = isSelected;
return row;
})
);
} else {
rows[rowIndex].selected = isSelected;
setRows([...rows]);
}
}}
variant={TableVariant.compact}
cells={[
t("common:name"),
{ title: t("assignedType"), cellFormatters: [dropdown()] },
t("common:description"),
]}
rows={rows}
actions={[
columns={[
{
title: t("common:remove"),
onClick: async (_, rowId) => {
try {
await removeScope(
adminClient,
clientId,
rows[rowId].clientScope,
rows[rowId].type
);
addAlert(
t("clientScopeRemoveSuccess"),
AlertVariant.success
);
refresh();
} catch (error) {
addAlert(
t("clientScopeRemoveError", { error }),
AlertVariant.danger
);
}
name: "name",
},
{
name: "type",
displayKey: "clients:assignedType",
cellRenderer: TypeSelector,
},
{ name: "description" },
]}
aria-label={t("clientScopeList")}
>
<TableHeader />
<TableBody />
</Table>
</TableToolbar>
)}
{rows && rows.length === 0 && (
emptyState={
<ListEmptyState
message={t("clients:emptyClientScopes")}
instructions={t("clients:emptyClientScopesInstructions")}
primaryActionText={t("clients:emptyClientScopesPrimaryAction")}
onPrimaryAction={() => setAddDialogOpen(true)}
/>
)}
}
/>
</>
);
};

View file

@ -1,18 +1,12 @@
import React, { Fragment, useContext, useState } from "react";
import React, { useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Table,
TableBody,
TableHeader,
TableVariant,
} from "@patternfly/react-table";
import { Badge, Button, Checkbox, ToolbarItem } from "@patternfly/react-core";
import { useAdminClient } from "../../context/auth/AdminClient";
import { DataLoader } from "../../components/data-loader/DataLoader";
import { TableToolbar } from "../../components/table-toolbar/TableToolbar";
import { RealmContext } from "../../context/realm-context/RealmContext";
import RoleRepresentation from "keycloak-admin/lib/defs/roleRepresentation";
import ClientRepresentation from "keycloak-admin/lib/defs/clientRepresentation";
import { useAdminClient } from "../../context/auth/AdminClient";
import { RealmContext } from "../../context/realm-context/RealmContext";
import { KeycloakDataTable } from "../../components/table-toolbar/KeycloakDataTable";
import { emptyFormatter } from "../../util";
import "./service-account.css";
@ -21,6 +15,11 @@ type ServiceAccountProps = {
clientId: string;
};
type Row = {
client: ClientRepresentation;
role: CompositeRole;
};
type CompositeRole = RoleRepresentation & {
parent: RoleRepresentation;
};
@ -66,6 +65,7 @@ export const ServiceAccount = ({ clientId }: ServiceAccountProps) => {
};
const clientRolesFlat = clientRoles.map((row) => row.roles).flat();
console.log(clientRolesFlat);
const addInherentData = await (async () =>
Promise.all(
@ -92,10 +92,15 @@ export const ServiceAccount = ({ clientId }: ServiceAccountProps) => {
] as CompositeRole[])
.sort((r1, r2) => r1.name!.localeCompare(r2.name!))
.map((role) => {
const client = findClient(role);
return {
cells: [
<Fragment key={role.id}>
client: findClient(role),
role,
} as Row;
});
};
const RoleLink = ({ role, client }: Row) => (
<>
{client && (
<Badge
key={client.id}
@ -106,21 +111,15 @@ export const ServiceAccount = ({ clientId }: ServiceAccountProps) => {
</Badge>
)}
{role.name}
</Fragment>,
role.parent ? role.parent.name : "",
role.description,
],
};
});
};
const filterData = () => {};
</>
);
return (
<TableToolbar
inputGroupName="clientsServiceAccountRoleToolbarTextInput"
inputGroupPlaceholder={t("searchByName")}
inputGroupOnChange={filterData}
<KeycloakDataTable
loader={loader}
onSelect={() => {}}
searchPlaceholderKey="clients:searchByName"
ariaLabelKey="clients:clientScopeList"
toolbarItem={
<>
<ToolbarItem>
@ -136,34 +135,23 @@ export const ServiceAccount = ({ clientId }: ServiceAccountProps) => {
</ToolbarItem>
</>
}
>
<DataLoader loader={loader} deps={[clientId]}>
{(clientRoles) => (
<>
{hide ? "" : " "}
<Table
onSelect={() => {}}
variant={TableVariant.compact}
cells={[
t("roles:roleName"),
columns={[
{
title: t("inherentFrom"),
name: "role.name",
displayKey: t("name"),
cellRenderer: RoleLink,
},
{
name: "role.parent.name",
displayKey: t("inherentFrom"),
cellFormatters: [emptyFormatter()],
},
{
title: t("common:description"),
name: "role.description",
displayKey: t("description"),
cellFormatters: [emptyFormatter()],
},
]}
rows={clientRoles}
aria-label="roleList"
>
<TableHeader />
<TableBody />
</Table>
</>
)}
</DataLoader>
</TableToolbar>
/>
);
};

View file

@ -92,13 +92,13 @@ export const routes: RoutesFn = (t: TFunction) => [
access: "manage-clients",
},
{
path: "/:realm/client-scopes/:id/mappers/oidc-role-name-mapper",
path: "/:realm/client-scopes/:id/:tab/oidc-role-name-mapper",
component: RoleMappingForm,
breadcrumb: t("client-scopes:mappingDetails"),
access: "view-clients",
},
{
path: "/:realm/client-scopes/:id/mappers/:mapperId",
path: "/:realm/client-scopes/:id/:tab/:mapperId",
component: MappingDetails,
breadcrumb: t("client-scopes:mappingDetails"),
access: "view-clients",