@@ -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;
|
||||
};
|
||||
|
||||
@@ -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))) ?? [];
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user