Merge pull request #20974 from benkeil/feat/20594/add-entire-enetity-to-graph

feat: add the entire entity to `EntityNodeData`
This commit is contained in:
Fredrik Adelöw
2023-11-09 09:59:20 +01:00
committed by GitHub
13 changed files with 320 additions and 220 deletions
+16
View File
@@ -0,0 +1,16 @@
---
'@backstage/plugin-catalog-graph': minor
---
Add the entire `Entity` to `EntityNodeData` and deprecate `name`, `kind`, `title`, `namespace` and `spec`.
To get the deprecated properties in your custom component you can use:
```typescript
import { DEFAULT_NAMESPACE } from '@backstage/catalog-model';
const {
kind,
metadata: { name, namespace = DEFAULT_NAMESPACE, title },
} = entity;
```
+49
View File
@@ -85,6 +85,55 @@ To use the catalog graph plugin, you have to add some things to your Backstage a
</Grid>
```
### Customization
Copy the default implementation `DefaultRenderNode.tsx` and add more classes to the styles:
```typescript
const useStyles = makeStyles<Theme>(
theme => ({
node: {
'&.system': {
fill: '#F5DC70',
stroke: '#F2CE34',
},
'&.domain': {
fill: '#F5DC70',
stroke: '#F2CE34',
},
);
```
Now you can use the new classes in your component with `className={classNames(classes.node, kind?.toLowerCase(), type?.toLowerCase())}`
```tsx
return (
<g onClick={onClick} className={classNames(onClick && classes.clickable)}>
<rect
className={classNames(
classes.node,
kind?.toLowerCase(),
type?.toLowerCase(),
)}
width={paddedWidth}
height={paddedHeight}
/>
<text
ref={idRef}
className={classNames(classes.text, focused && 'focused')}
y={paddedHeight / 2}
x={paddedWidth / 2}
textAnchor="middle"
alignmentBaseline="middle"
>
{displayTitle}
</text>
</g>
);
```
## Development
Run `yarn` in the root of this plugin to install all dependencies and then `yarn start` to run a [development version](./dev/index.tsx) of this plugin.
+5 -3
View File
@@ -8,6 +8,7 @@
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { CompoundEntityRef } from '@backstage/catalog-model';
import { DependencyGraphTypes } from '@backstage/core-components';
import { Entity } from '@backstage/catalog-model';
import { ExternalRouteRef } from '@backstage/core-plugin-api';
import { InfoCardVariants } from '@backstage/core-components';
import { JsonObject } from '@backstage/types';
@@ -90,14 +91,15 @@ export type EntityNode = DependencyGraphTypes.DependencyNode<EntityNodeData>;
// @public
export type EntityNodeData = {
entity: Entity;
focused?: boolean;
color?: 'primary' | 'secondary' | 'default';
onClick?: MouseEventHandler<unknown>;
name: string;
kind?: string;
title?: string;
namespace: string;
spec?: JsonObject;
focused?: boolean;
color?: 'primary' | 'secondary' | 'default';
onClick?: MouseEventHandler<unknown>;
};
// @public
@@ -37,7 +37,7 @@ import {
EntityNode,
EntityRelationsGraph,
} from '../EntityRelationsGraph';
import { EntityRelationsGraphProps } from '../EntityRelationsGraph/EntityRelationsGraph';
import { EntityRelationsGraphProps } from '../EntityRelationsGraph';
const useStyles = makeStyles<Theme, { height: number | undefined }>(
{
@@ -97,7 +97,7 @@ export const CatalogGraphCard = (
});
analytics.captureEvent(
'click',
node.title ?? humanizeEntityRef(nodeEntityName),
node.entity.metadata.title ?? humanizeEntityRef(nodeEntityName),
{ attributes: { to: path } },
);
navigate(path);
@@ -157,14 +157,14 @@ export const CatalogGraphPage = (
analytics.captureEvent(
'click',
node.title ?? humanizeEntityRef(nodeEntityName),
node.entity.metadata.title ?? humanizeEntityRef(nodeEntityName),
{ attributes: { to: path } },
);
navigate(path);
} else {
analytics.captureEvent(
'click',
node.title ?? humanizeEntityRef(nodeEntityName),
node.entity.metadata.title ?? humanizeEntityRef(nodeEntityName),
);
setRootEntityNames([nodeEntityName]);
}
@@ -20,13 +20,13 @@ import {
} from '@backstage/catalog-model';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { CustomLabel } from './CustomLabel';
import { DefaultRenderLabel } from './DefaultRenderLabel';
describe('<CustomLabel />', () => {
test('renders label', () => {
render(
<svg xmlns="http://www.w3.org/2000/svg">
<CustomLabel
<DefaultRenderLabel
edge={{
label: 'visible',
relations: [RELATION_PARENT_OF],
@@ -43,7 +43,7 @@ describe('<CustomLabel />', () => {
test('renders label with multiple relations', () => {
render(
<svg xmlns="http://www.w3.org/2000/svg">
<CustomLabel
<DefaultRenderLabel
edge={{
label: 'visible',
relations: [RELATION_PARENT_OF, RELATION_CHILD_OF],
@@ -31,7 +31,7 @@ const useStyles = makeStyles(
{ name: 'PluginCatalogGraphCustomLabel' },
);
export function CustomLabel({
export function DefaultRenderLabel({
edge: { relations },
}: DependencyGraphTypes.RenderLabelProps<EntityEdgeData>) {
const classes = useStyles();
@@ -17,21 +17,30 @@
import { renderInTestApp } from '@backstage/test-utils';
import { screen } from '@testing-library/react';
import React from 'react';
import { CustomNode } from './CustomNode';
import { DefaultRenderNode } from './DefaultRenderNode';
import userEvent from '@testing-library/user-event';
describe('<CustomNode />', () => {
test('renders node', async () => {
await renderInTestApp(
<svg xmlns="http://www.w3.org/2000/svg">
<CustomNode
<DefaultRenderNode
node={{
id: 'kind:namespace/name',
entity: {
kind: 'kind',
apiVersion: 'v1',
metadata: {
name: 'name',
namespace: 'namespace',
},
},
focused: false,
color: 'primary',
// @deprecated
kind: 'kind',
name: 'name',
namespace: 'namespace',
id: 'kind:namespace/name',
color: 'primary',
}}
/>
</svg>,
@@ -43,13 +52,22 @@ describe('<CustomNode />', () => {
test('renders node, skips default namespace', async () => {
await renderInTestApp(
<svg xmlns="http://www.w3.org/2000/svg">
<CustomNode
<DefaultRenderNode
node={{
id: 'kind:default/name',
entity: {
kind: 'kind',
apiVersion: 'v1',
metadata: {
name: 'name',
namespace: 'default',
},
},
focused: false,
// @deprecated
kind: 'kind',
name: 'name',
namespace: 'default',
id: 'kind:default/name',
}}
/>
</svg>,
@@ -62,14 +80,23 @@ describe('<CustomNode />', () => {
const onClick = jest.fn();
await renderInTestApp(
<svg xmlns="http://www.w3.org/2000/svg">
<CustomNode
<DefaultRenderNode
node={{
id: 'kind:namespace/name',
entity: {
kind: 'kind',
apiVersion: 'v1',
metadata: {
name: 'name',
namespace: 'namespace',
},
},
focused: false,
onClick,
// @deprecated
kind: 'kind',
name: 'name',
namespace: 'namespace',
onClick,
id: 'kind:namespace/name',
}}
/>
</svg>,
@@ -83,14 +110,24 @@ describe('<CustomNode />', () => {
test('renders title if entity has one', async () => {
await renderInTestApp(
<svg xmlns="http://www.w3.org/2000/svg">
<CustomNode
<DefaultRenderNode
node={{
id: 'kind:namespace/name',
entity: {
kind: 'kind',
apiVersion: 'v1',
metadata: {
name: 'name',
namespace: 'namespace',
title: 'Custom Title',
},
},
focused: false,
// @deprecated
kind: 'kind',
name: 'name',
namespace: 'namespace',
title: 'Custom Title',
id: 'kind:namespace/name',
}}
/>
</svg>,
@@ -20,6 +20,7 @@ import classNames from 'classnames';
import React, { useLayoutEffect, useRef, useState } from 'react';
import { EntityKindIcon } from './EntityKindIcon';
import { EntityNodeData } from './types';
import { DEFAULT_NAMESPACE } from '@backstage/catalog-model';
const useStyles = makeStyles(
theme => ({
@@ -56,17 +57,8 @@ const useStyles = makeStyles(
{ name: 'PluginCatalogGraphCustomNode' },
);
export function CustomNode({
node: {
id,
kind,
namespace,
name,
color = 'default',
focused,
title,
onClick,
},
export function DefaultRenderNode({
node: { id, entity, color = 'default', focused, onClick },
}: DependencyGraphTypes.RenderNodeProps<EntityNodeData>) {
const classes = useStyles();
const [width, setWidth] = useState(0);
@@ -88,6 +80,11 @@ export function CustomNode({
}
}, [width, height]);
const {
kind,
metadata: { name, namespace = DEFAULT_NAMESPACE, title },
} = entity;
const padding = 10;
const iconSize = height;
const paddedIconWidth = kind ? iconSize + padding : 0;
@@ -26,8 +26,8 @@ import { errorApiRef, useApi } from '@backstage/core-plugin-api';
import { CircularProgress, makeStyles, useTheme } from '@material-ui/core';
import classNames from 'classnames';
import React, { MouseEvent, useEffect, useMemo } from 'react';
import { CustomLabel } from './CustomLabel';
import { CustomNode } from './CustomNode';
import { DefaultRenderLabel } from './DefaultRenderLabel';
import { DefaultRenderNode } from './DefaultRenderNode';
import { ALL_RELATION_PAIRS, RelationPairs } from './relations';
import { Direction, EntityEdge, EntityNode } from './types';
import { useEntityRelationNodesAndEdges } from './useEntityRelationNodesAndEdges';
@@ -51,7 +51,7 @@ const useStyles = makeStyles(
width: '100%',
flex: 1,
// Right now there is no good way to style edges between nodes, we have to
// fallback to these hacks:
// fall back to these hacks:
'& path[marker-end]': {
transition: 'filter 0.1s ease-in-out',
},
@@ -144,8 +144,8 @@ export const EntityRelationsGraph = (props: EntityRelationsGraphProps) => {
<DependencyGraph
nodes={nodes}
edges={edges}
renderNode={renderNode || CustomNode}
renderLabel={renderLabel || CustomLabel}
renderNode={renderNode || DefaultRenderNode}
renderLabel={renderLabel || DefaultRenderLabel}
direction={direction}
className={classes.graph}
paddingX={theme.spacing(4)}
@@ -15,8 +15,9 @@
*/
import { DependencyGraphTypes } from '@backstage/core-components';
import { JsonObject } from '@backstage/types';
import { MouseEventHandler } from 'react';
import { Entity } from '@backstage/catalog-model';
import { JsonObject } from '@backstage/types';
/**
* Additional Data for entities.
@@ -31,7 +32,7 @@ export type EntityEdgeData = {
/**
* Whether the entity is visible or not.
*/
// Not used, but has to be non empty to draw a label at all!
// Not used, but has to be non-empty to draw a label at all!
label: 'visible';
};
@@ -49,25 +50,9 @@ export type EntityEdge = DependencyGraphTypes.DependencyEdge<EntityEdgeData>;
*/
export type EntityNodeData = {
/**
* Name of the entity.
* The Entity
*/
name: string;
/**
* Optional kind of the entity.
*/
kind?: string;
/**
* Optional title of the entity.
*/
title?: string;
/**
* Namespace of the entity.
*/
namespace: string;
/**
* Optional spec of the entity.
*/
spec?: JsonObject;
entity: Entity;
/**
* Whether the entity is focused, optional, defaults to false. Focused
* entities are highlighted in the graph.
@@ -81,6 +66,33 @@ export type EntityNodeData = {
* Optional click handler.
*/
onClick?: MouseEventHandler<unknown>;
/**
* Name of the entity.
* @deprecated use {@link EntityNodeData#entity} instead
*/
name: string;
/**
* Optional kind of the entity.
* @deprecated use {@link EntityNodeData#entity} instead
*/
kind?: string;
/**
* Optional title of the entity.
* @deprecated use {@link EntityNodeData#entity} instead
*/
title?: string;
/**
* Namespace of the entity.
* @deprecated use {@link EntityNodeData#entity} instead
* The Entity
*/
namespace: string;
/**
* Optional spec of the entity.
* @deprecated use {@link EntityNodeData#entity} instead
*/
spec?: JsonObject;
};
/**
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import {
DEFAULT_NAMESPACE,
Entity,
RELATION_HAS_PART,
RELATION_OWNED_BY,
@@ -25,6 +26,7 @@ import { renderHook, waitFor } from '@testing-library/react';
import { filter, keyBy } from 'lodash';
import { useEntityRelationGraph as useEntityRelationGraphMocked } from './useEntityRelationGraph';
import { useEntityRelationNodesAndEdges } from './useEntityRelationNodesAndEdges';
import { EntityNode } from './types';
jest.mock('./useEntityRelationGraph');
@@ -32,83 +34,92 @@ const useEntityRelationGraph = useEntityRelationGraphMocked as jest.Mock<
ReturnType<typeof useEntityRelationGraphMocked>
>;
const entities: { [ref: string]: Entity } = {
'b:d/c': {
apiVersion: 'a',
kind: 'b',
metadata: {
name: 'c',
namespace: 'd',
},
relations: [
{
targetRef: 'k:d/a1',
type: RELATION_OWNER_OF,
},
{
targetRef: 'b:d/c1',
type: RELATION_HAS_PART,
},
],
},
'k:d/a1': {
apiVersion: 'a',
kind: 'k',
metadata: {
name: 'a1',
namespace: 'd',
},
relations: [
{
targetRef: 'b:d/c',
type: RELATION_OWNED_BY,
},
{
targetRef: 'b:d/c1',
type: RELATION_OWNED_BY,
},
],
},
'b:d/c1': {
apiVersion: 'a',
kind: 'b',
metadata: {
name: 'c1',
namespace: 'd',
},
relations: [
{
targetRef: 'b:d/c',
type: RELATION_PART_OF,
},
{
targetRef: 'k:d/a1',
type: RELATION_OWNER_OF,
},
{
targetRef: 'b:d/c2',
type: RELATION_HAS_PART,
},
],
},
'b:d/c2': {
apiVersion: 'a',
kind: 'b',
metadata: {
name: 'c2',
namespace: 'd',
},
relations: [
{
targetRef: 'b:d/c1',
type: RELATION_PART_OF,
},
],
},
};
function deprecatedProperties(entity: Entity): Partial<EntityNode> {
return {
kind: entity.kind,
name: entity.metadata.name,
namespace: entity.metadata.namespace || DEFAULT_NAMESPACE,
title: entity.metadata.title,
};
}
describe('useEntityRelationNodesAndEdges', () => {
beforeEach(() => {
const entities: { [ref: string]: Entity } = {
'b:d/c': {
apiVersion: 'a',
kind: 'b',
metadata: {
name: 'c',
namespace: 'd',
},
relations: [
{
targetRef: 'k:d/a1',
type: RELATION_OWNER_OF,
},
{
targetRef: 'b:d/c1',
type: RELATION_HAS_PART,
},
],
},
'k:d/a1': {
apiVersion: 'a',
kind: 'k',
metadata: {
name: 'a1',
namespace: 'd',
},
relations: [
{
targetRef: 'b:d/c',
type: RELATION_OWNED_BY,
},
{
targetRef: 'b:d/c1',
type: RELATION_OWNED_BY,
},
],
},
'b:d/c1': {
apiVersion: 'a',
kind: 'b',
metadata: {
name: 'c1',
namespace: 'd',
},
relations: [
{
targetRef: 'b:d/c',
type: RELATION_PART_OF,
},
{
targetRef: 'k:d/a1',
type: RELATION_OWNER_OF,
},
{
targetRef: 'b:d/c2',
type: RELATION_HAS_PART,
},
],
},
'b:d/c2': {
apiVersion: 'a',
kind: 'b',
metadata: {
name: 'c2',
namespace: 'd',
},
relations: [
{
targetRef: 'b:d/c1',
type: RELATION_PART_OF,
},
],
},
};
useEntityRelationGraph.mockImplementation(({ filter: { kinds } }) => ({
loading: false,
entities: keyBy(
@@ -184,33 +195,29 @@ describe('useEntityRelationNodesAndEdges', () => {
color: 'secondary',
focused: true,
id: 'b:d/c',
kind: 'b',
name: 'c',
namespace: 'd',
entity: entities['b:d/c'],
...deprecatedProperties(entities['b:d/c']),
},
{
color: 'primary',
focused: false,
id: 'k:d/a1',
kind: 'k',
name: 'a1',
namespace: 'd',
entity: entities['k:d/a1'],
...deprecatedProperties(entities['k:d/a1']),
},
{
color: 'primary',
focused: false,
id: 'b:d/c1',
kind: 'b',
name: 'c1',
namespace: 'd',
entity: entities['b:d/c1'],
...deprecatedProperties(entities['b:d/c1']),
},
{
color: 'primary',
focused: false,
id: 'b:d/c2',
kind: 'b',
name: 'c2',
namespace: 'd',
entity: entities['b:d/c2'],
...deprecatedProperties(entities['b:d/c2']),
},
]);
expect(edges).toEqual([
@@ -257,33 +264,29 @@ describe('useEntityRelationNodesAndEdges', () => {
color: 'secondary',
focused: true,
id: 'b:d/c',
kind: 'b',
name: 'c',
namespace: 'd',
entity: entities['b:d/c'],
...deprecatedProperties(entities['b:d/c']),
},
{
color: 'primary',
focused: false,
id: 'k:d/a1',
kind: 'k',
name: 'a1',
namespace: 'd',
entity: entities['k:d/a1'],
...deprecatedProperties(entities['k:d/a1']),
},
{
color: 'primary',
focused: false,
id: 'b:d/c1',
kind: 'b',
name: 'c1',
namespace: 'd',
entity: entities['b:d/c1'],
...deprecatedProperties(entities['b:d/c1']),
},
{
color: 'primary',
focused: false,
id: 'b:d/c2',
kind: 'b',
name: 'c2',
namespace: 'd',
entity: entities['b:d/c2'],
...deprecatedProperties(entities['b:d/c2']),
},
]);
expect(edges).toEqual([
@@ -330,33 +333,29 @@ describe('useEntityRelationNodesAndEdges', () => {
color: 'secondary',
focused: true,
id: 'b:d/c',
kind: 'b',
name: 'c',
namespace: 'd',
entity: entities['b:d/c'],
...deprecatedProperties(entities['b:d/c']),
},
{
color: 'primary',
focused: false,
id: 'k:d/a1',
kind: 'k',
name: 'a1',
namespace: 'd',
entity: entities['k:d/a1'],
...deprecatedProperties(entities['k:d/a1']),
},
{
color: 'primary',
focused: false,
id: 'b:d/c1',
kind: 'b',
name: 'c1',
namespace: 'd',
entity: entities['b:d/c1'],
...deprecatedProperties(entities['b:d/c1']),
},
{
color: 'primary',
focused: false,
id: 'b:d/c2',
kind: 'b',
name: 'c2',
namespace: 'd',
entity: entities['b:d/c2'],
...deprecatedProperties(entities['b:d/c2']),
},
]);
expect(edges).toEqual([
@@ -433,33 +432,29 @@ describe('useEntityRelationNodesAndEdges', () => {
color: 'secondary',
focused: true,
id: 'b:d/c',
kind: 'b',
name: 'c',
namespace: 'd',
entity: entities['b:d/c'],
...deprecatedProperties(entities['b:d/c']),
},
{
color: 'primary',
focused: false,
id: 'k:d/a1',
kind: 'k',
name: 'a1',
namespace: 'd',
entity: entities['k:d/a1'],
...deprecatedProperties(entities['k:d/a1']),
},
{
color: 'primary',
focused: false,
id: 'b:d/c1',
kind: 'b',
name: 'c1',
namespace: 'd',
entity: entities['b:d/c1'],
...deprecatedProperties(entities['b:d/c1']),
},
{
color: 'primary',
focused: false,
id: 'b:d/c2',
kind: 'b',
name: 'c2',
namespace: 'd',
entity: entities['b:d/c2'],
...deprecatedProperties(entities['b:d/c2']),
},
]);
expect(edges).toEqual([
@@ -534,33 +529,29 @@ describe('useEntityRelationNodesAndEdges', () => {
color: 'secondary',
focused: true,
id: 'b:d/c',
kind: 'b',
name: 'c',
namespace: 'd',
entity: entities['b:d/c'],
...deprecatedProperties(entities['b:d/c']),
},
{
color: 'primary',
focused: false,
id: 'k:d/a1',
kind: 'k',
name: 'a1',
namespace: 'd',
entity: entities['k:d/a1'],
...deprecatedProperties(entities['k:d/a1']),
},
{
color: 'primary',
focused: false,
id: 'b:d/c1',
kind: 'b',
name: 'c1',
namespace: 'd',
entity: entities['b:d/c1'],
...deprecatedProperties(entities['b:d/c1']),
},
{
color: 'secondary',
focused: true,
id: 'b:d/c2',
kind: 'b',
name: 'c2',
namespace: 'd',
entity: entities['b:d/c2'],
...deprecatedProperties(entities['b:d/c2']),
},
]);
expect(edges).toEqual([
@@ -606,33 +597,29 @@ describe('useEntityRelationNodesAndEdges', () => {
color: 'secondary',
focused: true,
id: 'b:d/c',
kind: 'b',
name: 'c',
namespace: 'd',
entity: entities['b:d/c'],
...deprecatedProperties(entities['b:d/c']),
},
{
color: 'primary',
focused: false,
id: 'k:d/a1',
kind: 'k',
name: 'a1',
namespace: 'd',
entity: entities['k:d/a1'],
...deprecatedProperties(entities['k:d/a1']),
},
{
color: 'primary',
focused: false,
id: 'b:d/c1',
kind: 'b',
name: 'c1',
namespace: 'd',
entity: entities['b:d/c1'],
...deprecatedProperties(entities['b:d/c1']),
},
{
color: 'primary',
focused: false,
id: 'b:d/c2',
kind: 'b',
name: 'c2',
namespace: 'd',
entity: entities['b:d/c2'],
...deprecatedProperties(entities['b:d/c2']),
},
]);
expect(edges).toEqual([
@@ -658,6 +645,7 @@ describe('useEntityRelationNodesAndEdges', () => {
});
const { nodes, edges, loading, error } = result.current;
// nodes?.sort((a, b) => a.id.localeCompare(b.id));
expect(loading).toBe(false);
expect(error).toBeUndefined();
@@ -666,25 +654,22 @@ describe('useEntityRelationNodesAndEdges', () => {
color: 'secondary',
focused: true,
id: 'b:d/c',
kind: 'b',
name: 'c',
namespace: 'd',
entity: entities['b:d/c'],
...deprecatedProperties(entities['b:d/c']),
},
{
color: 'primary',
focused: false,
id: 'b:d/c1',
kind: 'b',
name: 'c1',
namespace: 'd',
entity: entities['b:d/c1'],
...deprecatedProperties(entities['b:d/c1']),
},
{
color: 'primary',
focused: false,
id: 'b:d/c2',
kind: 'b',
name: 'c2',
namespace: 'd',
entity: entities['b:d/c2'],
...deprecatedProperties(entities['b:d/c2']),
},
]);
expect(edges).toEqual([
@@ -13,12 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import { MouseEvent, useState } from 'react';
import useDebounce from 'react-use/lib/useDebounce';
import { RelationPairs, ALL_RELATION_PAIRS } from './relations';
import { EntityEdge, EntityNode } from './types';
import { useEntityRelationGraph } from './useEntityRelationGraph';
import { DEFAULT_NAMESPACE } from '@backstage/catalog-model';
/**
* Generate nodes and edges to render the entity graph.
@@ -71,13 +71,15 @@ export function useEntityRelationNodesAndEdges({
const focused = rootEntityRefs.includes(entityRef);
const node: EntityNode = {
id: entityRef,
title: entity.metadata?.title ?? undefined,
kind: entity.kind,
name: entity.metadata.name,
namespace: entity.metadata.namespace ?? DEFAULT_NAMESPACE,
spec: entity.spec ?? undefined,
entity,
focused,
color: focused ? 'secondary' : 'primary',
// @deprecated
kind: entity.kind,
name: entity.metadata.name,
namespace: entity.metadata.namespace || DEFAULT_NAMESPACE,
title: entity.metadata.title,
spec: entity.spec,
};
if (onNodeClick) {