Move language settings to 'preferences' (#12723)

* Move language settings to 'preferences'

Their new home is in this tab

* Update snapshot

* Move playwright test code

* Add test

* tests

* Update screenshot
This commit is contained in:
David Baker 2024-07-05 23:04:27 +01:00 committed by GitHub
parent dcf7643d4a
commit 81f29d13dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 198 additions and 110 deletions

View file

@ -73,24 +73,6 @@ test.describe("General user settings tab", () => {
// Assert that the add button is rendered // Assert that the add button is rendered
await expect(phoneNumbers.getByRole("button", { name: "Add" })).toBeVisible(); await expect(phoneNumbers.getByRole("button", { name: "Add" })).toBeVisible();
// Check language and region setting dropdown
const languageInput = uut.locator(".mx_GeneralUserSettingsTab_section_languageInput");
await languageInput.scrollIntoViewIfNeeded();
// Check the default value
await expect(languageInput.getByText("English")).toBeVisible();
// Click the button to display the dropdown menu
await languageInput.getByRole("button", { name: "Language Dropdown" }).click();
// Assert that the default option is rendered and highlighted
languageInput.getByRole("option", { name: /Albanian/ });
await expect(languageInput.getByRole("option", { name: /Albanian/ })).toHaveClass(
/mx_Dropdown_option_highlight/,
);
await expect(languageInput.getByRole("option", { name: /Deutsch/ })).toBeVisible();
// Click again to close the dropdown
await languageInput.getByRole("button", { name: "Language Dropdown" }).click();
// Assert that the default value is rendered again
await expect(languageInput.getByText("English")).toBeVisible();
const setIntegrationManager = uut.locator(".mx_SetIntegrationManager"); const setIntegrationManager = uut.locator(".mx_SetIntegrationManager");
await setIntegrationManager.scrollIntoViewIfNeeded(); await setIntegrationManager.scrollIntoViewIfNeeded();
await expect( await expect(

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2023 Suguru Hirahara Copyright 2023 Suguru Hirahara
Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -19,6 +20,10 @@ import { test, expect } from "../../element-web-test";
test.describe("Preferences user settings tab", () => { test.describe("Preferences user settings tab", () => {
test.use({ test.use({
displayName: "Bob", displayName: "Bob",
uut: async ({ app, user }, use) => {
const locator = await app.settings.openUserSettings("Preferences");
await use(locator);
},
}); });
test("should be rendered properly", async ({ app, user }) => { test("should be rendered properly", async ({ app, user }) => {
@ -28,4 +33,24 @@ test.describe("Preferences user settings tab", () => {
await expect(tab.getByRole("heading", { name: "Preferences" })).toBeVisible(); await expect(tab.getByRole("heading", { name: "Preferences" })).toBeVisible();
await expect(tab).toMatchScreenshot(); await expect(tab).toMatchScreenshot();
}); });
test("should be able to change the app language", async ({ uut, user }) => {
// Check language and region setting dropdown
const languageInput = uut.locator(".mx_GeneralUserSettingsTab_section_languageInput");
await languageInput.scrollIntoViewIfNeeded();
// Check the default value
await expect(languageInput.getByText("English")).toBeVisible();
// Click the button to display the dropdown menu
await languageInput.getByRole("button", { name: "Language Dropdown" }).click();
// Assert that the default option is rendered and highlighted
languageInput.getByRole("option", { name: /Albanian/ });
await expect(languageInput.getByRole("option", { name: /Albanian/ })).toHaveClass(
/mx_Dropdown_option_highlight/,
);
await expect(languageInput.getByRole("option", { name: /Deutsch/ })).toBeVisible();
// Click again to close the dropdown
await languageInput.getByRole("button", { name: "Language Dropdown" }).click();
// Assert that the default value is rendered again
await expect(languageInput.getByText("English")).toBeVisible();
});
}); });

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 54 KiB

View file

@ -34,3 +34,8 @@ limitations under the License.
margin-right: $spacing-8; margin-right: $spacing-8;
margin-bottom: 2px; margin-bottom: 2px;
} }
.mx_GeneralUserSettingsTab_section_hint {
font: var(--cpd-font-body-sm-regular);
color: var(--cpd-color-text-secondary);
}

View file

@ -22,25 +22,17 @@ import { logger } from "matrix-js-sdk/src/logger";
import { UserFriendlyError, _t } from "../../../../../languageHandler"; import { UserFriendlyError, _t } from "../../../../../languageHandler";
import UserProfileSettings from "../../UserProfileSettings"; import UserProfileSettings from "../../UserProfileSettings";
import * as languageHandler from "../../../../../languageHandler";
import SettingsStore from "../../../../../settings/SettingsStore"; import SettingsStore from "../../../../../settings/SettingsStore";
import LanguageDropdown from "../../../elements/LanguageDropdown";
import SpellCheckSettings from "../../SpellCheckSettings";
import AccessibleButton from "../../../elements/AccessibleButton"; import AccessibleButton from "../../../elements/AccessibleButton";
import DeactivateAccountDialog from "../../../dialogs/DeactivateAccountDialog"; import DeactivateAccountDialog from "../../../dialogs/DeactivateAccountDialog";
import PlatformPeg from "../../../../../PlatformPeg";
import Modal from "../../../../../Modal"; import Modal from "../../../../../Modal";
import { SettingLevel } from "../../../../../settings/SettingLevel";
import { UIFeature } from "../../../../../settings/UIFeature"; import { UIFeature } from "../../../../../settings/UIFeature";
import ErrorDialog, { extractErrorMessageFromError } from "../../../dialogs/ErrorDialog"; import ErrorDialog, { extractErrorMessageFromError } from "../../../dialogs/ErrorDialog";
import ChangePassword from "../../ChangePassword"; import ChangePassword from "../../ChangePassword";
import SetIntegrationManager from "../../SetIntegrationManager"; import SetIntegrationManager from "../../SetIntegrationManager";
import ToggleSwitch from "../../../elements/ToggleSwitch";
import { IS_MAC } from "../../../../../Keyboard";
import SettingsTab from "../SettingsTab"; import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection"; import { SettingsSection } from "../../shared/SettingsSection";
import SettingsSubsection, { SettingsSubsectionText } from "../../shared/SettingsSubsection"; import SettingsSubsection, { SettingsSubsectionText } from "../../shared/SettingsSubsection";
import { SettingsSubsectionHeading } from "../../shared/SettingsSubsectionHeading";
import { SDKContext } from "../../../../../contexts/SDKContext"; import { SDKContext } from "../../../../../contexts/SDKContext";
import UserPersonalInfoSettings from "../../UserPersonalInfoSettings"; import UserPersonalInfoSettings from "../../UserPersonalInfoSettings";
@ -49,9 +41,6 @@ interface IProps {
} }
interface IState { interface IState {
language: string;
spellCheckEnabled?: boolean;
spellCheckLanguages: string[];
canChangePassword: boolean; canChangePassword: boolean;
idServerName?: string; idServerName?: string;
externalAccountManagementUrl?: string; externalAccountManagementUrl?: string;
@ -69,9 +58,6 @@ export default class GeneralUserSettingsTab extends React.Component<IProps, ISta
this.context = context; this.context = context;
this.state = { this.state = {
language: languageHandler.getCurrentLanguage(),
spellCheckEnabled: false,
spellCheckLanguages: [],
canChangePassword: false, canChangePassword: false,
canMake3pidChanges: false, canMake3pidChanges: false,
canSetDisplayName: false, canSetDisplayName: false,
@ -81,21 +67,6 @@ export default class GeneralUserSettingsTab extends React.Component<IProps, ISta
this.getCapabilities(); this.getCapabilities();
} }
public async componentDidMount(): Promise<void> {
const plat = PlatformPeg.get();
const [spellCheckEnabled, spellCheckLanguages] = await Promise.all([
plat?.getSpellCheckEnabled(),
plat?.getSpellCheckLanguages(),
]);
if (spellCheckLanguages) {
this.setState({
spellCheckEnabled,
spellCheckLanguages,
});
}
}
private async getCapabilities(): Promise<void> { private async getCapabilities(): Promise<void> {
const cli = this.context.client!; const cli = this.context.client!;
@ -127,28 +98,6 @@ export default class GeneralUserSettingsTab extends React.Component<IProps, ISta
}); });
} }
private onLanguageChange = (newLanguage: string): void => {
if (this.state.language === newLanguage) return;
SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLanguage);
this.setState({ language: newLanguage });
const platform = PlatformPeg.get();
if (platform) {
platform.setLanguage([newLanguage]);
platform.reload();
}
};
private onSpellCheckLanguagesChange = (languages: string[]): void => {
this.setState({ spellCheckLanguages: languages });
PlatformPeg.get()?.setSpellCheckLanguages(languages);
};
private onSpellCheckEnabledChange = (spellCheckEnabled: boolean): void => {
this.setState({ spellCheckEnabled });
PlatformPeg.get()?.setSpellCheckEnabled(spellCheckEnabled);
};
private onPasswordChangeError = (err: Error): void => { private onPasswordChangeError = (err: Error): void => {
logger.error("Failed to change password: " + err); logger.error("Failed to change password: " + err);
@ -228,37 +177,6 @@ export default class GeneralUserSettingsTab extends React.Component<IProps, ISta
); );
} }
private renderLanguageSection(): JSX.Element {
// TODO: Convert to new-styled Field
return (
<SettingsSubsection heading={_t("settings|general|language_section")} stretchContent>
<LanguageDropdown
className="mx_GeneralUserSettingsTab_section_languageInput"
onOptionChange={this.onLanguageChange}
value={this.state.language}
/>
</SettingsSubsection>
);
}
private renderSpellCheckSection(): JSX.Element {
const heading = (
<SettingsSubsectionHeading heading={_t("settings|general|spell_check_section")}>
<ToggleSwitch checked={!!this.state.spellCheckEnabled} onChange={this.onSpellCheckEnabledChange} />
</SettingsSubsectionHeading>
);
return (
<SettingsSubsection heading={heading} stretchContent>
{this.state.spellCheckEnabled && !IS_MAC && (
<SpellCheckSettings
languages={this.state.spellCheckLanguages}
onLanguagesChange={this.onSpellCheckLanguagesChange}
/>
)}
</SettingsSubsection>
);
}
private renderManagementSection(): JSX.Element { private renderManagementSection(): JSX.Element {
// TODO: Improve warning text for account deactivation // TODO: Improve warning text for account deactivation
return ( return (
@ -283,9 +201,6 @@ export default class GeneralUserSettingsTab extends React.Component<IProps, ISta
} }
public render(): React.ReactNode { public render(): React.ReactNode {
const plaf = PlatformPeg.get();
const supportsMultiLanguageSpellCheck = plaf?.supportsSpellCheckSettings();
let accountManagementSection: JSX.Element | undefined; let accountManagementSection: JSX.Element | undefined;
const isAccountManagedExternally = !!this.state.externalAccountManagementUrl; const isAccountManagedExternally = !!this.state.externalAccountManagementUrl;
if (SettingsStore.getValue(UIFeature.Deactivate) && !isAccountManagedExternally) { if (SettingsStore.getValue(UIFeature.Deactivate) && !isAccountManagedExternally) {
@ -302,8 +217,6 @@ export default class GeneralUserSettingsTab extends React.Component<IProps, ISta
/> />
<UserPersonalInfoSettings canMake3pidChanges={this.state.canMake3pidChanges} /> <UserPersonalInfoSettings canMake3pidChanges={this.state.canMake3pidChanges} />
{this.renderAccountSection()} {this.renderAccountSection()}
{this.renderLanguageSection()}
{supportsMultiLanguageSpellCheck ? this.renderSpellCheckSection() : null}
</SettingsSection> </SettingsSection>
{this.renderIntegrationManagerSection()} {this.renderIntegrationManagerSection()}
{accountManagementSection} {accountManagementSection}

View file

@ -15,9 +15,9 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react"; import React, { useCallback, useEffect, useState } from "react";
import { _t } from "../../../../../languageHandler"; import { _t, getCurrentLanguage } from "../../../../../languageHandler";
import { UseCase } from "../../../../../settings/enums/UseCase"; import { UseCase } from "../../../../../settings/enums/UseCase";
import SettingsStore from "../../../../../settings/SettingsStore"; import SettingsStore from "../../../../../settings/SettingsStore";
import Field from "../../../elements/Field"; import Field from "../../../elements/Field";
@ -33,6 +33,11 @@ import { showUserOnboardingPage } from "../../../user-onboarding/UserOnboardingP
import SettingsSubsection from "../../shared/SettingsSubsection"; import SettingsSubsection from "../../shared/SettingsSubsection";
import SettingsTab from "../SettingsTab"; import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection"; import { SettingsSection } from "../../shared/SettingsSection";
import LanguageDropdown from "../../../elements/LanguageDropdown";
import PlatformPeg from "../../../../../PlatformPeg";
import { IS_MAC } from "../../../../../Keyboard";
import SpellCheckSettings from "../../SpellCheckSettings";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
interface IProps { interface IProps {
closeSettingsFn(success: boolean): void; closeSettingsFn(success: boolean): void;
@ -44,6 +49,79 @@ interface IState {
readMarkerOutOfViewThresholdMs: string; readMarkerOutOfViewThresholdMs: string;
} }
const LanguageSection: React.FC = () => {
const [language, setLanguage] = useState(getCurrentLanguage());
const onLanguageChange = useCallback(
(newLanguage: string) => {
if (language === newLanguage) return;
SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLanguage);
setLanguage(newLanguage);
const platform = PlatformPeg.get();
if (platform) {
platform.setLanguage([newLanguage]);
platform.reload();
}
},
[language],
);
return (
<div className="mx_SettingsSubsection_contentStretch">
{_t("settings|general|application_language")}
<LanguageDropdown
className="mx_GeneralUserSettingsTab_section_languageInput"
onOptionChange={onLanguageChange}
value={language}
/>
<div className="mx_GeneralUserSettingsTab_section_hint">
{_t("settings|general|application_language_reload_hint")}
</div>
</div>
);
};
const SpellCheckSection: React.FC = () => {
const [spellCheckEnabled, setSpellCheckEnabled] = useState<boolean | undefined>();
const [spellCheckLanguages, setSpellCheckLanguages] = useState<string[] | undefined>();
useEffect(() => {
(async () => {
const plaf = PlatformPeg.get();
const [enabled, langs] = await Promise.all([plaf?.getSpellCheckEnabled(), plaf?.getSpellCheckLanguages()]);
setSpellCheckEnabled(enabled);
setSpellCheckLanguages(langs || undefined);
})();
}, []);
const onSpellCheckEnabledChange = useCallback((enabled: boolean) => {
setSpellCheckEnabled(enabled);
PlatformPeg.get()?.setSpellCheckEnabled(enabled);
}, []);
const onSpellCheckLanguagesChange = useCallback((languages: string[]): void => {
setSpellCheckLanguages(languages);
PlatformPeg.get()?.setSpellCheckLanguages(languages);
}, []);
if (!PlatformPeg.get()?.supportsSpellCheckSettings()) return null;
return (
<>
<LabelledToggleSwitch
label={_t("settings|general|allow_spellcheck")}
value={Boolean(spellCheckEnabled)}
onChange={onSpellCheckEnabledChange}
/>
{spellCheckEnabled && spellCheckLanguages !== undefined && !IS_MAC && (
<SpellCheckSettings languages={spellCheckLanguages} onLanguagesChange={onSpellCheckLanguagesChange} />
)}
</>
);
};
export default class PreferencesUserSettingsTab extends React.Component<IProps, IState> { export default class PreferencesUserSettingsTab extends React.Component<IProps, IState> {
private static ROOM_LIST_SETTINGS = ["breadcrumbs", "FTUE.userOnboardingButton"]; private static ROOM_LIST_SETTINGS = ["breadcrumbs", "FTUE.userOnboardingButton"];
@ -146,6 +224,12 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
return ( return (
<SettingsTab data-testid="mx_PreferencesUserSettingsTab"> <SettingsTab data-testid="mx_PreferencesUserSettingsTab">
<SettingsSection> <SettingsSection>
{/* The heading string is still 'general' from where it was moved, but this section should become 'general' */}
<SettingsSubsection heading={_t("settings|general|language_section")}>
<LanguageSection />
<SpellCheckSection />
</SettingsSubsection>
{roomListSettings.length > 0 && ( {roomListSettings.length > 0 && (
<SettingsSubsection heading={_t("settings|preferences|room_list_heading")}> <SettingsSubsection heading={_t("settings|preferences|room_list_heading")}>
{this.renderGroup(roomListSettings)} {this.renderGroup(roomListSettings)}

View file

@ -2463,6 +2463,9 @@
"add_msisdn_dialog_title": "Add Phone Number", "add_msisdn_dialog_title": "Add Phone Number",
"add_msisdn_instructions": "A text message has been sent to +%(msisdn)s. Please enter the verification code it contains.", "add_msisdn_instructions": "A text message has been sent to +%(msisdn)s. Please enter the verification code it contains.",
"add_msisdn_misconfigured": "The add / bind with MSISDN flow is misconfigured", "add_msisdn_misconfigured": "The add / bind with MSISDN flow is misconfigured",
"allow_spellcheck": "Allow spell check",
"application_language": "Application language",
"application_language_reload_hint": "The app will reload after selecting another language",
"avatar_remove_progress": "Removing image...", "avatar_remove_progress": "Removing image...",
"avatar_save_progress": "Uploading image...", "avatar_save_progress": "Uploading image...",
"avatar_upload_error_text": "The file format is not supported or the image is larger than %(size)s.", "avatar_upload_error_text": "The file format is not supported or the image is larger than %(size)s.",
@ -2516,7 +2519,7 @@
"identity_server_no_token": "No identity access token found", "identity_server_no_token": "No identity access token found",
"identity_server_not_set": "Identity server not set", "identity_server_not_set": "Identity server not set",
"incorrect_msisdn_verification": "Incorrect verification code", "incorrect_msisdn_verification": "Incorrect verification code",
"language_section": "Language and region", "language_section": "Language",
"msisdn_in_use": "This phone number is already in use", "msisdn_in_use": "This phone number is already in use",
"msisdn_label": "Phone Number", "msisdn_label": "Phone Number",
"msisdn_verification_field_label": "Verification code", "msisdn_verification_field_label": "Verification code",
@ -2532,7 +2535,6 @@
"remove_email_prompt": "Remove %(email)s?", "remove_email_prompt": "Remove %(email)s?",
"remove_msisdn_prompt": "Remove %(phone)s?", "remove_msisdn_prompt": "Remove %(phone)s?",
"spell_check_locale_placeholder": "Choose a locale", "spell_check_locale_placeholder": "Choose a locale",
"spell_check_section": "Spell check",
"unable_to_load_emails": "Unable to load email addresses", "unable_to_load_emails": "Unable to load email addresses",
"unable_to_load_msisdns": "Unable to load phone numbers", "unable_to_load_msisdns": "Unable to load phone numbers",
"username": "Username" "username": "Username"

View file

@ -15,7 +15,8 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import { fireEvent, render, RenderResult, waitFor } from "@testing-library/react"; import { fireEvent, render, RenderResult, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import PreferencesUserSettingsTab from "../../../../../../src/components/views/settings/tabs/user/PreferencesUserSettingsTab"; import PreferencesUserSettingsTab from "../../../../../../src/components/views/settings/tabs/user/PreferencesUserSettingsTab";
import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
@ -23,6 +24,7 @@ import { mockPlatformPeg, stubClient } from "../../../../../test-utils";
import SettingsStore from "../../../../../../src/settings/SettingsStore"; import SettingsStore from "../../../../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../../../../src/settings/SettingLevel"; import { SettingLevel } from "../../../../../../src/settings/SettingLevel";
import MatrixClientBackedController from "../../../../../../src/settings/controllers/MatrixClientBackedController"; import MatrixClientBackedController from "../../../../../../src/settings/controllers/MatrixClientBackedController";
import PlatformPeg from "../../../../../../src/PlatformPeg";
describe("PreferencesUserSettingsTab", () => { describe("PreferencesUserSettingsTab", () => {
beforeEach(() => { beforeEach(() => {
@ -38,6 +40,43 @@ describe("PreferencesUserSettingsTab", () => {
expect(asFragment()).toMatchSnapshot(); expect(asFragment()).toMatchSnapshot();
}); });
it("should reload when changing language", async () => {
const reloadStub = jest.fn();
PlatformPeg.get()!.reload = reloadStub;
renderTab();
const languageDropdown = await screen.findByRole("button", { name: "Language Dropdown" });
expect(languageDropdown).toBeInTheDocument();
await userEvent.click(languageDropdown);
const germanOption = await screen.findByText("Deutsch");
await userEvent.click(germanOption);
expect(reloadStub).toHaveBeenCalled();
});
it("should not show spell check setting if unsupported", async () => {
PlatformPeg.get()!.supportsSpellCheckSettings = jest.fn().mockReturnValue(false);
renderTab();
expect(screen.queryByRole("switch", { name: "Allow spell check" })).not.toBeInTheDocument();
});
it("should enable spell check", async () => {
const spellCheckEnableFn = jest.fn();
PlatformPeg.get()!.supportsSpellCheckSettings = jest.fn().mockReturnValue(true);
PlatformPeg.get()!.getSpellCheckEnabled = jest.fn().mockReturnValue(false);
PlatformPeg.get()!.setSpellCheckEnabled = spellCheckEnableFn;
renderTab();
const toggle = await screen.findByRole("switch", { name: "Allow spell check" });
expect(toggle).toHaveAttribute("aria-checked", "false");
await userEvent.click(toggle);
expect(spellCheckEnableFn).toHaveBeenCalledWith(true);
});
describe("send read receipts", () => { describe("send read receipts", () => {
beforeEach(() => { beforeEach(() => {
stubClient(); stubClient();

View file

@ -15,6 +15,44 @@ exports[`PreferencesUserSettingsTab should render 1`] = `
<div <div
class="mx_SettingsSection_subSections" class="mx_SettingsSection_subSections"
> >
<div
class="mx_SettingsSubsection"
>
<div
class="mx_SettingsSubsectionHeading"
>
<h3
class="mx_Heading_h4 mx_SettingsSubsectionHeading_heading"
>
Language
</h3>
</div>
<div
class="mx_SettingsSubsection_content"
>
<div
class="mx_SettingsSubsection_contentStretch"
>
Application language
<div
class="mx_Spinner"
>
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
<div
class="mx_GeneralUserSettingsTab_section_hint"
>
The app will reload after selecting another language
</div>
</div>
</div>
</div>
<div <div
class="mx_SettingsSubsection" class="mx_SettingsSubsection"
> >