diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 1ae7648ca309..cca1889c8aa7 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -323,6 +323,7 @@ @import "./views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss"; @import "./views/rooms/wysiwyg_composer/components/_LinkModal.pcss"; @import "./views/settings/_AvatarSetting.pcss"; +@import "./views/settings/_PowerLevelSelector.pcss"; @import "./views/settings/_CrossSigningPanel.pcss"; @import "./views/settings/_CryptographyPanel.pcss"; @import "./views/settings/_FontScalingPanel.pcss"; diff --git a/res/css/views/settings/_PowerLevelSelector.pcss b/res/css/views/settings/_PowerLevelSelector.pcss new file mode 100644 index 000000000000..50745d1cd89f --- /dev/null +++ b/res/css/views/settings/_PowerLevelSelector.pcss @@ -0,0 +1,21 @@ +/* + * + * Copyright 2024 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * / + */ + +.mx_PowerLevelSelector_Button { + align-self: flex-start; +} diff --git a/src/components/views/settings/PowerLevelSelector.tsx b/src/components/views/settings/PowerLevelSelector.tsx new file mode 100644 index 000000000000..4dae7b3ec9f4 --- /dev/null +++ b/src/components/views/settings/PowerLevelSelector.tsx @@ -0,0 +1,140 @@ +/* + * + * Copyright 2024 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * / + */ + +import React, { useState, JSX, PropsWithChildren } from "react"; +import { Button } from "@vector-im/compound-web"; +import { compare } from "matrix-js-sdk/src/utils"; + +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; +import PowerSelector from "../elements/PowerSelector"; +import { _t } from "../../../languageHandler"; +import SettingsFieldset from "./SettingsFieldset"; + +/** + * Display in a fieldset, the power level of the users and allow to change them. + * The apply button is disabled until the power level of an user is changed + */ +interface PowerLevelSelectorProps { + /** + * The power levels of the users + * The key is the user id and the value is the power level + */ + userLevels: Record; + /** + * Whether the user can change the power levels of other users + */ + canChangeLevels: boolean; + /** + * The current user power level + */ + currentUserLevel: number; + /** + * The callback when the apply button is clicked + * @param value - new power level for the user + * @param userId - the user id + */ + onClick: (value: number, userId: string) => void; + /** + * Filter the users to display + * @param user + */ + filter: (user: string) => boolean; + /** + * The title of the fieldset + */ + title: string; +} + +export function PowerLevelSelector({ + userLevels, + canChangeLevels, + currentUserLevel, + onClick, + filter, + title, + children, +}: PropsWithChildren): JSX.Element | null { + const matrixClient = useMatrixClientContext(); + const [currentPowerLevel, setCurrentPowerLevel] = useState<{ value: number; userId: string } | null>(null); + + // If the power level has changed, we need to enable the apply button + const powerLevelChanged = Boolean( + currentPowerLevel && currentPowerLevel.value !== userLevels[currentPowerLevel?.userId], + ); + + // We sort the users by power level, then we filter them + const users = Object.keys(userLevels) + .sort((userA, userB) => sortUser(userA, userB, userLevels)) + .filter(filter); + + // No user to display, we return the children into fragment to convert it to JSX.Element type + if (!users.length) return <>{children}; + + return ( + + {users.map((userId) => { + // We only want to display users with a valid power level aka an integer + if (!Number.isInteger(userLevels[userId])) return; + + const isMe = userId === matrixClient.getUserId(); + // If I can change levels, I can change the level of anyone with a lower level than mine + const canChange = canChangeLevels && (userLevels[userId] < currentUserLevel || isMe); + + // When the new power level is selected, the fields are rerendered and we need to keep the current value + const userLevel = currentPowerLevel?.userId === userId ? currentPowerLevel?.value : userLevels[userId]; + + return ( + setCurrentPowerLevel({ value, userId })} + /> + ); + })} + + + + ); +} + +/** + * Sort the users by power level, then by name + * @param userA + * @param userB + * @param userLevels + */ +function sortUser(userA: string, userB: string, userLevels: PowerLevelSelectorProps["userLevels"]): number { + const powerLevelDiff = userLevels[userA] - userLevels[userB]; + return powerLevelDiff !== 0 ? powerLevelDiff : compare(userA.toLocaleLowerCase(), userB.toLocaleLowerCase()); +} diff --git a/test/components/views/settings/PowerLevelSelector-test.tsx b/test/components/views/settings/PowerLevelSelector-test.tsx new file mode 100644 index 000000000000..a7cd3d1d926a --- /dev/null +++ b/test/components/views/settings/PowerLevelSelector-test.tsx @@ -0,0 +1,112 @@ +/* + * + * Copyright 2024 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * / + */ + +import { render, screen } from "@testing-library/react"; +import React, { ComponentProps } from "react"; +import userEvent from "@testing-library/user-event"; + +import { PowerLevelSelector } from "../../../../src/components/views/settings/PowerLevelSelector"; +import { stubClient } from "../../../test-utils"; +import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; + +describe("PowerLevelSelector", () => { + const matrixClient = stubClient(); + + const currentUser = matrixClient.getUserId()!; + const userLevels = { + [currentUser]: 100, + "@alice:server.org": 50, + "@bob:server.org": 0, + }; + + const renderPLS = (props: Partial>) => + render( + + true} + onClick={jest.fn()} + {...props} + > + empty label + + , + ); + + it("should render", () => { + renderPLS({}); + expect(screen.getByRole("group")).toMatchSnapshot(); + }); + + it("should display only the current user", async () => { + // Display only the current user + renderPLS({ filter: (user) => user === currentUser }); + + // Only alice should be displayed + const userSelects = screen.getAllByRole("combobox"); + expect(userSelects).toHaveLength(1); + expect(userSelects[0]).toHaveAccessibleName(currentUser); + + expect(screen.getByRole("group")).toMatchSnapshot(); + }); + + it("should be able to change the power level of the current user", async () => { + const onClick = jest.fn(); + renderPLS({ onClick }); + + // Until the power level is changed, the apply button should be disabled + // compound button is using aria-disabled instead of the disabled attribute, we can't toBeDisabled on it + expect(screen.getByRole("button", { name: "Apply" })).toHaveAttribute("aria-disabled", "true"); + + // Change current user power level to 50 + const select = screen.getByRole("combobox", { name: currentUser }); + expect(select).toHaveValue("100"); + + // After the user level changes, the apply button should be enabled + await userEvent.selectOptions(select, "50"); + expect(select).toHaveValue("50"); + expect(screen.getByRole("button", { name: "Apply" })).toHaveAttribute("aria-disabled", "false"); + + // Click on Apply should call onClick with the new power level + await userEvent.click(screen.getByRole("button", { name: "Apply" })); + expect(onClick).toHaveBeenCalledWith(50, currentUser); + }); + + it("should not be able to change the power level if `canChangeLevels` is false", async () => { + renderPLS({ canChangeLevels: false }); + + // The selects should be disabled + const userSelects = screen.getAllByRole("combobox"); + userSelects.forEach((select) => expect(select).toBeDisabled()); + }); + + it("should be able to change only the level of someone with a lower level", async () => { + const userLevels = { + [currentUser]: 50, + "@alice:server.org": 100, + }; + renderPLS({ userLevels }); + + expect(screen.getByRole("combobox", { name: currentUser })).toBeEnabled(); + expect(screen.getByRole("combobox", { name: "@alice:server.org" })).toBeDisabled(); + }); +}); diff --git a/test/components/views/settings/__snapshots__/PowerLevelSelector-test.tsx.snap b/test/components/views/settings/__snapshots__/PowerLevelSelector-test.tsx.snap new file mode 100644 index 000000000000..a29be0079196 --- /dev/null +++ b/test/components/views/settings/__snapshots__/PowerLevelSelector-test.tsx.snap @@ -0,0 +1,233 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PowerLevelSelector should display only the current user 1`] = ` +
+ + title + +
+
+
+ + +
+
+ +
+
+`; + +exports[`PowerLevelSelector should render 1`] = ` +
+ + title + +
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+ +
+
+`;