feat: catalog graph support i18n

Signed-off-by: mario ma <mario.ma.node@gmail.com>
This commit is contained in:
mario ma
2025-05-28 16:53:32 +08:00
parent f92e794480
commit fe1a2f46a2
17 changed files with 204 additions and 58 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-graph': patch
---
Catalog graph plugin support i18n
+29
View File
@@ -16,6 +16,35 @@ import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
import { FrontendPlugin } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { TranslationRef } from '@backstage/frontend-plugin-api';
// @alpha (undocumented)
export const catalogGraphTranslationRef: TranslationRef<
'catalog-graph',
{
readonly 'catalogGraphCard.title': 'Relations';
readonly 'catalogGraphCard.deepLinkTitle': 'View graph';
readonly 'catalogGraphPage.title': 'Catalog Graph';
readonly 'catalogGraphPage.filterToggleButtonTitle': 'Filters';
readonly 'catalogGraphPage.supportButtonDescription': 'Start tracking your component in by adding it to the software catalog.';
readonly 'catalogGraphPage.simplifiedSwitchLabel': 'Simplified';
readonly 'catalogGraphPage.mergeRelationsSwitchLabel': 'Merge relations';
readonly 'catalogGraphPage.zoomOutDescription': 'Use pinch &amp; zoom to move around the diagram. Click to change active node, shift click to navigate to entity.';
readonly 'catalogGraphPage.curveFilter.title': 'Curve';
readonly 'catalogGraphPage.curveFilter.curveStepBefore': 'Step Before';
readonly 'catalogGraphPage.curveFilter.curveMonotoneX': 'Monotone X';
readonly 'catalogGraphPage.directionFilter.title': 'Direction';
readonly 'catalogGraphPage.directionFilter.leftToRight': 'Left to right';
readonly 'catalogGraphPage.directionFilter.rightToLeft': 'Right to left';
readonly 'catalogGraphPage.directionFilter.topToBottom': 'Top to bottom';
readonly 'catalogGraphPage.directionFilter.bottomToTop': 'Bottom to top';
readonly 'catalogGraphPage.maxDepthFilter.title': 'Max depth';
readonly 'catalogGraphPage.maxDepthFilter.inputPlaceholder': '∞ Infinite';
readonly 'catalogGraphPage.maxDepthFilter.clearButtonAriaLabel': 'clear max depth';
readonly 'catalogGraphPage.selectedKindsFilter.title': 'Kinds';
readonly 'catalogGraphPage.selectedRelationsFilter.title': 'Relations';
}
>;
// @public (undocumented)
const _default: FrontendPlugin<
+2
View File
@@ -96,3 +96,5 @@ export default createFrontendPlugin({
},
extensions: [CatalogGraphPage, CatalogGraphEntityCard],
});
export { catalogGraphTranslationRef } from './translation';
@@ -34,6 +34,7 @@ import userEvent from '@testing-library/user-event';
import { catalogGraphRouteRef } from '../../routes';
import { CatalogGraphCard } from './CatalogGraphCard';
import Button from '@material-ui/core/Button';
import { translationApiRef } from '@backstage/core-plugin-api/alpha';
describe('<CatalogGraphCard/>', () => {
let entity: Entity;
@@ -52,7 +53,10 @@ describe('<CatalogGraphCard/>', () => {
namespace: 'd',
},
};
apis = TestApiRegistry.from([catalogApiRef, catalog]);
apis = TestApiRegistry.from(
[catalogApiRef, catalog],
[translationApiRef, mockApis.translation()],
);
wrapper = (
<ApiProvider apis={apis}>
@@ -213,7 +217,12 @@ describe('<CatalogGraphCard/>', () => {
const analyticsApi = mockApis.analytics();
await renderInTestApp(
<TestApiProvider apis={[[analyticsApiRef, analyticsApi]]}>
<TestApiProvider
apis={[
[analyticsApiRef, analyticsApi],
[translationApiRef, mockApis.translation()],
]}
>
{wrapper}
</TestApiProvider>,
{
@@ -38,6 +38,8 @@ import {
EntityRelationsGraph,
EntityRelationsGraphProps,
} from '../EntityRelationsGraph';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { catalogGraphTranslationRef } from '../../translation';
/** @public */
export type CatalogGraphCardClassKey = 'card' | 'graph';
@@ -66,6 +68,7 @@ export const CatalogGraphCard = (
action?: ReactNode;
},
) => {
const { t } = useTranslationRef(catalogGraphTranslationRef);
const {
variant = 'gridItem',
relationPairs = ALL_RELATION_PAIRS,
@@ -81,7 +84,7 @@ export const CatalogGraphCard = (
action,
rootEntityNames,
onNodeClick,
title = 'Relations',
title = t('catalogGraphCard.title'),
zoom = 'enable-on-click',
} = props;
@@ -133,7 +136,7 @@ export const CatalogGraphCard = (
variant={variant}
noPadding
deepLink={{
title: 'View graph',
title: t('catalogGraphCard.deepLinkTitle'),
link: catalogGraphUrl,
}}
>
@@ -50,6 +50,8 @@ import { SelectedKindsFilter } from './SelectedKindsFilter';
import { SelectedRelationsFilter } from './SelectedRelationsFilter';
import { SwitchFilter } from './SwitchFilter';
import { useCatalogGraphPage } from './useCatalogGraphPage';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { catalogGraphTranslationRef } from '../../translation';
/** @public */
export type CatalogGraphPageClassKey =
@@ -136,7 +138,7 @@ export const CatalogGraphPage = (
initialState,
entityFilter,
} = props;
const { t } = useTranslationRef(catalogGraphTranslationRef);
const navigate = useNavigate();
const classes = useStyles();
const catalogEntityRoute = useRouteRef(entityRouteRef);
@@ -192,7 +194,7 @@ export const CatalogGraphPage = (
return (
<Page themeId="home">
<Header
title="Catalog Graph"
title={t('catalogGraphPage.title')}
subtitle={rootEntityNames.map(e => humanizeEntityRef(e)).join(', ')}
/>
<Content stretch className={classes.content}>
@@ -203,13 +205,12 @@ export const CatalogGraphPage = (
selected={showFilters}
onChange={() => toggleShowFilters()}
>
<FilterListIcon /> Filters
<FilterListIcon /> {t('catalogGraphPage.filterToggleButtonTitle')}
</ToggleButton>
}
>
<SupportButton>
Start tracking your component in by adding it to the software
catalog.
{t('catalogGraphPage.supportButtonDescription')}
</SupportButton>
</ContentHeader>
<Grid container alignItems="stretch" className={classes.container}>
@@ -230,12 +231,12 @@ export const CatalogGraphPage = (
<SwitchFilter
value={unidirectional}
onChange={setUnidirectional}
label="Simplified"
label={t('catalogGraphPage.simplifiedSwitchLabel')}
/>
<SwitchFilter
value={mergeRelations}
onChange={setMergeRelations}
label="Merge Relations"
label={t('catalogGraphPage.mergeRelationsSwitchLabel')}
/>
</Grid>
)}
@@ -247,9 +248,8 @@ export const CatalogGraphPage = (
display="block"
className={classes.legend}
>
<ZoomOutMap className="icon" /> Use pinch &amp; zoom to move
around the diagram. Click to change active node, shift click to
navigate to entity.
<ZoomOutMap className="icon" />{' '}
{t('catalogGraphPage.zoomOutDescription')}
</Typography>
<EntityRelationsGraph
{...props}
@@ -14,21 +14,26 @@
* limitations under the License.
*/
import { render, waitFor, screen, within } from '@testing-library/react';
import { waitFor, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CurveFilter } from './CurveFilter';
import { renderInTestApp } from '@backstage/test-utils';
describe('<CurveFilter/>', () => {
test('should display current curve label', () => {
test('should display current curve label', async () => {
const onChange = jest.fn();
render(<CurveFilter value="curveMonotoneX" onChange={onChange} />);
await renderInTestApp(
<CurveFilter value="curveMonotoneX" onChange={onChange} />,
);
expect(screen.getByText('Monotone X')).toBeInTheDocument();
});
test('should select an alternative curve factory', async () => {
const onChange = jest.fn();
render(<CurveFilter value="curveStepBefore" onChange={onChange} />);
await renderInTestApp(
<CurveFilter value="curveStepBefore" onChange={onChange} />,
);
expect(screen.getByText('Step Before')).toBeInTheDocument();
@@ -16,12 +16,10 @@
import { Select, SelectedItems } from '@backstage/core-components';
import Box from '@material-ui/core/Box';
import { useCallback } from 'react';
import { catalogGraphTranslationRef } from '../../translation';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
type Curve = 'curveStepBefore' | 'curveMonotoneX';
const CURVE_DISPLAY_NAMES: Record<Curve, string> = {
curveMonotoneX: 'Monotone X',
curveStepBefore: 'Step Before',
};
export type Props = {
value: Curve;
@@ -31,6 +29,12 @@ export type Props = {
const curves: Array<Curve> = ['curveMonotoneX', 'curveStepBefore'];
export const CurveFilter = ({ value, onChange }: Props) => {
const { t } = useTranslationRef(catalogGraphTranslationRef);
const CURVE_DISPLAY_NAMES: Record<Curve, string> = {
curveMonotoneX: t('catalogGraphPage.curveFilter.curveMonotoneX'),
curveStepBefore: t('catalogGraphPage.curveFilter.curveStepBefore'),
};
const handleChange = useCallback(
(v: SelectedItems) => onChange(v as Curve),
[onChange],
@@ -39,7 +43,7 @@ export const CurveFilter = ({ value, onChange }: Props) => {
return (
<Box pb={1} pt={1}>
<Select
label="Curve"
label={t('catalogGraphPage.curveFilter.title')}
selected={value}
items={curves.map(v => ({
label: CURVE_DISPLAY_NAMES[v],
@@ -14,14 +14,15 @@
* limitations under the License.
*/
import { render, waitFor, screen, within } from '@testing-library/react';
import { waitFor, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Direction } from '../EntityRelationsGraph';
import { DirectionFilter } from './DirectionFilter';
import { renderInTestApp } from '@backstage/test-utils';
describe('<DirectionFilter/>', () => {
test('should display current value', () => {
render(
test('should display current value', async () => {
await renderInTestApp(
<DirectionFilter value={Direction.LEFT_RIGHT} onChange={() => {}} />,
);
@@ -30,7 +31,7 @@ describe('<DirectionFilter/>', () => {
test('should select direction', async () => {
const onChange = jest.fn();
render(
await renderInTestApp(
<DirectionFilter value={Direction.RIGHT_LEFT} onChange={onChange} />,
);
@@ -17,13 +17,8 @@ import { Select, SelectedItems } from '@backstage/core-components';
import Box from '@material-ui/core/Box';
import { useCallback } from 'react';
import { Direction } from '../EntityRelationsGraph';
const DIRECTION_DISPLAY_NAMES = {
[Direction.LEFT_RIGHT]: 'Left to right',
[Direction.RIGHT_LEFT]: 'Right to left',
[Direction.TOP_BOTTOM]: 'Top to bottom',
[Direction.BOTTOM_TOP]: 'Bottom to top',
};
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { catalogGraphTranslationRef } from '../../translation';
export type Props = {
value: Direction;
@@ -31,6 +26,13 @@ export type Props = {
};
export const DirectionFilter = ({ value, onChange }: Props) => {
const { t } = useTranslationRef(catalogGraphTranslationRef);
const DIRECTION_DISPLAY_NAMES = {
[Direction.LEFT_RIGHT]: t('catalogGraphPage.directionFilter.leftToRight'),
[Direction.RIGHT_LEFT]: t('catalogGraphPage.directionFilter.rightToLeft'),
[Direction.TOP_BOTTOM]: t('catalogGraphPage.directionFilter.topToBottom'),
[Direction.BOTTOM_TOP]: t('catalogGraphPage.directionFilter.bottomToTop'),
};
const handleChange = useCallback(
(v: SelectedItems) => onChange(v as Direction),
[onChange],
@@ -39,7 +41,7 @@ export const DirectionFilter = ({ value, onChange }: Props) => {
return (
<Box pb={1} pt={1}>
<Select
label="Direction"
label={t('catalogGraphPage.directionFilter.title')}
selected={value}
items={Object.values(Direction).map(v => ({
label: DIRECTION_DISPLAY_NAMES[v],
@@ -14,20 +14,21 @@
* limitations under the License.
*/
import { render, screen } from '@testing-library/react';
import { screen } from '@testing-library/react';
import user from '@testing-library/user-event';
import { MaxDepthFilter } from './MaxDepthFilter';
import { renderInTestApp } from '@backstage/test-utils';
describe('<MaxDepthFilter/>', () => {
test('should display current value', () => {
render(<MaxDepthFilter value={5} onChange={() => {}} />);
test('should display current value', async () => {
await renderInTestApp(<MaxDepthFilter value={5} onChange={() => {}} />);
expect(screen.getByLabelText('maxp')).toBeInTheDocument();
expect(screen.getByLabelText('maxp')).toHaveValue(5);
});
test('should display infinite if non finite', () => {
render(
test('should display infinite if non finite', async () => {
await renderInTestApp(
<MaxDepthFilter value={Number.POSITIVE_INFINITY} onChange={() => {}} />,
);
@@ -37,7 +38,7 @@ describe('<MaxDepthFilter/>', () => {
test('should clear max depth', async () => {
const onChange = jest.fn();
render(<MaxDepthFilter value={10} onChange={onChange} />);
await renderInTestApp(<MaxDepthFilter value={10} onChange={onChange} />);
expect(onChange).not.toHaveBeenCalled();
await user.click(screen.getByLabelText('clear max depth'));
@@ -46,7 +47,7 @@ describe('<MaxDepthFilter/>', () => {
test('should set max depth to undefined if below one', async () => {
const onChange = jest.fn();
render(<MaxDepthFilter value={1} onChange={onChange} />);
await renderInTestApp(<MaxDepthFilter value={1} onChange={onChange} />);
await user.clear(screen.getByLabelText('maxp'));
await user.type(screen.getByLabelText('maxp'), '0');
@@ -56,7 +57,7 @@ describe('<MaxDepthFilter/>', () => {
test('should select direction', async () => {
let value = 5;
render(
await renderInTestApp(
<MaxDepthFilter
value={value}
onChange={v => {
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import Box from '@material-ui/core/Box';
import FormControl from '@material-ui/core/FormControl';
import IconButton from '@material-ui/core/IconButton';
@@ -22,6 +23,7 @@ import Typography from '@material-ui/core/Typography';
import { makeStyles } from '@material-ui/core/styles';
import ClearIcon from '@material-ui/icons/Clear';
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
import { catalogGraphTranslationRef } from '../../translation';
export type Props = {
value: number;
@@ -45,6 +47,7 @@ export const MaxDepthFilter = ({ value, onChange }: Props) => {
const classes = useStyles();
const onChangeRef = useRef(onChange);
const [currentValue, setCurrentValue] = useState(value);
const { t } = useTranslationRef(catalogGraphTranslationRef);
// Keep a fresh reference to the latest callback
useEffect(() => {
@@ -75,16 +78,20 @@ export const MaxDepthFilter = ({ value, onChange }: Props) => {
return (
<Box pb={1} pt={1}>
<FormControl variant="outlined" className={classes.formControl}>
<Typography variant="button">Max Depth</Typography>
<Typography variant="button">
{t('catalogGraphPage.maxDepthFilter.title')}
</Typography>
<OutlinedInput
type="number"
placeholder="∞ Infinite"
placeholder={t('catalogGraphPage.maxDepthFilter.inputPlaceholder')}
value={Number.isFinite(currentValue) ? String(currentValue) : ''}
onChange={handleChange}
endAdornment={
<InputAdornment position="end">
<IconButton
aria-label="clear max depth"
aria-label={t(
'catalogGraphPage.maxDepthFilter.clearButtonAriaLabel',
)}
onClick={reset}
edge="end"
>
@@ -15,13 +15,18 @@
*/
import { ApiProvider } from '@backstage/core-app-api';
import { AlertApi, alertApiRef } from '@backstage/core-plugin-api';
import { AlertApi, alertApiRef, errorApiRef } from '@backstage/core-plugin-api';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { renderWithEffects, TestApiRegistry } from '@backstage/test-utils';
import {
mockApis,
renderWithEffects,
TestApiRegistry,
} from '@backstage/test-utils';
import { waitFor, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SelectedKindsFilter } from './SelectedKindsFilter';
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
import { translationApiRef } from '@backstage/core-plugin-api/alpha';
const catalogApi = catalogApiMock.mock({
getEntityFacets: jest.fn().mockResolvedValue({
@@ -38,6 +43,8 @@ const catalogApi = catalogApiMock.mock({
const apis = TestApiRegistry.from(
[catalogApiRef, catalogApi],
[alertApiRef, {} as AlertApi],
[translationApiRef, mockApis.translation()],
[errorApiRef, { post: jest.fn() }],
);
describe('<SelectedKindsFilter/>', () => {
@@ -27,6 +27,8 @@ import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import Autocomplete from '@material-ui/lab/Autocomplete';
import { useCallback, useEffect, useMemo } from 'react';
import useAsync from 'react-use/esm/useAsync';
import { catalogGraphTranslationRef } from '../../translation';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
/** @public */
export type SelectedKindsFilterClassKey = 'formControl';
@@ -49,6 +51,7 @@ export const SelectedKindsFilter = ({ value, onChange }: Props) => {
const classes = useStyles();
const alertApi = useApi(alertApiRef);
const catalogApi = useApi(catalogApiRef);
const { t } = useTranslationRef(catalogGraphTranslationRef);
const { error, value: kinds } = useAsync(async () => {
return await catalogApi
@@ -91,13 +94,15 @@ export const SelectedKindsFilter = ({ value, onChange }: Props) => {
return (
<Box pb={1} pt={1}>
<Typography variant="button">Kinds</Typography>
<Typography variant="button">
{t('catalogGraphPage.selectedKindsFilter.title')}
</Typography>
<Autocomplete
className={classes.formControl}
multiple
limitTags={4}
disableCloseOnSelect
aria-label="Kinds"
aria-label={t('catalogGraphPage.selectedKindsFilter.title')}
options={normalizedKinds}
value={value ?? normalizedKinds}
getOptionLabel={k => kinds[normalizedKinds.indexOf(k)] ?? k}
@@ -19,14 +19,15 @@ import {
RELATION_HAS_MEMBER,
RELATION_OWNED_BY,
} from '@backstage/catalog-model';
import { render, waitFor, screen } from '@testing-library/react';
import { waitFor, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ALL_RELATION_PAIRS } from '../EntityRelationsGraph';
import { SelectedRelationsFilter } from './SelectedRelationsFilter';
import { renderInTestApp } from '@backstage/test-utils';
describe('<SelectedRelationsFilter/>', () => {
test('should render current value', () => {
render(
test('should render current value', async () => {
await renderInTestApp(
<SelectedRelationsFilter
relationPairs={ALL_RELATION_PAIRS}
value={[RELATION_OWNED_BY, RELATION_CHILD_OF]}
@@ -40,7 +41,7 @@ describe('<SelectedRelationsFilter/>', () => {
test('should select value', async () => {
const onChange = jest.fn();
render(
await renderInTestApp(
<SelectedRelationsFilter
relationPairs={ALL_RELATION_PAIRS}
value={[RELATION_OWNED_BY, RELATION_CHILD_OF]}
@@ -67,7 +68,7 @@ describe('<SelectedRelationsFilter/>', () => {
test('should return undefined if all values are selected', async () => {
const onChange = jest.fn();
render(
await renderInTestApp(
<SelectedRelationsFilter
relationPairs={ALL_RELATION_PAIRS}
value={ALL_RELATION_PAIRS.flatMap(p => p).filter(
@@ -92,7 +93,7 @@ describe('<SelectedRelationsFilter/>', () => {
test('should return all values when cleared', async () => {
const onChange = jest.fn();
render(
await renderInTestApp(
<SelectedRelationsFilter
relationPairs={ALL_RELATION_PAIRS}
value={[]}
@@ -25,6 +25,8 @@ import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import Autocomplete from '@material-ui/lab/Autocomplete';
import { useCallback, useMemo } from 'react';
import { RelationPairs } from '../EntityRelationsGraph';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { catalogGraphTranslationRef } from '../../translation';
/** @public */
export type SelectedRelationsFilterClassKey = 'formControl';
@@ -51,6 +53,7 @@ export const SelectedRelationsFilter = ({
}: Props) => {
const classes = useStyles();
const relations = useMemo(() => relationPairs.flat(), [relationPairs]);
const { t } = useTranslationRef(catalogGraphTranslationRef);
const handleChange = useCallback(
(_: unknown, v: string[]) => {
@@ -65,13 +68,15 @@ export const SelectedRelationsFilter = ({
return (
<Box pb={1} pt={1}>
<Typography variant="button">Relations</Typography>
<Typography variant="button">
{t('catalogGraphPage.selectedRelationsFilter.title')}
</Typography>
<Autocomplete
className={classes.formControl}
multiple
limitTags={4}
disableCloseOnSelect
aria-label="Relations"
aria-label={t('catalogGraphPage.selectedRelationsFilter.title')}
options={relations}
value={value ?? relations}
onChange={handleChange}
+60
View File
@@ -0,0 +1,60 @@
/*
* Copyright 2025 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 { createTranslationRef } from '@backstage/frontend-plugin-api';
/** @alpha */
export const catalogGraphTranslationRef = createTranslationRef({
id: 'catalog-graph',
messages: {
catalogGraphCard: {
title: 'Relations',
deepLinkTitle: 'View graph',
},
catalogGraphPage: {
title: 'Catalog Graph',
filterToggleButtonTitle: 'Filters',
supportButtonDescription:
'Start tracking your component in by adding it to the software catalog.',
simplifiedSwitchLabel: 'Simplified',
mergeRelationsSwitchLabel: 'Merge relations',
zoomOutDescription:
'Use pinch &amp; zoom to move around the diagram. Click to change active node, shift click to navigate to entity.',
curveFilter: {
title: 'Curve',
curveMonotoneX: 'Monotone X',
curveStepBefore: 'Step Before',
},
directionFilter: {
title: 'Direction',
leftToRight: 'Left to right',
rightToLeft: 'Right to left',
topToBottom: 'Top to bottom',
bottomToTop: 'Bottom to top',
},
maxDepthFilter: {
title: 'Max depth',
inputPlaceholder: '∞ Infinite',
clearButtonAriaLabel: 'clear max depth',
},
selectedKindsFilter: {
title: 'Kinds',
},
selectedRelationsFilter: {
title: 'Relations',
},
},
},
});