Mvvm split user info, create powerlevels component (#30005)

* feat: mvvm user info powerlevels

* chore: remove unecesssary comments and add new

* chore: fix lint and rebase

* fix: lint error
This commit is contained in:
Marc
2025-06-30 15:26:37 +02:00
committed by GitHub
parent 4a8b365bf8
commit 58875e5cf2
6 changed files with 552 additions and 185 deletions

View File

@@ -0,0 +1,222 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { renderHook } from "jest-matrix-react";
import { type Mocked, mocked } from "jest-mock";
import { RoomMember, MatrixEvent, type Room, EventType, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
import { useUserInfoPowerlevelViewModel } from "../../../../../../src/components/viewmodels/right_panel/UserInfoPowerlevelViewModel";
import { withClientContextRenderOptions } from "../../../../../test-utils";
import { type IRoomPermissions } from "../../../../../../src/components/views/right_panel/UserInfo";
import Modal from "../../../../../../src/Modal";
import { warnSelfDemote } from "../../../../../../src/components/views/right_panel/UserInfo";
jest.mock("../../../../../../src/Modal", () => ({
createDialog: jest.fn(),
}));
jest.mock("../../../../../../src/components/views/right_panel/UserInfo", () => ({
warnSelfDemote: jest.fn(),
}));
describe("UserInfoAdminPowerlevelViewModel", () => {
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
const defaultMeId = "@me:example.com";
const selfUser = new RoomMember(defaultRoomId, defaultMeId);
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
const startPowerLevel = 50;
const changedPowerLevel = 100;
let mockClient: Mocked<MatrixClient>;
let mockRoom: Mocked<Room>;
let defaultProps: {
user: RoomMember;
room: Room;
roomPermissions: IRoomPermissions;
};
beforeEach(() => {
defaultProps = {
user: defaultMember,
room: mockRoom,
roomPermissions: {
modifyLevelMax: 100,
canEdit: false,
canInvite: false,
},
};
mockRoom = mocked({
roomId: defaultRoomId,
getType: jest.fn().mockReturnValue(undefined),
isSpaceRoom: jest.fn().mockReturnValue(false),
getMember: jest.fn().mockReturnValue(undefined),
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
name: "test room",
on: jest.fn(),
off: jest.fn(),
currentState: {
getStateEvents: jest.fn(),
on: jest.fn(),
off: jest.fn(),
},
getEventReadUpTo: jest.fn(),
} as unknown as Room);
mockClient = mocked({
getUser: jest.fn(),
isGuest: jest.fn().mockReturnValue(false),
isUserIgnored: jest.fn(),
getIgnoredUsers: jest.fn(),
setIgnoredUsers: jest.fn(),
getUserId: jest.fn(),
getSafeUserId: jest.fn(),
getDomain: jest.fn(),
on: jest.fn(),
off: jest.fn(),
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false),
getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")),
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
removeListener: jest.fn(),
currentState: {
on: jest.fn(),
},
getRoom: jest.fn(),
credentials: {},
setPowerLevel: jest.fn().mockResolvedValueOnce({ event_id: "123" }),
} as unknown as MatrixClient);
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
(Modal.createDialog as jest.Mock).mockImplementation(() => ({
finished: Promise.resolve([true]),
}));
(warnSelfDemote as jest.Mock).mockResolvedValue(true);
});
const renderComponentHook = (props = defaultProps, client = mockClient) => {
return renderHook(
() => useUserInfoPowerlevelViewModel(props.user, props.room),
withClientContextRenderOptions(client),
);
};
afterEach(() => {
jest.clearAllMocks();
});
it("should give default power level", () => {
const defaultPowerLevel = 1;
const powerLevelEvent = new MatrixEvent({
type: EventType.RoomPowerLevels,
content: { users: { [defaultUserId]: defaultPowerLevel }, users_default: defaultPowerLevel },
});
mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
const { result } = renderComponentHook({ ...defaultProps, room: mockRoom });
expect(result.current.powerLevelUsersDefault).toBe(defaultPowerLevel);
});
it("handles successful power level change", async () => {
const powerLevelEvent = new MatrixEvent({
type: EventType.RoomPowerLevels,
content: { users: { [defaultUserId]: startPowerLevel }, users_default: 1 },
});
mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
mockClient.getSafeUserId.mockReturnValueOnce(defaultUserId);
mockClient.getUserId.mockReturnValueOnce(defaultUserId);
const { result } = renderComponentHook({ ...defaultProps, room: mockRoom }, mockClient);
await result.current.onPowerChange(changedPowerLevel);
expect(mockClient.setPowerLevel).toHaveBeenCalledTimes(1);
expect(mockClient.setPowerLevel).toHaveBeenCalledWith(mockRoom.roomId, defaultMember.userId, changedPowerLevel);
});
it("shows warning when promoting user to higher power level", async () => {
const powerLevelEvent = new MatrixEvent({
type: EventType.RoomPowerLevels,
content: {
users: {
[defaultUserId]: startPowerLevel,
[defaultMeId]: startPowerLevel,
},
users_default: 1,
},
});
mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
mockClient.getUserId.mockReturnValue(defaultMeId);
const { result } = renderComponentHook({ ...defaultProps, room: mockRoom }, mockClient);
await result.current.onPowerChange(changedPowerLevel);
expect(Modal.createDialog).toHaveBeenCalled();
expect(mockClient.setPowerLevel).toHaveBeenCalled();
});
it("shows warning when self-demoting", async () => {
const powerLevelEvent = new MatrixEvent({
type: EventType.RoomPowerLevels,
content: {
users: { [defaultMeId]: changedPowerLevel },
users_default: 1,
},
});
mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
mockClient.getUserId.mockReturnValue(defaultMeId);
const { result } = renderComponentHook({ ...defaultProps, room: mockRoom, user: selfUser }, mockClient);
await result.current.onPowerChange(startPowerLevel);
expect(warnSelfDemote).toHaveBeenCalled();
expect(mockClient.setPowerLevel).toHaveBeenCalled();
});
it("cancels power level change when user declines warning", async () => {
(Modal.createDialog as jest.Mock).mockImplementation(() => ({
finished: Promise.resolve([false]),
}));
const powerLevelEvent = new MatrixEvent({
type: EventType.RoomPowerLevels,
content: {
users: {
[defaultUserId]: startPowerLevel,
"@me:example.com": startPowerLevel,
},
users_default: 1,
},
});
mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
mockClient.getUserId.mockReturnValue(defaultMeId);
const { result } = renderComponentHook({ ...defaultProps, room: mockRoom }, mockClient);
await result.current.onPowerChange(changedPowerLevel);
expect(Modal.createDialog).toHaveBeenCalled();
expect(mockClient.setPowerLevel).not.toHaveBeenCalled();
});
it("handles missing power level event", async () => {
mockRoom.currentState.getStateEvents.mockReturnValue(null);
const { result } = renderComponentHook({ ...defaultProps, room: mockRoom }, mockClient);
await result.current.onPowerChange(changedPowerLevel);
expect(mockClient.setPowerLevel).not.toHaveBeenCalled();
});
});

View File

@@ -7,18 +7,10 @@ Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { fireEvent, render, screen, act, waitForElementToBeRemoved, waitFor } from "jest-matrix-react";
import { render, screen, act, waitForElementToBeRemoved, waitFor } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { type Mocked, mocked } from "jest-mock";
import {
type Room,
User,
type MatrixClient,
RoomMember,
MatrixEvent,
EventType,
Device,
} from "matrix-js-sdk/src/matrix";
import { type Room, User, type MatrixClient, RoomMember, Device } from "matrix-js-sdk/src/matrix";
import { EventEmitter } from "events";
import {
UserVerificationStatus,
@@ -31,7 +23,6 @@ import {
import UserInfo, {
disambiguateDevices,
getPowerLevels,
PowerLevelEditor,
UserInfoHeader,
UserOptionsSection,
} from "../../../../../src/components/views/right_panel/UserInfo";
@@ -717,65 +708,6 @@ describe("<UserOptionsSection />", () => {
);
});
describe("<PowerLevelEditor />", () => {
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
let defaultProps: Parameters<typeof PowerLevelEditor>[0];
beforeEach(() => {
defaultProps = {
user: defaultMember,
room: mockRoom,
roomPermissions: {
modifyLevelMax: 100,
canEdit: false,
canInvite: false,
},
};
});
const renderComponent = (props = {}) => {
const Wrapper = (wrapperProps = {}) => {
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
};
return render(<PowerLevelEditor {...defaultProps} {...props} />, {
wrapper: Wrapper,
});
};
it("renders a power level combobox", () => {
renderComponent();
expect(screen.getByRole("combobox", { name: "Power level" })).toBeInTheDocument();
});
it("renders a combobox and attempts to change power level on change of the combobox", async () => {
const startPowerLevel = 999;
const powerLevelEvent = new MatrixEvent({
type: EventType.RoomPowerLevels,
content: { users: { [defaultUserId]: startPowerLevel }, users_default: 1 },
});
mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
mockClient.getSafeUserId.mockReturnValueOnce(defaultUserId);
mockClient.getUserId.mockReturnValueOnce(defaultUserId);
mockClient.setPowerLevel.mockResolvedValueOnce({ event_id: "123" });
renderComponent();
const changedPowerLevel = 100;
fireEvent.change(screen.getByRole("combobox", { name: "Power level" }), {
target: { value: changedPowerLevel },
});
await screen.findByText("Demote", { exact: true });
// firing the event will raise a dialog warning about self demotion, wait for this to appear then click on it
await userEvent.click(await screen.findByText("Demote", { exact: true }));
expect(mockClient.setPowerLevel).toHaveBeenCalledTimes(1);
expect(mockClient.setPowerLevel).toHaveBeenCalledWith(mockRoom.roomId, defaultMember.userId, changedPowerLevel);
});
});
describe("disambiguateDevices", () => {
it("does not add ambiguous key to unique names", () => {
const initialDevices = [

View File

@@ -0,0 +1,164 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import userEvent from "@testing-library/user-event";
import { fireEvent, render, screen } from "jest-matrix-react";
import { type Mocked, mocked } from "jest-mock";
import { MatrixEvent, type MatrixClient, RoomMember, type Room, EventType } from "matrix-js-sdk/src/matrix";
import MatrixClientContext from "../../../../../../src/contexts/MatrixClientContext";
import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
import { type IRoomPermissions } from "../../../../../../src/components/views/right_panel/UserInfo";
import { PowerLevelSection } from "../../../../../../src/components/views/right_panel/user_info/UserInfoPowerLevels";
describe("<PowerLevelEditor />", () => {
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
let mockClient: Mocked<MatrixClient>;
let mockRoom: Mocked<Room>;
let defaultProps: {
user: RoomMember;
room: Room;
roomPermissions: IRoomPermissions;
};
beforeEach(() => {
defaultProps = {
user: defaultMember,
room: mockRoom,
roomPermissions: {
modifyLevelMax: 100,
canEdit: false,
canInvite: false,
},
};
mockRoom = mocked({
roomId: defaultRoomId,
getType: jest.fn().mockReturnValue(undefined),
isSpaceRoom: jest.fn().mockReturnValue(false),
getMember: jest.fn().mockReturnValue(undefined),
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
name: "test room",
on: jest.fn(),
off: jest.fn(),
currentState: {
getStateEvents: jest.fn(),
on: jest.fn(),
off: jest.fn(),
},
getEventReadUpTo: jest.fn(),
} as unknown as Room);
mockClient = mocked({
getUser: jest.fn(),
isGuest: jest.fn().mockReturnValue(false),
isUserIgnored: jest.fn(),
getIgnoredUsers: jest.fn(),
setIgnoredUsers: jest.fn(),
getUserId: jest.fn(),
getSafeUserId: jest.fn(),
getDomain: jest.fn(),
on: jest.fn(),
off: jest.fn(),
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false),
getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")),
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
removeListener: jest.fn(),
currentState: {
on: jest.fn(),
},
getRoom: jest.fn(),
credentials: {},
setPowerLevel: jest.fn().mockResolvedValueOnce({ event_id: "123" }),
} as unknown as MatrixClient);
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
});
afterAll(() => {
defaultProps = {
user: defaultMember,
room: mockRoom,
roomPermissions: {
modifyLevelMax: 100,
canEdit: false,
canInvite: false,
},
};
jest.clearAllMocks();
});
const renderComponent = (props = defaultProps) => {
const Wrapper = (wrapperProps = {}) => {
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
};
return render(<PowerLevelSection {...props} />, {
wrapper: Wrapper,
});
};
it("renders a power level combobox if can edit is true", () => {
const startPowerLevel = 999;
const powerLevelEvent = new MatrixEvent({
type: EventType.RoomPowerLevels,
content: { users: { [defaultUserId]: startPowerLevel }, users_default: 1 },
});
mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
renderComponent({
...defaultProps,
room: mockRoom,
roomPermissions: { ...defaultProps.roomPermissions, canEdit: true },
});
expect(screen.getByRole("combobox", { name: "Power level" })).toBeInTheDocument();
});
it("renders a user role if can edit is false", () => {
const member = new RoomMember(defaultRoomId, defaultUserId);
member.powerLevel = 100;
renderComponent({ ...defaultProps, user: member });
expect(screen.getByText("Admin")).toBeInTheDocument();
});
it("renders a combobox and attempts to change power level on change of the combobox", async () => {
const startPowerLevel = 999;
const powerLevelEvent = new MatrixEvent({
type: EventType.RoomPowerLevels,
content: { users: { [defaultUserId]: startPowerLevel }, users_default: 1 },
});
mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
mockClient.getSafeUserId.mockReturnValueOnce(defaultUserId);
mockClient.getUserId.mockReturnValueOnce(defaultUserId);
renderComponent({
...defaultProps,
room: mockRoom,
roomPermissions: { ...defaultProps.roomPermissions, canEdit: true },
});
const changedPowerLevel = 100;
fireEvent.change(screen.getByRole("combobox", { name: "Power level" }), {
target: { value: changedPowerLevel },
});
await screen.findByText("Demote", { exact: true });
// firing the event will raise a dialog warning about self demotion, wait for this to appear then click on it
await userEvent.click(await screen.findByText("Demote", { exact: true }));
expect(mockClient.setPowerLevel).toHaveBeenCalledTimes(1);
expect(mockClient.setPowerLevel).toHaveBeenCalledWith(mockRoom.roomId, defaultMember.userId, changedPowerLevel);
});
});