Add settings page and remove popup

This commit is contained in:
Marcus Eide
2020-10-06 13:06:58 +02:00
parent fdd976a15a
commit 8d68c6652a
20 changed files with 198 additions and 310 deletions
@@ -16,11 +16,14 @@
import React from 'react';
import { List } from '@material-ui/core';
import { InfoCard } from '@backstage/core';
type Props = {
providers: React.ReactNode;
};
export const AuthProvidersList = ({ providers }: Props) => (
<List dense>{providers}</List>
export const AuthProviders = ({ providers }: Props) => (
<InfoCard>
<List dense>{providers}</List>
</InfoCard>
);
@@ -23,7 +23,7 @@ import {
} from '@backstage/core';
import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
import { fireEvent } from '@testing-library/react';
import * as React from 'react';
import React from 'react';
import { DefaultProviderSettings } from './DefaultProviderSettings';
const mockSignInHandler = jest.fn().mockReturnValue('');
@@ -22,12 +22,13 @@ import {
FeatureFlagName,
FeatureFlagState,
FeatureFlagsRegistryItem,
InfoCard,
} from '@backstage/core';
import { FlagItem } from './FeatureFlagsItem';
type Props = { featureFlags: FeatureFlagsRegistryItem[] };
export const FeatureFlagsList = ({ featureFlags }: Props) => {
export const FeatureFlags = ({ featureFlags }: Props) => {
const featureFlagApi = useApi(featureFlagsApiRef);
const initialFlagState = featureFlags.reduce(
(result, featureFlag: FeatureFlagsRegistryItem) => {
@@ -57,19 +58,21 @@ export const FeatureFlagsList = ({ featureFlags }: Props) => {
);
return (
<List dense>
{featureFlags.map(featureFlag => {
const enabled = Boolean(state[featureFlag.name]);
<InfoCard>
<List dense>
{featureFlags.map(featureFlag => {
const enabled = Boolean(state[featureFlag.name]);
return (
<FlagItem
key={featureFlag.name}
flag={featureFlag}
enabled={enabled}
toggleHandler={toggleFlag}
/>
);
})}
</List>
return (
<FlagItem
key={featureFlag.name}
flag={featureFlag}
enabled={enabled}
toggleHandler={toggleFlag}
/>
);
})}
</List>
</InfoCard>
);
};
@@ -13,14 +13,25 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { InfoCard } from '@backstage/core';
import { Grid, List } from '@material-ui/core';
import React from 'react';
import { List } from '@material-ui/core';
import { ThemeToggle } from './ThemeToggle';
import { PinButton } from './PinButton';
import { Profile } from './Profile';
import { ThemeToggle } from './ThemeToggle';
export const AppSettingsList = () => (
<List dense>
<ThemeToggle />
<PinButton />
</List>
export const General = () => (
<Grid container spacing={3}>
<Grid item md={12}>
<Profile />
</Grid>
<Grid item md={12}>
<InfoCard>
<List dense>
<ThemeToggle />
<PinButton />
</List>
</InfoCard>
</Grid>
</Grid>
);
@@ -17,7 +17,7 @@
import { SidebarPinStateContext } from '@backstage/core';
import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
import { fireEvent } from '@testing-library/react';
import * as React from 'react';
import React from 'react';
import { PinButton } from './PinButton';
describe('<PinButton />', () => {
@@ -0,0 +1,54 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { InfoCard } from '@backstage/core';
import { Grid, Typography } from '@material-ui/core';
import React from 'react';
import { SignInAvatar } from './SignInAvatar';
import { UserSettingsMenu } from './UserSettingsMenu';
import { useUserProfile } from './useUserProfileInfo';
export const Profile = () => {
const { profile, displayName } = useUserProfile();
return (
<Grid container spacing={3}>
<Grid item md={12}>
<InfoCard title="Profile">
<Grid container spacing={6}>
<Grid item>
<SignInAvatar size={96} />
</Grid>
<Grid item xs={12} sm container>
<Grid item xs container direction="column" spacing={2}>
<Grid item xs>
<Typography variant="subtitle1" gutterBottom>
{displayName}
</Typography>
<Typography variant="body2" color="textSecondary">
{profile.email}
</Typography>
</Grid>
</Grid>
<Grid item>
<UserSettingsMenu />
</Grid>
</Grid>
</Grid>
</InfoCard>
</Grid>
</Grid>
);
};
@@ -14,13 +14,21 @@
* limitations under the License.
*/
import * as React from 'react';
import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
import { UserSettings } from './UserSettings';
import React from 'react';
import { SidebarItem } from '@backstage/core';
import { SignInAvatar } from './SignInAvatar';
import { useUserProfile } from './useUserProfileInfo';
import { settingsRouteRef } from '../plugin';
describe('<UserSettings />', () => {
it('renders', async () => {
const rendered = await renderWithEffects(wrapInTestApp(<UserSettings />));
expect(rendered.getByText('G')).toBeInTheDocument();
});
});
export const Settings = () => {
const { displayName } = useUserProfile();
const SidebarAvatar = () => <SignInAvatar />;
return (
<SidebarItem
text={displayName}
to={settingsRouteRef.path}
icon={SidebarAvatar}
/>
);
};
@@ -1,56 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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 {
ApiProvider,
ApiRegistry,
FeatureFlags,
featureFlagsApiRef,
} from '@backstage/core';
import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
import { fireEvent } from '@testing-library/react';
import React from 'react';
import { SettingsDialog } from './SettingsDialog';
const apiRegistry = ApiRegistry.from([
[featureFlagsApiRef, new FeatureFlags()],
]);
describe('<SettingsDialog />', () => {
const mockFn = jest.fn();
it('displays the users name and email, and the tabs and titles and updates the position', async () => {
const rendered = await renderWithEffects(
wrapInTestApp(
<ApiProvider apis={apiRegistry}>
<SettingsDialog updatePosition={mockFn} />
</ApiProvider>,
),
);
expect(rendered.getByText('Guest')).toBeInTheDocument();
expect(rendered.getByText('guest@example.com')).toBeInTheDocument();
expect(rendered.getByText('App Settings')).toBeInTheDocument();
expect(rendered.getByText('Additional Settings')).toBeInTheDocument();
expect(rendered.getByText('Auth Providers')).toBeInTheDocument();
expect(rendered.getByText('Feature Flags')).toBeInTheDocument();
const flagButton = rendered.getByText('Feature Flags');
expect(mockFn).toBeCalledTimes(1);
fireEvent.click(flagButton);
expect(mockFn).toBeCalledTimes(2);
});
});
@@ -1,110 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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, { ChangeEvent, useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, makeStyles } from '@material-ui/core';
import { AppSettingsList } from './AppSettingsList';
import { AuthProvidersList } from './AuthProviderList';
import { FeatureFlagsList } from './FeatureFlagsList';
import { SignInAvatar } from './SignInAvatar';
import { UserSettingsMenu } from './UserSettingsMenu';
import { useUserProfile } from './useUserProfileInfo';
import {
useApi,
featureFlagsApiRef,
TabbedCard,
CardTab,
InfoCard,
} from '@backstage/core';
import { DefaultProviderSettings } from './DefaultProviderSettings';
import { ProviderSettings } from './UserSettings';
import { Alert } from '@material-ui/lab';
const useStyles = makeStyles({
root: {
minWidth: 400,
maxWidth: 500,
},
});
type Props = {
providerSettings?: ProviderSettings;
updatePosition: () => void;
};
export const SettingsDialog = ({ providerSettings, updatePosition }: Props) => {
const classes = useStyles();
const { profile, displayName } = useUserProfile();
const featureFlagsApi = useApi(featureFlagsApiRef);
const featureFlags = featureFlagsApi.getRegisteredFlags();
const [selectedTab, setSelectedTab] = useState<string | number>('auth');
const providers = providerSettings ?? <DefaultProviderSettings />;
const ref = React.useRef(updatePosition);
const handleChange = (
_ev: ChangeEvent<{}>,
newSelectedTab: string | number,
) => {
setSelectedTab(newSelectedTab);
};
// Update the position of the popover to handle different heights
useEffect(() => {
ref.current?.();
}, [selectedTab]);
return (
<Card className={classes.root}>
<CardHeader
avatar={<SignInAvatar size={48} />}
action={<UserSettingsMenu />}
title={displayName}
subheader={profile.email}
/>
<CardContent>
<InfoCard
title="App Settings"
subheader="General settings related to how the app feels and looks"
variant="flat"
noPadding
>
<AppSettingsList />
</InfoCard>
<TabbedCard
title="Additional Settings"
subheader="Settings that are specific to a plugin, or feature of the app"
variant="flat"
value={selectedTab}
onChange={handleChange}
noPadding
>
<CardTab value="auth" label="Auth Providers">
<AuthProvidersList providers={providers} />
</CardTab>
<CardTab value="flags" label="Feature Flags">
{featureFlags.length > 0 ? (
<FeatureFlagsList featureFlags={featureFlags} />
) : (
// TODO(marcuseide): Replace with empty state component?
<Alert severity="info">No registered Feature Flags found</Alert>
)}
</CardTab>
</TabbedCard>
</CardContent>
</Card>
);
};
@@ -0,0 +1,64 @@
/*
* Copyright 2020 Spotify AB
*
* 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 } from 'react';
import {
Content,
featureFlagsApiRef,
Header,
HeaderTabs,
Page,
pageTheme,
useApi,
} from '@backstage/core';
import { General } from './General';
import { AuthProviders } from './AuthProviders';
import { DefaultProviderSettings } from './DefaultProviderSettings';
import { FeatureFlags } from './FeatureFlags';
type Props = {
providerSettings?: JSX.Element;
};
export const SettingsPage = ({ providerSettings }: Props) => {
const [activeTab, setActiveTab] = useState<number>(0);
const onTabChange = (index: number) => {
setActiveTab(index);
};
const featureFlagsApi = useApi(featureFlagsApiRef);
const featureFlags = featureFlagsApi.getRegisteredFlags();
const providers = providerSettings ?? <DefaultProviderSettings />;
const tabs = [
{ id: 'general', label: 'General' },
{ id: 'auth-providers', label: 'Authentication Providers' },
{ id: 'feature-flags', label: 'Feature Flags' },
];
const content = [
<General />,
<AuthProviders providers={providers} />,
<FeatureFlags featureFlags={featureFlags} />,
];
return (
<Page theme={pageTheme.home}>
<Header title="Settings" />
<HeaderTabs tabs={tabs} onChange={onTabChange} />
<Content>{content[activeTab]}</Content>
</Page>
);
};
@@ -20,23 +20,21 @@ import { makeStyles, Avatar } from '@material-ui/core';
import { useUserProfile } from './useUserProfileInfo';
import { sidebarConfig } from '@backstage/core';
const useStyles = makeStyles<BackstageTheme, { size: number }>({
const useStyles = makeStyles<BackstageTheme, { size: number }>(theme => ({
avatar: {
width: ({ size }) => size,
height: ({ size }) => size,
fontSize: ({ size }) => size * 0.7,
border: `1px solid ${theme.palette.textSubtle}`,
},
});
}));
type Props = { size?: number };
export const SignInAvatar = ({ size }: Props) => {
const { iconSize } = sidebarConfig;
const classes = useStyles(size ? { size } : { size: iconSize });
const { profile, displayName } = useUserProfile();
const { profile } = useUserProfile();
return (
<Avatar src={profile.picture} className={classes.avatar}>
{displayName[0]}
</Avatar>
);
return <Avatar src={profile.picture} className={classes.avatar} />;
};
@@ -23,7 +23,7 @@ import {
import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
import { lightTheme } from '@backstage/theme';
import { fireEvent } from '@testing-library/react';
import * as React from 'react';
import React from 'react';
import { ThemeToggle } from './ThemeToggle';
const mockTheme = {
@@ -1,94 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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, { useEffect, useContext, useCallback } from 'react';
import { Popover, PopoverActions } from '@material-ui/core';
import { SignInAvatar } from './SignInAvatar';
import { SettingsDialog } from './SettingsDialog';
import { SidebarItem, SidebarContext } from '@backstage/core';
import { useUserProfile } from './useUserProfileInfo';
export type ProviderSettings = JSX.Element;
type Props = {
/**
* Available auth providers. Providers will be rendered as children
* of a MUI List.
*
* If undefined, providers that are configured in app-config.yaml
* will be displayed instead.
*/
providerSettings?: ProviderSettings;
};
export const UserSettings = ({ providerSettings }: Props) => {
const { isOpen: sidebarOpen } = useContext(SidebarContext);
const { displayName } = useUserProfile();
const [open, setOpen] = React.useState(false);
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | undefined>(
undefined,
);
const popoverActionRef = React.useRef<PopoverActions | null>(null);
const updatePosition = useCallback(() => {
popoverActionRef?.current?.updatePosition();
}, []);
const handleOpen = (event?: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event?.currentTarget ?? undefined);
setOpen(true);
};
const handleClose = () => {
setAnchorEl(undefined);
setOpen(false);
};
useEffect(() => {
if (!sidebarOpen && open) setOpen(false);
}, [open, sidebarOpen]);
const SidebarAvatar = () => <SignInAvatar />;
return (
<>
<SidebarItem
text={displayName}
onClick={handleOpen}
icon={SidebarAvatar}
/>
<Popover
action={popoverActionRef}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: 'center',
horizontal: 'center',
}}
transformOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
>
<SettingsDialog
updatePosition={updatePosition}
providerSettings={providerSettings}
/>
</Popover>
</>
);
};
@@ -16,7 +16,7 @@
import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
import { fireEvent } from '@testing-library/react';
import * as React from 'react';
import React from 'react';
import { UserSettingsMenu } from './UserSettingsMenu';
describe('<UserSettingsMenu />', () => {
+2 -1
View File
@@ -14,4 +14,5 @@
* limitations under the License.
*/
export { plugin } from './plugin';
export { UserSettings } from './components/UserSettings';
export { Settings } from './components/Settings';
export { SettingsPage as Router } from './components/SettingsPage';
+6 -1
View File
@@ -13,7 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createPlugin } from '@backstage/core';
import { createPlugin, createRouteRef } from '@backstage/core';
export const settingsRouteRef = createRouteRef({
path: '/settings',
title: 'Settings',
});
export const plugin = createPlugin({
id: 'user-settings',