add tests and error propagation

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2023-11-23 11:31:03 +01:00
parent e223f2264d
commit 976519c3dc
12 changed files with 449 additions and 100 deletions
@@ -42,6 +42,21 @@ export const catalogPermissions: (
// @alpha
export function parseFilterExpression(
expression: string,
options?: {
onParseError?: (
error: Error,
context: {
expression: string;
},
) => void;
onEvaluateError?: (
error: Error,
context: {
expression: string;
entity: Entity;
},
) => void;
},
): (entity: Entity) => boolean;
// @alpha
@@ -0,0 +1,109 @@
/*
* 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: {},
annotations: {},
links: [],
},
} 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;
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(['annotations'], err)(empty1)).toBe(false);
expect(createHasMatcher(['annotations'], err)(empty2)).toBe(false);
expect(createHasMatcher(['annotations'], err)(annotations)).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(createHasMatcher(['labels', 'links'], err)(annotations)).toBe(false);
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','annotations','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','annotations','links'`,
}),
);
expect(err).toHaveBeenCalledWith(
expect.objectContaining({
message: `'bar' is not a valid parameter for 'has' filter expressions, expected one of 'labels','annotations','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);
});
});
@@ -20,7 +20,10 @@ import { EntityMatcherFn } from './types';
/**
* Matches on the non-empty presence of different parts of the entity
*/
export function createHasMatcher(parameters: string[]): EntityMatcherFn {
export function createHasMatcher(
parameters: string[],
onParseError: (error: Error) => void,
): EntityMatcherFn {
const allowedMatchers: Record<string, EntityMatcherFn> = {
labels: entity => {
return Object.keys(entity.metadata.labels ?? {}).length > 0;
@@ -33,16 +36,20 @@ export function createHasMatcher(parameters: string[]): EntityMatcherFn {
},
};
const matchers = parameters.map(parameter => {
const matchers = parameters.flatMap(parameter => {
const matcher = allowedMatchers[parameter.toLocaleLowerCase('en-US')];
if (!matcher) {
const known = Object.keys(allowedMatchers).map(m => `'${m}'`);
throw new InputError(
`'${parameter}' is not a valid parameter for 'has' filter expressions, expected one of ${known}`,
onParseError(
new InputError(
`'${parameter}' is not a valid parameter for 'has' filter expressions, expected one of ${known}`,
),
);
return [];
}
return matcher;
return [matcher];
});
return entity => matchers.some(matcher => matcher(entity));
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);
});
});
@@ -20,22 +20,29 @@ import { EntityMatcherFn } from './types';
/**
* Matches on different semantic properties of the entity
*/
export function createIsMatcher(parameters: string[]): EntityMatcherFn {
export function createIsMatcher(
parameters: string[],
onParseError: (error: Error) => void,
): EntityMatcherFn {
const allowedMatchers: Record<string, EntityMatcherFn> = {
orphan: entity =>
Boolean(entity.metadata.annotations?.['backstage.io/orphan']),
};
const matchers = parameters.map(parameter => {
const matchers = parameters.flatMap(parameter => {
const matcher = allowedMatchers[parameter.toLocaleLowerCase('en-US')];
if (!matcher) {
const known = Object.keys(allowedMatchers).map(m => `'${m}'`);
throw new InputError(
`'${parameter}' is not a valid parameter for 'is' filter expressions, expected one of ${known}`,
onParseError(
new InputError(
`'${parameter}' is not a valid parameter for 'is' filter expressions, expected one of ${known}`,
),
);
return [];
}
return matcher;
return [matcher];
});
return entity => matchers.some(matcher => matcher(entity));
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();
});
});
@@ -19,7 +19,10 @@ import { EntityMatcherFn } from './types';
/**
* Matches on kind
*/
export function createKindMatcher(parameters: string[]): EntityMatcherFn {
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();
});
});
@@ -19,7 +19,10 @@ import { EntityMatcherFn } from './types';
/**
* Matches on spec.type
*/
export function createTypeMatcher(parameters: string[]): EntityMatcherFn {
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;
@@ -21,19 +21,34 @@ import {
} from './parseFilterExpression';
describe('parseFilterExpression', () => {
function run(expression: string) {
return parseFilterExpression(expression, {
onParseError(e: Error) {
throw e;
},
});
}
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(parseFilterExpression('kind:component')(component)).toBe(true);
expect(parseFilterExpression('kind:componenT')(component)).toBe(true);
expect(parseFilterExpression('kind:user')(component)).toBe(false);
expect(run('kind:user,component')(user)).toBe(true);
expect(run('kind:user,component')(component)).toBe(true);
expect(run('kind:user,component')(resource)).toBe(false);
});
// match ANY of the parameters
expect(parseFilterExpression('kind:user,component')(user)).toBe(true);
expect(parseFilterExpression('kind:user,component')(component)).toBe(true);
expect(parseFilterExpression('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', () => {
@@ -46,123 +61,90 @@ describe('parseFilterExpression', () => {
},
} as unknown as Entity;
expect(parseFilterExpression('is:orphan')(empty)).toBe(false);
expect(parseFilterExpression('is:orphan')(orphan)).toBe(true);
expect(run('is:orphan')(empty)).toBe(false);
expect(run('is:orphan')(orphan)).toBe(true);
expect(() =>
parseFilterExpression('is:orphan,bar'),
).toThrowErrorMatchingInlineSnapshot(
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 empty1 = {
const empty = {
metadata: {},
} as unknown as Entity;
const empty2 = {
metadata: {
labels: {},
annotations: {},
links: [],
},
} as unknown as Entity;
const labels = {
metadata: {
labels: { a: 'b' },
},
metadata: { labels: { a: 'b' } },
} as unknown as Entity;
const annotations = {
metadata: {
annotations: { a: 'b' },
},
metadata: { annotations: { a: 'b' } },
} as unknown as Entity;
const links = {
metadata: { links: [{}] },
} as unknown as Entity;
expect(parseFilterExpression('has:labels')(empty1)).toBe(false);
expect(parseFilterExpression('has:labels')(empty2)).toBe(false);
expect(parseFilterExpression('has:labels')(labels)).toBe(true);
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(parseFilterExpression('has:annotations')(empty1)).toBe(false);
expect(parseFilterExpression('has:annotations')(empty2)).toBe(false);
expect(parseFilterExpression('has:annotations')(annotations)).toBe(true);
expect(parseFilterExpression('has:links')(empty1)).toBe(false);
expect(parseFilterExpression('has:links')(empty2)).toBe(false);
expect(parseFilterExpression('has:links')(links)).toBe(true);
// match ANY of the parameters
expect(parseFilterExpression('has:labels,links')(empty1)).toBe(false);
expect(parseFilterExpression('has:labels,links')(empty2)).toBe(false);
expect(parseFilterExpression('has:labels,links')(labels)).toBe(true);
expect(parseFilterExpression('has:labels,links')(links)).toBe(true);
expect(parseFilterExpression('has:labels,links')(annotations)).toBe(false);
expect(() =>
parseFilterExpression('has:labels,bar'),
).toThrowErrorMatchingInlineSnapshot(
expect(() => run('has:labels,bar')).toThrowErrorMatchingInlineSnapshot(
`"'bar' is not a valid parameter for 'has' filter expressions, expected one of 'labels','annotations','links'"`,
);
});
it('rejects unknown keys', () => {
expect(() =>
parseFilterExpression('unknown:foo'),
).toThrowErrorMatchingInlineSnapshot(
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(() => parseFilterExpression(':')).toThrowErrorMatchingInlineSnapshot(
expect(() => run(':')).toThrowErrorMatchingInlineSnapshot(
`"':' is not a valid filter expression, expected 'key:parameter' form"`,
);
expect(() =>
parseFilterExpression(':a'),
).toThrowErrorMatchingInlineSnapshot(
expect(() => run(':a')).toThrowErrorMatchingInlineSnapshot(
`"':a' is not a valid filter expression, expected 'key:parameter' form"`,
);
expect(() =>
parseFilterExpression('a:'),
).toThrowErrorMatchingInlineSnapshot(
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(splitFilterExpression('')).toEqual([]);
expect(splitFilterExpression(' ')).toEqual([]);
expect(splitFilterExpression('kind:component')).toEqual([
expect(run('')).toEqual([]);
expect(run(' ')).toEqual([]);
expect(run('kind:component')).toEqual([
{ key: 'kind', parameters: ['component'] },
]);
expect(splitFilterExpression('kind:component,user')).toEqual([
expect(run('kind:component,user')).toEqual([
{ key: 'kind', parameters: ['component', 'user'] },
]);
expect(splitFilterExpression('kind:component,user type:foo')).toEqual([
expect(run('kind:component,user type:foo')).toEqual([
{ key: 'kind', parameters: ['component', 'user'] },
{ key: 'type', parameters: ['foo'] },
]);
expect(splitFilterExpression('with:multiple:colons')).toEqual([
expect(run('with:multiple:colons')).toEqual([
{ key: 'with', parameters: ['multiple:colons'] },
]);
});
it('rejects malformed inputs', () => {
expect(() => splitFilterExpression(':')).toThrowErrorMatchingInlineSnapshot(
expect(() => run(':')).toThrowErrorMatchingInlineSnapshot(
`"':' is not a valid filter expression, expected 'key:parameter' form"`,
);
expect(() =>
splitFilterExpression(':a'),
).toThrowErrorMatchingInlineSnapshot(
expect(() => run(':a')).toThrowErrorMatchingInlineSnapshot(
`"':a' is not a valid filter expression, expected 'key:parameter' form"`,
);
expect(() =>
splitFilterExpression('a:'),
).toThrowErrorMatchingInlineSnapshot(
expect(() => run('a:')).toThrowErrorMatchingInlineSnapshot(
`"'a:' is not a valid filter expression, expected 'key:parameter' form"`,
);
});
@@ -24,7 +24,10 @@ import { createHasMatcher } from './matrchers/createHasMatcher';
const rootMatcherFactories: Record<
string,
(parameters: string[]) => EntityMatcherFn
(
parameters: string[],
onParseError: (error: Error) => void,
) => EntityMatcherFn
> = {
kind: createKindMatcher,
type: createTypeMatcher,
@@ -43,11 +46,39 @@ const rootMatcherFactories: Record<
* 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 `onParseError` callback is called whenever an error was encountered
* during initial parsing of the expression. If the callback throws,
* `parseFilterExpression` throws that same error. If the callback does not
* throw, the part of the input expression that had an error is ignored entirely
* and parsing continues.
*
* The `onEvaluateError` callback is called whenever an error was encountered
* during evaluation of the returned function. If the callback throws, the
* evaluation call throws the same error. If the callback does not throw, the
* whole evaluation returns false.
*/
export function parseFilterExpression(
expression: string,
options?: {
onParseError?: (
error: Error,
context: {
expression: string;
},
) => void;
onEvaluateError?: (
error: Error,
context: {
expression: string;
entity: Entity;
},
) => void;
},
): (entity: Entity) => boolean {
const parts = splitFilterExpression(expression);
const parts = splitFilterExpression(expression, e =>
options?.onParseError?.(e, { expression }),
);
const matchers = parts.map(part => {
const factory = rootMatcherFactories[part.key];
@@ -57,14 +88,17 @@ export function parseFilterExpression(
`'${part.key}' is not a valid filter expression key, expected one of ${known}`,
);
}
return factory(part.parameters);
return factory(part.parameters, e =>
options?.onParseError?.(e, { expression }),
);
});
return (entity: Entity) => {
return matchers.every(matcher => {
try {
return matcher(entity);
} catch {
} catch (e) {
options?.onEvaluateError?.(e, { expression, entity });
return false;
}
});
@@ -73,6 +107,7 @@ export function parseFilterExpression(
export function splitFilterExpression(
expression: string,
onParseError: (error: Error) => void,
): Array<{ key: string; parameters: string[] }> {
const words = expression
.split(' ')
@@ -84,13 +119,16 @@ export function splitFilterExpression(
for (const word of words) {
const match = word.match(/^([^:]+):(.+)$/);
if (!match) {
throw new InputError(
`'${word}' is not a valid filter expression, expected 'key:parameter' form`,
onParseError(
new InputError(
`'${word}' is not a valid filter expression, expected 'key:parameter' form`,
),
);
continue;
}
const key = match[1];
const parameters = match[2].split(',');
const parameters = match[2].split(',').filter(Boolean); // silently ignore double commas
result.push({ key, parameters });
}
@@ -40,10 +40,21 @@ function CardWrapper(props: {
} else if (typeof filter === 'function') {
return subject => filter(subject);
}
return parseFilterExpression(filter);
return parseFilterExpression(filter, {
onParseError(_error) {
// ignore silently
},
onEvaluateError(_error) {
// ignore silently
},
});
}, [filter]);
return filterFn(entity) ? <>{element}</> : null;
return filterFn(entity) ? (
<Grid item md={6} xs={12}>
{element}
</Grid>
) : null;
}
export function EntityOverviewPage(props: EntityOverviewPageProps) {
@@ -51,13 +62,12 @@ export function EntityOverviewPage(props: EntityOverviewPageProps) {
return (
<Grid container spacing={3} alignItems="stretch">
{props.cards.map((card, index) => (
<Grid key={index} item md={6} xs={12}>
<CardWrapper
entity={entity}
element={card.element}
filter={card.filter}
/>
</Grid>
<CardWrapper
key={index}
entity={entity}
element={card.element}
filter={card.filter}
/>
))}
</Grid>
);