Merge pull request #21480 from backstage/mob/entity-filters
implement text based entity card/content filters
This commit is contained in:
@@ -24,17 +24,14 @@ export function createEntityCardExtension<
|
||||
};
|
||||
disabled?: boolean;
|
||||
inputs?: TInputs;
|
||||
filter?: (ctx: { entity: Entity }) => boolean;
|
||||
filter?:
|
||||
| typeof entityFilterFunctionExtensionDataRef.T
|
||||
| typeof entityFilterExpressionExtensionDataRef.T;
|
||||
loader: (options: {
|
||||
inputs: Expand<ExtensionInputValues<TInputs>>;
|
||||
}) => Promise<JSX.Element>;
|
||||
}): Extension<{
|
||||
filter?:
|
||||
| {
|
||||
isKind?: string | undefined;
|
||||
isType?: string | undefined;
|
||||
}[]
|
||||
| undefined;
|
||||
filter?: string | undefined;
|
||||
}>;
|
||||
|
||||
// @alpha (undocumented)
|
||||
@@ -51,19 +48,16 @@ export function createEntityContentExtension<
|
||||
routeRef?: RouteRef;
|
||||
defaultPath: string;
|
||||
defaultTitle: string;
|
||||
filter?: (ctx: { entity: Entity }) => boolean;
|
||||
filter?:
|
||||
| typeof entityFilterFunctionExtensionDataRef.T
|
||||
| typeof entityFilterExpressionExtensionDataRef.T;
|
||||
loader: (options: {
|
||||
inputs: Expand<ExtensionInputValues<TInputs>>;
|
||||
}) => Promise<JSX.Element>;
|
||||
}): Extension<{
|
||||
title: string;
|
||||
path: string;
|
||||
filter?:
|
||||
| {
|
||||
isKind?: string | undefined;
|
||||
isType?: string | undefined;
|
||||
}[]
|
||||
| undefined;
|
||||
filter?: string | undefined;
|
||||
}>;
|
||||
|
||||
// @alpha (undocumented)
|
||||
@@ -73,8 +67,14 @@ export const entityContentTitleExtensionDataRef: ConfigurableExtensionDataRef<
|
||||
>;
|
||||
|
||||
// @alpha (undocumented)
|
||||
export const entityFilterExtensionDataRef: ConfigurableExtensionDataRef<
|
||||
(ctx: { entity: Entity }) => boolean,
|
||||
export const entityFilterExpressionExtensionDataRef: ConfigurableExtensionDataRef<
|
||||
string,
|
||||
{}
|
||||
>;
|
||||
|
||||
// @alpha (undocumented)
|
||||
export const entityFilterFunctionExtensionDataRef: ConfigurableExtensionDataRef<
|
||||
(entity: Entity) => boolean,
|
||||
{}
|
||||
>;
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { lazy } from 'react';
|
||||
import {
|
||||
AnyExtensionInputMap,
|
||||
ExtensionBoundary,
|
||||
@@ -25,53 +24,26 @@ import {
|
||||
createExtensionDataRef,
|
||||
createSchemaFromZod,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import React, { lazy } from 'react';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { Expand } from '../../../packages/frontend-plugin-api/src/types';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
export { isOwnerOf } from './utils';
|
||||
export { useEntityPermission } from './hooks/useEntityPermission';
|
||||
export { isOwnerOf } from './utils';
|
||||
|
||||
/** @alpha */
|
||||
export const entityContentTitleExtensionDataRef =
|
||||
createExtensionDataRef<string>('plugin.catalog.entity.content.title');
|
||||
|
||||
/** @alpha */
|
||||
export const entityFilterExtensionDataRef = createExtensionDataRef<
|
||||
(ctx: { entity: Entity }) => boolean
|
||||
>('plugin.catalog.entity.filter');
|
||||
export const entityFilterFunctionExtensionDataRef = createExtensionDataRef<
|
||||
(entity: Entity) => boolean
|
||||
>('plugin.catalog.entity.filter.fn');
|
||||
|
||||
function applyFilter(a?: string, b?: string): boolean {
|
||||
if (!a) {
|
||||
return true;
|
||||
}
|
||||
return a.toLocaleLowerCase('en-US') === b?.toLocaleLowerCase('en-US');
|
||||
}
|
||||
|
||||
// TODO: Only two hardcoded isKind and isType filters are available for now
|
||||
// This is just an initial config filter implementation and needs to be revisited
|
||||
function buildFilter(
|
||||
config: { filter?: { isKind?: string; isType?: string }[] },
|
||||
filterFunc?: (ctx: { entity: Entity }) => boolean,
|
||||
) {
|
||||
return (ctx: { entity: Entity }) => {
|
||||
const configuredFilterMatch = config.filter?.some(filter => {
|
||||
const kindMatch = applyFilter(filter.isKind, ctx.entity.kind);
|
||||
const typeMatch = applyFilter(
|
||||
filter.isType,
|
||||
ctx.entity.spec?.type?.toString(),
|
||||
);
|
||||
return kindMatch && typeMatch;
|
||||
});
|
||||
if (configuredFilterMatch) {
|
||||
return true;
|
||||
}
|
||||
if (filterFunc) {
|
||||
return filterFunc(ctx);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
/** @alpha */
|
||||
export const entityFilterExpressionExtensionDataRef =
|
||||
createExtensionDataRef<string>('plugin.catalog.entity.filter.expression');
|
||||
|
||||
// TODO: Figure out how to merge with provided config schema
|
||||
/** @alpha */
|
||||
@@ -82,7 +54,9 @@ export function createEntityCardExtension<
|
||||
attachTo?: { id: string; input: string };
|
||||
disabled?: boolean;
|
||||
inputs?: TInputs;
|
||||
filter?: (ctx: { entity: Entity }) => boolean;
|
||||
filter?:
|
||||
| typeof entityFilterFunctionExtensionDataRef.T
|
||||
| typeof entityFilterExpressionExtensionDataRef.T;
|
||||
loader: (options: {
|
||||
inputs: Expand<ExtensionInputValues<TInputs>>;
|
||||
}) => Promise<JSX.Element>;
|
||||
@@ -98,19 +72,13 @@ export function createEntityCardExtension<
|
||||
disabled: options.disabled ?? true,
|
||||
output: {
|
||||
element: coreExtensionData.reactElement,
|
||||
filter: entityFilterExtensionDataRef,
|
||||
filterFunction: entityFilterFunctionExtensionDataRef.optional(),
|
||||
filterExpression: entityFilterExpressionExtensionDataRef.optional(),
|
||||
},
|
||||
inputs: options.inputs,
|
||||
configSchema: createSchemaFromZod(z =>
|
||||
z.object({
|
||||
filter: z
|
||||
.array(
|
||||
z.object({
|
||||
isKind: z.string().optional(),
|
||||
isType: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
filter: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
factory({ config, inputs, node }) {
|
||||
@@ -126,7 +94,7 @@ export function createEntityCardExtension<
|
||||
<ExtensionComponent />
|
||||
</ExtensionBoundary>
|
||||
),
|
||||
filter: buildFilter(config, options.filter),
|
||||
...mergeFilters({ config, options }),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -143,7 +111,9 @@ export function createEntityContentExtension<
|
||||
routeRef?: RouteRef;
|
||||
defaultPath: string;
|
||||
defaultTitle: string;
|
||||
filter?: (ctx: { entity: Entity }) => boolean;
|
||||
filter?:
|
||||
| typeof entityFilterFunctionExtensionDataRef.T
|
||||
| typeof entityFilterExpressionExtensionDataRef.T;
|
||||
loader: (options: {
|
||||
inputs: Expand<ExtensionInputValues<TInputs>>;
|
||||
}) => Promise<JSX.Element>;
|
||||
@@ -162,21 +132,15 @@ export function createEntityContentExtension<
|
||||
path: coreExtensionData.routePath,
|
||||
routeRef: coreExtensionData.routeRef.optional(),
|
||||
title: entityContentTitleExtensionDataRef,
|
||||
filter: entityFilterExtensionDataRef,
|
||||
filterFunction: entityFilterFunctionExtensionDataRef.optional(),
|
||||
filterExpression: entityFilterExpressionExtensionDataRef.optional(),
|
||||
},
|
||||
inputs: options.inputs,
|
||||
configSchema: createSchemaFromZod(z =>
|
||||
z.object({
|
||||
path: z.string().default(options.defaultPath),
|
||||
title: z.string().default(options.defaultTitle),
|
||||
filter: z
|
||||
.array(
|
||||
z.object({
|
||||
isKind: z.string().optional(),
|
||||
isType: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
filter: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
factory({ config, inputs, node }) {
|
||||
@@ -195,8 +159,35 @@ export function createEntityContentExtension<
|
||||
<ExtensionComponent />
|
||||
</ExtensionBoundary>
|
||||
),
|
||||
filter: buildFilter(config, options.filter),
|
||||
...mergeFilters({ config, options }),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides what filter outputs to produce, given some options and config
|
||||
*/
|
||||
function mergeFilters(inputs: {
|
||||
options: {
|
||||
filter?:
|
||||
| typeof entityFilterFunctionExtensionDataRef.T
|
||||
| typeof entityFilterExpressionExtensionDataRef.T;
|
||||
};
|
||||
config: {
|
||||
filter?: string;
|
||||
};
|
||||
}): {
|
||||
filterFunction?: typeof entityFilterFunctionExtensionDataRef.T;
|
||||
filterExpression?: typeof entityFilterExpressionExtensionDataRef.T;
|
||||
} {
|
||||
const { options, config } = inputs;
|
||||
if (config.filter) {
|
||||
return { filterExpression: config.filter };
|
||||
} else if (typeof options.filter === 'string') {
|
||||
return { filterExpression: options.filter };
|
||||
} else if (typeof options.filter === 'function') {
|
||||
return { filterFunction: options.filter };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -14,29 +14,95 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import Grid from '@material-ui/core/Grid';
|
||||
import React, { useMemo } from 'react';
|
||||
import { parseFilterExpression } from './filter/parseFilterExpression';
|
||||
|
||||
interface EntityOverviewPageProps {
|
||||
cards: Array<{
|
||||
element: React.JSX.Element;
|
||||
filter: (ctx: { entity: Entity }) => boolean;
|
||||
filterFunction?: (entity: Entity) => boolean;
|
||||
filterExpression?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Keeps track of what filter expression strings that we've seen duplicates of
|
||||
// with functions, or which emitted parsing errors for so far
|
||||
const seenParseErrorExpressionStrings = new Set<string>();
|
||||
const seenDuplicateExpressionStrings = new Set<string>();
|
||||
|
||||
// Given an optional filter function and an optional filter expression, make
|
||||
// sure that at most one of them was given, and return a filter function that
|
||||
// does the right thing.
|
||||
function buildFilterFn(
|
||||
filterFunction?: (entity: Entity) => boolean,
|
||||
filterExpression?: string,
|
||||
): (entity: Entity) => boolean {
|
||||
if (
|
||||
filterFunction &&
|
||||
filterExpression &&
|
||||
!seenDuplicateExpressionStrings.has(filterExpression)
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`Duplicate entity filter methods found, both '${filterExpression}' as well as a callback function, which is not permitted - using the callback`,
|
||||
);
|
||||
seenDuplicateExpressionStrings.add(filterExpression);
|
||||
}
|
||||
|
||||
const filter = filterFunction || filterExpression;
|
||||
if (!filter) {
|
||||
return () => true;
|
||||
} else if (typeof filter === 'function') {
|
||||
return subject => filter(subject);
|
||||
}
|
||||
|
||||
const result = parseFilterExpression(filter);
|
||||
if (
|
||||
result.expressionParseErrors.length &&
|
||||
!seenParseErrorExpressionStrings.has(filter)
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`Error(s) in entity filter expression '${filter}'`,
|
||||
result.expressionParseErrors,
|
||||
);
|
||||
seenParseErrorExpressionStrings.add(filter);
|
||||
}
|
||||
|
||||
return result.filterFn;
|
||||
}
|
||||
|
||||
// Handles the memoized parsing of filter expressions for each card
|
||||
function CardWrapper(props: {
|
||||
entity: Entity;
|
||||
element: React.JSX.Element;
|
||||
filterFunction?: (entity: Entity) => boolean;
|
||||
filterExpression?: string;
|
||||
}) {
|
||||
const { entity, element, filterFunction, filterExpression } = props;
|
||||
|
||||
const filterFn = useMemo(
|
||||
() => buildFilterFn(filterFunction, filterExpression),
|
||||
[filterFunction, filterExpression],
|
||||
);
|
||||
|
||||
return filterFn(entity) ? (
|
||||
<Grid item md={6} xs={12}>
|
||||
{element}
|
||||
</Grid>
|
||||
) : null;
|
||||
}
|
||||
|
||||
export function EntityOverviewPage(props: EntityOverviewPageProps) {
|
||||
const { entity } = useEntity();
|
||||
return (
|
||||
<Grid container spacing={3} alignItems="stretch">
|
||||
{props.cards
|
||||
.filter(card => card.filter({ entity }))
|
||||
.map(card => (
|
||||
<Grid item md={6} xs={12}>
|
||||
{card.element}
|
||||
</Grid>
|
||||
))}
|
||||
{props.cards.map((card, index) => (
|
||||
<CardWrapper key={index} entity={entity} {...card} />
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export const EntityAboutCard = createEntityCardExtension({
|
||||
|
||||
export const EntityLinksCard = createEntityCardExtension({
|
||||
id: 'links',
|
||||
filter: ({ entity }) => Boolean(entity.metadata.links),
|
||||
filter: 'has:links',
|
||||
loader: async () =>
|
||||
import('../components/EntityLinksCard').then(m => {
|
||||
return <m.EntityLinksCard variant="gridItem" />;
|
||||
@@ -36,7 +36,7 @@ export const EntityLinksCard = createEntityCardExtension({
|
||||
|
||||
export const EntityLabelsCard = createEntityCardExtension({
|
||||
id: 'labels',
|
||||
filter: ({ entity }) => Boolean(entity.metadata.labels),
|
||||
filter: 'has:labels',
|
||||
loader: async () =>
|
||||
import('../components/EntityLabelsCard').then(m => (
|
||||
<m.EntityLabelsCard variant="gridItem" />
|
||||
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import {
|
||||
createEntityContentExtension,
|
||||
entityFilterExtensionDataRef,
|
||||
entityFilterFunctionExtensionDataRef,
|
||||
entityFilterExpressionExtensionDataRef,
|
||||
} from '@backstage/plugin-catalog-react/alpha';
|
||||
|
||||
export const OverviewEntityContent = createEntityContentExtension({
|
||||
@@ -32,7 +33,8 @@ export const OverviewEntityContent = createEntityContentExtension({
|
||||
inputs: {
|
||||
cards: createExtensionInput({
|
||||
element: coreExtensionData.reactElement,
|
||||
filter: entityFilterExtensionDataRef,
|
||||
filterFunction: entityFilterFunctionExtensionDataRef.optional(),
|
||||
filterExpression: entityFilterExpressionExtensionDataRef.optional(),
|
||||
}),
|
||||
},
|
||||
loader: async ({ inputs }) =>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright 2023 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 { Entity } from '@backstage/catalog-model';
|
||||
import { createHasMatcher } from './createHasMatcher';
|
||||
|
||||
describe('createHasMatcher', () => {
|
||||
const empty1 = {
|
||||
metadata: {},
|
||||
} as unknown as Entity;
|
||||
const empty2 = {
|
||||
metadata: {
|
||||
labels: {},
|
||||
links: [],
|
||||
},
|
||||
} as unknown as Entity;
|
||||
const labels = {
|
||||
metadata: {
|
||||
labels: { a: 'b' },
|
||||
},
|
||||
} as unknown as Entity;
|
||||
const links = {
|
||||
metadata: { links: [{}] },
|
||||
} as unknown as Entity;
|
||||
|
||||
const err = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('supports valid parameters', () => {
|
||||
expect(createHasMatcher(['labels'], err)(empty1)).toBe(false);
|
||||
expect(createHasMatcher(['labels'], err)(empty2)).toBe(false);
|
||||
expect(createHasMatcher(['labels'], err)(labels)).toBe(true);
|
||||
|
||||
expect(createHasMatcher(['links'], err)(empty1)).toBe(false);
|
||||
expect(createHasMatcher(['links'], err)(empty2)).toBe(false);
|
||||
expect(createHasMatcher(['links'], err)(links)).toBe(true);
|
||||
|
||||
expect(err).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('matches if ANY parameter matches', () => {
|
||||
expect(createHasMatcher(['labels', 'links'], err)(empty1)).toBe(false);
|
||||
expect(createHasMatcher(['labels', 'links'], err)(empty2)).toBe(false);
|
||||
expect(createHasMatcher(['labels', 'links'], err)(labels)).toBe(true);
|
||||
expect(createHasMatcher(['labels', 'links'], err)(links)).toBe(true);
|
||||
|
||||
expect(err).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits errors properly, and skips over bad parameters', () => {
|
||||
// throw if callback throws
|
||||
expect(() =>
|
||||
createHasMatcher(['labels', 'bar'], e => {
|
||||
throw e;
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"'bar' is not a valid parameter for 'has' filter expressions, expected one of 'labels','links'"`,
|
||||
);
|
||||
expect(err).not.toHaveBeenCalled();
|
||||
|
||||
// continue if callback does not throw, and skip over the bad parameter
|
||||
let matcher = createHasMatcher(['labels', 'foo', 'bar'], err);
|
||||
expect(err).toHaveBeenCalledTimes(2);
|
||||
expect(err).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: `'foo' is not a valid parameter for 'has' filter expressions, expected one of 'labels','links'`,
|
||||
}),
|
||||
);
|
||||
expect(err).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: `'bar' is not a valid parameter for 'has' filter expressions, expected one of 'labels','links'`,
|
||||
}),
|
||||
);
|
||||
expect(matcher(empty1)).toBe(false);
|
||||
expect(matcher(labels)).toBe(true);
|
||||
|
||||
// when no parameters at all match, just return true
|
||||
matcher = createHasMatcher(['foo', 'bar'], err);
|
||||
expect(matcher(empty1)).toBe(true);
|
||||
expect(matcher(labels)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 2023 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 { InputError } from '@backstage/errors';
|
||||
import { EntityMatcherFn } from './types';
|
||||
|
||||
const allowedMatchers: Record<string, EntityMatcherFn> = {
|
||||
labels: entity => {
|
||||
return Object.keys(entity.metadata.labels ?? {}).length > 0;
|
||||
},
|
||||
links: entity => {
|
||||
return (entity.metadata.links ?? []).length > 0;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Matches on the non-empty presence of different parts of the entity
|
||||
*/
|
||||
export function createHasMatcher(
|
||||
parameters: string[],
|
||||
onParseError: (error: Error) => void,
|
||||
): EntityMatcherFn {
|
||||
const matchers = parameters.flatMap(parameter => {
|
||||
const matcher = allowedMatchers[parameter.toLocaleLowerCase('en-US')];
|
||||
if (!matcher) {
|
||||
const known = Object.keys(allowedMatchers).map(m => `'${m}'`);
|
||||
onParseError(
|
||||
new InputError(
|
||||
`'${parameter}' is not a valid parameter for 'has' filter expressions, expected one of ${known}`,
|
||||
),
|
||||
);
|
||||
return [];
|
||||
}
|
||||
return [matcher];
|
||||
});
|
||||
|
||||
return entity =>
|
||||
matchers.length ? matchers.some(matcher => matcher(entity)) : true;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2023 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 { Entity } from '@backstage/catalog-model';
|
||||
import { createIsMatcher } from './createIsMatcher';
|
||||
|
||||
describe('createIsMatcher', () => {
|
||||
const empty = {
|
||||
metadata: {},
|
||||
} as unknown as Entity;
|
||||
const orphan = {
|
||||
metadata: {
|
||||
annotations: { ['backstage.io/orphan']: 'true' },
|
||||
},
|
||||
} as unknown as Entity;
|
||||
|
||||
const err = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('supports valid parameters', () => {
|
||||
expect(createIsMatcher(['orphan'], err)(empty)).toBe(false);
|
||||
expect(createIsMatcher(['orphan'], err)(orphan)).toBe(true);
|
||||
|
||||
expect(err).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits errors properly, and skips over bad parameters', () => {
|
||||
// throw if callback throws
|
||||
expect(() =>
|
||||
createIsMatcher(['orphan', 'foo'], e => {
|
||||
throw e;
|
||||
}),
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"'foo' is not a valid parameter for 'is' filter expressions, expected one of 'orphan'"`,
|
||||
);
|
||||
expect(err).not.toHaveBeenCalled();
|
||||
|
||||
// continue if callback does not throw, and skip over the bad parameter
|
||||
let matcher = createIsMatcher(['orphan', 'foo', 'bar'], err);
|
||||
expect(err).toHaveBeenCalledTimes(2);
|
||||
expect(err).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: `'foo' is not a valid parameter for 'is' filter expressions, expected one of 'orphan'`,
|
||||
}),
|
||||
);
|
||||
expect(err).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: `'bar' is not a valid parameter for 'is' filter expressions, expected one of 'orphan'`,
|
||||
}),
|
||||
);
|
||||
expect(matcher(empty)).toBe(false);
|
||||
expect(matcher(orphan)).toBe(true);
|
||||
|
||||
// when no parameters at all match, just return true
|
||||
matcher = createIsMatcher(['foo', 'bar'], err);
|
||||
expect(matcher(empty)).toBe(true);
|
||||
expect(matcher(orphan)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright 2023 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 { InputError } from '@backstage/errors';
|
||||
import { EntityMatcherFn } from './types';
|
||||
|
||||
const allowedMatchers: Record<string, EntityMatcherFn> = {
|
||||
orphan: entity =>
|
||||
Boolean(entity.metadata.annotations?.['backstage.io/orphan']),
|
||||
};
|
||||
|
||||
/**
|
||||
* Matches on different semantic properties of the entity
|
||||
*/
|
||||
export function createIsMatcher(
|
||||
parameters: string[],
|
||||
onParseError: (error: Error) => void,
|
||||
): EntityMatcherFn {
|
||||
const matchers = parameters.flatMap(parameter => {
|
||||
const matcher = allowedMatchers[parameter.toLocaleLowerCase('en-US')];
|
||||
if (!matcher) {
|
||||
const known = Object.keys(allowedMatchers).map(m => `'${m}'`);
|
||||
onParseError(
|
||||
new InputError(
|
||||
`'${parameter}' is not a valid parameter for 'is' filter expressions, expected one of ${known}`,
|
||||
),
|
||||
);
|
||||
return [];
|
||||
}
|
||||
return [matcher];
|
||||
});
|
||||
|
||||
return entity =>
|
||||
matchers.length ? matchers.some(matcher => matcher(entity)) : true;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2023 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 { Entity } from '@backstage/catalog-model';
|
||||
import { createKindMatcher } from './createKindMatcher';
|
||||
|
||||
describe('createKindMatcher', () => {
|
||||
const component = { kind: 'Component' } as unknown as Entity;
|
||||
const user = { kind: 'User' } as unknown as Entity;
|
||||
const resource = { kind: 'Resource' } as unknown as Entity;
|
||||
|
||||
const err = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('supports valid parameters', () => {
|
||||
expect(createKindMatcher(['component'], err)(component)).toBe(true);
|
||||
expect(createKindMatcher(['componenT'], err)(component)).toBe(true);
|
||||
expect(createKindMatcher(['user'], err)(component)).toBe(false);
|
||||
|
||||
expect(err).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('matches if ANY parameter matches', () => {
|
||||
expect(createKindMatcher(['user', 'component'], err)(user)).toBe(true);
|
||||
expect(createKindMatcher(['user', 'component'], err)(component)).toBe(true);
|
||||
expect(createKindMatcher(['user', 'component'], err)(resource)).toBe(false);
|
||||
|
||||
expect(err).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2023 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 { EntityMatcherFn } from './types';
|
||||
|
||||
/**
|
||||
* Matches on kind
|
||||
*/
|
||||
export function createKindMatcher(
|
||||
parameters: string[],
|
||||
_onParseError: (error: Error) => void,
|
||||
): EntityMatcherFn {
|
||||
const items = parameters.map(p => p.toLocaleLowerCase('en-US'));
|
||||
return entity => items.includes(entity.kind.toLocaleLowerCase('en-US'));
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2023 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 { Entity } from '@backstage/catalog-model';
|
||||
import { createTypeMatcher } from './createTypeMatcher';
|
||||
|
||||
describe('createTypeMatcher', () => {
|
||||
const empty1 = {} as unknown as Entity;
|
||||
const empty2 = { spec: {} } as unknown as Entity;
|
||||
const service = { spec: { type: 'service' } } as unknown as Entity;
|
||||
const website = { spec: { type: 'website' } } as unknown as Entity;
|
||||
const pipeline = { spec: { type: 'pipeline' } } as unknown as Entity;
|
||||
|
||||
const err = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('supports valid parameters', () => {
|
||||
expect(createTypeMatcher(['service'], err)(empty1)).toBe(false);
|
||||
expect(createTypeMatcher(['service'], err)(empty2)).toBe(false);
|
||||
|
||||
expect(createTypeMatcher(['service'], err)(service)).toBe(true);
|
||||
expect(createTypeMatcher(['sErViCe'], err)(service)).toBe(true);
|
||||
expect(createTypeMatcher(['service'], err)(service)).toBe(true);
|
||||
expect(createTypeMatcher(['website'], err)(service)).toBe(false);
|
||||
|
||||
expect(err).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('matches if ANY parameter matches', () => {
|
||||
expect(createTypeMatcher(['service', 'website'], err)(service)).toBe(true);
|
||||
expect(createTypeMatcher(['service', 'website'], err)(website)).toBe(true);
|
||||
expect(createTypeMatcher(['service', 'website'], err)(pipeline)).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
expect(err).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2023 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 { EntityMatcherFn } from './types';
|
||||
|
||||
/**
|
||||
* Matches on spec.type
|
||||
*/
|
||||
export function createTypeMatcher(
|
||||
parameters: string[],
|
||||
_onParseError: (error: Error) => void,
|
||||
): EntityMatcherFn {
|
||||
const items = parameters.map(p => p.toLocaleLowerCase('en-US'));
|
||||
return entity => {
|
||||
const value = entity.spec?.type;
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
items.includes(value.toLocaleLowerCase('en-US'))
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright 2023 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 { Entity } from '@backstage/catalog-model';
|
||||
|
||||
export type EntityMatcherFn = (entity: Entity) => boolean;
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Copyright 2023 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 { Entity } from '@backstage/catalog-model';
|
||||
import {
|
||||
parseFilterExpression,
|
||||
splitFilterExpression,
|
||||
} from './parseFilterExpression';
|
||||
|
||||
describe('parseFilterExpression', () => {
|
||||
function run(expression: string) {
|
||||
const result = parseFilterExpression(expression);
|
||||
if (result.expressionParseErrors.length) {
|
||||
throw result.expressionParseErrors[0];
|
||||
}
|
||||
return result.filterFn;
|
||||
}
|
||||
|
||||
it('supports "kind" expressions', () => {
|
||||
const component = { kind: 'Component' } as unknown as Entity;
|
||||
const user = { kind: 'User' } as unknown as Entity;
|
||||
const resource = { kind: 'Resource' } as unknown as Entity;
|
||||
|
||||
expect(run('kind:user,component')(user)).toBe(true);
|
||||
expect(run('kind:user,component')(component)).toBe(true);
|
||||
expect(run('kind:user,component')(resource)).toBe(false);
|
||||
});
|
||||
|
||||
it('supports "type" expressions', () => {
|
||||
const empty = {} as unknown as Entity;
|
||||
const service = { spec: { type: 'service' } } as unknown as Entity;
|
||||
const website = { spec: { type: 'website' } } as unknown as Entity;
|
||||
const pipeline = { spec: { type: 'pipeline' } } as unknown as Entity;
|
||||
|
||||
expect(run('type:service,website')(empty)).toBe(false);
|
||||
expect(run('type:service,website')(service)).toBe(true);
|
||||
expect(run('type:service,website')(website)).toBe(true);
|
||||
expect(run('type:service,website')(pipeline)).toBe(false);
|
||||
});
|
||||
|
||||
it('supports "is" expressions', () => {
|
||||
const empty = {
|
||||
metadata: {},
|
||||
} as unknown as Entity;
|
||||
const orphan = {
|
||||
metadata: {
|
||||
annotations: { ['backstage.io/orphan']: 'true' },
|
||||
},
|
||||
} as unknown as Entity;
|
||||
|
||||
expect(run('is:orphan')(empty)).toBe(false);
|
||||
expect(run('is:orphan')(orphan)).toBe(true);
|
||||
|
||||
expect(() => run('is:orphan,bar')).toThrowErrorMatchingInlineSnapshot(
|
||||
`"'bar' is not a valid parameter for 'is' filter expressions, expected one of 'orphan'"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('supports "has" expressions', () => {
|
||||
const empty = {
|
||||
metadata: {},
|
||||
} as unknown as Entity;
|
||||
const labels = {
|
||||
metadata: { labels: { a: 'b' } },
|
||||
} as unknown as Entity;
|
||||
const annotations = {
|
||||
metadata: { annotations: { a: 'b' } },
|
||||
} as unknown as Entity;
|
||||
const links = {
|
||||
metadata: { links: [{}] },
|
||||
} as unknown as Entity;
|
||||
|
||||
expect(run('has:labels,links')(empty)).toBe(false);
|
||||
expect(run('has:labels,links')(labels)).toBe(true);
|
||||
expect(run('has:labels,links')(links)).toBe(true);
|
||||
expect(run('has:labels,links')(annotations)).toBe(false);
|
||||
|
||||
expect(() => run('has:labels,bar')).toThrowErrorMatchingInlineSnapshot(
|
||||
`"'bar' is not a valid parameter for 'has' filter expressions, expected one of 'labels','links'"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unknown keys', () => {
|
||||
expect(() => run('unknown:foo')).toThrowErrorMatchingInlineSnapshot(
|
||||
`"'unknown' is not a valid filter expression key, expected one of 'kind','type','is','has'"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects malformed inputs', () => {
|
||||
expect(() => run(':')).toThrowErrorMatchingInlineSnapshot(
|
||||
`"':' is not a valid filter expression, expected 'key:parameter' form"`,
|
||||
);
|
||||
expect(() => run(':a')).toThrowErrorMatchingInlineSnapshot(
|
||||
`"':a' is not a valid filter expression, expected 'key:parameter' form"`,
|
||||
);
|
||||
expect(() => run('a:')).toThrowErrorMatchingInlineSnapshot(
|
||||
`"'a:' is not a valid filter expression, expected 'key:parameter' form"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitFilterExpression', () => {
|
||||
function run(expression: string) {
|
||||
return splitFilterExpression(expression, e => {
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
it('properly splits into expression atoms', () => {
|
||||
expect(run('')).toEqual([]);
|
||||
expect(run(' ')).toEqual([]);
|
||||
expect(run('kind:component')).toEqual([
|
||||
{ key: 'kind', parameters: ['component'] },
|
||||
]);
|
||||
expect(run('kind:component,user')).toEqual([
|
||||
{ key: 'kind', parameters: ['component', 'user'] },
|
||||
]);
|
||||
expect(run('kind:component,user type:foo')).toEqual([
|
||||
{ key: 'kind', parameters: ['component', 'user'] },
|
||||
{ key: 'type', parameters: ['foo'] },
|
||||
]);
|
||||
expect(run('with:multiple:colons')).toEqual([
|
||||
{ key: 'with', parameters: ['multiple:colons'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects malformed inputs', () => {
|
||||
expect(() => run(':')).toThrowErrorMatchingInlineSnapshot(
|
||||
`"':' is not a valid filter expression, expected 'key:parameter' form"`,
|
||||
);
|
||||
expect(() => run(':a')).toThrowErrorMatchingInlineSnapshot(
|
||||
`"':a' is not a valid filter expression, expected 'key:parameter' form"`,
|
||||
);
|
||||
expect(() => run('a:')).toThrowErrorMatchingInlineSnapshot(
|
||||
`"'a:' is not a valid filter expression, expected 'key:parameter' form"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright 2023 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 { Entity } from '@backstage/catalog-model';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { EntityMatcherFn } from './matrchers/types';
|
||||
import { createKindMatcher } from './matrchers/createKindMatcher';
|
||||
import { createTypeMatcher } from './matrchers/createTypeMatcher';
|
||||
import { createIsMatcher } from './matrchers/createIsMatcher';
|
||||
import { createHasMatcher } from './matrchers/createHasMatcher';
|
||||
|
||||
const rootMatcherFactories: Record<
|
||||
string,
|
||||
(
|
||||
parameters: string[],
|
||||
onParseError: (error: Error) => void,
|
||||
) => EntityMatcherFn
|
||||
> = {
|
||||
kind: createKindMatcher,
|
||||
type: createTypeMatcher,
|
||||
is: createIsMatcher,
|
||||
has: createHasMatcher,
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a filter expression that decides whether to render an entity component
|
||||
* or not. Returns a function that matches entities based on that expression.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Filter strings are on the form `kind:user,group is:orphan`. There's
|
||||
* effectively an AND between the space separated parts, and an OR between comma
|
||||
* separated parameters. So the example filter string semantically means
|
||||
* "entities that are of either User or Group kind, and also are orphans".
|
||||
*
|
||||
* The `expressionParseErrors` array contains any errors that were encountered
|
||||
* during initial parsing of the expression. Note that the parts of the input
|
||||
* expression that had errors are ignored entirely and parsing continues as if
|
||||
* they didn't exist.
|
||||
*/
|
||||
export function parseFilterExpression(expression: string): {
|
||||
filterFn: (entity: Entity) => boolean;
|
||||
expressionParseErrors: Error[];
|
||||
} {
|
||||
const expressionParseErrors: Error[] = [];
|
||||
|
||||
const parts = splitFilterExpression(expression, e =>
|
||||
expressionParseErrors.push(e),
|
||||
);
|
||||
|
||||
const matchers = parts.flatMap(part => {
|
||||
const factory = rootMatcherFactories[part.key];
|
||||
if (!factory) {
|
||||
const known = Object.keys(rootMatcherFactories).map(m => `'${m}'`);
|
||||
expressionParseErrors.push(
|
||||
new InputError(
|
||||
`'${part.key}' is not a valid filter expression key, expected one of ${known}`,
|
||||
),
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const matcher = factory(part.parameters, e =>
|
||||
expressionParseErrors.push(e),
|
||||
);
|
||||
return [matcher];
|
||||
});
|
||||
|
||||
const filterFn = (entity: Entity) =>
|
||||
matchers.every(matcher => {
|
||||
try {
|
||||
return matcher(entity);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
filterFn,
|
||||
expressionParseErrors,
|
||||
};
|
||||
}
|
||||
|
||||
export function splitFilterExpression(
|
||||
expression: string,
|
||||
onParseError: (error: Error) => void,
|
||||
): Array<{ key: string; parameters: string[] }> {
|
||||
const words = expression
|
||||
.split(' ')
|
||||
.map(w => w.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const result = new Array<{ key: string; parameters: string[] }>();
|
||||
|
||||
for (const word of words) {
|
||||
const match = word.match(/^([^:]+):(.+)$/);
|
||||
if (!match) {
|
||||
onParseError(
|
||||
new InputError(
|
||||
`'${word}' is not a valid filter expression, expected 'key:parameter' form`,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = match[1];
|
||||
const parameters = match[2].split(',').filter(Boolean); // silently ignore double commas
|
||||
|
||||
result.push({ key, parameters });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -22,8 +22,8 @@ import { entityRouteRef } from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
createComponentRouteRef,
|
||||
createFromTemplateRouteRef,
|
||||
unregisterRedirectRouteRef,
|
||||
rootRouteRef,
|
||||
unregisterRedirectRouteRef,
|
||||
viewTechDocRouteRef,
|
||||
} from '../routes';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user