Fix sort order in space hierarchy (#30975)

* Fix sort order in space hierarchy

To match spec and not add unexpected sorting by space vs room

* Update SpaceHierarchy.tsx

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update snapshot

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add test

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update snapshot

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2025-10-21 09:25:51 +01:00 committed by GitHub
parent 77c41d6789
commit 87fd279079
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 570 additions and 174 deletions

View File

@ -32,7 +32,6 @@ import {
RoomType,
GuestAccess,
HistoryVisibility,
type HierarchyRelation,
type HierarchyRoom,
JoinRule,
} from "matrix-js-sdk/src/matrix";
@ -71,6 +70,7 @@ import { getTopic } from "../../hooks/room/useTopic";
import { SdkContextClass } from "../../contexts/SDKContext";
import { getDisplayAliasForAliasSet } from "../../Rooms";
import SettingsStore from "../../settings/SettingsStore";
import { filterBoolean } from "../../utils/arrays.ts";
interface IProps {
space: Room;
@ -504,25 +504,23 @@ export const HierarchyLevel: React.FC<IHierarchyLevelProps> = ({
const space = cli.getRoom(root.room_id);
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getSafeUserId());
const sortedChildren = sortBy(root.children_state, (ev) => {
const sortedChildren = filterBoolean(
sortBy(root.children_state, (ev) => {
return getChildOrder(ev.content.order, ev.origin_server_ts, ev.state_key);
});
const [subspaces, childRooms] = sortedChildren.reduce(
(result, ev: HierarchyRelation) => {
const room = hierarchy.roomMap.get(ev.state_key);
if (room && roomSet.has(room)) {
result[room.room_type === RoomType.Space ? 0 : 1].push(toLocalRoom(cli, room, hierarchy));
}
return result;
},
[[] as HierarchyRoom[], [] as HierarchyRoom[]],
}).map((ev) => {
const hierarchyRoom = hierarchy.roomMap.get(ev.state_key);
if (!hierarchyRoom || !roomSet.has(hierarchyRoom)) return null;
// Find the most up-to-date info for this room, if it has been upgraded and we know about it.
return toLocalRoom(cli, hierarchyRoom, hierarchy);
}),
);
const newParents = new Set(parents).add(root.room_id);
return (
<React.Fragment>
{uniqBy(childRooms, "room_id").map((room) => (
{uniqBy(sortedChildren, "room_id").map((room) => {
if (room.room_type !== RoomType.Space) {
return (
<Tile
key={room.room_id}
room={room}
@ -533,29 +531,28 @@ export const HierarchyLevel: React.FC<IHierarchyLevelProps> = ({
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined}
/>
))}
{subspaces
.filter((room) => !newParents.has(room.room_id))
.map((space) => (
);
} else {
if (newParents.has(room.room_id)) return null; // prevent cycles
return (
<Tile
key={space.room_id}
room={space}
key={room.room_id}
room={room}
numChildRooms={
space.children_state.filter((ev) => {
const room = hierarchy.roomMap.get(ev.state_key);
return room && roomSet.has(room) && !room.room_type;
room.children_state.filter((ev) => {
const child = hierarchy.roomMap.get(ev.state_key);
return child && roomSet.has(child) && !child.room_type;
}).length
}
suggested={hierarchy.isSuggested(root.room_id, space.room_id)}
selected={selectedMap?.get(root.room_id)?.has(space.room_id)}
onViewRoomClick={() => onViewRoomClick(space.room_id, RoomType.Space)}
onJoinRoomClick={() => onJoinRoomClick(space.room_id, newParents)}
suggested={hierarchy.isSuggested(root.room_id, room.room_id)}
selected={selectedMap?.get(root.room_id)?.has(room.room_id)}
onViewRoomClick={() => onViewRoomClick(room.room_id, RoomType.Space)}
onJoinRoomClick={() => onJoinRoomClick(room.room_id, newParents)}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, space.room_id) : undefined}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined}
>
<HierarchyLevel
root={space}
root={room}
roomSet={roomSet}
hierarchy={hierarchy}
parents={newParents}
@ -565,7 +562,9 @@ export const HierarchyLevel: React.FC<IHierarchyLevelProps> = ({
onToggleClick={onToggleClick}
/>
</Tile>
))}
);
}
})}
</React.Fragment>
);
};

View File

@ -169,11 +169,14 @@ describe("SpaceHierarchy", () => {
const room2 = mkStubRoom("room-id-3", "Room 2", client);
const space1 = mkStubRoom("space-id-4", "Space 2", client);
const room3 = mkStubRoom("room-id-5", "Room 3", client);
const space2 = mkStubRoom("space-id-6", "Space 3", client);
mocked(client.getRooms).mockReturnValue([root]);
mocked(client.getRoom).mockImplementation(
(roomId) => client.getRooms().find((room) => room.roomId === roomId) ?? null,
);
[room1, room2, space1, room3].forEach((r) => mocked(r.getMyMembership).mockReturnValue(KnownMembership.Leave));
[room1, room2, space1, room3, space2].forEach((r) =>
mocked(r.getMyMembership).mockReturnValue(KnownMembership.Leave),
);
const hierarchyRoot: HierarchyRoom = {
room_id: root.roomId,
@ -324,5 +327,36 @@ describe("SpaceHierarchy", () => {
undefined,
);
});
it("should not render cycles", async () => {
const hierarchySpace2: HierarchyRoom = {
room_id: space2.roomId,
name: "Space with cycle",
num_joined_members: 1,
room_type: "m.space",
children_state: [
{
state_key: root.roomId,
content: { order: "1" },
origin_server_ts: 111,
type: "m.space.child",
sender: "@other:server",
},
],
world_readable: true,
guest_can_join: true,
};
mocked(client.getRoomHierarchy).mockResolvedValue({
rooms: [hierarchyRoot, hierarchyRoom1, hierarchyRoom2, hierarchySpace1, hierarchySpace2],
});
const { getAllByText, queryByText, asFragment } = render(getComponent());
// Wait for spinners to go away
await waitForElementToBeRemoved(screen.getAllByRole("progressbar"));
expect(getAllByText("Nested space")).toHaveLength(1);
expect(queryByText("Space 1")).not.toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
});
});

View File

@ -254,12 +254,13 @@ exports[`SpaceHierarchy <SpaceHierarchy /> renders 1`] = `
</div>
</li>
<li
aria-expanded="true"
aria-labelledby="_r_g_"
class="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
>
<div
class="mx_AccessibleButton mx_SpaceHierarchy_roomTile"
class="mx_AccessibleButton mx_SpaceHierarchy_roomTile mx_SpaceHierarchy_subspace"
role="button"
tabindex="-1"
>
@ -271,13 +272,13 @@ exports[`SpaceHierarchy <SpaceHierarchy /> renders 1`] = `
>
<span
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="6"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 20px;"
>
K
N
</span>
</div>
<div
@ -286,24 +287,24 @@ exports[`SpaceHierarchy <SpaceHierarchy /> renders 1`] = `
<span
id="_r_g_"
>
Knock room
Nested space
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_info"
>
3 members
1 member · 1 room
</div>
</div>
<div
class="mx_SpaceHierarchy_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="-1"
>
View
Join
</div>
<form
class="_root_19upo_16"
@ -349,11 +350,477 @@ exports[`SpaceHierarchy <SpaceHierarchy /> renders 1`] = `
</div>
</form>
</div>
<div
class="mx_SpaceHierarchy_subspace_toggle mx_SpaceHierarchy_subspace_toggle_shown"
/>
</div>
<div
class="mx_SpaceHierarchy_subspace_children"
role="group"
/>
</li>
<li
aria-labelledby="_r_k_"
class="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
>
<div
class="mx_AccessibleButton mx_SpaceHierarchy_roomTile"
role="button"
tabindex="-1"
>
<div
class="mx_SpaceHierarchy_roomTile_item"
>
<div
class="mx_SpaceHierarchy_roomTile_avatar"
>
<span
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 20px;"
>
N
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_name"
>
<span
id="_r_k_"
>
Nested room
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_info"
>
3 members
</div>
</div>
<div
class="mx_SpaceHierarchy_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="-1"
>
Join
</div>
<span
aria-labelledby="_r_l_"
tabindex="0"
>
<form
class="_root_19upo_16"
>
<div
class="_inline-field_19upo_32"
>
<div
class="_inline-field-control_19upo_44"
>
<div
class="_container_1hel1_10"
>
<input
aria-labelledby="_r_k_"
class="_input_1hel1_18"
disabled=""
id="checkbox_RD7nyrA2oh"
role="presentation"
tabindex="-1"
type="checkbox"
/>
<div
class="_ui_1hel1_19"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
</div>
</div>
<div
class="_inline-field-body_19upo_38"
/>
</div>
</form>
</span>
</div>
</div>
</li>
<li
aria-labelledby="_r_t_"
class="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
>
<div
class="mx_AccessibleButton mx_SpaceHierarchy_roomTile"
role="button"
tabindex="-1"
>
<div
class="mx_SpaceHierarchy_roomTile_item"
>
<div
class="mx_SpaceHierarchy_roomTile_avatar"
>
<span
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="6"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 20px;"
>
K
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_name"
>
<span
id="_r_t_"
>
Knock room
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_info"
>
3 members
</div>
</div>
<div
class="mx_SpaceHierarchy_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline"
role="button"
tabindex="-1"
>
View
</div>
<form
class="_root_19upo_16"
>
<div
class="_inline-field_19upo_32"
>
<div
class="_inline-field-control_19upo_44"
>
<div
class="_container_1hel1_10"
>
<input
aria-labelledby="_r_t_"
class="_input_1hel1_18"
id="checkbox_jWVJIPauy1"
role="presentation"
tabindex="-1"
type="checkbox"
/>
<div
class="_ui_1hel1_19"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
</div>
</div>
<div
class="_inline-field-body_19upo_38"
/>
</div>
</form>
</div>
</div>
</li>
</ul>
</DocumentFragment>
`;
exports[`SpaceHierarchy <SpaceHierarchy /> should not render cycles 1`] = `
<DocumentFragment>
<div
class="mx_SearchBox mx_textinput"
>
<input
autocomplete="off"
class="mx_textinput_icon mx_textinput_search mx_SpaceHierarchy_search mx_textinput_icon mx_textinput_search"
data-testid="searchbox-input"
placeholder="Search names and descriptions"
type="text"
value=""
/>
<div
class="mx_AccessibleButton mx_SearchBox_closeButton"
role="button"
tabindex="-1"
/>
</div>
<div
class="mx_SpaceHierarchy_listHeader"
>
<h4
class="mx_SpaceHierarchy_listHeader_header"
>
Rooms and spaces
</h4>
<div
class="mx_SpaceHierarchy_listHeader_buttons"
>
<div
aria-disabled="true"
aria-label="Remove"
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger_outline mx_AccessibleButton_disabled"
disabled=""
role="button"
tabindex="0"
>
Remove
</div>
<div
aria-disabled="true"
aria-label="Mark as not suggested"
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline mx_AccessibleButton_disabled"
disabled=""
role="button"
tabindex="0"
>
Mark as not suggested
</div>
</div>
</div>
<ul
aria-label="Space"
class="mx_SpaceHierarchy_list"
role="tree"
>
<li
aria-labelledby="_r_3i_"
class="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
>
<div
class="mx_AccessibleButton mx_SpaceHierarchy_roomTile"
role="button"
tabindex="0"
>
<div
class="mx_SpaceHierarchy_roomTile_item"
>
<div
class="mx_SpaceHierarchy_roomTile_avatar"
>
<span
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="5"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 20px;"
>
U
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_name"
>
<span
id="_r_3i_"
>
Unnamed Room
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_info"
>
2 members
</div>
</div>
<div
class="mx_SpaceHierarchy_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Join
</div>
<form
class="_root_19upo_16"
>
<div
class="_inline-field_19upo_32"
>
<div
class="_inline-field-control_19upo_44"
>
<div
class="_container_1hel1_10"
>
<input
aria-labelledby="_r_3i_"
class="_input_1hel1_18"
id="checkbox_EetmBG4yVC"
role="presentation"
tabindex="-1"
type="checkbox"
/>
<div
class="_ui_1hel1_19"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
</div>
</div>
<div
class="_inline-field-body_19upo_38"
/>
</div>
</form>
</div>
</div>
</li>
<li
aria-labelledby="_r_3m_"
class="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
>
<div
class="mx_AccessibleButton mx_SpaceHierarchy_roomTile"
role="button"
tabindex="-1"
>
<div
class="mx_SpaceHierarchy_roomTile_item"
>
<div
class="mx_SpaceHierarchy_roomTile_avatar"
>
<span
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="6"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 20px;"
>
U
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_name"
>
<span
id="_r_3m_"
>
Unnamed Room
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_info"
>
3 members
</div>
</div>
<div
class="mx_SpaceHierarchy_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="-1"
>
Join
</div>
<form
class="_root_19upo_16"
>
<div
class="_inline-field_19upo_32"
>
<div
class="_inline-field-control_19upo_44"
>
<div
class="_container_1hel1_10"
>
<input
aria-labelledby="_r_3m_"
class="_input_1hel1_18"
id="checkbox_eEefiPqpMR"
role="presentation"
tabindex="-1"
type="checkbox"
/>
<div
class="_ui_1hel1_19"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
</div>
</div>
<div
class="_inline-field-body_19upo_38"
/>
</div>
</form>
</div>
</div>
</li>
<li
aria-expanded="true"
aria-labelledby="_r_k_"
aria-labelledby="_r_3q_"
class="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
>
@ -383,7 +850,7 @@ exports[`SpaceHierarchy <SpaceHierarchy /> renders 1`] = `
class="mx_SpaceHierarchy_roomTile_name"
>
<span
id="_r_k_"
id="_r_3q_"
>
Nested space
</span>
@ -391,7 +858,7 @@ exports[`SpaceHierarchy <SpaceHierarchy /> renders 1`] = `
<div
class="mx_SpaceHierarchy_roomTile_info"
>
1 member · 1 room
1 member · 0 rooms
</div>
</div>
<div
@ -417,9 +884,9 @@ exports[`SpaceHierarchy <SpaceHierarchy /> renders 1`] = `
class="_container_1hel1_10"
>
<input
aria-labelledby="_r_k_"
aria-labelledby="_r_3q_"
class="_input_1hel1_18"
id="checkbox_RD7nyrA2oh"
id="checkbox_MwbPDmfGtm"
role="presentation"
tabindex="-1"
type="checkbox"
@ -457,110 +924,6 @@ exports[`SpaceHierarchy <SpaceHierarchy /> renders 1`] = `
role="group"
/>
</li>
<li
aria-labelledby="_r_o_"
class="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem"
>
<div
class="mx_AccessibleButton mx_SpaceHierarchy_roomTile"
role="button"
tabindex="-1"
>
<div
class="mx_SpaceHierarchy_roomTile_item"
>
<div
class="mx_SpaceHierarchy_roomTile_avatar"
>
<span
class="_avatar_1qbcf_8 mx_BaseAvatar _avatar-imageless_1qbcf_52"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 20px;"
>
N
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_name"
>
<span
id="_r_o_"
>
Nested room
</span>
</div>
<div
class="mx_SpaceHierarchy_roomTile_info"
>
3 members
</div>
</div>
<div
class="mx_SpaceHierarchy_actions"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="-1"
>
Join
</div>
<span
aria-labelledby="_r_p_"
tabindex="0"
>
<form
class="_root_19upo_16"
>
<div
class="_inline-field_19upo_32"
>
<div
class="_inline-field-control_19upo_44"
>
<div
class="_container_1hel1_10"
>
<input
aria-labelledby="_r_o_"
class="_input_1hel1_18"
disabled=""
id="checkbox_jWVJIPauy1"
role="presentation"
tabindex="-1"
type="checkbox"
/>
<div
class="_ui_1hel1_19"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.55 17.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213L4.55 13q-.274-.274-.262-.713.012-.437.287-.712a.95.95 0 0 1 .7-.275q.425 0 .7.275L9.55 15.15l8.475-8.475q.274-.275.713-.275.437 0 .712.275.275.274.275.713 0 .437-.275.712l-9.2 9.2q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
</div>
</div>
<div
class="_inline-field-body_19upo_38"
/>
</div>
</form>
</span>
</div>
</div>
</li>
</ul>
</DocumentFragment>
`;