diff --git a/.changeset/itchy-walls-boil.md b/.changeset/itchy-walls-boil.md new file mode 100644 index 0000000000..2afd0287fe --- /dev/null +++ b/.changeset/itchy-walls-boil.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-user-settings': minor +--- + +Added the ability to fully customize settings page. Deprecated UserSettingsTab in favour of SettingsLayout.Route diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 2526f6740f..fd73e99bcb 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -80,8 +80,8 @@ import { TextSize, } from '@backstage/plugin-techdocs-module-addons-contrib'; import { + SettingsLayout, UserSettingsPage, - UserSettingsTab, } from '@backstage/plugin-user-settings'; import { AdvancedSettings } from './components/advancedSettings'; import AlarmIcon from '@material-ui/icons/Alarm'; @@ -267,9 +267,9 @@ const routes = ( element={} /> }> - + - + } /> } /> diff --git a/plugins/user-settings/README.md b/plugins/user-settings/README.md index ce07d76257..9a99a69b36 100644 --- a/plugins/user-settings/README.md +++ b/plugins/user-settings/README.md @@ -16,7 +16,7 @@ for installation instructions. Add the item to the Sidebar: -```ts +```tsx import { Settings as SidebarSettings } from '@backstage/plugin-user-settings'; @@ -28,7 +28,7 @@ import { Settings as SidebarSettings } from '@backstage/plugin-user-settings'; Add the page to the App routing: -```ts +```tsx import { UserSettingsPage } from '@backstage/plugin-user-settings'; const AppRoutes = () => ( @@ -46,7 +46,7 @@ By default, the plugin provides a list of configured authentication providers fe If you want to supply your own custom list of Authentication Providers, use the `providerSettings` prop: -```ts +```tsx const MyAuthProviders = () => ( @@ -71,20 +71,20 @@ const AppRoutes = () => ( By default, the plugin renders 3 tabs of settings; GENERAL, AUTHENTICATION PROVIDERS, and FEATURE FLAGS. If you want to add more options for your users, -just pass the extra tabs using `UserSettingsTab` components as children of the `UserSettingsPage` route. +just pass the extra tabs using `SettingsLayout.Route` components as children of the `UserSettingsPage` route. The path is in this case a child of the settings path, in the example below it would be `/settings/advanced` so that you can easily link to it. ```tsx import { + SettingsLayout, UserSettingsPage, - UserSettingsTab, } from '@backstage/plugin-user-settings'; }> - + - + ; ``` @@ -93,3 +93,40 @@ make sure you use a similar component structure as the other tabs. You can take a look at [the example extra tab](https://github.com/backstage/backstage/blob/master/packages/app/src/components/advancedSettings/AdvancedSettings.tsx) we have created in Backstage's demo app. + +To change the layout altogether, create a custom page in `packages/app/src/components/user-settings/SettingsPage.tsx`: + +```tsx +import React from 'react'; +import { + SettingsLayout, + UserSettingsGeneral, +} from '@backstage/plugin-user-settings'; +import { AdvancedSettings } from './advancedSettings'; + +export const settingsPage = ( + + + + + + + + +); +``` + +Now register the new settings page in `packages/app/src/App.tsx`: + +```diff ++ import {settingsPage} from './components/settings/settingsPage'; + +const routes = ( + +- } /> ++ }> ++ {settingsPage} ++ + +); +``` diff --git a/plugins/user-settings/api-report.md b/plugins/user-settings/api-report.md index c51d4f992a..dd8f9428ca 100644 --- a/plugins/user-settings/api-report.md +++ b/plugins/user-settings/api-report.md @@ -18,10 +18,12 @@ import { Observable } from '@backstage/types'; import { ProfileInfo } from '@backstage/core-plugin-api'; import { ProfileInfoApi } from '@backstage/core-plugin-api'; import { PropsWithChildren } from 'react'; +import { default as React_2 } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; import { SessionApi } from '@backstage/core-plugin-api'; import { StorageApi } from '@backstage/core-plugin-api'; import { StorageValueSnapshot } from '@backstage/core-plugin-api'; +import { TabProps } from '@material-ui/core'; // @public (undocumented) export const DefaultProviderSettings: (props: { @@ -43,7 +45,33 @@ export const Router: (props: { providerSettings?: JSX.Element }) => JSX.Element; export const Settings: (props: { icon?: IconComponent }) => JSX.Element; // @public (undocumented) -export const USER_SETTINGS_TAB_KEY = 'user-settings.tab'; +export const SettingsLayout: { + (props: SettingsLayoutProps): JSX.Element; + Route: (props: SettingsLayoutRouteProps) => null; +}; + +// @public (undocumented) +export type SettingsLayoutProps = { + title?: string; + subtitle?: string; + children?: React_2.ReactNode; +}; + +// @public (undocumented) +export type SettingsLayoutRouteProps = { + path: string; + title: string; + children: JSX.Element; + tabProps?: TabProps< + React_2.ElementType, + { + component?: React_2.ElementType; + } + >; +}; + +// @public @deprecated (undocumented) +export const USER_SETTINGS_TAB_KEY = 'plugin.user-settings.settingsLayoutRoute'; // @public (undocumented) export const UserSettingsAppearanceCard: () => JSX.Element; @@ -116,10 +144,10 @@ export class UserSettingsStorage implements StorageApi { snapshot(key: string): StorageValueSnapshot; } -// @public +// @public @deprecated export const UserSettingsTab: (props: UserSettingsTabProps) => JSX.Element; -// @public (undocumented) +// @public @deprecated (undocumented) export type UserSettingsTabProps = PropsWithChildren<{ path: string; title: string; diff --git a/plugins/user-settings/src/components/SettingsPage.test.tsx b/plugins/user-settings/src/components/DefaultSettingsPage/DefaultSettingsPage.test.tsx similarity index 63% rename from plugins/user-settings/src/components/SettingsPage.test.tsx rename to plugins/user-settings/src/components/DefaultSettingsPage/DefaultSettingsPage.test.tsx index 01810bcc8e..d4560bdd06 100644 --- a/plugins/user-settings/src/components/SettingsPage.test.tsx +++ b/plugins/user-settings/src/components/DefaultSettingsPage/DefaultSettingsPage.test.tsx @@ -16,39 +16,53 @@ import React from 'react'; import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; -import { SettingsPage } from './SettingsPage'; -import { UserSettingsTab } from './UserSettingsTab'; +import { DefaultSettingsPage } from './DefaultSettingsPage'; +import { UserSettingsTab } from '../UserSettingsTab'; +import { useOutlet } from 'react-router'; +import { SettingsLayout } from '../SettingsLayout'; jest.mock('react-router', () => ({ ...jest.requireActual('react-router'), useOutlet: jest.fn().mockReturnValue(undefined), })); -import { useOutlet } from 'react-router'; - -describe('', () => { +describe('', () => { beforeEach(() => { (useOutlet as jest.Mock).mockReset(); }); it('should render the settings page with 3 tabs', async () => { const { container } = await renderWithEffects( - wrapInTestApp(), + wrapInTestApp(), ); const tabs = container.querySelectorAll('[class*=MuiTabs-root] button'); expect(tabs).toHaveLength(3); }); - it('should render the settings page with 4 tabs when extra tabs are provided', async () => { + it('should render the settings page with 4 tabs when extra tabs are provided via UserSettingsTab', async () => { const advancedTabRoute = (
Advanced settings
); - (useOutlet as jest.Mock).mockReturnValue(advancedTabRoute); const { container } = await renderWithEffects( - wrapInTestApp(), + wrapInTestApp(), + ); + + const tabs = container.querySelectorAll('[class*=MuiTabs-root] button'); + expect(tabs).toHaveLength(4); + expect(tabs[3].textContent).toEqual('Advanced'); + }); + + it('should render the settings page with 4 tabs when extra tabs are provided via SettingsLayout.Route', async () => { + const advancedTabRoute = ( + +
Advanced settings
+
+ ); + const { container } = await renderWithEffects( + wrapInTestApp(), ); const tabs = container.querySelectorAll('[class*=MuiTabs-root] button'); diff --git a/plugins/user-settings/src/components/DefaultSettingsPage/DefaultSettingsPage.tsx b/plugins/user-settings/src/components/DefaultSettingsPage/DefaultSettingsPage.tsx new file mode 100644 index 0000000000..6b5b63d6bd --- /dev/null +++ b/plugins/user-settings/src/components/DefaultSettingsPage/DefaultSettingsPage.tsx @@ -0,0 +1,49 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 from 'react'; +import { UserSettingsAuthProviders } from '../AuthProviders'; +import { UserSettingsFeatureFlags } from '../FeatureFlags'; +import { UserSettingsGeneral } from '../General'; +import { SettingsLayout, SettingsLayoutRouteProps } from '../SettingsLayout'; + +/** + * @public + */ +export const DefaultSettingsPage = (props: { + tabs?: React.ReactElement[]; + providerSettings?: JSX.Element; +}) => { + const { providerSettings, tabs } = props; + + return ( + + + + + + + + + + + {tabs} + + ); +}; diff --git a/plugins/user-settings/src/components/DefaultSettingsPage/index.ts b/plugins/user-settings/src/components/DefaultSettingsPage/index.ts new file mode 100644 index 0000000000..34dcb9340d --- /dev/null +++ b/plugins/user-settings/src/components/DefaultSettingsPage/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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. + */ + +export { DefaultSettingsPage } from './DefaultSettingsPage'; diff --git a/plugins/user-settings/src/components/SettingsLayout/SettingsLayout.tsx b/plugins/user-settings/src/components/SettingsLayout/SettingsLayout.tsx new file mode 100644 index 0000000000..9855fdd048 --- /dev/null +++ b/plugins/user-settings/src/components/SettingsLayout/SettingsLayout.tsx @@ -0,0 +1,82 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 from 'react'; +import { TabProps } from '@material-ui/core'; +import { + Header, + Page, + RoutedTabs, + useSidebarPinState, +} from '@backstage/core-components'; +import { + attachComponentData, + useElementFilter, +} from '@backstage/core-plugin-api'; + +/** @public */ +export type SettingsLayoutRouteProps = { + path: string; + title: string; + children: JSX.Element; + tabProps?: TabProps; +}; + +export const LAYOUT_DATA_KEY = 'plugin.user-settings.settingsLayout'; +export const LAYOUT_ROUTE_DATA_KEY = 'plugin.user-settings.settingsLayoutRoute'; + +const Route: (props: SettingsLayoutRouteProps) => null = () => null; +attachComponentData(Route, LAYOUT_ROUTE_DATA_KEY, true); + +// This causes all mount points that are discovered within this route to use the path of the route itself +attachComponentData(Route, 'core.gatherMountPoints', true); + +/** @public */ +export type SettingsLayoutProps = { + title?: string; + subtitle?: string; + children?: React.ReactNode; +}; + +/** + * @public + */ +export const SettingsLayout = (props: SettingsLayoutProps) => { + const { title, children } = props; + const { isMobile } = useSidebarPinState(); + + const routes = useElementFilter(children, elements => + elements + .selectByComponentData({ + key: LAYOUT_ROUTE_DATA_KEY, + withStrictError: + 'Child of SettingsLayout must be an SettingsLayout.Route', + }) + .getElements() + .map(child => child.props), + ); + + return ( + + {!isMobile &&
} + + + ); +}; + +attachComponentData(SettingsLayout, LAYOUT_DATA_KEY, true); + +SettingsLayout.Route = Route; diff --git a/plugins/user-settings/src/components/SettingsLayout/index.ts b/plugins/user-settings/src/components/SettingsLayout/index.ts new file mode 100644 index 0000000000..6e610f8078 --- /dev/null +++ b/plugins/user-settings/src/components/SettingsLayout/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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. + */ + +export type { + SettingsLayoutProps, + SettingsLayoutRouteProps, +} from './SettingsLayout'; +export { SettingsLayout } from './SettingsLayout'; diff --git a/plugins/user-settings/src/components/SettingsPage.tsx b/plugins/user-settings/src/components/SettingsPage.tsx deleted file mode 100644 index 9803bf4761..0000000000 --- a/plugins/user-settings/src/components/SettingsPage.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * 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 { - Header, - Page, - TabbedLayout, - useSidebarPinState, -} from '@backstage/core-components'; -import React from 'react'; -import { useOutlet } from 'react-router'; -import { useElementFilter } from '@backstage/core-plugin-api'; -import { UserSettingsAuthProviders } from './AuthProviders'; -import { UserSettingsFeatureFlags } from './FeatureFlags'; -import { UserSettingsGeneral } from './General'; -import { USER_SETTINGS_TAB_KEY, UserSettingsTabProps } from './UserSettingsTab'; - -/** - * @public - */ -export const SettingsPage = (props: { providerSettings?: JSX.Element }) => { - const { providerSettings } = props; - const { isMobile } = useSidebarPinState(); - const outlet = useOutlet(); - - const tabs = useElementFilter(outlet, elements => - elements - .selectByComponentData({ - key: USER_SETTINGS_TAB_KEY, - }) - .getElements(), - ); - - return ( - - {!isMobile &&
} - - - - - - - - - - - - {tabs.map((child, i) => ( - - {child} - - ))} - - - ); -}; diff --git a/plugins/user-settings/src/components/SettingsPage/SettingsPage.test.tsx b/plugins/user-settings/src/components/SettingsPage/SettingsPage.test.tsx new file mode 100644 index 0000000000..b6277f0b59 --- /dev/null +++ b/plugins/user-settings/src/components/SettingsPage/SettingsPage.test.tsx @@ -0,0 +1,106 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 from 'react'; +import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; +import { SettingsPage } from './SettingsPage'; +import { UserSettingsTab } from '../UserSettingsTab'; +import { useOutlet } from 'react-router'; +import { SettingsLayout } from '../SettingsLayout'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +jest.mock('react-router', () => ({ + ...jest.requireActual('react-router'), + useOutlet: jest.fn().mockReturnValue(undefined), +})); + +describe('', () => { + beforeEach(() => { + (useOutlet as jest.Mock).mockReset(); + }); + + it('should render the default settings page with 3 tabs', async () => { + const { container } = await renderWithEffects( + wrapInTestApp(), + ); + + const tabs = container.querySelectorAll('[class*=MuiTabs-root] button'); + expect(tabs).toHaveLength(3); + }); + + it('should render the default settings page with 4 tabs when extra tabs are provided via UserSettingsTab', async () => { + const advancedTabRoute = ( + +
Advanced settings
+
+ ); + (useOutlet as jest.Mock).mockReturnValue(advancedTabRoute); + const { container } = await renderWithEffects( + wrapInTestApp(), + ); + + const tabs = container.querySelectorAll('[class*=MuiTabs-root] button'); + expect(tabs).toHaveLength(4); + expect(tabs[3].textContent).toEqual('Advanced'); + }); + + it('should render the default settings page with 4 tabs when extra tabs are provided via SettingsLayout.Route', async () => { + const advancedTabRoute = ( + +
Advanced settings
+
+ ); + (useOutlet as jest.Mock).mockReturnValue(advancedTabRoute); + const { container } = await renderWithEffects( + wrapInTestApp(), + ); + + const tabs = container.querySelectorAll('[class*=MuiTabs-root] button'); + expect(tabs).toHaveLength(4); + expect(tabs[3].textContent).toEqual('Advanced'); + const user = userEvent.setup(); + await user.click(screen.getByText(/Advanced/i)); + const content = container.querySelectorAll('article'); + expect(content[0].textContent).toEqual('Advanced settings'); + }); + + it('should render the custom settings page when custom layout is provided', async () => { + const customLayout = ( + + +
User settings
+
+ +
Advanced settings
+
+
+ ); + (useOutlet as jest.Mock).mockReturnValue(customLayout); + const { container } = await renderWithEffects( + wrapInTestApp(), + ); + + const tabs = container.querySelectorAll('[class*=MuiTabs-root] button'); + expect(tabs).toHaveLength(2); + expect(tabs[0].textContent).toEqual('General'); + expect(tabs[1].textContent).toEqual('Advanced'); + const user = userEvent.setup(); + await user.click(screen.getByText(/Advanced/i)); + const content = container.querySelectorAll('article'); + expect(content[0].textContent).toEqual('Advanced settings'); + }); +}); diff --git a/plugins/user-settings/src/components/SettingsPage/SettingsPage.tsx b/plugins/user-settings/src/components/SettingsPage/SettingsPage.tsx new file mode 100644 index 0000000000..eec386d60e --- /dev/null +++ b/plugins/user-settings/src/components/SettingsPage/SettingsPage.tsx @@ -0,0 +1,55 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 { useOutlet } from 'react-router'; +import React from 'react'; +import { DefaultSettingsPage } from '../DefaultSettingsPage'; +import { useElementFilter } from '@backstage/core-plugin-api'; +import { + SettingsLayoutProps, + SettingsLayoutRouteProps, +} from '../SettingsLayout'; +import { + LAYOUT_DATA_KEY, + LAYOUT_ROUTE_DATA_KEY, +} from '../SettingsLayout/SettingsLayout'; + +/** @public */ +export const SettingsPage = (props: { providerSettings?: JSX.Element }) => { + const { providerSettings } = props; + const outlet = useOutlet(); + const layout = useElementFilter(outlet, elements => + elements + .selectByComponentData({ + key: LAYOUT_DATA_KEY, + }) + .getElements(), + ); + const tabs = useElementFilter(outlet, elements => + elements + .selectByComponentData({ + key: LAYOUT_ROUTE_DATA_KEY, + }) + .getElements(), + ); + + return ( + <> + {(layout.length !== 0 && layout) || ( + + )} + + ); +}; diff --git a/plugins/user-settings/src/components/SettingsPage/index.ts b/plugins/user-settings/src/components/SettingsPage/index.ts new file mode 100644 index 0000000000..e957a1bf09 --- /dev/null +++ b/plugins/user-settings/src/components/SettingsPage/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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. + */ +export { SettingsPage } from './SettingsPage'; diff --git a/plugins/user-settings/src/components/UserSettingsTab/UserSettingsTab.tsx b/plugins/user-settings/src/components/UserSettingsTab/UserSettingsTab.tsx index 171c54ecf3..ebddac00c1 100644 --- a/plugins/user-settings/src/components/UserSettingsTab/UserSettingsTab.tsx +++ b/plugins/user-settings/src/components/UserSettingsTab/UserSettingsTab.tsx @@ -15,11 +15,15 @@ */ import React, { PropsWithChildren } from 'react'; import { attachComponentData } from '@backstage/core-plugin-api'; +import { + LAYOUT_ROUTE_DATA_KEY, + SettingsLayout, +} from '../SettingsLayout/SettingsLayout'; -/** @public */ -export const USER_SETTINGS_TAB_KEY = 'user-settings.tab'; +/** @public @deprecated Use SettingsLayout.Route approach instead */ +export const USER_SETTINGS_TAB_KEY = LAYOUT_ROUTE_DATA_KEY; -/** @public */ +/** @public @deprecated Use SettingsLayoutRouteProps instead */ export type UserSettingsTabProps = PropsWithChildren<{ /** * The path to the tab in the settings route @@ -34,9 +38,12 @@ export type UserSettingsTabProps = PropsWithChildren<{ * Renders a tab inside the settings page * @param props - Component props * @public + * @deprecated Use SettingsLayout.Route instead */ -export const UserSettingsTab = (props: UserSettingsTabProps) => { - return <>{props.children}; -}; +export const UserSettingsTab = (props: UserSettingsTabProps) => ( + + <>props.children + +); attachComponentData(UserSettingsTab, USER_SETTINGS_TAB_KEY, 'UserSettingsTab'); diff --git a/plugins/user-settings/src/components/index.ts b/plugins/user-settings/src/components/index.ts index 8dc76ee7f1..8eb43a7a10 100644 --- a/plugins/user-settings/src/components/index.ts +++ b/plugins/user-settings/src/components/index.ts @@ -21,3 +21,4 @@ export * from './General'; export * from './FeatureFlags'; export { useUserProfile } from './useUserProfileInfo'; export * from './UserSettingsTab'; +export * from './SettingsLayout';