Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CIAM-6389 Pagination for groups/subgroups in Admin Console groups user groups #22493

Closed
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,35 @@

package org.keycloak.representations.idm;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;

/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/
public class GroupRepresentation {
// For an individual group these are the sufficient minimum fields
// to identify a group and operate on it in a basic way
protected String id;
protected String name;
protected String path;
protected String parentId;
protected Long subGroupCount;
// For navigating a hierarchy of groups, we can also include a minimum representation of subGroups
// These aren't populated by default and are only included as-needed
protected List<GroupRepresentation> subGroups = new ArrayList<>();
protected Map<String, List<String>> attributes;
protected List<String> realmRoles;
protected Map<String, List<String>> clientRoles;
protected List<GroupRepresentation> subGroups;

private Map<String, Boolean> access;

public String getId() {
Expand All @@ -60,6 +72,22 @@ public void setPath(String path) {
this.path = path;
}

public String getParentId() {
return parentId;
}

public void setParentId(String parentId) {
this.parentId = parentId;
}

public Long getSubGroupCount() {
return subGroupCount;
}

public void setSubGroupCount(Long subGroupCount) {
this.subGroupCount = subGroupCount;
}

public List<String> getRealmRoles() {
return realmRoles;
}
Expand Down Expand Up @@ -106,4 +134,41 @@ public Map<String, Boolean> getAccess() {
public void setAccess(Map<String, Boolean> access) {
this.access = access;
}

public void merge(GroupRepresentation g) {
merge(this, g);
}

private void merge(GroupRepresentation g1, GroupRepresentation g2) {
if(g1.equals(g2)) {
Map<String, GroupRepresentation> g1Children = g1.getSubGroups().stream().collect(Collectors.toMap(GroupRepresentation::getId, g -> g));
Map<String, GroupRepresentation> g2Children = g2.getSubGroups().stream().collect(Collectors.toMap(GroupRepresentation::getId, g -> g));

g2Children.forEach((key, value) -> {
if (g1Children.containsKey(key)) {
merge(g1Children.get(key), value);
} else {
g1Children.put(key, value);
}
});
g1.setSubGroups(new ArrayList<>(g1Children.values()));
}
}

@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
GroupRepresentation that = (GroupRepresentation) o;
return id.equals(that.id) && Objects.equals(parentId, that.parentId);
}

@Override
public int hashCode() {
return Objects.hash(id, parentId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

package org.keycloak.admin.client.resource;

import java.util.stream.Stream;
import org.keycloak.representations.idm.GroupRepresentation;
import org.keycloak.representations.idm.ManagementPermissionReference;
import org.keycloak.representations.idm.ManagementPermissionRepresentation;
Expand Down Expand Up @@ -85,6 +86,19 @@ public interface GroupResource {
@DELETE
void remove();

/**
* Set or create child. This will just set the parent if it exists. Create it and set the parent
* if the group doesn't exist.
*
* @param first
* @param max
* @param full
*/
@GET
@Path("children")
@Produces(jakarta.ws.rs.core.MediaType.APPLICATION_JSON)
@Consumes(jakarta.ws.rs.core.MediaType.APPLICATION_JSON)
List<GroupRepresentation> getSubGroups(@QueryParam("first") Integer first, @QueryParam("max") Integer max, @QueryParam("full") Boolean full);

/**
* Set or create child. This will just set the parent if it exists. Create it and set the parent
Expand Down
142 changes: 99 additions & 43 deletions js/apps/admin-ui/src/components/group/GroupPickerDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,19 +80,30 @@ export const GroupPickerDialog = ({
first: `${first}`,
max: `${max + 1}`,
},
isSearching ? null : { search: filter },
isSearching ? { search: filter } : null,
),
);
} else if (!navigation.map(({ id }) => id).includes(groupId)) {
group = await adminClient.groups.findOne({ id: groupId });
group = await fetchAdminUI<GroupRepresentation | undefined>(
"ui-ext/groups/" + groupId,
);

if (!group) {
throw new Error(t("common:notFound"));
}
group.subGroups! = await fetchAdminUI<GroupRepresentation[]>(
"ui-ext/groups/subgroup",
{
id: group.id!,
first: `${first}`,
max: `${max}`,
},
);
groups = group.subGroups!;
}

if (isSearching) {
count = (await adminClient.groups.count({ search: filter, top: true }))
count = (await adminClient.groups.count({ search: filter, top: false }))
.count;
}

Expand Down Expand Up @@ -128,6 +139,32 @@ export const GroupPickerDialog = ({
].some((group) => group === row?.id);
};

const getMoreSubgroups = async (groupId: string, first: any, max: any) => {
let subGroups: GroupRepresentation[] = [];
subGroups = await fetchAdminUI<GroupRepresentation[]>(
"ui-ext/groups/subgroup",
{
id: groupId!,
first: first,
max: max,
},
);
return { subGroups };
};

const processSubgroups = (groupId: string, first: any, max: any) => {
getMoreSubgroups(groupId, first, max).then((r) => {
if (r.subGroups) {
r.subGroups.forEach((group: SelectableGroup) => {
group.checked = !!selectedRows.find(
(selectedGroup) => selectedGroup.id === group.id,
);
});
setGroups(r.subGroups);
}
});
};

return (
<Modal
variant={isSearching ? ModalVariant.medium : ModalVariant.small}
Expand Down Expand Up @@ -160,16 +197,33 @@ export const GroupPickerDialog = ({
>
<PaginatingTableToolbar
count={
(isSearching ? count : groups.length) -
(groupId || isSearching ? first : 0)
(isSearching
? count
: currentGroup().subGroupCount !== undefined
? currentGroup()!.subGroupCount!
: groups.length) - (groupId || isSearching ? first : 0)
}
first={first}
max={max}
onNextClick={setFirst}
onPreviousClick={setFirst}
onNextClick={(f: number) => {
setFirst(f);
setMax(max);
if (groupId != null) {
processSubgroups(groupId, f, max);
}
}}
onPreviousClick={(f: number) => {
setFirst(f);
if (groupId != null) {
processSubgroups(groupId, f, max);
}
}}
onPerPageSelect={(first, max) => {
setFirst(first);
setMax(max);
if (groupId != null) {
processSubgroups(groupId, first, max);
}
}}
inputGroupName={"common:search"}
inputGroupOnEnter={(search) => {
Expand Down Expand Up @@ -218,40 +272,38 @@ export const GroupPickerDialog = ({
))}
</Breadcrumb>
<DataList aria-label={t("groups")} isCompact>
{groups
.slice(groupId ? first : 0, max + (groupId ? first : 0))
.map((group: SelectableGroup) => (
<>
<GroupRow
key={group.id}
group={group}
isRowDisabled={isRowDisabled}
onSelect={setGroupId}
type={type}
isSearching={isSearching}
setIsSearching={setIsSearching}
selectedRows={selectedRows}
setSelectedRows={setSelectedRows}
canBrowse={canBrowse}
/>
{isSearching &&
group.subGroups?.length !== 0 &&
group.subGroups!.map((g) => (
<GroupRow
key={g.id}
group={g}
isRowDisabled={isRowDisabled}
onSelect={setGroupId}
type={type}
isSearching={isSearching}
setIsSearching={setIsSearching}
selectedRows={selectedRows}
setSelectedRows={setSelectedRows}
canBrowse={canBrowse}
/>
))}
</>
))}
{groups.map((group: SelectableGroup) => (
<>
<GroupRow
key={group.id}
group={group}
isRowDisabled={isRowDisabled}
onSelect={setGroupId}
type={type}
isSearching={isSearching}
setIsSearching={setIsSearching}
selectedRows={selectedRows}
setSelectedRows={setSelectedRows}
canBrowse={canBrowse}
/>
{isSearching &&
group.subGroups?.length !== 0 &&
group.subGroups!.map((g) => (
<GroupRow
key={g.id}
group={g}
isRowDisabled={isRowDisabled}
onSelect={setGroupId}
type={type}
isSearching={isSearching}
setIsSearching={setIsSearching}
selectedRows={selectedRows}
setSelectedRows={setSelectedRows}
canBrowse={canBrowse}
/>
))}
</>
))}
</DataList>
{groups.length === 0 && !isSearching && (
<ListEmptyState
Expand Down Expand Up @@ -296,8 +348,12 @@ const GroupRow = ({
}: GroupRowProps) => {
const { t } = useTranslation();

const hasSubgroups = (group: GroupRepresentation) =>
group.subGroups?.length !== 0;
const hasSubgroups = (group: GroupRepresentation) => {
if (group.subGroupCount === undefined) {
return false;
}
return group.subGroupCount! > 0;
};

return (
<DataListItem
Expand Down
2 changes: 1 addition & 1 deletion js/apps/admin-ui/src/groups/components/GroupTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ export const GroupTree = ({
search === "" ? null : { search },
),
);
const count = (await adminClient.groups.count({ search, top: true }))
const count = (await adminClient.groups.count({ search, top: false }))
.count;
let subGroups: GroupRepresentation[] = [];
if (activeItem) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export default interface GroupRepresentation {
name?: string;
path?: string;
subGroups?: GroupRepresentation[];
subGroupCount?: number;

// optional in response
access?: Record<string, boolean>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,21 +27,40 @@
*/
public class DefaultLazyLoader<S, D> implements LazyLoader<S, D> {


@FunctionalInterface
public interface PagedSearchFunction<S, D> {

D apply(S t, String search, Integer f, Integer m);
}

private final Function<S, D> loader;
private final PagedSearchFunction<S, D> pagedLoader;
private final Supplier<D> fallback;
private D data;

public DefaultLazyLoader(Function<S, D> loader, Supplier<D> fallback) {
this.loader = loader;
this.pagedLoader = null;
this.fallback = fallback;
}

public DefaultLazyLoader(PagedSearchFunction<S, D> pagedLoader, Supplier<D> fallback) {
this.loader = null;
this.pagedLoader = pagedLoader;
this.fallback = fallback;
}
@Override
public D get(Supplier<S> sourceSupplier) {
if (data == null) {
S source = sourceSupplier.get();
data = source == null ? fallback.get() : this.loader.apply(source);
data = source == null || loader == null ? fallback.get() : this.loader.apply(source);
}
return data;
}

public D bind(Supplier<S> sourceSupplier, String search, Integer first, Integer max) {
S source = sourceSupplier.get();
return source == null || pagedLoader == null ? fallback.get() : this.pagedLoader.apply(source, search, first, max);
}
}
Loading
Loading