Merge branch 'master' into rugvip/depr

This commit is contained in:
Patrik Oldsberg
2022-01-17 13:07:16 +01:00
committed by GitHub
106 changed files with 2791 additions and 1360 deletions
+1
View File
@@ -57,6 +57,7 @@
"@roadiehq/backstage-plugin-github-insights": "^1.4.2",
"@roadiehq/backstage-plugin-github-pull-requests": "^1.3.2",
"@roadiehq/backstage-plugin-travis-ci": "^1.3.2",
"@roadiehq/backstage-plugin-buildkite": "^1.3.4",
"history": "^5.0.0",
"prop-types": "^15.7.2",
"react": "^16.13.1",
+44 -30
View File
@@ -23,12 +23,17 @@ import MapIcon from '@material-ui/icons/MyLocation';
import LayersIcon from '@material-ui/icons/Layers';
import LibraryBooks from '@material-ui/icons/LibraryBooks';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import SearchIcon from '@material-ui/icons/Search';
import MenuIcon from '@material-ui/icons/Menu';
import MoneyIcon from '@material-ui/icons/MonetizationOn';
import LogoFull from './LogoFull';
import LogoIcon from './LogoIcon';
import { NavLink } from 'react-router-dom';
import { GraphiQLIcon } from '@backstage/plugin-graphiql';
import { Settings as SidebarSettings } from '@backstage/plugin-user-settings';
import {
Settings as SidebarSettings,
UserSettingsSignInAvatar,
} from '@backstage/plugin-user-settings';
import {
SidebarSearchModal,
SearchContextProvider,
@@ -36,15 +41,15 @@ import {
import { Shortcuts } from '@backstage/plugin-shortcuts';
import {
Sidebar,
SidebarPage,
sidebarConfig,
SidebarContext,
SidebarItem,
SidebarDivider,
SidebarSpace,
SidebarGroup,
SidebarItem,
SidebarPage,
SidebarScrollWrapper,
SidebarSpace,
} from '@backstage/core-components';
import { AzurePullRequestsIcon } from '@backstage/plugin-azure-devops';
const useSidebarLogoStyles = makeStyles({
root: {
@@ -83,34 +88,43 @@ export const Root = ({ children }: PropsWithChildren<{}>) => (
<SidebarPage>
<Sidebar>
<SidebarLogo />
<SearchContextProvider>
<SidebarSearchModal />
</SearchContextProvider>
<SidebarGroup label="Search" icon={<SearchIcon />} to="/search">
<SearchContextProvider>
<SidebarSearchModal />
</SearchContextProvider>
</SidebarGroup>
<SidebarDivider />
{/* Global nav, not org-specific */}
<SidebarItem icon={HomeIcon} to="catalog" text="Home" />
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
<SidebarItem icon={LibraryBooks} to="docs" text="Docs" />
<SidebarItem icon={LayersIcon} to="explore" text="Explore" />
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />
{/* End global nav */}
<SidebarDivider />
<SidebarScrollWrapper>
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
<SidebarItem icon={RuleIcon} to="lighthouse" text="Lighthouse" />
<SidebarItem icon={MoneyIcon} to="cost-insights" text="Cost Insights" />
<SidebarItem icon={GraphiQLIcon} to="graphiql" text="GraphiQL" />
<SidebarItem
icon={AzurePullRequestsIcon}
to="azure-pull-requests"
text="Azure PRs"
/>
</SidebarScrollWrapper>
<SidebarDivider />
<Shortcuts />
<SidebarGroup label="Menu" icon={<MenuIcon />}>
{/* Global nav, not org-specific */}
<SidebarItem icon={HomeIcon} to="catalog" text="Home" />
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
<SidebarItem icon={LibraryBooks} to="docs" text="Docs" />
<SidebarItem icon={LayersIcon} to="explore" text="Explore" />
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />
{/* End global nav */}
<SidebarDivider />
<SidebarScrollWrapper>
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
<SidebarItem icon={RuleIcon} to="lighthouse" text="Lighthouse" />
<SidebarItem
icon={MoneyIcon}
to="cost-insights"
text="Cost Insights"
/>
<SidebarItem icon={GraphiQLIcon} to="graphiql" text="GraphiQL" />
</SidebarScrollWrapper>
<SidebarDivider />
<Shortcuts />
</SidebarGroup>
<SidebarSpace />
<SidebarDivider />
<SidebarSettings />
<SidebarGroup
label="Settings"
icon={<UserSettingsSignInAvatar />}
to="/settings"
>
<SidebarSettings />
</SidebarGroup>
</Sidebar>
{children}
</SidebarPage>
@@ -124,6 +124,10 @@ import {
EntityTravisCIOverviewCard,
isTravisciAvailable,
} from '@roadiehq/backstage-plugin-travis-ci';
import {
EntityBuildkiteContent,
isBuildkiteAvailable,
} from '@roadiehq/backstage-plugin-buildkite';
import {
isNewRelicDashboardAvailable,
EntityNewRelicDashboardContent,
@@ -174,6 +178,10 @@ export const cicdContent = (
<EntityJenkinsContent />
</EntitySwitch.Case>
<EntitySwitch.Case if={isBuildkiteAvailable}>
<EntityBuildkiteContent />
</EntitySwitch.Case>
<EntitySwitch.Case if={isCircleCIAvailable}>
<EntityCircleCIContent />
</EntitySwitch.Case>
@@ -21,6 +21,7 @@ import {
Header,
Lifecycle,
Page,
SidebarPinStateContext,
} from '@backstage/core-components';
import { CatalogResultListItem } from '@backstage/plugin-catalog';
import {
@@ -33,7 +34,7 @@ import {
} from '@backstage/plugin-search';
import { DocsResultListItem } from '@backstage/plugin-techdocs';
import { Grid, List, makeStyles, Paper, Theme } from '@material-ui/core';
import React from 'react';
import React, { useContext } from 'react';
const useStyles = makeStyles((theme: Theme) => ({
bar: {
@@ -52,9 +53,11 @@ const useStyles = makeStyles((theme: Theme) => ({
const SearchPage = () => {
const classes = useStyles();
const { isMobile } = useContext(SidebarPinStateContext);
return (
<Page themeId="home">
<Header title="Search" subtitle={<Lifecycle alpha />} />
{!isMobile && <Header title="Search" subtitle={<Lifecycle alpha />} />}
<Content>
<Grid container direction="row">
<Grid item xs={12}>
@@ -62,37 +65,39 @@ const SearchPage = () => {
<SearchBar debounceTime={100} />
</Paper>
</Grid>
<Grid item xs={3}>
<SearchType.Accordion
name="Result Type"
defaultValue="software-catalog"
types={[
{
value: 'software-catalog',
name: 'Software Catalog',
icon: <CatalogIcon />,
},
{
value: 'techdocs',
name: 'Documentation',
icon: <DocsIcon />,
},
]}
/>
<Paper className={classes.filters}>
<SearchFilter.Select
className={classes.filter}
name="kind"
values={['Component', 'Template']}
{!isMobile && (
<Grid item xs={3}>
<SearchType.Accordion
name="Result Type"
defaultValue="software-catalog"
types={[
{
value: 'software-catalog',
name: 'Software Catalog',
icon: <CatalogIcon />,
},
{
value: 'techdocs',
name: 'Documentation',
icon: <DocsIcon />,
},
]}
/>
<SearchFilter.Checkbox
className={classes.filter}
name="lifecycle"
values={['experimental', 'production']}
/>
</Paper>
</Grid>
<Grid item xs={9}>
<Paper className={classes.filters}>
<SearchFilter.Select
className={classes.filter}
name="kind"
values={['Component', 'Template']}
/>
<SearchFilter.Checkbox
className={classes.filter}
name="lifecycle"
values={['experimental', 'production']}
/>
</Paper>
</Grid>
)}
<Grid item xs>
<SearchResult>
{({ results }) => (
<List>
+4 -1
View File
@@ -39,7 +39,10 @@ const updateRedactionList = (
configs: AppConfig[],
logger: Logger,
) => {
const secretAppConfigs = schema.process(configs, { visibility: ['secret'] });
const secretAppConfigs = schema.process(configs, {
visibility: ['secret'],
withDeprecatedKeys: true,
});
const secretConfig = ConfigReader.fromConfigs(secretAppConfigs);
const values = new Set<string>();
const data = secretConfig.get();
+1
View File
@@ -32,6 +32,7 @@
"@backstage/integration": "^0.7.1",
"@backstage/plugin-app-backend": "^0.3.21",
"@backstage/plugin-auth-backend": "^0.6.2",
"@backstage/plugin-azure-devops-backend": "^0.3.0",
"@backstage/plugin-badges-backend": "^0.1.15",
"@backstage/plugin-catalog-backend": "^0.20.0",
"@backstage/plugin-code-coverage-backend": "^0.1.19",
+3
View File
@@ -40,6 +40,7 @@ import { Config } from '@backstage/config';
import healthcheck from './plugins/healthcheck';
import { metricsInit, metricsHandler } from './metrics';
import auth from './plugins/auth';
import azureDevOps from './plugins/azure-devops';
import catalog from './plugins/catalog';
import codeCoverage from './plugins/codecoverage';
import kubernetes from './plugins/kubernetes';
@@ -116,6 +117,7 @@ async function main() {
);
const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder'));
const authEnv = useHotMemoize(module, () => createEnv('auth'));
const azureDevOpsEnv = useHotMemoize(module, () => createEnv('azure-devops'));
const proxyEnv = useHotMemoize(module, () => createEnv('proxy'));
const rollbarEnv = useHotMemoize(module, () => createEnv('rollbar'));
const searchEnv = useHotMemoize(module, () => createEnv('search'));
@@ -139,6 +141,7 @@ async function main() {
apiRouter.use('/scaffolder', await scaffolder(scaffolderEnv));
apiRouter.use('/tech-insights', await techInsights(techInsightsEnv));
apiRouter.use('/auth', await auth(authEnv));
apiRouter.use('/azure-devops', await azureDevOps(azureDevOpsEnv));
apiRouter.use('/search', await search(searchEnv));
apiRouter.use('/techdocs', await techdocs(techdocsEnv));
apiRouter.use('/todo', await todo(todoEnv));
@@ -0,0 +1,26 @@
/*
* 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 { createRouter } from '@backstage/plugin-azure-devops-backend';
import { Router } from 'express';
import type { PluginEnvironment } from '../types';
export default function createPlugin({
logger,
config,
}: PluginEnvironment): Promise<Router> {
return createRouter({ logger, config });
}
+2 -2
View File
@@ -45,8 +45,8 @@
"@spotify/eslint-config-typescript": "^12.0.0",
"@sucrase/jest-plugin": "^2.1.1",
"@sucrase/webpack-loader": "^2.0.0",
"@svgr/plugin-jsx": "5.5.x",
"@svgr/plugin-svgo": "5.4.x",
"@svgr/plugin-jsx": "6.2.x",
"@svgr/plugin-svgo": "6.2.x",
"@svgr/rollup": "5.5.x",
"@svgr/webpack": "5.5.x",
"@types/webpack-env": "^1.15.2",
@@ -23,5 +23,6 @@ export default async (cmd: Command) => {
fromPackage: cmd.package,
mockEnv: cmd.lax,
fullVisibility: !cmd.frontend,
withDeprecatedKeys: cmd.deprecated,
});
};
+1
View File
@@ -193,6 +193,7 @@ export function registerCommands(program: CommanderStatic) {
)
.option('--lax', 'Do not require environment variables to be set')
.option('--frontend', 'Only validate the frontend configuration')
.option('--deprecated', 'Output deprecated configuration settings')
.option(...configOption)
.description(
'Validate that the given configuration loads and matches schema',
+2
View File
@@ -30,6 +30,7 @@ type Options = {
fromPackage?: string;
mockEnv?: boolean;
withFilteredKeys?: boolean;
withDeprecatedKeys?: boolean;
fullVisibility?: boolean;
};
@@ -91,6 +92,7 @@ export async function loadCliConfig(options: Options) {
? ['frontend', 'backend', 'secret']
: ['frontend'],
withFilteredKeys: options.withFilteredKeys,
withDeprecatedKeys: options.withDeprecatedKeys,
});
const frontendConfig = ConfigReader.fromConfigs(frontendAppConfigs);
+1
View File
@@ -21,6 +21,7 @@ export type ConfigSchemaProcessingOptions = {
visibility?: ConfigVisibility[];
valueTransform?: TransformFunc<any>;
withFilteredKeys?: boolean;
withDeprecatedKeys?: boolean;
};
// @public (undocumented)
@@ -182,10 +182,10 @@ function compileTsSchemas(paths: string[]) {
program,
// All schemas should export a `Config` symbol
'Config',
// This enables usage of @visibility is doc comments
// This enables usage of @visibility and @deprecated in doc comments
{
required: true,
validationKeywords: ['visibility'],
validationKeywords: ['visibility', 'deprecated'],
},
[path.split(sep).join('/')], // Unix paths are expected for all OSes here
) as JsonObject | null;
@@ -40,6 +40,7 @@ describe('compileConfigSchemas', () => {
],
visibilityByDataPath: new Map(),
visibilityBySchemaPath: new Map(),
deprecationByDataPath: new Map(),
});
expect(validate([{ data: { b: 'b' }, context: 'test' }])).toEqual({
errors: [
@@ -53,6 +54,7 @@ describe('compileConfigSchemas', () => {
],
visibilityByDataPath: new Map(),
visibilityBySchemaPath: new Map(),
deprecationByDataPath: new Map(),
});
});
@@ -112,6 +114,7 @@ describe('compileConfigSchemas', () => {
'/properties/d/items': 'frontend',
}),
),
deprecationByDataPath: new Map(),
});
});
@@ -137,4 +140,34 @@ describe('compileConfigSchemas', () => {
"Config schema visibility is both 'frontend' and 'secret' for properties/a/visibility",
);
});
it('should discover deprecations', () => {
const validate = compileConfigSchemas([
{
path: 'a1',
value: {
type: 'object',
properties: {
a: { type: 'string', deprecated: 'deprecation reason for a' },
b: { type: 'string', deprecated: 'deprecation reason for b' },
c: { type: 'string' },
},
},
},
]);
expect(
validate([
{ data: { a: 'a', b: 'b', c: 'c', d: ['d'] }, context: 'test' },
]),
).toEqual({
deprecationByDataPath: new Map(
Object.entries({
'/a': 'deprecation reason for a',
'/b': 'deprecation reason for b',
}),
),
visibilityByDataPath: new Map(),
visibilityBySchemaPath: new Map(),
});
});
});
@@ -40,6 +40,7 @@ export function compileConfigSchemas(
// output during validation. We work around this by having this extra piece
// of state that we reset before each validation.
const visibilityByDataPath = new Map<string, ConfigVisibility>();
const deprecationByDataPath = new Map<string, string>();
const ajv = new Ajv({
allErrors: true,
@@ -47,28 +48,48 @@ export function compileConfigSchemas(
schemas: {
'https://backstage.io/schema/config-v1': true,
},
}).addKeyword({
keyword: 'visibility',
metaSchema: {
type: 'string',
enum: CONFIG_VISIBILITIES,
},
compile(visibility: ConfigVisibility) {
return (_data, context) => {
if (context?.dataPath === undefined) {
return false;
}
if (visibility && visibility !== 'backend') {
})
.addKeyword({
keyword: 'visibility',
metaSchema: {
type: 'string',
enum: CONFIG_VISIBILITIES,
},
compile(visibility: ConfigVisibility) {
return (_data, context) => {
if (context?.dataPath === undefined) {
return false;
}
if (visibility && visibility !== 'backend') {
const normalizedPath = context.dataPath.replace(
/\['?(.*?)'?\]/g,
(_, segment) => `/${segment}`,
);
visibilityByDataPath.set(normalizedPath, visibility);
}
return true;
};
},
})
.removeKeyword('deprecated') // remove `deprecated` keyword so that we can implement our own compiler
.addKeyword({
keyword: 'deprecated',
metaSchema: { type: 'string' },
compile(deprecationDescription: string) {
return (_data, context) => {
if (context?.dataPath === undefined) {
return false;
}
const normalizedPath = context.dataPath.replace(
/\['?(.*?)'?\]/g,
(_, segment) => `/${segment}`,
);
visibilityByDataPath.set(normalizedPath, visibility);
}
return true;
};
},
});
// create mapping of deprecation description and data path of property
deprecationByDataPath.set(normalizedPath, deprecationDescription);
return true;
};
},
});
for (const schema of schemas) {
try {
@@ -79,6 +100,7 @@ export function compileConfigSchemas(
}
const merged = mergeConfigSchemas(schemas.map(_ => _.value));
const validate = ajv.compile(merged);
const visibilityBySchemaPath = new Map<string, ConfigVisibility>();
@@ -94,17 +116,20 @@ export function compileConfigSchemas(
visibilityByDataPath.clear();
const valid = validate(config);
if (!valid) {
return {
errors: validate.errors ?? [],
visibilityByDataPath: new Map(visibilityByDataPath),
visibilityBySchemaPath,
deprecationByDataPath,
};
}
return {
visibilityByDataPath: new Map(visibilityByDataPath),
visibilityBySchemaPath,
deprecationByDataPath,
};
};
}
@@ -64,6 +64,13 @@ const visibility = new Map<string, ConfigVisibility>(
}),
);
const deprecations = new Map<string, string>(
Object.entries({
'/arr': 'deprecated array',
'/objB/never': 'deprecated nested property',
}),
);
describe('filterByVisibility', () => {
test.each<[ConfigVisibility[], JsonObject]>([
[
@@ -185,9 +192,34 @@ describe('filterByVisibility', () => {
[['frontend', 'backend', 'secret'], { data, filteredKeys: [] }],
])('should filter correctly with %p', (filter, expected) => {
expect(
filterByVisibility(data, filter, visibility, undefined, true),
filterByVisibility(
data,
filter,
visibility,
deprecations,
undefined,
true,
false,
),
).toEqual(expected);
});
it('should include deprecated keys regardless of visibility', () => {
expect(
filterByVisibility(
data,
[],
visibility,
deprecations,
undefined,
true,
true,
).deprecatedKeys,
).toEqual([
{ key: 'arr', description: 'deprecated array' },
{ key: 'objB.never', description: 'deprecated nested property' },
]);
});
});
describe('filterErrorsByVisibility', () => {
@@ -30,10 +30,17 @@ export function filterByVisibility(
data: JsonObject,
includeVisibilities: ConfigVisibility[],
visibilityByDataPath: Map<string, ConfigVisibility>,
deprecationByDataPath: Map<string, string>,
transformFunc?: TransformFunc<number | string | boolean>,
withFilteredKeys?: boolean,
): { data: JsonObject; filteredKeys?: string[] } {
withDeprecatedKeys?: boolean,
): {
data: JsonObject;
filteredKeys?: string[];
deprecatedKeys?: { key: string; description: string }[];
} {
const filteredKeys = new Array<string>();
const deprecatedKeys = new Array<{ key: string; description: string }>();
function transform(
jsonVal: JsonValue,
@@ -44,6 +51,12 @@ export function filterByVisibility(
visibilityByDataPath.get(visibilityPath) ?? DEFAULT_CONFIG_VISIBILITY;
const isVisible = includeVisibilities.includes(visibility);
// deprecated keys are added regardless of visibility indicator
const deprecation = deprecationByDataPath.get(visibilityPath);
if (deprecation) {
deprecatedKeys.push({ key: filterPath, description: deprecation });
}
if (typeof jsonVal !== 'object') {
if (isVisible) {
if (transformFunc) {
@@ -109,6 +122,7 @@ export function filterByVisibility(
return {
filteredKeys: withFilteredKeys ? filteredKeys : undefined,
deprecatedKeys: withDeprecatedKeys ? deprecatedKeys : undefined,
data: (transform(data, '', '') as JsonObject) ?? {},
};
}
@@ -62,7 +62,10 @@ describe('loadConfigSchema', () => {
expect(schema.process(configs)).toEqual(configs);
expect(schema.process(configs, { visibility: ['frontend'] })).toEqual([
{ data: { key1: 'a' }, context: 'test' },
{
data: { key1: 'a' },
context: 'test',
},
]);
expect(
schema.process(configs, {
@@ -71,7 +74,11 @@ describe('loadConfigSchema', () => {
withFilteredKeys: true,
}),
).toEqual([
{ data: { key1: 'X' }, context: 'test', filteredKeys: ['key2'] },
{
data: { key1: 'X' },
context: 'test',
filteredKeys: ['key2'],
},
]);
expect(
schema.process(configs, {
@@ -79,14 +86,21 @@ describe('loadConfigSchema', () => {
withFilteredKeys: true,
}),
).toEqual([
{ data: { key1: 'X', key2: 'X' }, context: 'test', filteredKeys: [] },
{
data: { key1: 'X', key2: 'X' },
context: 'test',
filteredKeys: [],
},
]);
const serialized = schema.serialize();
const schema2 = await loadConfigSchema({ serialized });
expect(schema2.process(configs, { visibility: ['frontend'] })).toEqual([
{ data: { key1: 'a' }, context: 'test' },
{
data: { key1: 'a' },
context: 'test',
},
]);
expect(() =>
schema2.process([...configs, { data: { key1: 3 }, context: 'test2' }]),
@@ -131,7 +145,10 @@ describe('loadConfigSchema', () => {
'Config validation failed, Config should be number { type=number } at /key2',
);
expect(schema.process(configs, { visibility: ['frontend'] })).toEqual([
{ data: { key1: 'a' }, context: 'test' },
{
data: { key1: 'a' },
context: 'test',
},
]);
expect(() => schema.process(configs, { visibility: ['secret'] })).toThrow(
'Config validation failed, Config should be number { type=number } at /key2',
@@ -179,7 +196,12 @@ describe('loadConfigSchema', () => {
];
expect(
schema.process(mkConfig({ x: 1 }), { visibility: ['frontend'] }),
).toEqual([{ data: { nested: [{}] }, context: 'test' }]);
).toEqual([
{
data: { nested: [{}] },
context: 'test',
},
]);
expect(() => schema.process(mkConfig({ y: 1 }))).toThrow(
'Config validation failed, Config should be string { type=string } at /nested/0/y',
);
@@ -190,10 +212,20 @@ describe('loadConfigSchema', () => {
);
expect(
schema.process(mkConfig({ x: 'a' }), { visibility: ['frontend'] }),
).toEqual([{ data: { nested: [{}] }, context: 'test' }]);
).toEqual([
{
data: { nested: [{}] },
context: 'test',
},
]);
expect(
schema.process(mkConfig({ y: 'aaa' }), { visibility: ['frontend'] }),
).toEqual([{ data: { nested: [{ y: 'aaa' }] }, context: 'test' }]);
).toEqual([
{
data: { nested: [{ y: 'aaa' }] },
context: 'test',
},
]);
expect(() =>
schema.process(mkConfig({ y: 'aaaa' }), { visibility: ['frontend'] }),
).toThrow(
@@ -82,7 +82,7 @@ export async function loadConfigSchema(
return {
process(
configs: AppConfig[],
{ visibility, valueTransform, withFilteredKeys } = {},
{ visibility, valueTransform, withFilteredKeys, withDeprecatedKeys } = {},
): AppConfig[] {
const result = validate(configs);
@@ -105,8 +105,10 @@ export async function loadConfigSchema(
data,
visibility,
result.visibilityByDataPath,
result.deprecationByDataPath,
valueTransform,
withFilteredKeys,
withDeprecatedKeys,
),
}));
} else if (valueTransform) {
@@ -116,8 +118,10 @@ export async function loadConfigSchema(
data,
Array.from(CONFIG_VISIBILITIES),
result.visibilityByDataPath,
result.deprecationByDataPath,
valueTransform,
withFilteredKeys,
withDeprecatedKeys,
),
}));
}
@@ -81,6 +81,13 @@ type ValidationResult = {
* The path in the key uses the form `/properties/<key>/items/additionalProperties/<leaf-key>`
*/
visibilityBySchemaPath: Map<string, ConfigVisibility>;
/**
* The deprecated options that were discovered during validation.
*
* The path in the key uses the form `/<key>/<sub-key>/<array-index>/<leaf-key>`
*/
deprecationByDataPath: Map<string, string>;
};
/**
@@ -124,6 +131,13 @@ export type ConfigSchemaProcessingOptions = {
* Default: `false`.
*/
withFilteredKeys?: boolean;
/**
* Whether or not to include the `deprecatedKeys` property in the output `AppConfig`s.
*
* Default: `true`.
*/
withDeprecatedKeys?: boolean;
};
/**
+4
View File
@@ -13,6 +13,10 @@ export type AppConfig = {
context: string;
data: JsonObject_2;
filteredKeys?: string[];
deprecatedKeys?: {
key: string;
description: string;
}[];
};
// @public
+13 -1
View File
@@ -83,9 +83,21 @@ export class ConfigReader implements Config {
// Merge together all configs into a single config with recursive fallback
// readers, giving the first config object in the array the lowest priority.
return configs.reduce<ConfigReader>(
(previousReader, { data, context, filteredKeys }) => {
(previousReader, { data, context, filteredKeys, deprecatedKeys }) => {
const reader = new ConfigReader(data, context, previousReader);
reader.filteredKeys = filteredKeys;
if (deprecatedKeys) {
for (const { key, description } of deprecatedKeys) {
// eslint-disable-next-line no-console
console.warn(
`The configuration key '${key}' of ${context} is deprecated and may be removed soon. ${
description || ''
}`,
);
}
}
return reader;
},
undefined!,
+6
View File
@@ -36,6 +36,12 @@ export type AppConfig = {
* This can be used to warn the user if they try to read any of these keys.
*/
filteredKeys?: string[];
/**
* A list of deprecated keys that were found in the configuration when it was loaded.
*
* This can be used to warn the user if they are using deprecated properties.
*/
deprecatedKeys?: { key: string; description: string }[];
};
/**
+52 -23
View File
@@ -10,6 +10,7 @@ import { BackstageIdentityApi } from '@backstage/core-plugin-api';
import { BackstagePalette } from '@backstage/theme';
import { BackstageTheme } from '@backstage/theme';
import { BackstageUserIdentity } from '@backstage/core-plugin-api';
import { BottomNavigationActionProps } from '@material-ui/core/BottomNavigationAction';
import { ButtonProps as ButtonProps_2 } from '@material-ui/core/Button';
import { CardHeaderProps } from '@material-ui/core/CardHeader';
import { Column } from '@material-table/core';
@@ -104,7 +105,7 @@ export type BottomLinkProps = {
// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts
//
// @public
export function Breadcrumbs(props: Props_20): JSX.Element;
export function Breadcrumbs(props: Props_19): JSX.Element;
// @public (undocumented)
export type BreadcrumbsClickableTextClassKey = 'root';
@@ -695,6 +696,14 @@ export function MissingAnnotationEmptyState(props: Props_3): JSX.Element;
// @public (undocumented)
export type MissingAnnotationEmptyStateClassKey = 'code';
// @public
export const MobileSidebar: (props: MobileSidebarProps) => JSX.Element | null;
// @public
export type MobileSidebarProps = {
children?: React_2.ReactNode;
};
// Warning: (ae-missing-release-tag) "OAuthRequestDialog" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -844,11 +853,8 @@ export type SelectItem = {
value: string | number;
};
// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "Sidebar" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export function Sidebar(props: PropsWithChildren<Props_17>): JSX.Element;
// @public
export const Sidebar: (props: SidebarProps) => JSX.Element;
// Warning: (ae-missing-release-tag) "SIDEBAR_INTRO_LOCAL_STORAGE" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -856,10 +862,8 @@ export function Sidebar(props: PropsWithChildren<Props_17>): JSX.Element;
export const SIDEBAR_INTRO_LOCAL_STORAGE =
'@backstage/core/sidebar-intro-dismissed';
// Warning: (ae-missing-release-tag) "SidebarClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type SidebarClassKey = 'root' | 'drawer' | 'drawerOpen';
export type SidebarClassKey = 'drawer' | 'drawerOpen';
// Warning: (ae-missing-release-tag) "sidebarConfig" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -877,16 +881,17 @@ export const sidebarConfig: {
selectedIndicatorWidth: number;
userBadgePadding: number;
userBadgeDiameter: number;
mobileSidebarHeight: number;
};
// Warning: (ae-missing-release-tag) "SidebarContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
// @public
export const SidebarContext: Context<SidebarContextType>;
// Warning: (ae-missing-release-tag) "SidebarContextType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
// @public
export type SidebarContextType = {
isOpen: boolean;
setOpen: (open: boolean) => void;
@@ -1169,6 +1174,16 @@ export type SidebarDividerClassKey = 'root';
// @public
export const SidebarExpandButton: () => JSX.Element | null;
// @public
export const SidebarGroup: (props: SidebarGroupProps) => JSX.Element;
// @public
export interface SidebarGroupProps extends BottomNavigationActionProps {
children?: React_2.ReactNode;
priority?: number;
to?: string;
}
// Warning: (ae-missing-release-tag) "SidebarIntro" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -1185,19 +1200,19 @@ export type SidebarIntroClassKey =
// Warning: (ae-forgotten-export) The symbol "SidebarItemProps" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "SidebarItem" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
// @public
export const SidebarItem: React_2.ForwardRefExoticComponent<
SidebarItemProps & React_2.RefAttributes<any>
>;
// Warning: (ae-missing-release-tag) "SidebarItemClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type SidebarItemClassKey =
| 'root'
| 'buttonItem'
| 'closed'
| 'open'
| 'highlightable'
| 'highlighted'
| 'label'
| 'iconContainer'
| 'searchRoot'
@@ -1205,29 +1220,43 @@ export type SidebarItemClassKey =
| 'searchFieldHTMLInput'
| 'searchContainer'
| 'secondaryAction'
| 'closedItemIcon'
| 'submenuArrow'
| 'expandButton'
| 'arrows'
| 'selected';
// Warning: (ae-missing-release-tag) "SidebarPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export function SidebarPage(props: PropsWithChildren<{}>): JSX.Element;
export function SidebarPage(props: SidebarPageProps): JSX.Element;
// Warning: (ae-missing-release-tag) "SidebarPageClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type SidebarPageClassKey = 'root';
// Warning: (ae-missing-release-tag) "SidebarPinStateContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
// @public
export type SidebarPageProps = {
children?: React_2.ReactNode;
};
// @public
export const SidebarPinStateContext: React_2.Context<SidebarPinStateContextType>;
// Warning: (ae-missing-release-tag) "SidebarPinStateContextType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
// @public
export type SidebarPinStateContextType = {
isPinned: boolean;
toggleSidebarPinState: () => any;
isMobile?: boolean;
};
// @public (undocumented)
export type SidebarProps = {
openDelayMs?: number;
closeDelayMs?: number;
disableExpandOnHover?: boolean;
children?: React_2.ReactNode;
};
// Warning: (ae-missing-release-tag) "SidebarScrollWrapper" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -2085,7 +2114,7 @@ export type SidebarSubmenuProps = {
// Warning: (ae-missing-release-tag) "SignInPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export function SignInPage(props: Props_18): JSX.Element;
export function SignInPage(props: Props_17): JSX.Element;
// Warning: (ae-missing-release-tag) "SignInPageClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -2256,7 +2285,7 @@ export type TabBarClassKey = 'indicator' | 'flexContainer' | 'root';
// Warning: (ae-missing-release-tag) "TabbedCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export function TabbedCard(props: PropsWithChildren<Props_19>): JSX.Element;
export function TabbedCard(props: PropsWithChildren<Props_18>): JSX.Element;
// Warning: (ae-missing-release-tag) "TabbedCardClassKey" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
+1 -1
View File
@@ -58,7 +58,7 @@
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-sparklines": "^1.7.0",
"react-syntax-highlighter": "^15.4.3",
"react-syntax-highlighter": "^15.4.5",
"react-text-truncate": "^0.16.0",
"react-use": "^17.2.4",
"react-virtualized-auto-sizer": "^1.0.6",
@@ -20,7 +20,7 @@ import { makeStyles, ThemeProvider } from '@material-ui/core/styles';
export type PageClassKey = 'root';
const useStyles = makeStyles(
const useStyles = makeStyles<BackstageTheme>(
() => ({
root: {
display: 'grid',
@@ -28,7 +28,7 @@ const useStyles = makeStyles(
"'pageHeader pageHeader pageHeader' 'pageSubheader pageSubheader pageSubheader' 'pageNav pageContent pageSidebar'",
gridTemplateRows: 'max-content auto 1fr',
gridTemplateColumns: 'auto 1fr auto',
height: '100vh',
height: '100%',
overflowY: 'auto',
},
}),
@@ -14,24 +14,32 @@
* limitations under the License.
*/
import React from 'react';
import { renderInTestApp } from '@backstage/test-utils';
import AcUnitIcon from '@material-ui/icons/AcUnit';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import BuildRoundedIcon from '@material-ui/icons/BuildRounded';
import MenuBookIcon from '@material-ui/icons/MenuBook';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import BuildRoundedIcon from '@material-ui/icons/BuildRounded';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import MenuBookIcon from '@material-ui/icons/MenuBook';
import AcUnitIcon from '@material-ui/icons/AcUnit';
import { Sidebar, SidebarExpandButton } from './Bar';
import { SidebarItem, SidebarSearchField } from './Items';
import { SidebarSubmenuItem } from './SidebarSubmenuItem';
import { SidebarSubmenu } from './SidebarSubmenu';
import { SidebarPinStateContext } from '.';
import React from 'react';
import {
Sidebar,
SidebarExpandButton,
SidebarItem,
SidebarSearchField,
SidebarPinStateContext,
SidebarSubmenu,
SidebarSubmenuItem,
} from '.';
async function renderScalableSidebar() {
await renderInTestApp(
<SidebarPinStateContext.Provider
value={{ isPinned: false, toggleSidebarPinState: () => {} }}
value={{
isPinned: false,
isMobile: false,
toggleSidebarPinState: () => {},
}}
>
<Sidebar disableExpandOnHover>
<SidebarSearchField onSearch={() => {}} to="/search" />
@@ -17,23 +17,17 @@
import { makeStyles } from '@material-ui/core/styles';
import useMediaQuery from '@material-ui/core/useMediaQuery';
import classnames from 'classnames';
import React, { useState, useContext, PropsWithChildren, useRef } from 'react';
import React, { useState, useContext, useRef } from 'react';
import { sidebarConfig, SidebarContext } from './config';
import { BackstageTheme } from '@backstage/theme';
import { SidebarPinStateContext } from './Page';
import DoubleArrowRight from './icons/DoubleArrowRight';
import DoubleArrowLeft from './icons/DoubleArrowLeft';
import { MobileSidebar } from './MobileSidebar';
export type SidebarClassKey = 'root' | 'drawer' | 'drawerOpen';
/** @public */
export type SidebarClassKey = 'drawer' | 'drawerOpen';
const useStyles = makeStyles<BackstageTheme>(
theme => ({
root: {
zIndex: 1000,
position: 'relative',
overflow: 'visible',
width: theme.spacing(7) + 1,
},
drawer: {
display: 'flex',
flexFlow: 'column nowrap',
@@ -42,7 +36,7 @@ const useStyles = makeStyles<BackstageTheme>(
left: 0,
top: 0,
bottom: 0,
padding: 0,
zIndex: theme.zIndex.appBar,
background: theme.palette.navigation.background,
overflowX: 'hidden',
msOverflowStyle: 'none',
@@ -59,19 +53,6 @@ const useStyles = makeStyles<BackstageTheme>(
display: 'none',
},
},
expandButton: {
background: 'none',
border: 'none',
color: theme.palette.navigation.color,
width: '100%',
cursor: 'pointer',
position: 'relative',
height: 48,
},
arrows: {
position: 'absolute',
right: 10,
},
drawerOpen: {
width: sidebarConfig.drawerWidthOpen,
transition: theme.transitions.create('width', {
@@ -89,29 +70,44 @@ enum State {
Open,
}
type Props = {
/** @public */
export type SidebarProps = {
openDelayMs?: number;
closeDelayMs?: number;
disableExpandOnHover?: boolean;
children?: React.ReactNode;
};
export function Sidebar(props: PropsWithChildren<Props>) {
/**
* Places the Sidebar & wraps the children providing context weather the `Sidebar` is open or not.
*
* Handles & delays hover events for expanding the `Sidebar`
*
* @param props `disableExpandOnHover` disables the default hover behaviour;
* `openDelayMs` & `closeDelayMs` set delay until sidebar will open/close on hover
* @returns
* @internal
*/
const DesktopSidebar = (props: SidebarProps) => {
const {
disableExpandOnHover = false,
openDelayMs = sidebarConfig.defaultOpenDelayMs,
closeDelayMs = sidebarConfig.defaultCloseDelayMs,
disableExpandOnHover,
children,
} = props;
const classes = useStyles();
const isSmallScreen = useMediaQuery<BackstageTheme>(theme =>
theme.breakpoints.down('md'),
const isSmallScreen = useMediaQuery<BackstageTheme>(
theme => theme.breakpoints.down('md'),
{ noSsr: true },
);
const [state, setState] = useState(State.Closed);
const hoverTimerRef = useRef<number>();
const { isPinned } = useContext(SidebarPinStateContext);
const { isPinned, toggleSidebarPinState } = useContext(
SidebarPinStateContext,
);
const handleOpen = () => {
if (isPinned) {
if (isPinned || disableExpandOnHover) {
return;
}
if (hoverTimerRef.current) {
@@ -129,7 +125,7 @@ export function Sidebar(props: PropsWithChildren<Props>) {
};
const handleClose = () => {
if (isPinned) {
if (isPinned || disableExpandOnHover) {
return;
}
if (hoverTimerRef.current) {
@@ -148,73 +144,60 @@ export function Sidebar(props: PropsWithChildren<Props>) {
const isOpen = (state === State.Open && !isSmallScreen) || isPinned;
/**
* Close/Open Sidebar directily without delays. Also toggles `SidebarPinState` to avoid hidden content behind Sidebar.
*/
const setOpen = (open: boolean) => {
if (open) {
handleOpen();
setState(State.Open);
toggleSidebarPinState();
} else {
handleClose();
setState(State.Closed);
toggleSidebarPinState();
}
};
return (
<div
className={classes.root}
data-testid="sidebar-root"
onMouseEnter={disableExpandOnHover ? () => {} : handleOpen}
onFocus={disableExpandOnHover ? () => {} : handleOpen}
onMouseLeave={disableExpandOnHover ? () => {} : handleClose}
onBlur={disableExpandOnHover ? () => {} : handleClose}
<SidebarContext.Provider
value={{
isOpen,
setOpen,
}}
>
<SidebarContext.Provider
value={{
isOpen,
setOpen,
}}
<div
onMouseEnter={handleOpen}
onFocus={handleOpen}
onMouseLeave={handleClose}
onBlur={handleClose}
data-testid="sidebar-root"
className={classnames(classes.drawer, {
[classes.drawerOpen]: isOpen,
})}
>
<div
className={classnames(classes.drawer, {
[classes.drawerOpen]: isOpen,
})}
>
{children}
</div>
</SidebarContext.Provider>
</div>
{children}
</div>
</SidebarContext.Provider>
);
}
};
/**
* A button which allows you to expand the sidebar when clicked.
* Use optionally to replace sidebar's expand-on-hover feature with expand-on-click.
* Passing children into the desktop or mobile sidebar depending on the context
*
* @public
*/
export const SidebarExpandButton = () => {
const classes = useStyles();
const { isOpen, setOpen } = useContext(SidebarContext);
const { isPinned } = useContext(SidebarPinStateContext);
const isSmallScreen = useMediaQuery<BackstageTheme>(theme =>
theme.breakpoints.down('md'),
);
export const Sidebar = (props: SidebarProps) => {
const { children, openDelayMs, closeDelayMs, disableExpandOnHover } = props;
const { isMobile } = useContext(SidebarPinStateContext);
const handleClick = () => {
setOpen(!isOpen);
};
if (isPinned || isSmallScreen) {
return null;
}
return (
<button
onClick={handleClick}
className={classes.expandButton}
aria-label="Expand Sidebar"
data-testid="sidebar-expand-button"
return isMobile ? (
<MobileSidebar>{children}</MobileSidebar>
) : (
<DesktopSidebar
openDelayMs={openDelayMs}
closeDelayMs={closeDelayMs}
disableExpandOnHover={disableExpandOnHover}
>
<div className={classes.arrows}>
{isOpen ? <DoubleArrowLeft /> : <DoubleArrowRight />}
</div>
</button>
{children}
</DesktopSidebar>
);
};
@@ -20,8 +20,8 @@ import { createEvent, fireEvent, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import HomeIcon from '@material-ui/icons/Home';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import { Sidebar, SidebarExpandButton } from './Bar';
import { SidebarItem, SidebarSearchField } from './Items';
import { Sidebar } from './Bar';
import { SidebarItem, SidebarSearchField, SidebarExpandButton } from './Items';
import { renderHook } from '@testing-library/react-hooks';
import { hexToRgb, makeStyles } from '@material-ui/core/styles';
@@ -17,18 +17,19 @@
import { IconComponent, useElementFilter } from '@backstage/core-plugin-api';
import { BackstageTheme } from '@backstage/theme';
import { makeStyles, styled, Theme } from '@material-ui/core/styles';
import useMediaQuery from '@material-ui/core/useMediaQuery';
import Badge from '@material-ui/core/Badge';
import TextField from '@material-ui/core/TextField';
import Typography from '@material-ui/core/Typography';
import { CreateCSSProperties } from '@material-ui/core/styles/withStyles';
import ArrowRightIcon from '@material-ui/icons/ArrowRight';
import SearchIcon from '@material-ui/icons/Search';
import ArrowDropUp from '@material-ui/icons/ArrowDropUp';
import ArrowDropDown from '@material-ui/icons/ArrowDropDown';
import classnames from 'classnames';
import React, {
Children,
forwardRef,
KeyboardEventHandler,
PropsWithChildren,
ReactNode,
useContext,
useState,
@@ -45,15 +46,24 @@ import {
SidebarContext,
SidebarItemWithSubmenuContext,
} from './config';
import { SidebarSubmenu } from './SidebarSubmenu';
import {
SidebarSubmenuItemProps,
SidebarSubmenuProps,
SidebarSubmenu,
} from '.';
import DoubleArrowLeft from './icons/DoubleArrowLeft';
import DoubleArrowRight from './icons/DoubleArrowRight';
import { isLocationMatch } from './utils';
import { Location } from 'history';
/** @public */
export type SidebarItemClassKey =
| 'root'
| 'buttonItem'
| 'closed'
| 'open'
| 'highlightable'
| 'highlighted'
| 'label'
| 'iconContainer'
| 'searchRoot'
@@ -61,6 +71,10 @@ export type SidebarItemClassKey =
| 'searchFieldHTMLInput'
| 'searchContainer'
| 'secondaryAction'
| 'closedItemIcon'
| 'submenuArrow'
| 'expandButton'
| 'arrows'
| 'selected';
const useStyles = makeStyles<BackstageTheme>(
@@ -83,7 +97,7 @@ const useStyles = makeStyles<BackstageTheme>(
buttonItem: {
background: 'none',
border: 'none',
width: 'auto',
width: '100%',
margin: 0,
padding: 0,
textAlign: 'inherit',
@@ -94,7 +108,9 @@ const useStyles = makeStyles<BackstageTheme>(
justifyContent: 'center',
},
open: {
width: drawerWidthOpen,
[theme.breakpoints.up('sm')]: {
width: drawerWidthOpen,
},
},
highlightable: {
'&:hover': {
@@ -134,7 +150,7 @@ const useStyles = makeStyles<BackstageTheme>(
fontSize: theme.typography.fontSize,
},
searchFieldHTMLInput: {
padding: `${theme.spacing(2)} 0 ${theme.spacing(2)}`,
padding: theme.spacing(2, 0, 2),
},
searchContainer: {
width: drawerWidthOpen - iconContainerWidth,
@@ -149,8 +165,20 @@ const useStyles = makeStyles<BackstageTheme>(
justifyContent: 'center',
},
submenuArrow: {
display: 'flex',
},
expandButton: {
background: 'none',
border: 'none',
color: theme.palette.navigation.color,
width: '100%',
cursor: 'pointer',
position: 'relative',
height: 48,
},
arrows: {
position: 'absolute',
right: 0,
right: 10,
},
selected: {
'&$root': {
@@ -172,112 +200,49 @@ const useStyles = makeStyles<BackstageTheme>(
{ name: 'BackstageSidebarItem' },
);
function isSidebarItemWithSubmenuActive(
submenu: ReactNode,
currentLocation: Location,
) {
// Item is active if any of submenu items have active paths
const toPathnames: string[] = [];
let isActive = false;
let submenuItems: ReactNode;
Children.forEach(submenu, element => {
if (!React.isValidElement(element)) return;
submenuItems = element.props.children;
});
Children.forEach(submenuItems, element => {
if (!React.isValidElement(element)) return;
if (element.props.dropdownItems) {
element.props.dropdownItems.map((item: { to: string }) =>
toPathnames.push(item.to),
);
} else if (element.props.to) {
toPathnames.push(element.props.to);
}
});
isActive = toPathnames.some(to => {
const toLocation = resolvePath(to);
return isLocationMatch(currentLocation, toLocation);
});
return isActive;
}
const SidebarItemWithSubmenu = ({
text,
hasNotifications = false,
icon: Icon,
children,
}: PropsWithChildren<SidebarItemWithSubmenuProps>) => {
const classes = useStyles();
const [isHoveredOn, setIsHoveredOn] = useState(false);
const currentLocation = useLocation();
const isActive = isSidebarItemWithSubmenuActive(children, currentLocation);
const handleMouseEnter = () => {
setIsHoveredOn(true);
};
const handleMouseLeave = () => {
setIsHoveredOn(false);
};
const { isOpen } = useContext(SidebarContext);
const itemIcon = (
<Badge
color="secondary"
variant="dot"
overlap="circular"
className={isOpen ? '' : classes.closedItemIcon}
invisible={!hasNotifications}
>
<Icon fontSize="small" />
</Badge>
/**
* Evaluates the routes of the SubmenuItems & nested DropdownItems.
* The reeveluation is only triggered, if the `locationPathname` changes, as `useElementFilter` uses memorization.
*
* @param submenu SidebarSubmenu component
* @param location Location
* @returns boolean
*/
const useLocationMatch = (
submenu: React.ReactElement<SidebarSubmenuProps>,
location: Location,
): boolean =>
useElementFilter(
submenu.props.children,
elements => {
let active = false;
elements
.getElements()
.forEach(
({
props: { to, dropdownItems },
}: {
props: Partial<SidebarSubmenuItemProps>;
}) => {
if (!active) {
if (dropdownItems?.length) {
dropdownItems.forEach(
({ to: _to }) =>
(active =
active || isLocationMatch(location, resolvePath(_to))),
);
return;
}
if (to) {
active = isLocationMatch(location, resolvePath(to));
}
}
},
);
return active;
},
[location.pathname],
);
const openContent = (
<>
<div data-testid="login-button" className={classes.iconContainer}>
{itemIcon}
</div>
{text && (
<Typography variant="subtitle2" className={classes.label}>
{text}
</Typography>
)}
<div className={classes.secondaryAction}>{}</div>
</>
);
const closedContent = itemIcon;
return (
<SidebarItemWithSubmenuContext.Provider
value={{
isHoveredOn,
setIsHoveredOn,
}}
>
<div
onMouseLeave={handleMouseLeave}
className={classnames(isHoveredOn && classes.highlighted)}
>
<div
onMouseEnter={handleMouseEnter}
data-testid="item-with-submenu"
className={classnames(
classes.root,
isOpen ? classes.open : classes.closed,
isActive && classes.selected,
classes.highlightable,
isHoveredOn && classes.highlighted,
)}
>
{isOpen ? openContent : closedContent}
{!isHoveredOn && (
<ArrowRightIcon fontSize="small" className={classes.submenuArrow} />
)}
</div>
{isHoveredOn && children}
</div>
</SidebarItemWithSubmenuContext.Provider>
);
};
type SidebarItemBaseProps = {
icon: IconComponent;
@@ -370,7 +335,10 @@ export const WorkaroundNavLink = React.forwardRef<
);
});
export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
/**
* Common component used by SidebarItem & SidebarItemWithSubmenu
*/
const SidebarItemBase = forwardRef<any, SidebarItemProps>((props, ref) => {
const {
icon: Icon,
text,
@@ -399,8 +367,6 @@ export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
</Badge>
);
const closedContent = itemIcon;
const openContent = (
<>
<div data-testid="login-button" className={classes.iconContainer}>
@@ -415,7 +381,7 @@ export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
</>
);
const content = isOpen ? openContent : closedContent;
const content = isOpen ? openContent : itemIcon;
const childProps = {
onClick,
@@ -428,39 +394,6 @@ export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
),
};
let hasSubmenu = false;
let submenu: ReactNode;
const componentType = (
<SidebarSubmenu>
<></>
</SidebarSubmenu>
).type;
// Filter children for SidebarSubmenu components
const submenus = useElementFilter(children, elements =>
elements.getElements().filter(child => child.type === componentType),
);
// Error thrown if more than one SidebarSubmenu in a SidebarItem
if (submenus.length > 1) {
throw new Error(
'Cannot render more than one SidebarSubmenu inside a SidebarItem',
);
} else if (submenus.length === 1) {
hasSubmenu = true;
submenu = submenus[0];
}
if (hasSubmenu) {
return (
<SidebarItemWithSubmenu
text={text}
icon={Icon}
hasNotifications={hasNotifications}
>
{submenu}
</SidebarItemWithSubmenu>
);
}
if (isButtonItem(props)) {
return (
<button aria-label={text} {...childProps} ref={ref}>
@@ -483,6 +416,90 @@ export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
);
});
const SidebarItemWithSubmenu = ({
children,
...props
}: SidebarItemBaseProps & {
children: React.ReactElement<SidebarSubmenuProps>;
}) => {
const classes = useStyles();
const [isHoveredOn, setIsHoveredOn] = useState(false);
const location = useLocation();
const isActive = useLocationMatch(children, location);
const isSmallScreen = useMediaQuery<BackstageTheme>((theme: BackstageTheme) =>
theme.breakpoints.down('sm'),
);
const handleMouseEnter = () => {
setIsHoveredOn(true);
};
const handleMouseLeave = () => {
setIsHoveredOn(false);
};
const arrowIcon = () => {
if (isSmallScreen) {
return isHoveredOn ? (
<ArrowDropUp fontSize="small" className={classes.submenuArrow} />
) : (
<ArrowDropDown fontSize="small" className={classes.submenuArrow} />
);
}
return (
!isHoveredOn && (
<ArrowRightIcon fontSize="small" className={classes.submenuArrow} />
)
);
};
return (
<SidebarItemWithSubmenuContext.Provider
value={{
isHoveredOn,
setIsHoveredOn,
}}
>
<div
data-testid="item-with-submenu"
onMouseLeave={handleMouseLeave}
onTouchStart={isHoveredOn ? handleMouseLeave : handleMouseEnter}
onMouseEnter={handleMouseEnter}
className={classnames(isHoveredOn && classes.highlighted)}
>
<SidebarItemBase
className={isActive ? classes.selected : ''}
{...props}
>
{arrowIcon()}
</SidebarItemBase>
{isHoveredOn && children}
</div>
</SidebarItemWithSubmenuContext.Provider>
);
};
/**
* Creates a `SidebarItem`
*
* If children contain a `SidebarSubmenu` component the `SidebarItem` will have a expandable submenu
*/
export const SidebarItem = forwardRef<any, SidebarItemProps>((props, ref) => {
// Filter children for SidebarSubmenu components
const [submenu] = useElementFilter(props.children, elements =>
elements.getElements().filter(child => child.type === SidebarSubmenu),
);
if (submenu) {
return (
<SidebarItemWithSubmenu {...props}>
{submenu as React.ReactElement<SidebarSubmenuProps>}
</SidebarItemWithSubmenu>
);
}
return <SidebarItemBase {...props} ref={ref} />;
});
type SidebarSearchFieldProps = {
onSearch: (input: string) => void;
to?: string;
@@ -609,3 +626,41 @@ export const SidebarScrollWrapper = styled('div')(({ theme }) => {
'&:hover': scrollbarStyles,
};
});
/**
* A button which allows you to expand the sidebar when clicked.
* Use optionally to replace sidebar's expand-on-hover feature with expand-on-click.
*
* If you are using this you might want to set the `disableExpandOnHover` of the `Sidebar` to `true`.
*
* @public
*/
export const SidebarExpandButton = () => {
const classes = useStyles();
const { isOpen, setOpen } = useContext(SidebarContext);
const isSmallScreen = useMediaQuery<BackstageTheme>(
theme => theme.breakpoints.down('md'),
{ noSsr: true },
);
if (isSmallScreen) {
return null;
}
const handleClick = () => {
setOpen(!isOpen);
};
return (
<button
onClick={handleClick}
className={classes.expandButton}
aria-label="Expand Sidebar"
data-testid="sidebar-expand-button"
>
<div className={classes.arrows}>
{isOpen ? <DoubleArrowLeft /> : <DoubleArrowRight />}
</div>
</button>
);
};
@@ -0,0 +1,105 @@
/*
* 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 { mockBreakpoint, renderInTestApp } from '@backstage/test-utils';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import HomeIcon from '@material-ui/icons/Home';
import LayersIcon from '@material-ui/icons/Layers';
import LibraryBooks from '@material-ui/icons/LibraryBooks';
import { fireEvent } from '@testing-library/react';
import React from 'react';
import {
MobileSidebar,
Sidebar,
SidebarGroup,
SidebarItem,
SidebarPage,
} from '.';
const MobileSidebarWithGroups = () => (
<SidebarPage>
<MobileSidebar>
<h1>Header</h1>
<SidebarGroup icon={<HomeIcon />} label="Menu">
<SidebarItem icon={HomeIcon} to="/" text="Home" />
<SidebarItem icon={LayersIcon} to="/" text="Explore" />
<SidebarItem icon={LibraryBooks} to="/" text="Docs" />
</SidebarGroup>
<div>Content</div>
<div>More Content</div>
<SidebarGroup icon={<CreateComponentIcon />} label="Create" to="#" />
<footer>Footer</footer>
</MobileSidebar>
</SidebarPage>
);
const MobileSidebarWithoutGroups = () => (
<SidebarPage>
<MobileSidebar>
<SidebarItem icon={HomeIcon} to="/one" text="Home" />
<SidebarItem icon={LayersIcon} to="/two" text="Explore" />
<SidebarItem icon={LibraryBooks} to="/three" text="Docs" />
</MobileSidebar>
</SidebarPage>
);
describe('<MobileSidebar />', () => {
beforeEach(() => {
mockBreakpoint({ matches: true });
});
it('should render MobileSidebar on smaller screens', async () => {
const { getByTestId } = await renderInTestApp(
<SidebarPage>
<Sidebar>
<SidebarItem icon={HomeIcon} to="/one" text="Home" />
</Sidebar>
</SidebarPage>,
);
expect(getByTestId('mobile-sidebar-root')).toBeVisible();
});
it('should render only SidebarGroups inside MobileSidebar', async () => {
const { findAllByRole, getByTestId, findByText } = await renderInTestApp(
<MobileSidebarWithGroups />,
);
expect(getByTestId('mobile-sidebar-root').children.length).toBe(2);
expect((await findAllByRole('button')).length).toBe(2);
expect(await findByText('Menu')).toBeValid();
expect(await findByText('Create')).toBeValid();
});
it('should render default MobileSidebar when there are no SidebarGroups', async () => {
const { findAllByRole, getByTestId } = await renderInTestApp(
<MobileSidebarWithoutGroups />,
);
expect(getByTestId('mobile-sidebar-root').children.length).toBe(1);
const defaultSidebarGroup = await findAllByRole('button');
expect(defaultSidebarGroup.length).toBe(1);
});
it('should render OverlayMenu displaying SidebarItems', async () => {
const { findByText, getByRole } = await renderInTestApp(
<MobileSidebarWithGroups />,
);
const menuButton = await findByText('Menu');
fireEvent.click(menuButton);
expect(getByRole('heading', { name: 'Menu' })).toBeVisible();
expect(getByRole('link', { name: 'Home' })).toBeVisible();
expect(getByRole('link', { name: 'Explore' })).toBeVisible();
expect(getByRole('link', { name: 'Docs' })).toBeVisible();
});
});
@@ -0,0 +1,215 @@
/*
* 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 { useElementFilter } from '@backstage/core-plugin-api';
import { BackstageTheme } from '@backstage/theme';
import BottomNavigation from '@material-ui/core/BottomNavigation';
import Box from '@material-ui/core/Box';
import IconButton from '@material-ui/core/IconButton';
import { makeStyles } from '@material-ui/core/styles';
import Drawer from '@material-ui/core/Drawer';
import Typography from '@material-ui/core/Typography';
import CloseIcon from '@material-ui/icons/Close';
import MenuIcon from '@material-ui/icons/Menu';
import { orderBy } from 'lodash';
import React, { createContext, useEffect, useState } from 'react';
import { useLocation } from 'react-router';
import { SidebarContext } from '.';
import { sidebarConfig } from './config';
import { SidebarGroup } from './SidebarGroup';
/**
* Type of `MobileSidebarContext`
*
* @internal
*/
export type MobileSidebarContextType = {
selectedMenuItemIndex: number;
setSelectedMenuItemIndex: React.Dispatch<React.SetStateAction<number>>;
};
/**
* Props of MobileSidebar
*
* @public
*/
export type MobileSidebarProps = {
children?: React.ReactNode;
};
/**
* @internal
*/
type OverlayMenuProps = {
label?: string;
onClose: () => void;
open: boolean;
children?: React.ReactNode;
};
const useStyles = makeStyles<BackstageTheme>(theme => ({
root: {
position: 'fixed',
backgroundColor: theme.palette.navigation.background,
color: theme.palette.navigation.color,
bottom: 0,
left: 0,
right: 0,
zIndex: theme.zIndex.snackbar,
// SidebarDivider color
borderTop: '1px solid #383838',
},
overlay: {
background: theme.palette.navigation.background,
width: '100%',
bottom: `${sidebarConfig.mobileSidebarHeight}px`,
height: `calc(100% - ${sidebarConfig.mobileSidebarHeight}px)`,
flex: '0 1 auto',
overflow: 'auto',
},
overlayHeader: {
display: 'flex',
color: theme.palette.bursts.fontColor,
alignItems: 'center',
justifyContent: 'space-between',
padding: theme.spacing(2, 3),
},
overlayHeaderClose: {
color: theme.palette.bursts.fontColor,
},
}));
const sortSidebarGroupsForPriority = (children: React.ReactElement[]) =>
orderBy(
children,
({ props: { priority } }) => (Number.isInteger(priority) ? priority : -1),
'desc',
);
const OverlayMenu = ({
children,
label = 'Menu',
open,
onClose,
}: OverlayMenuProps) => {
const classes = useStyles();
return (
<Drawer
anchor="bottom"
open={open}
onClose={onClose}
classes={{ paperAnchorBottom: classes.overlay }}
>
<Box className={classes.overlayHeader}>
<Typography variant="h3">{label}</Typography>
<IconButton
onClick={onClose}
classes={{ root: classes.overlayHeaderClose }}
>
<CloseIcon />
</IconButton>
</Box>
<Box>{children}</Box>
</Drawer>
);
};
/**
* Context on which `SidebarGroup` is currently selected
*
* @internal
*/
export const MobileSidebarContext = createContext<MobileSidebarContextType>({
selectedMenuItemIndex: -1,
setSelectedMenuItemIndex: () => {},
});
/**
* A navigation component for mobile screens, which sticks to the bottom.
*
* It alternates the normal sidebar by grouping the `SidebarItems` based on provided `SidebarGroup`s
* either rendering them as a link or an overlay menu.
* If no `SidebarGroup`s are provided the sidebar content is wrapped in an default overlay menu.
*
* @public
*/
export const MobileSidebar = (props: MobileSidebarProps) => {
const { children } = props;
const classes = useStyles();
const location = useLocation();
const [selectedMenuItemIndex, setSelectedMenuItemIndex] =
useState<number>(-1);
useEffect(() => {
setSelectedMenuItemIndex(-1);
}, [location.pathname]);
// Filter children for SidebarGroups
let sidebarGroups = useElementFilter(children, elements =>
elements.getElements().filter(child => child.type === SidebarGroup),
);
if (!children) {
// If Sidebar has no children the MobileSidebar won't be rendered
return null;
} else if (!sidebarGroups.length) {
// If Sidebar has no SidebarGroup as a children a default
// SidebarGroup with the complete Sidebar content will be created
sidebarGroups.push(
<SidebarGroup key="default_menu" icon={<MenuIcon />}>
{children}
</SidebarGroup>,
);
} else {
// Sort SidebarGroups for the given Priority
sidebarGroups = sortSidebarGroupsForPriority(sidebarGroups);
}
const shouldShowGroupChildren =
selectedMenuItemIndex >= 0 &&
!sidebarGroups[selectedMenuItemIndex].props.to;
return (
<SidebarContext.Provider value={{ isOpen: true, setOpen: () => {} }}>
<MobileSidebarContext.Provider
value={{ selectedMenuItemIndex, setSelectedMenuItemIndex }}
>
<OverlayMenu
label={
sidebarGroups[selectedMenuItemIndex] &&
(sidebarGroups[selectedMenuItemIndex]!.props.label as string)
}
open={shouldShowGroupChildren}
onClose={() => setSelectedMenuItemIndex(-1)}
>
{sidebarGroups[selectedMenuItemIndex] &&
(sidebarGroups[selectedMenuItemIndex].props
.children as React.ReactChildren)}
</OverlayMenu>
<BottomNavigation
className={classes.root}
data-testid="mobile-sidebar-root"
>
{sidebarGroups}
</BottomNavigation>
</MobileSidebarContext.Provider>
</SidebarContext.Provider>
);
};
@@ -15,46 +15,67 @@
*/
import { makeStyles } from '@material-ui/core/styles';
import React, {
createContext,
PropsWithChildren,
useEffect,
useState,
} from 'react';
import React, { createContext, useEffect, useState } from 'react';
import { sidebarConfig } from './config';
import { BackstageTheme } from '@backstage/theme';
import { LocalStorage } from './localStorage';
import useMediaQuery from '@material-ui/core/useMediaQuery';
export type SidebarPageClassKey = 'root';
const useStyles = makeStyles<BackstageTheme, { isPinned: boolean }>(
{
theme => ({
root: {
width: '100%',
minHeight: '100%',
transition: 'padding-left 0.1s ease-out',
paddingLeft: ({ isPinned }) =>
isPinned
? sidebarConfig.drawerWidthOpen
: sidebarConfig.drawerWidthClosed,
[theme.breakpoints.up('sm')]: {
paddingLeft: ({ isPinned }) =>
isPinned
? sidebarConfig.drawerWidthOpen
: sidebarConfig.drawerWidthClosed,
},
[theme.breakpoints.down('xs')]: {
paddingBottom: sidebarConfig.mobileSidebarHeight,
},
},
},
}),
{ name: 'BackstageSidebarPage' },
);
/**
* Type of `SidebarPinStateContext`
*
* @public
*/
export type SidebarPinStateContextType = {
isPinned: boolean;
toggleSidebarPinState: () => any;
isMobile?: boolean;
};
/**
* Props for SidebarPage
*
* @public
*/
export type SidebarPageProps = {
children?: React.ReactNode;
};
/**
* Contains the state on how the `Sidebar` is rendered
*
* @public
*/
export const SidebarPinStateContext = createContext<SidebarPinStateContextType>(
{
isPinned: true,
toggleSidebarPinState: () => {},
isMobile: false,
},
);
export function SidebarPage(props: PropsWithChildren<{}>) {
export function SidebarPage(props: SidebarPageProps) {
const [isPinned, setIsPinned] = useState(() =>
LocalStorage.getSidebarPinState(),
);
@@ -63,6 +84,11 @@ export function SidebarPage(props: PropsWithChildren<{}>) {
LocalStorage.setSidebarPinState(isPinned);
}, [isPinned]);
const isMobile = useMediaQuery<BackstageTheme>(
theme => theme.breakpoints.down('xs'),
{ noSsr: true },
);
const toggleSidebarPinState = () => setIsPinned(!isPinned);
const classes = useStyles({ isPinned });
@@ -71,6 +97,7 @@ export function SidebarPage(props: PropsWithChildren<{}>) {
value={{
isPinned,
toggleSidebarPinState,
isMobile,
}}
>
<div className={classes.root}>{props.children}</div>
@@ -13,36 +13,41 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createRouteRef } from '@backstage/core-plugin-api';
import { wrapInTestApp } from '@backstage/test-utils';
import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline';
import HomeOutlinedIcon from '@material-ui/icons/HomeOutlined';
import MenuIcon from '@material-ui/icons/Menu';
import BuildRoundedIcon from '@material-ui/icons/BuildRounded';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import MenuBookIcon from '@material-ui/icons/MenuBook';
import CloudQueueIcon from '@material-ui/icons/CloudQueue';
import AcUnitIcon from '@material-ui/icons/AcUnit';
import AppsIcon from '@material-ui/icons/Apps';
import React, { ComponentType } from 'react';
import {
Sidebar,
SidebarDivider,
SidebarGroup,
SidebarExpandButton,
SidebarIntro,
SidebarItem,
SidebarPage,
SidebarSearchField,
SidebarSpace,
SidebarSubmenu,
SidebarSubmenuItem,
} from '.';
import { SidebarSubmenuItem } from './SidebarSubmenuItem';
import MenuBookIcon from '@material-ui/icons/MenuBook';
import CloudQueueIcon from '@material-ui/icons/CloudQueue';
import AppsIcon from '@material-ui/icons/Apps';
import AcUnitIcon from '@material-ui/icons/AcUnit';
import { SidebarSubmenu } from './SidebarSubmenu';
const routeRef = createRouteRef({
id: 'storybook.test-route',
});
export default {
title: 'Layout/Sidebar',
component: Sidebar,
decorators: [
(storyFn: () => JSX.Element) => (
<MemoryRouter initialEntries={['/']}>{storyFn()}</MemoryRouter>
),
(Story: ComponentType<{}>) =>
wrapInTestApp(<Story />, { mountedRoutes: { '/': routeRef } }),
],
};
@@ -54,13 +59,15 @@ const handleSearch = (input: string) => {
export const SampleSidebar = () => (
<SidebarPage>
<Sidebar>
<SidebarSearchField onSearch={handleSearch} to="/search" />
<SidebarDivider />
<SidebarItem icon={HomeOutlinedIcon} to="#" text="Plugins" />
<SidebarItem icon={AddCircleOutlineIcon} to="#" text="Create..." />
<SidebarDivider />
<SidebarIntro />
<SidebarSpace />
<SidebarGroup label="Menu" icon={MenuIcon}>
<SidebarSearchField onSearch={handleSearch} to="/search" />
<SidebarDivider />
<SidebarItem icon={HomeOutlinedIcon} to="#" text="Plugins" />
<SidebarItem icon={AddCircleOutlineIcon} to="#" text="Create..." />
<SidebarDivider />
<SidebarIntro />
<SidebarSpace />
</SidebarGroup>
</Sidebar>
</SidebarPage>
);
@@ -70,30 +77,32 @@ export const SampleScalableSidebar = () => (
<Sidebar disableExpandOnHover>
<SidebarSearchField onSearch={handleSearch} to="/search" />
<SidebarDivider />
<SidebarItem icon={MenuBookIcon} text="Catalog">
<SidebarSubmenu title="Catalog">
<SidebarSubmenuItem title="Tools" to="/1" icon={BuildRoundedIcon} />
<SidebarSubmenuItem title="APIs" to="/2" icon={CloudQueueIcon} />
<SidebarSubmenuItem title="Components" to="/3" icon={AppsIcon} />
<SidebarSubmenuItem
title="Misc"
to="/6"
icon={AcUnitIcon}
dropdownItems={[
{
title: 'Lorem Ipsum',
to: '/7',
},
{
title: 'Lorem Ipsum',
to: '/8',
},
]}
/>
</SidebarSubmenu>
</SidebarItem>
<SidebarItem icon={HomeOutlinedIcon} to="#" text="Plugins" />
<SidebarItem icon={AddCircleOutlineIcon} to="#" text="Create..." />
<SidebarGroup label="Menu" icon={<MenuIcon />}>
<SidebarItem icon={MenuBookIcon} text="Catalog">
<SidebarSubmenu title="Catalog">
<SidebarSubmenuItem title="Tools" to="/1" icon={BuildRoundedIcon} />
<SidebarSubmenuItem title="APIs" to="/2" icon={CloudQueueIcon} />
<SidebarSubmenuItem title="Components" to="/3" icon={AppsIcon} />
<SidebarSubmenuItem
title="Misc"
to="/6"
icon={AcUnitIcon}
dropdownItems={[
{
title: 'Lorem Ipsum',
to: '/7',
},
{
title: 'Lorem Ipsum',
to: '/8',
},
]}
/>
</SidebarSubmenu>
</SidebarItem>
<SidebarItem icon={HomeOutlinedIcon} to="#" text="Plugins" />
<SidebarItem icon={AddCircleOutlineIcon} to="#" text="Create..." />
</SidebarGroup>
<SidebarDivider />
<SidebarIntro />
<SidebarSpace />
@@ -0,0 +1,72 @@
/*
* 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 { mockBreakpoint, renderInTestApp } from '@backstage/test-utils';
import HomeIcon from '@material-ui/icons/Home';
import LayersIcon from '@material-ui/icons/Layers';
import LibraryBooks from '@material-ui/icons/LibraryBooks';
import { fireEvent } from '@testing-library/react';
import React from 'react';
import { MobileSidebarContext } from './MobileSidebar';
import { SidebarGroup, SidebarItem, SidebarPage } from '.';
const SidebarGroupWithItems = () => (
<SidebarPage>
<SidebarGroup icon={<HomeIcon />} label="Menu">
<SidebarItem icon={HomeIcon} to="/one" text="Home" />
<SidebarItem icon={LayersIcon} to="/two" text="Explore" />
<SidebarItem icon={LibraryBooks} to="/three" text="Docs" />
</SidebarGroup>
</SidebarPage>
);
describe('<SidebarGroup />', () => {
it('should render Items in BottomNavigationAction on small screens', async () => {
mockBreakpoint({ matches: true });
const { getByRole, getAllByRole } = await renderInTestApp(
<SidebarGroupWithItems />,
);
expect(getAllByRole('button').length).toEqual(1);
expect(getByRole('button')).toBeVisible();
});
it('should render Items without wrapper on bigger screens', async () => {
mockBreakpoint({ matches: false });
const { getByRole, queryByRole } = await renderInTestApp(
<SidebarGroupWithItems />,
);
expect(queryByRole('button')).not.toBeInTheDocument();
expect(getByRole('link', { name: 'Home' })).toBeVisible();
expect(getByRole('link', { name: 'Explore' })).toBeVisible();
expect(getByRole('link', { name: 'Docs' })).toBeVisible();
});
it('should trigger update of MobileSidebarContext', async () => {
mockBreakpoint({ matches: true });
const value = {
selectedMenuItemIndex: -1,
setSelectedMenuItemIndex: jest.fn(),
};
const { findByRole } = await renderInTestApp(
<MobileSidebarContext.Provider value={value}>
<SidebarGroupWithItems />
</MobileSidebarContext.Provider>,
);
const group = await findByRole('button');
fireEvent.click(group);
expect(value.setSelectedMenuItemIndex).toHaveBeenCalled();
});
});
@@ -0,0 +1,130 @@
/* eslint-disable @typescript-eslint/no-shadow */
/*
* 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 { BackstageTheme } from '@backstage/theme';
import BottomNavigationAction, {
BottomNavigationActionProps,
} from '@material-ui/core/BottomNavigationAction';
import { makeStyles } from '@material-ui/core/styles';
import React, { useContext } from 'react';
import { useLocation } from 'react-router-dom';
import { SidebarPinStateContext } from '.';
import { Link } from '../../components';
import { sidebarConfig } from './config';
import { MobileSidebarContext } from './MobileSidebar';
/**
* Props for the `SidebarGroup`
*
* @public
*/
export interface SidebarGroupProps extends BottomNavigationActionProps {
/**
* If the `SidebarGroup` should be a `Link`, `to` should be a pathname to that location
*/
to?: string;
/**
* If the `SidebarGroup`s should be in a different order than in the normal `Sidebar`, you can provide
* each `SidebarGroup` it's own priority to reorder them.
*/
priority?: number;
/**
* React children
*/
children?: React.ReactNode;
}
const useStyles = makeStyles<BackstageTheme>(theme => ({
root: {
flexGrow: 0,
margin: theme.spacing(0, 2),
color: theme.palette.navigation.color,
},
selected: {
color: `${theme.palette.navigation.selectedColor}!important`,
borderTop: `solid ${sidebarConfig.selectedIndicatorWidth}px ${theme.palette.navigation.indicator}`,
marginTop: '-1px',
},
label: {
display: 'none',
},
}));
/**
* Returns a MUI `BottomNavigationAction`, which is aware of the current location & the selected item in the `BottomNavigation`,
* such that it will highlight a `MobileSidebarGroup` either on location change or if the selected item changes.
*
* @param props `to`: pathname of link; `value`: index of the selected item
* @internal
*/
const MobileSidebarGroup = (props: SidebarGroupProps) => {
const { to, label, icon, value } = props;
const classes = useStyles();
const location = useLocation();
const { selectedMenuItemIndex, setSelectedMenuItemIndex } =
useContext(MobileSidebarContext);
const onChange = (_: React.ChangeEvent<{}>, value: number) => {
if (value === selectedMenuItemIndex) {
setSelectedMenuItemIndex(-1);
} else {
setSelectedMenuItemIndex(value);
}
};
const selected =
(value === selectedMenuItemIndex && selectedMenuItemIndex >= 0) ||
(!(value === selectedMenuItemIndex) &&
!(selectedMenuItemIndex >= 0) &&
to === location.pathname);
return (
// Material UI issue: https://github.com/mui-org/material-ui/issues/27820
// @ts-ignore
<BottomNavigationAction
label={label}
icon={icon}
component={Link}
to={to ? to : location.pathname}
onChange={onChange}
value={value}
selected={selected}
classes={classes}
/>
);
};
/**
* Groups items of the `Sidebar` together.
*
* On bigger screens, this won't have any effect at the moment.
* On small screens, it will add an action to the bottom navigation - either triggering an overlay menu or acting as a link
*
* @public
*/
export const SidebarGroup = (props: SidebarGroupProps) => {
const { children, to, label, icon, value } = props;
const { isMobile } = useContext(SidebarPinStateContext);
return isMobile ? (
<MobileSidebarGroup to={to} label={label} icon={icon} value={value} />
) : (
<>{children}</>
);
};
@@ -38,7 +38,13 @@ const useStyles = (props: { left: number }) =>
flexFlow: 'column nowrap',
alignItems: 'flex-start',
position: 'fixed',
left: props.left,
[theme.breakpoints.up('sm')]: {
marginLeft: props.left,
transition: theme.transitions.create('margin-left', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.shortest,
}),
},
top: 0,
bottom: 0,
padding: 0,
@@ -58,12 +64,22 @@ const useStyles = (props: { left: number }) =>
},
drawerOpen: {
width: submenuConfig.drawerWidthOpen,
[theme.breakpoints.down('xs')]: {
width: '100%',
position: 'relative',
paddingLeft: theme.spacing(3),
left: 0,
top: 0,
},
},
title: {
fontSize: 24,
fontWeight: 500,
color: '#FFF',
padding: 20,
[theme.breakpoints.down('xs')]: {
display: 'none',
},
},
}));
@@ -88,7 +104,7 @@ export const SidebarSubmenu = (props: SidebarSubmenuProps) => {
const left = isOpen
? sidebarConfig.drawerWidthOpen
: sidebarConfig.drawerWidthClosed;
const classes = useStyles({ left: left })();
const classes = useStyles({ left })();
const { isHoveredOn } = useContext(SidebarItemWithSubmenuContext);
const [isSubmenuOpen, setIsSubmenuOpen] = useState(false);
@@ -77,6 +77,10 @@ const useStyles = makeStyles<BackstageTheme>(theme => ({
color: theme.palette.navigation.color,
display: 'flex',
justifyContent: 'center',
[theme.breakpoints.down('xs')]: {
display: 'block',
paddingLeft: theme.spacing(4),
},
fontSize: '14px',
},
}));
@@ -139,6 +143,7 @@ export const SidebarSubmenuItem = (props: SidebarSubmenuItemProps) => {
<div className={classes.itemContainer}>
<button
onClick={handleClickDropdown}
onTouchStart={e => e.stopPropagation()}
className={classnames(
classes.item,
isActive ? classes.selected : undefined,
@@ -163,6 +168,7 @@ export const SidebarSubmenuItem = (props: SidebarSubmenuItemProps) => {
underline="none"
className={classes.dropdownItem}
onClick={closeSubmenu}
onTouchStart={e => e.stopPropagation()}
key={key}
>
<Typography className={classes.textContent}>
@@ -187,6 +193,7 @@ export const SidebarSubmenuItem = (props: SidebarSubmenuItemProps) => {
isActive ? classes.selected : undefined,
)}
onClick={closeSubmenu}
onTouchStart={e => e.stopPropagation()}
>
<Icon fontSize="small" />
<Typography variant="subtitle1" className={classes.label}>
@@ -35,6 +35,7 @@ export const sidebarConfig = {
selectedIndicatorWidth: 3,
userBadgePadding,
userBadgeDiameter: drawerWidthClosed - userBadgePadding * 2,
mobileSidebarHeight: 56,
};
export const submenuConfig = {
@@ -46,14 +47,20 @@ export const submenuConfig = {
export const SIDEBAR_INTRO_LOCAL_STORAGE =
'@backstage/core/sidebar-intro-dismissed';
/**
* Types for the `SidebarContext`
*/
export type SidebarContextType = {
isOpen: boolean;
setOpen: (open: boolean) => void;
};
/**
* Context wether the `Sidebar` is open
*/
export const SidebarContext = createContext<SidebarContextType>({
isOpen: false,
setOpen: _open => {},
setOpen: () => {},
});
export type SidebarItemWithSubmenuContextType = {
@@ -14,7 +14,11 @@
* limitations under the License.
*/
export { Sidebar, SidebarExpandButton } from './Bar';
export { Sidebar } from './Bar';
export { MobileSidebar } from './MobileSidebar';
export type { MobileSidebarProps } from './MobileSidebar';
export { SidebarGroup } from './SidebarGroup';
export type { SidebarGroupProps } from './SidebarGroup';
export { SidebarSubmenuItem } from './SidebarSubmenuItem';
export { SidebarSubmenu } from './SidebarSubmenu';
export type { SidebarSubmenuProps } from './SidebarSubmenu';
@@ -22,9 +26,16 @@ export type {
SidebarSubmenuItemProps,
SidebarSubmenuItemDropdownItem,
} from './SidebarSubmenuItem';
export type { SidebarClassKey } from './Bar';
export { SidebarPage, SidebarPinStateContext } from './Page';
export type { SidebarPinStateContextType, SidebarPageClassKey } from './Page';
export type { SidebarClassKey, SidebarProps } from './Bar';
export {
SidebarPage,
SidebarPinStateContext as SidebarPinStateContext,
} from './Page';
export type {
SidebarPinStateContextType as SidebarPinStateContextType,
SidebarPageClassKey,
SidebarPageProps,
} from './Page';
export {
SidebarDivider,
SidebarItem,
@@ -32,6 +43,7 @@ export {
SidebarSpace,
SidebarSpacer,
SidebarScrollWrapper,
SidebarExpandButton,
} from './Items';
export type {
SidebarItemClassKey,
+1 -1
View File
@@ -16,7 +16,7 @@
function makeCreateEnv(config: Config) {
...
+ const permissions = ServerPerimssionClient.fromConfig(config, {
+ const permissions = ServerPermissionClient.fromConfig(config, {
+ discovery,
+ tokenManager,
+ });
@@ -24,21 +24,27 @@ import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import LogoFull from './LogoFull';
import LogoIcon from './LogoIcon';
import { NavLink } from 'react-router-dom';
import { Settings as SidebarSettings } from '@backstage/plugin-user-settings';
import {
Settings as SidebarSettings,
UserSettingsSignInAvatar,
} from '@backstage/plugin-user-settings';
import {
SidebarSearchModal,
SearchContextProvider,
} from '@backstage/plugin-search';
import {
Sidebar,
SidebarPage,
sidebarConfig,
SidebarContext,
SidebarItem,
SidebarDivider,
SidebarSpace,
SidebarGroup,
SidebarItem,
SidebarPage,
SidebarScrollWrapper,
SidebarSpace,
} from '@backstage/core-components';
import MenuIcon from '@material-ui/icons/Menu';
import SearchIcon from '@material-ui/icons/Search';
const useSidebarLogoStyles = makeStyles({
root: {
@@ -77,23 +83,33 @@ export const Root = ({ children }: PropsWithChildren<{}>) => (
<SidebarPage>
<Sidebar>
<SidebarLogo />
<SearchContextProvider>
<SidebarSearchModal />
</SearchContextProvider>
<SidebarGroup label="Search" icon={<SearchIcon />} to="/search">
<SearchContextProvider>
<SidebarSearchModal />
</SearchContextProvider>{' '}
</SidebarGroup>
<SidebarDivider />
{/* Global nav, not org-specific */}
<SidebarItem icon={HomeIcon} to="catalog" text="Home" />
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
<SidebarItem icon={LibraryBooks} to="docs" text="Docs" />
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />
{/* End global nav */}
<SidebarDivider />
<SidebarScrollWrapper>
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
</SidebarScrollWrapper>
<SidebarGroup label="Menu" icon={<MenuIcon />}>
{/* Global nav, not org-specific */}
<SidebarItem icon={HomeIcon} to="catalog" text="Home" />
<SidebarItem icon={ExtensionIcon} to="api-docs" text="APIs" />
<SidebarItem icon={LibraryBooks} to="docs" text="Docs" />
<SidebarItem icon={CreateComponentIcon} to="create" text="Create..." />
{/* End global nav */}
<SidebarDivider />
<SidebarScrollWrapper>
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
</SidebarScrollWrapper>
</SidebarGroup>
<SidebarSpace />
<SidebarDivider />
<SidebarSettings />
<SidebarGroup
label="Settings"
icon={<UserSettingsSignInAvatar />}
to="/settings"
>
<SidebarSettings />
</SidebarGroup>
</Sidebar>
{children}
</SidebarPage>