feat: updates anchor connector schema

This commit is contained in:
Mark Mankarious
2023-10-04 17:56:55 +01:00
parent ccea412b8d
commit 5d6f3d0aaf
14 changed files with 293 additions and 121 deletions

View File

@@ -2,7 +2,12 @@ import React, { useMemo } from 'react';
import { useTheme, Box } from '@mui/material';
import { Connector as ConnectorI } from 'src/types';
import { UNPROJECTED_TILE_SIZE } from 'src/config';
import { getAnchorPosition, CoordsUtils, getColorVariant } from 'src/utils';
import {
getAnchorPosition,
CoordsUtils,
getColorVariant,
getAllAnchors
} from 'src/utils';
import { Circle } from 'src/components/Circle/Circle';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useSceneStore } from 'src/stores/sceneStore';
@@ -27,6 +32,9 @@ export const Connector = ({ connector }: Props) => {
const nodes = useSceneStore((state) => {
return state.nodes;
});
const connectors = useSceneStore((state) => {
return state.connectors;
});
const drawOffset = useMemo(() => {
return {
@@ -45,14 +53,24 @@ export const Connector = ({ connector }: Props) => {
const anchorPositions = useMemo(() => {
return connector.anchors.map((anchor) => {
const position = getAnchorPosition({ anchor, nodes });
const position = getAnchorPosition({
anchor,
nodes,
allAnchors: getAllAnchors(connectors)
});
return {
x: (connector.path.rectangle.from.x - position.x) * unprojectedTileSize,
y: (connector.path.rectangle.from.y - position.y) * unprojectedTileSize
};
});
}, [connector.path.rectangle, connector.anchors, nodes, unprojectedTileSize]);
}, [
connector.path.rectangle,
connector.anchors,
nodes,
connectors,
unprojectedTileSize
]);
const connectorWidthPx = useMemo(() => {
return (unprojectedTileSize / 100) * connector.width;

View File

@@ -51,16 +51,16 @@ export const initialScene: InitialScene = {
connectors: [
{
id: 'connector1',
anchors: [{ nodeId: 'server' }, { nodeId: 'database' }]
anchors: [{ ref: { node: 'server' } }, { ref: { node: 'database' } }]
},
{
id: 'connector2',
style: ConnectorStyleEnum.DOTTED,
width: 10,
anchors: [
{ nodeId: 'server' },
{ tile: { x: -1, y: 2 } },
{ nodeId: 'client' }
{ ref: { node: 'server' } },
{ ref: { tile: { x: -1, y: 2 } } },
{ ref: { node: 'client' } }
]
}
],

View File

@@ -7,7 +7,8 @@ import {
getBoundingBoxSize,
sortByPosition,
clamp,
getAnchorPosition
getAnchorPosition,
getAllAnchors
} from 'src/utils';
import { useGetTilePosition } from 'src/hooks/useGetTilePosition';
import { useScroll } from 'src/hooks/useScroll';
@@ -43,7 +44,11 @@ export const useDiagramUtils = () => {
return [
...acc,
...item.anchors.map((anchor) => {
return getAnchorPosition({ anchor, nodes: scene.nodes });
return getAnchorPosition({
anchor,
nodes: scene.nodes,
allAnchors: getAllAnchors(scene.connectors)
});
})
];
case 'RECTANGLE':
@@ -60,7 +65,7 @@ export const useDiagramUtils = () => {
return corners;
},
[scene.nodes]
[scene.nodes, scene.connectors]
);
const getUnprojectedBounds = useCallback((): Size & Coords => {

View File

@@ -6,7 +6,8 @@ import {
connectorToConnectorInput,
getConnectorPath,
hasMovedTile,
setWindowCursor
setWindowCursor,
getAllAnchors
} from 'src/utils';
import { ModeActions } from 'src/types';
@@ -37,13 +38,16 @@ export const Connector: ModeActions = {
if (!draftState.connector) return;
draftState.connector.anchors[1] = {
type: 'NODE',
id: itemAtTile.id
ref: {
type: 'NODE',
id: itemAtTile.id
}
};
draftState.connector.path = getConnectorPath({
anchors: draftState.connector.anchors,
nodes: scene.nodes
nodes: scene.nodes,
allAnchors: getAllAnchors(scene.connectors)
});
});
@@ -53,13 +57,16 @@ export const Connector: ModeActions = {
if (!draftState.connector) return;
draftState.connector.anchors[1] = {
type: 'TILE',
coords: uiState.mouse.position.tile
ref: {
type: 'TILE',
coords: uiState.mouse.position.tile
}
};
draftState.connector.path = getConnectorPath({
anchors: draftState.connector.anchors,
nodes: scene.nodes
nodes: scene.nodes,
allAnchors: getAllAnchors(scene.connectors)
});
});
@@ -79,9 +86,13 @@ export const Connector: ModeActions = {
draftState.connector = connectorInputToConnector(
{
id: generateId(),
anchors: [{ nodeId: itemAtTile.id }, { nodeId: itemAtTile.id }]
anchors: [
{ ref: { node: itemAtTile.id } },
{ ref: { node: itemAtTile.id } }
]
},
scene.nodes
scene.nodes,
getAllAnchors(scene.connectors)
);
});
@@ -92,11 +103,12 @@ export const Connector: ModeActions = {
{
id: generateId(),
anchors: [
{ tile: uiState.mouse.position.tile },
{ tile: uiState.mouse.position.tile }
{ ref: { tile: uiState.mouse.position.tile } },
{ ref: { tile: uiState.mouse.position.tile } }
]
},
scene.nodes
scene.nodes,
getAllAnchors(scene.connectors)
);
});

View File

@@ -12,7 +12,8 @@ import {
textBoxInputToTextBox,
sceneInputToScene,
nodeInputToNode,
getTextWidth
getTextWidth,
getAllAnchors
} from 'src/utils';
export const initialScene: Scene = {
@@ -65,13 +66,14 @@ const initialState = () => {
draftState.connectors.forEach((connector, i) => {
const needsUpdate = connector.anchors.find((anchor) => {
return anchor.type === 'NODE' && anchor.id === id;
return anchor.ref.type === 'NODE' && anchor.ref.id === id;
});
if (needsUpdate) {
draftState.connectors[i].path = getConnectorPath({
anchors: connector.anchors,
nodes: draftState.nodes
nodes: draftState.nodes,
allAnchors: getAllAnchors(draftState.connectors)
});
}
});
@@ -89,7 +91,7 @@ const initialState = () => {
draftState.connectors = draftState.connectors.filter(
(connector) => {
return !connector.anchors.find((anchor) => {
return anchor.type === 'NODE' && anchor.id === id;
return anchor.ref.type === 'NODE' && anchor.ref.id === id;
});
}
);
@@ -101,7 +103,11 @@ const initialState = () => {
createConnector: (connector) => {
const newScene = produce(get(), (draftState) => {
draftState.connectors.push(
connectorInputToConnector(connector, draftState.nodes)
connectorInputToConnector(
connector,
draftState.nodes,
getAllAnchors(draftState.connectors)
)
);
});

View File

@@ -45,11 +45,11 @@ export const scene: SceneInput = {
connectors: [
{
id: 'connector1',
anchors: [{ nodeId: 'node1' }, { nodeId: 'node2' }]
anchors: [{ ref: { node: 'node1' } }, { ref: { node: 'node2' } }]
},
{
id: 'connector2',
anchors: [{ nodeId: 'node2' }, { nodeId: 'node3' }]
anchors: [{ ref: { node: 'node2' } }, { ref: { node: 'node3' } }]
}
],
textBoxes: [],

View File

@@ -34,7 +34,7 @@ export interface Node {
isSelected: boolean;
}
export type ConnectorAnchor =
export type ConnectorAnchorRef =
| {
type: 'NODE';
id: string;
@@ -42,8 +42,17 @@ export type ConnectorAnchor =
| {
type: 'TILE';
coords: Coords;
}
| {
type: 'ANCHOR';
id: string;
};
export type ConnectorAnchor = {
id?: string;
ref: ConnectorAnchorRef;
};
export interface Connector {
type: SceneItemTypeEnum.CONNECTOR;
id: string;

View File

@@ -14,8 +14,7 @@ import {
TextBox,
Rectangle,
ConnectorAnchorInput,
ConnectorAnchor,
Coords
ConnectorAnchor
} from 'src/types';
import {
NODE_DEFAULTS,
@@ -63,22 +62,43 @@ export const rectangleInputToRectangle = (
const connectorAnchorInputToConnectorAnchor = (
anchor: ConnectorAnchorInput
): ConnectorAnchor => {
if (anchor.nodeId) {
if (anchor.ref.node) {
return {
type: 'NODE',
id: anchor.nodeId
id: anchor.id,
ref: {
type: 'NODE',
id: anchor.ref.node
}
};
}
return {
type: 'TILE',
coords: anchor.tile as Coords
};
if (anchor.ref.tile) {
return {
id: anchor.id,
ref: {
type: 'TILE',
coords: anchor.ref.tile
}
};
}
if (anchor.ref.anchor) {
return {
id: anchor.id,
ref: {
type: 'ANCHOR',
id: anchor.ref.anchor
}
};
}
throw new Error('Could not render connector anchor');
};
export const connectorInputToConnector = (
connectorInput: ConnectorInput,
nodes: Node[]
nodes: Node[],
allAnchors: ConnectorAnchor[]
): Connector => {
const anchors = connectorInput.anchors
.map((anchor) => {
@@ -95,7 +115,7 @@ export const connectorInputToConnector = (
width: connectorInput.width ?? CONNECTOR_DEFAULTS.width,
style: connectorInput.style ?? CONNECTOR_DEFAULTS.style,
anchors,
path: getConnectorPath({ anchors, nodes })
path: getConnectorPath({ anchors, nodes, allAnchors })
};
};
@@ -130,6 +150,17 @@ export const textBoxToTextBoxInput = (textBox: TextBox): TextBoxInput => {
};
};
export const getAllAnchorsFromInput = (connectors: ConnectorInput[]) => {
const allAnchors = connectors.reduce((acc, connectorInput) => {
const convertedAnchors = connectorInput.anchors.map((anchor) => {
return connectorAnchorInputToConnectorAnchor(anchor);
});
return [...acc, ...convertedAnchors];
}, [] as ConnectorAnchor[]);
return allAnchors;
};
export const sceneInputToScene = (sceneInput: SceneInput): Scene => {
const icons = sceneInput.icons.map((icon) => {
return iconInputToIcon(icon);
@@ -143,8 +174,10 @@ export const sceneInputToScene = (sceneInput: SceneInput): Scene => {
return rectangleInputToRectangle(rectangleInput);
});
const allAnchors = getAllAnchorsFromInput(sceneInput.connectors);
const connectors = sceneInput.connectors.map((connectorInput) => {
return connectorInputToConnector(connectorInput, nodes);
return connectorInputToConnector(connectorInput, nodes, allAnchors);
});
return {
@@ -178,14 +211,21 @@ export const nodeToNodeInput = (node: Node): NodeInput => {
export const connectorAnchorToConnectorAnchorInput = (
anchor: ConnectorAnchor
): ConnectorAnchorInput | null => {
switch (anchor.type) {
switch (anchor.ref.type) {
case 'NODE':
return {
nodeId: anchor.id
id: anchor.id,
ref: { node: anchor.ref.id }
};
case 'TILE':
return {
tile: anchor.coords
id: anchor.id,
ref: { tile: anchor.ref.coords }
};
case 'ANCHOR':
return {
id: anchor.id,
ref: { anchor: anchor.ref.id }
};
default:
return null;

View File

@@ -12,6 +12,7 @@ import {
Coords,
TileOriginEnum,
Node,
Connector,
Size,
Scroll,
Mouse,
@@ -327,21 +328,39 @@ export function getItemById<T extends { id: string }>(
return { item: items[index], index };
}
export const getAllAnchors = (connectors: Connector[]) => {
return connectors.reduce((acc, connector) => {
return [...acc, ...connector.anchors];
}, [] as ConnectorAnchor[]);
};
interface GetAnchorPositions {
anchor: ConnectorAnchor;
nodes: Node[];
allAnchors: ConnectorAnchor[];
}
export const getAnchorPosition = ({
anchor,
nodes
nodes,
allAnchors
}: GetAnchorPositions): Coords => {
if (anchor.type === 'NODE') {
const { item: node } = getItemById(nodes, anchor.id);
if (anchor.ref.type === 'NODE') {
const { item: node } = getItemById(nodes, anchor.ref.id);
return node.position;
}
return anchor.coords;
if (anchor.ref.type === 'ANCHOR') {
const anchorsWithIds = allAnchors.filter((_anchor) => {
return _anchor.id !== undefined;
}) as Required<ConnectorAnchor>[];
const nextAnchor = getItemById(anchorsWithIds, anchor.ref.id);
return getAnchorPosition({ anchor: nextAnchor.item, nodes, allAnchors });
}
return anchor.ref.coords;
};
interface NormalisePositionFromOrigin {
@@ -359,11 +378,13 @@ export const normalisePositionFromOrigin = ({
interface GetConnectorPath {
anchors: ConnectorAnchor[];
nodes: Node[];
allAnchors: ConnectorAnchor[];
}
export const getConnectorPath = ({
anchors,
nodes
nodes,
allAnchors
}: GetConnectorPath): {
tiles: Coords[];
rectangle: Rect;
@@ -374,7 +395,7 @@ export const getConnectorPath = ({
);
const anchorPositions = anchors.map((anchor) => {
return getAnchorPosition({ anchor, nodes });
return getAnchorPosition({ anchor, nodes, allAnchors });
});
const searchArea = getBoundingBox(

View File

@@ -1,5 +1,6 @@
// TODO: Split into individual files
import { z } from 'zod';
import { INITIAL_SCENE } from '../config';
import {
iconInput,
nodeInput,
@@ -7,7 +8,7 @@ import {
rectangleInput,
textBoxInput
} from './sceneItems';
import { findInvalidConnector, findInvalidNode } from './utils';
import { ensureValidConnectors, ensureValidNodes } from './utils';
export const sceneInput = z
.object({
@@ -17,30 +18,16 @@ export const sceneInput = z
textBoxes: z.array(textBoxInput),
rectangles: z.array(rectangleInput)
})
.superRefine((scene, ctx) => {
const icons = scene.icons ?? [];
const nodes = scene.nodes ?? [];
const connectors = scene.connectors ?? [];
.superRefine((_scene, ctx) => {
const scene = { ...INITIAL_SCENE, ..._scene };
const invalidNode = findInvalidNode(nodes, icons);
if (invalidNode) {
ctx.addIssue({
try {
ensureValidNodes(scene);
ensureValidConnectors(scene);
} catch (e) {
return ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['nodes', invalidNode.id],
message: 'Invalid node found in scene'
});
return;
}
const invalidConnector = findInvalidConnector(connectors, nodes);
if (invalidConnector) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['connectors', invalidConnector.id],
message: 'Invalid connector found in scene'
message: e.message
});
}
});

View File

@@ -23,18 +23,24 @@ export const nodeInput = z.object({
});
export const connectorAnchorInput = z
// TODO: See if we can use `z.discriminatedUnion` here. See https://github.com/colinhacks/zod#unions
.object({
nodeId: z.string(),
tile: coords
id: z.string().optional(),
ref: z
.object({
node: z.string(),
anchor: z.string(),
tile: coords
})
.partial()
})
.partial()
.superRefine((data, ctx) => {
if (!data.nodeId && !data.tile) {
const definedRefs = Object.keys(data.ref);
if (definedRefs.length !== 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['connectorAnchor'],
message: 'Connector anchor needs either a nodeId or tile coords.'
message:
'Connector anchor should be associated with only either a node, another anchor or a tile.'
});
}
});

View File

@@ -1,11 +1,10 @@
import { NodeInput, ConnectorInput } from 'src/types/inputs';
import { produce } from 'immer';
import { sceneInput } from '../scene';
import { findInvalidNode, findInvalidConnector } from '../utils';
import { scene } from '../../tests/fixtures/scene';
import { scene as sceneFixture } from '../../tests/fixtures/scene';
describe('scene validation works correctly', () => {
test('scene fixture is valid', () => {
const result = sceneInput.safeParse(scene);
const result = sceneInput.safeParse(sceneFixture);
expect(result).toEqual(
expect.objectContaining({
@@ -14,33 +13,55 @@ describe('scene validation works correctly', () => {
);
});
test('finds invalid nodes in scene', () => {
const { icons } = scene;
test('node with invalid iconId fails validation', () => {
const invalidNode = {
id: 'invalidNode',
iconId: 'doesntExist',
position: { x: -1, y: -1 }
};
const nodes: NodeInput[] = [...scene.nodes, invalidNode];
const scene = produce(sceneFixture, (draft) => {
draft.nodes.push(invalidNode);
});
const result = findInvalidNode(nodes, icons);
const result = sceneInput.safeParse(scene);
expect(result).toEqual(invalidNode);
expect(result.success).toBe(false);
if (result.success === false) {
expect(result.error.errors[0].message).toContain('invalid icon');
}
});
test('finds invalid connectors in scene', () => {
const { nodes } = scene;
test('connector with anchor that references an invalid node fails validation', () => {
const invalidConnector = {
id: 'invalidConnector',
anchors: [{ nodeId: 'node1' }, { nodeId: 'invalidNode' }]
anchors: [{ ref: { node: 'node1' } }, { ref: { node: 'invalidNode' } }]
};
const connectors: ConnectorInput[] = [
...scene.connectors,
invalidConnector
];
const scene = produce(sceneFixture, (draft) => {
draft.connectors.push(invalidConnector);
});
const result = findInvalidConnector(connectors, nodes);
const result = sceneInput.safeParse(scene);
expect(result).toEqual(invalidConnector);
expect(result.success).toBe(false);
if (result.success === false) {
expect(result.error.errors[0].message).toContain('node does not exist');
}
});
test('connector with anchor that references an invalid anchor fails validation', () => {
const invalidConnector = {
id: 'invalidConnector',
anchors: [{ ref: { anchor: 'invalidAnchor' } }]
};
const scene = produce(sceneFixture, (draft) => {
draft.connectors.push(invalidConnector);
});
const result = sceneInput.safeParse(scene);
expect(result.success).toBe(false);
if (result.success === false) {
expect(result.error.errors[0].message).toContain('anchor does not exist');
}
});
});

View File

@@ -1,32 +1,78 @@
import { ConnectorInput, NodeInput, IconInput } from 'src/types/inputs';
import {
ConnectorInput,
NodeInput,
ConnectorAnchorInput,
ConnectorAnchor,
SceneInput
} from 'src/types';
import { getAllAnchorsFromInput, getItemById } from 'src/utils';
export const findInvalidNode = (nodes: NodeInput[], icons: IconInput[]) => {
return nodes.find((node) => {
const validIcon = icons.find((icon) => {
return node.iconId === icon.id;
});
return !validIcon;
export const ensureValidNode = (node: NodeInput, scene: SceneInput) => {
try {
getItemById(scene.icons, node.iconId);
} catch (e) {
throw new Error(
`Found invalid node [id: "${node.id}"] referencing an invalid icon [id: "${node.iconId}"]`
);
}
};
export const ensureValidNodes = (scene: SceneInput) => {
scene.nodes.forEach((node) => {
ensureValidNode(node, scene);
});
};
export const findInvalidConnector = (
connectors: ConnectorInput[],
export const ensureValidConnectorAnchor = (
anchor: ConnectorAnchorInput,
allAnchors: ConnectorAnchor[],
nodes: NodeInput[]
) => {
return connectors.find((con) => {
const invalidAnchor = con.anchors.find((anchor) => {
if (
anchor.nodeId &&
!nodes.find((node) => {
return node.id === anchor.nodeId;
})
) {
return true;
}
return false;
if (anchor.ref.node) {
const nodeExists = nodes.find((node) => {
return node.id === anchor.ref.node;
});
return Boolean(invalidAnchor);
if (!nodeExists) {
throw new Error(
`Anchor [id: "${anchor.id}"] references a node [id: "${anchor.ref.node}"], but that node does not exist`
);
}
}
if (anchor.ref.anchor) {
const nextAnchor = allAnchors.find((_anchor) => {
return _anchor.id === anchor.ref.anchor;
});
if (!nextAnchor) {
throw new Error(
`Anchor [id: "${anchor.id}"] references another anchor [id: "${anchor.ref.anchor}"], but that anchor does not exist`
);
}
}
};
export const ensureValidConnector = (
connector: ConnectorInput,
allAnchors: ConnectorAnchor[],
nodes: NodeInput[]
) => {
connector.anchors.forEach((anchor) => {
try {
ensureValidConnectorAnchor(anchor, allAnchors, nodes);
} catch (e) {
throw new Error(
`Found invalid connector [id: "${connector.id}"]: ${e.message}`
);
}
});
};
export const ensureValidConnectors = (scene: SceneInput) => {
const allAnchors = getAllAnchorsFromInput(scene.connectors);
scene.connectors.forEach((con) => {
ensureValidConnector(con, allAnchors, scene.nodes);
});
};

View File

@@ -15,6 +15,7 @@
"module": "es6",
"moduleResolution": "node",
"skipLibCheck": true,
"useUnknownInCatchVariables": false
},
"exclude": ["node_modules", "./dist", "./docs"],
"include": [