Merge pull request #2013 from SDA-SE/tags

Add tags to catalog entities
This commit is contained in:
Ivan Shmidt
2020-08-19 11:31:53 +02:00
committed by GitHub
24 changed files with 356 additions and 3 deletions
@@ -42,6 +42,7 @@ software catalog API.
"labels": {
"system": "public-websites"
},
"tags": ["java"],
"name": "artist-web",
"uid": "2152f463-549d-4d8d-a94d-ce2b7676c6e2"
},
@@ -66,6 +67,8 @@ metadata:
annotations:
example.com/service-discovery: artistweb
circleci.com/project-slug: gh/example-org/artist-website
tags:
- java
spec:
type: website
lifecycle: production
@@ -232,6 +235,20 @@ The `backstage.io/` prefix is reserved for use by Backstage core components.
Values can be of any length, but are limited to being strings.
### `tags` [optional]
A list of single-valued strings, for example to classify catalog entities in
various ways. This is different to the labels in metadata, as labels are
key-value pairs.
The values are user defined, for example the programming language used for the
component, like `java` or `go`.
This field is optional, and currently has no special semantics.
Each tag must be sequences of `[a-zA-Z0-9]` separated by `-`, at most 63
characters in total.
## Kind: Component
Describes the following entity kind:
@@ -3,6 +3,9 @@ kind: Component
metadata:
name: artist-lookup
description: Artist Lookup
tags:
- java
- data
spec:
type: service
lifecycle: experimental
@@ -3,6 +3,8 @@ kind: Component
metadata:
name: podcast-api
description: Podcast API
tags:
- java
spec:
type: service
lifecycle: experimental
@@ -3,6 +3,9 @@ kind: Component
metadata:
name: queue-proxy
description: Queue Proxy
tags:
- go
- website
spec:
type: website
lifecycle: production
@@ -3,6 +3,8 @@ kind: Component
metadata:
name: searcher
description: Searcher
tags:
- go
spec:
type: service
lifecycle: production
@@ -3,6 +3,8 @@ kind: Component
metadata:
name: shuffle-api
description: Shuffle API
tags:
- go
spec:
type: service
lifecycle: production
@@ -3,6 +3,8 @@ kind: API
metadata:
name: streetlights
description: The Smartylighting Streetlights API allows you to remotely manage the city lights.
tags:
- unstable
spec:
type: asyncapi
definition: |
@@ -113,6 +113,12 @@ export type EntityMeta = JsonObject & {
* entity.
*/
annotations?: Record<string, string>;
/**
* A list of single-valued strings, to for example classify catalog entities in
* various ways.
*/
tags?: string[];
};
/**
@@ -35,6 +35,9 @@ describe('FieldFormatEntityPolicy', () => {
backstage.io/custom: ValueStuff
annotations:
example.com/bindings: are-secret
tags:
- java
- data-service
spec:
custom: stuff
`);
@@ -102,4 +105,9 @@ describe('FieldFormatEntityPolicy', () => {
data.metadata.annotations.a = 7;
await expect(policy.enforce(data)).rejects.toThrow(/annotation.*7/i);
});
it('rejects bad tag value', async () => {
data.metadata.tags.push('Hello World');
await expect(policy.enforce(data)).rejects.toThrow(/tags.*"Hello World"/i);
});
});
@@ -83,6 +83,12 @@ export class FieldFormatEntityPolicy implements EntityPolicy {
require(`annotations.${k}`, v, this.validators.isValidAnnotationValue);
}
const tags = entity.metadata.tags ?? [];
for (let i = 0; i < tags.length; ++i) {
require(`tags.${i}`, tags[i], this.validators.isValidTag);
}
return entity;
}
}
@@ -35,6 +35,9 @@ describe('ReservedFieldsEntityPolicy', () => {
backstage.io/custom: ValueStuff
annotations:
example.com/bindings: are-secret
tags:
- java
- data
spec:
custom: stuff
`);
@@ -29,6 +29,7 @@ const DEFAULT_RESERVED_ENTITY_FIELDS: string[] = [
'metadata.description',
'metadata.labels',
'metadata.annotations',
'metadata.tags',
// The below items are known to appear in core kinds, and therefore should
// not be appearing in metadata (which would indicate that the user made a
// mistake in where to place them).
@@ -36,6 +36,9 @@ describe('SchemaValidEntityPolicy', () => {
backstage.io/custom: ValueStuff
annotations:
example.com/bindings: are-secret
tags:
- java
- data
spec:
custom: stuff
`);
@@ -190,6 +193,11 @@ describe('SchemaValidEntityPolicy', () => {
await expect(policy.enforce(data)).rejects.toThrow(/annotations/);
});
it('rejects bad tags type', async () => {
data.metadata.tags = 7;
await expect(policy.enforce(data)).rejects.toThrow(/tags/);
});
//
// spec
//
@@ -31,6 +31,7 @@ const DEFAULT_ENTITY_SCHEMA = yup.object({
description: yup.string().notRequired(),
labels: yup.object<Record<string, string>>().notRequired(),
annotations: yup.object<Record<string, string>>().notRequired(),
tags: yup.array<string>().notRequired(),
})
.required(),
spec: yup.object({}).notRequired(),
@@ -28,6 +28,7 @@ const defaultValidators: Validators = {
isValidLabelValue: KubernetesValidatorFunctions.isValidLabelValue,
isValidAnnotationKey: KubernetesValidatorFunctions.isValidAnnotationKey,
isValidAnnotationValue: KubernetesValidatorFunctions.isValidAnnotationValue,
isValidTag: CommonValidatorFunctions.isValidDnsLabel,
};
export function makeValidator(overrides: Partial<Validators> = {}): Validators {
@@ -24,4 +24,5 @@ export type Validators = {
isValidLabelValue(value: any): boolean;
isValidAnnotationKey(value: any): boolean;
isValidAnnotationValue(value: any): boolean;
isValidTag(value: any): boolean;
};
+2 -1
View File
@@ -127,7 +127,8 @@ function convertColumns<T extends object>(
): TableColumn<T>[] {
return columns.map(column => {
const headerStyle: React.CSSProperties = {};
const cellStyle: React.CSSProperties = {};
const cellStyle: React.CSSProperties =
typeof column.cellStyle === 'object' ? column.cellStyle : {};
if (column.highlight) {
headerStyle.color = theme.palette.textContrast;
@@ -35,6 +35,7 @@ import { CatalogTable } from '../CatalogTable/CatalogTable';
import CatalogLayout from './CatalogLayout';
import { CatalogTabs, LabeledComponentType } from './CatalogTabs';
import { WelcomeBanner } from './WelcomeBanner';
import { ResultsFilter } from '../ResultsFilter/ResultsFilter';
const useStyles = makeStyles(theme => ({
contentWrapper: {
@@ -47,7 +48,12 @@ const useStyles = makeStyles(theme => ({
const CatalogPageContents = () => {
const styles = useStyles();
const { loading, error, matchingEntities } = useFilteredEntities();
const {
loading,
error,
matchingEntities,
availableTags,
} = useFilteredEntities();
const { isStarredEntity } = useStarredEntities();
const userId = useApi(identityApiRef).getUserId();
const [selectedTab, setSelectedTab] = useState<string>();
@@ -140,6 +146,7 @@ const CatalogPageContents = () => {
onChange={({ label }) => setSelectedSidebarItem(label)}
initiallySelected="owned"
/>
<ResultsFilter availableTags={availableTags} />
</div>
<CatalogTable
titlePreamble={selectedSidebarItem ?? ''}
@@ -15,7 +15,7 @@
*/
import { Entity, LocationSpec } from '@backstage/catalog-model';
import { Table, TableColumn, TableProps } from '@backstage/core';
import { Link } from '@material-ui/core';
import { Link, Chip } from '@material-ui/core';
import Edit from '@material-ui/icons/Edit';
import GitHub from '@material-ui/icons/GitHub';
import { Alert } from '@material-ui/lab';
@@ -63,6 +63,21 @@ const columns: TableColumn<Entity>[] = [
title: 'Description',
field: 'metadata.description',
},
{
title: 'Tags',
field: 'metadata.tags',
cellStyle: {
padding: '0px 16px 0px 20px',
},
render: (entity: Entity) => (
<>
{entity.metadata.tags &&
entity.metadata.tags.map(t => (
<Chip label={t} color="secondary" style={{ marginBottom: '0px' }} />
))}
</>
),
},
];
type CatalogTableProps = {
@@ -0,0 +1,102 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import {
ApiProvider,
ApiRegistry,
IdentityApi,
identityApiRef,
storageApiRef,
} from '@backstage/core';
import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
import { render } from '@testing-library/react';
import React from 'react';
import { CatalogApi, catalogApiRef } from '../../api/types';
import { EntityFilterGroupsProvider } from '../../filter';
import { ResultsFilter } from './ResultsFilter';
describe('Results Filter', () => {
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve([
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity1',
tags: ['java'],
},
spec: {
owner: 'tools@example.com',
type: 'service',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity2',
},
spec: {
owner: 'not-tools@example.com',
type: 'service',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'Entity3',
tags: ['java', 'test'],
},
spec: {
owner: 'tools@example.com',
type: 'service',
},
},
] as Entity[]),
};
const indentityApi: Partial<IdentityApi> = {
getUserId: () => 'tools@example.com',
};
const renderWrapped = (children: React.ReactNode) =>
render(
wrapInTestApp(
<ApiProvider
apis={ApiRegistry.from([
[catalogApiRef, catalogApi],
[identityApiRef, indentityApi],
[storageApiRef, MockStorageApi.create()],
])}
>
<EntityFilterGroupsProvider>{children}</EntityFilterGroupsProvider>,
</ApiProvider>,
),
);
it('should render all available tags', async () => {
const tags = ['test', 'java'];
const { findByText } = renderWrapped(
<ResultsFilter availableTags={tags} />,
);
for (const tag of tags) {
expect(await findByText(tag)).toBeInTheDocument();
}
});
});
@@ -0,0 +1,120 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Button,
Checkbox,
Divider,
List,
ListItem,
ListItemText,
makeStyles,
Theme,
Typography,
} from '@material-ui/core';
import React, { useCallback, useContext, useState } from 'react';
import { filterGroupsContext } from '../../filter/context';
const useStyles = makeStyles<Theme>(theme => ({
filterBox: {
display: 'flex',
margin: theme.spacing(2, 0, 0, 0),
},
filterBoxTitle: {
margin: theme.spacing(1, 0, 0, 1),
fontWeight: 'bold',
flex: 1,
},
title: {
margin: theme.spacing(1, 0, 0, 1),
textTransform: 'uppercase',
fontSize: 12,
fontWeight: 'bold',
},
checkbox: {
padding: theme.spacing(0, 1, 0, 1),
},
}));
type Props = {
availableTags: string[];
};
/**
* The additional results filter in the sidebar.
*/
export const ResultsFilter = ({ availableTags }: Props) => {
const classes = useStyles();
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const context = useContext(filterGroupsContext);
if (!context) {
throw new Error(`Must be used inside an EntityFilterGroupsProvider`);
}
const setSelectedTagsFilter = context?.setSelectedTags;
const updateSelectedTags = useCallback(
(tags: string[]) => {
setSelectedTags(tags);
setSelectedTagsFilter(tags);
},
[setSelectedTags, setSelectedTagsFilter],
);
return (
<>
<div className={classes.filterBox}>
<Typography variant="subtitle2" className={classes.filterBoxTitle}>
Refine Results
</Typography>{' '}
<Button onClick={() => updateSelectedTags([])}>Clear</Button>
</div>
<Divider />
<Typography variant="subtitle2" className={classes.title}>
Tags
</Typography>
<List disablePadding dense>
{availableTags.map(t => {
const labelId = `checkbox-list-label-${t}`;
return (
<ListItem
key={t}
dense
button
onClick={() =>
updateSelectedTags(
selectedTags.includes(t)
? selectedTags.filter(s => s !== t)
: [...selectedTags, t],
)
}
>
<Checkbox
edge="start"
checked={selectedTags.includes(t)}
tabIndex={-1}
disableRipple
className={classes.checkbox}
inputProps={{ 'aria-labelledby': labelId }}
/>
<ListItemText id={labelId} primary={t} />
</ListItem>
);
})}
</List>
</>
);
};
@@ -54,16 +54,19 @@ function useProvideEntityFilters(): FilterGroupsContext {
const selectedFilterKeys = useRef<{
[filterGroupId: string]: Set<string>;
}>({});
const selectedTags = useRef<string[]>([]);
const [filterGroupStates, setFilterGroupStates] = useState<{
[filterGroupId: string]: FilterGroupStates;
}>({});
const [matchingEntities, setMatchingEntities] = useState<Entity[]>([]);
const [availableTags, setAvailableTags] = useState<string[]>([]);
const rebuild = useCallback(() => {
setFilterGroupStates(
buildStates(
filterGroups.current,
selectedFilterKeys.current,
selectedTags.current,
entities,
error,
),
@@ -72,9 +75,11 @@ function useProvideEntityFilters(): FilterGroupsContext {
buildMatchingEntities(
filterGroups.current,
selectedFilterKeys.current,
selectedTags.current,
entities,
),
);
setAvailableTags(collectTags(entities));
}, [entities, error]);
const register = useCallback(
@@ -109,14 +114,24 @@ function useProvideEntityFilters(): FilterGroupsContext {
[rebuild],
);
const setSelectedTags = useCallback(
(tags: string[]) => {
selectedTags.current = tags;
rebuild();
},
[rebuild],
);
return {
register,
unregister,
setGroupSelectedFilters,
setSelectedTags,
loading: !error && !entities,
error,
filterGroupStates,
matchingEntities,
availableTags,
};
}
@@ -125,6 +140,7 @@ function useProvideEntityFilters(): FilterGroupsContext {
function buildStates(
filterGroups: { [filterGroupId: string]: FilterGroup },
selectedFilterKeys: { [filterGroupId: string]: Set<string> },
selectedTags: string[],
entities?: Entity[],
error?: Error,
): { [filterGroupId: string]: FilterGroupStates } {
@@ -153,6 +169,7 @@ function buildStates(
const otherMatchingEntities = buildMatchingEntities(
filterGroups,
selectedFilterKeys,
selectedTags,
entities,
filterGroupId,
);
@@ -170,11 +187,23 @@ function buildStates(
return result;
}
// Given all entites, find all possible tags and provide them in a sorted list.
function collectTags(entities?: Entity[]): string[] {
const tags = new Set<string>();
(entities || []).forEach(e => {
if (e.metadata.tags) {
e.metadata.tags.forEach(t => tags.add(t));
}
});
return Array.from(tags).sort();
}
// Given all filter groups and what filters are actually selected, extract all
// entities that match all those filter groups.
function buildMatchingEntities(
filterGroups: { [filterGroupId: string]: FilterGroup },
selectedFilterKeys: { [filterGroupId: string]: Set<string> },
selectedTags: string[],
entities?: Entity[],
excludeFilterGroupId?: string,
): Entity[] {
@@ -201,6 +230,16 @@ function buildMatchingEntities(
}
}
// Filter by tags, if at least one tag is selected. Include all entities
// that have at least one of the selected tags
if (selectedTags.length > 0) {
allFilters.push(
entity =>
!!entity.metadata.tags &&
entity.metadata.tags.some(t => selectedTags.includes(t)),
);
}
// All filter groups that had any checked filters need to match. Note that
// every() always returns true for an empty array.
return entities?.filter(entity => allFilters.every(fn => fn(entity))) ?? [];
+2
View File
@@ -26,10 +26,12 @@ export type FilterGroupsContext = {
) => void;
unregister: (filterGroupId: string) => void;
setGroupSelectedFilters: (filterGroupId: string, filterIds: string[]) => void;
setSelectedTags: (tags: string[]) => void;
loading: boolean;
error?: Error;
filterGroupStates: { [filterGroupId: string]: FilterGroupStates };
matchingEntities: Entity[];
availableTags: string[];
};
/**
@@ -30,5 +30,6 @@ export function useFilteredEntities() {
loading: context.loading,
error: context.error,
matchingEntities: context.matchingEntities,
availableTags: context.availableTags,
};
}