Bindings tests (#3800)

wip

### Change Type

<!--  Please select a 'Scope' label ️ -->

- [x] `sdk` — Changes the tldraw SDK
- [ ] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [ ] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [ ] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [ ] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [x] `tests` — Changes to any test code
- [ ] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know


### Test Plan

1. Add a step-by-step description of how to test your PR here.
2.

- [ ] Unit Tests
- [ ] End to end tests

### Release Notes

- Add a brief release note for your PR here.
This commit is contained in:
David Sheldrick 2024-06-11 06:55:16 +01:00 committed by GitHub
parent 902158f934
commit 23f8b3fd60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 463 additions and 7 deletions

View file

@ -44,10 +44,14 @@ class PinShapeUtil extends ShapeUtil<PinShape> {
return {} return {}
} }
override canBind({ toShapeType }: TLShapeUtilCanBindOpts<PinShape>) { override canBind({ toShapeType, bindingType }: TLShapeUtilCanBindOpts<PinShape>) {
// bindings can go _from_ pins to other shapes, but not the other way round if (bindingType === 'pin') {
// pins cannot bind to other pins!
return toShapeType !== 'pin' return toShapeType !== 'pin'
} }
// Allow pins to participate in other bindings, e.g. arrows
return true
}
override canEdit = () => false override canEdit = () => false
override canResize = () => false override canResize = () => false
override hideRotateHandle = () => true override hideRotateHandle = () => true

View file

@ -14,7 +14,6 @@ import {
TLEventHandlers, TLEventHandlers,
TLOnTranslateEndHandler, TLOnTranslateEndHandler,
TLOnTranslateStartHandler, TLOnTranslateStartHandler,
TLShapeUtilCanBindOpts,
TLUiComponents, TLUiComponents,
TLUiOverrides, TLUiOverrides,
Tldraw, Tldraw,
@ -40,9 +39,9 @@ class StickerShapeUtil extends ShapeUtil<StickerShape> {
return {} return {}
} }
override canBind({ toShapeType }: TLShapeUtilCanBindOpts<StickerShape>) { override canBind() {
// bindings can go _from_ stickers to other shapes, but not the other way round // stickers can bind to anything
return toShapeType !== 'sticker' return true
} }
override canEdit = () => false override canEdit = () => false
override canResize = () => false override canResize = () => false

View file

@ -479,6 +479,8 @@ export class Editor extends EventEmitter<TLEventMap> {
} }
}, },
beforeDelete: (shape) => { beforeDelete: (shape) => {
// if we triggered this delete with a recursive call, don't do anything
if (deletedShapeIds.has(shape.id)) return
// if the deleted shape has a parent shape make sure we call it's onChildrenChange callback // if the deleted shape has a parent shape make sure we call it's onChildrenChange callback
if (shape.parentId && isShapeId(shape.parentId)) { if (shape.parentId && isShapeId(shape.parentId)) {
invalidParents.add(shape.parentId) invalidParents.add(shape.parentId)

View file

@ -0,0 +1,451 @@
import {
BindingOnChangeOptions,
BindingOnCreateOptions,
BindingOnDeleteOptions,
BindingOnShapeChangeOptions,
BindingOnShapeDeleteOptions,
BindingOnShapeIsolateOptions,
BindingUtil,
TLShapeId,
TLUnknownBinding,
createBindingId,
createShapeId,
} from '@tldraw/editor'
import { TestEditor } from './TestEditor'
import { TL } from './test-jsx'
let editor: TestEditor
const ids = {
box1: createShapeId('box1'),
box2: createShapeId('box2'),
box3: createShapeId('box3'),
box4: createShapeId('box4'),
}
const mockOnOperationComplete = jest.fn() as jest.Mock<void, []>
const mockOnBeforeDelete = jest.fn() as jest.Mock<void, [BindingOnDeleteOptions<TLUnknownBinding>]>
const mockOnAfterDelete = jest.fn() as jest.Mock<void, [BindingOnDeleteOptions<TLUnknownBinding>]>
const mockOnBeforeFromShapeDelete = jest.fn() as jest.Mock<
void,
[BindingOnShapeDeleteOptions<TLUnknownBinding>]
>
const mockOnBeforeToShapeDelete = jest.fn() as jest.Mock<
void,
[BindingOnShapeDeleteOptions<TLUnknownBinding>]
>
const mockOnBeforeFromShapeIsolate = jest.fn() as jest.Mock<
void,
[BindingOnShapeIsolateOptions<TLUnknownBinding>]
>
const mockOnBeforeToShapeIsolate = jest.fn() as jest.Mock<
void,
[BindingOnShapeIsolateOptions<TLUnknownBinding>]
>
const mockOnBeforeCreate = jest.fn() as jest.Mock<void, [BindingOnCreateOptions<TLUnknownBinding>]>
const mockOnAfterCreate = jest.fn() as jest.Mock<void, [BindingOnCreateOptions<TLUnknownBinding>]>
const mockOnBeforeChange = jest.fn() as jest.Mock<void, [BindingOnChangeOptions<TLUnknownBinding>]>
const mockOnAfterChange = jest.fn() as jest.Mock<void, [BindingOnChangeOptions<TLUnknownBinding>]>
const mockOnAfterChangeFromShape = jest.fn() as jest.Mock<
void,
[BindingOnShapeChangeOptions<TLUnknownBinding>]
>
const mockOnAfterChangeToShape = jest.fn() as jest.Mock<
void,
[BindingOnShapeChangeOptions<TLUnknownBinding>]
>
const calls: string[] = []
class TestBindingUtil extends BindingUtil {
static override type = 'test'
static override props = {}
override getDefaultProps(): object {
return {}
}
override onOperationComplete(): void {
calls.push('onOperationComplete')
mockOnOperationComplete()
}
override onBeforeDelete(options: BindingOnDeleteOptions<TLUnknownBinding>): void {
calls.push('onBeforeDelete')
mockOnBeforeDelete(options)
}
override onAfterDelete(options: BindingOnDeleteOptions<TLUnknownBinding>): void {
calls.push('onAfterDelete')
mockOnAfterDelete(options)
}
override onBeforeDeleteFromShape(options: BindingOnShapeDeleteOptions<TLUnknownBinding>): void {
calls.push('onBeforeDeleteFromShape')
mockOnBeforeFromShapeDelete(options)
}
override onBeforeDeleteToShape(options: BindingOnShapeDeleteOptions<TLUnknownBinding>): void {
calls.push('onBeforeDeleteToShape')
mockOnBeforeToShapeDelete(options)
}
override onBeforeIsolateFromShape(options: BindingOnShapeIsolateOptions<TLUnknownBinding>): void {
calls.push('onBeforeIsolateFromShape')
mockOnBeforeFromShapeIsolate(options)
}
override onBeforeIsolateToShape(options: BindingOnShapeIsolateOptions<TLUnknownBinding>): void {
calls.push('onBeforeIsolateToShape')
mockOnBeforeToShapeIsolate(options)
}
override onBeforeCreate(options: BindingOnCreateOptions<TLUnknownBinding>): void {
calls.push('onBeforeCreate')
mockOnBeforeCreate(options)
}
override onAfterCreate(options: BindingOnCreateOptions<TLUnknownBinding>): void {
calls.push('onAfterCreate')
mockOnAfterCreate(options)
}
override onBeforeChange(options: BindingOnChangeOptions<TLUnknownBinding>): void {
calls.push('onBeforeChange')
mockOnBeforeChange(options)
}
override onAfterChange(options: BindingOnChangeOptions<TLUnknownBinding>): void {
calls.push('onAfterChange')
mockOnAfterChange(options)
}
override onAfterChangeFromShape(options: BindingOnShapeChangeOptions<TLUnknownBinding>): void {
calls.push('onAfterChangeFromShape')
mockOnAfterChangeFromShape(options)
}
override onAfterChangeToShape(options: BindingOnShapeChangeOptions<TLUnknownBinding>): void {
calls.push('onAfterChangeToShape')
mockOnAfterChangeToShape(options)
}
}
beforeEach(() => {
editor = new TestEditor({ bindingUtils: [TestBindingUtil] })
editor.createShapesFromJsx([
<TL.geo id={ids.box1} x={0} y={0} />,
<TL.geo id={ids.box2} x={0} y={0} />,
<TL.geo id={ids.box3} x={0} y={0} />,
<TL.geo id={ids.box4} x={0} y={0} />,
])
mockOnOperationComplete.mockReset()
mockOnBeforeDelete.mockReset()
mockOnAfterDelete.mockReset()
mockOnBeforeFromShapeDelete.mockReset()
mockOnBeforeToShapeDelete.mockReset()
mockOnBeforeFromShapeIsolate.mockReset()
mockOnBeforeToShapeIsolate.mockReset()
mockOnBeforeCreate.mockReset()
mockOnAfterCreate.mockReset()
mockOnBeforeChange.mockReset()
mockOnAfterChange.mockReset()
mockOnAfterChangeFromShape.mockReset()
mockOnAfterChangeToShape.mockReset()
})
function bindShapes(fromId: TLShapeId, toId: TLShapeId) {
const bindingId = createBindingId()
editor.createBinding({
id: bindingId,
type: 'test',
fromId,
toId,
})
return bindingId
}
test('deleting the from shape', () => {
bindShapes(ids.box1, ids.box2)
calls.length = 0
editor.deleteShape(ids.box1)
expect(calls).toMatchInlineSnapshot(`
[
"onBeforeIsolateToShape",
"onBeforeDeleteFromShape",
"onBeforeDelete",
"onAfterDelete",
"onOperationComplete",
]
`)
})
test('deleting the to shape', () => {
bindShapes(ids.box1, ids.box2)
calls.length = 0
editor.deleteShape(ids.box2)
expect(calls).toMatchInlineSnapshot(`
[
"onBeforeIsolateFromShape",
"onBeforeDeleteToShape",
"onBeforeDelete",
"onAfterDelete",
"onOperationComplete",
]
`)
})
test('deleting the binding', () => {
const bindingId = bindShapes(ids.box1, ids.box2)
calls.length = 0
editor.deleteBinding(bindingId)
expect(calls).toMatchInlineSnapshot(`
[
"onBeforeDelete",
"onAfterDelete",
"onOperationComplete",
]
`)
})
test('deleting the binding while isolating', () => {
const bindingId = bindShapes(ids.box1, ids.box2)
calls.length = 0
editor.deleteBinding(bindingId, { isolateShapes: true })
expect(calls).toMatchInlineSnapshot(`
[
"onBeforeIsolateFromShape",
"onBeforeIsolateToShape",
"onBeforeDelete",
"onAfterDelete",
"onOperationComplete",
]
`)
})
test('copying both bound shapes does not trigger the isolation operations', () => {
bindShapes(ids.box1, ids.box2)
editor.select(ids.box1, ids.box2)
calls.length = 0
editor.copy()
expect(calls).toMatchInlineSnapshot(`[]`)
})
test('copying the from shape on its own does trigger isolation operations', () => {
bindShapes(ids.box1, ids.box2)
editor.select(ids.box1)
calls.length = 0
editor.copy()
expect(calls).toMatchInlineSnapshot(`
[
"onBeforeIsolateFromShape",
"onBeforeIsolateToShape",
"onBeforeDelete",
"onAfterDelete",
"onOperationComplete",
"onBeforeCreate",
"onAfterCreate",
"onOperationComplete",
]
`)
})
test('copying the to shape on its own does trigger the unbind operation', () => {
bindShapes(ids.box1, ids.box2)
editor.select(ids.box2)
calls.length = 0
editor.copy()
expect(calls).toMatchInlineSnapshot(`
[
"onBeforeIsolateFromShape",
"onBeforeIsolateToShape",
"onBeforeDelete",
"onAfterDelete",
"onOperationComplete",
"onBeforeCreate",
"onAfterCreate",
"onOperationComplete",
]
`)
})
test('cascading deletes in beforeFromShapeDelete are handled correctly', () => {
mockOnBeforeFromShapeDelete.mockImplementation((options) => {
editor.deleteShape(options.binding.toId)
})
bindShapes(ids.box1, ids.box2)
bindShapes(ids.box2, ids.box3)
bindShapes(ids.box3, ids.box4)
calls.length = 0
editor.deleteShape(ids.box1)
expect(editor.getShape(ids.box1)).toBeUndefined()
expect(editor.getShape(ids.box2)).toBeUndefined()
expect(editor.getShape(ids.box3)).toBeUndefined()
expect(editor.getShape(ids.box4)).toBeUndefined()
expect(calls).toMatchInlineSnapshot(`
[
"onBeforeIsolateToShape",
"onBeforeDeleteFromShape",
"onBeforeIsolateFromShape",
"onBeforeDeleteToShape",
"onBeforeIsolateToShape",
"onBeforeDeleteFromShape",
"onBeforeIsolateFromShape",
"onBeforeDeleteToShape",
"onBeforeIsolateToShape",
"onBeforeDeleteFromShape",
"onBeforeIsolateFromShape",
"onBeforeDeleteToShape",
"onBeforeDelete",
"onBeforeDelete",
"onBeforeDelete",
"onAfterDelete",
"onAfterDelete",
"onAfterDelete",
"onOperationComplete",
]
`)
})
test('cascading deletes in beforeToShapeDelete are handled correctly', () => {
mockOnBeforeToShapeDelete.mockImplementation((options) => {
editor.deleteShape(options.binding.fromId)
})
bindShapes(ids.box1, ids.box2)
bindShapes(ids.box2, ids.box3)
bindShapes(ids.box3, ids.box4)
calls.length = 0
editor.deleteShape(ids.box4)
expect(editor.getShape(ids.box1)).toBeUndefined()
expect(editor.getShape(ids.box2)).toBeUndefined()
expect(editor.getShape(ids.box3)).toBeUndefined()
expect(editor.getShape(ids.box4)).toBeUndefined()
expect(calls).toMatchInlineSnapshot(`
[
"onBeforeIsolateFromShape",
"onBeforeDeleteToShape",
"onBeforeIsolateFromShape",
"onBeforeDeleteToShape",
"onBeforeIsolateFromShape",
"onBeforeDeleteToShape",
"onBeforeIsolateToShape",
"onBeforeDeleteFromShape",
"onBeforeDelete",
"onBeforeIsolateToShape",
"onBeforeDeleteFromShape",
"onBeforeDelete",
"onBeforeIsolateToShape",
"onBeforeDeleteFromShape",
"onBeforeDelete",
"onAfterDelete",
"onAfterDelete",
"onAfterDelete",
"onOperationComplete",
]
`)
})
test('onBeforeCreate is called before the binding is created', () => {
mockOnBeforeCreate.mockImplementationOnce(() => {
expect(editor.getBindingsFromShape(ids.box1, 'test')).toHaveLength(0)
})
bindShapes(ids.box1, ids.box2)
expect(editor.getBindingsFromShape(ids.box1, 'test')).toHaveLength(1)
})
test('onAfterCreate is called after the binding is created', () => {
mockOnAfterCreate.mockImplementationOnce(() => {
expect(editor.getBindingsFromShape(ids.box1, 'test')).toHaveLength(1)
})
bindShapes(ids.box1, ids.box2)
expect(editor.getBindingsFromShape(ids.box1, 'test')).toHaveLength(1)
expect.assertions(2)
})
test('onBeforeChange is called before the binding is updated', () => {
const bindingId = bindShapes(ids.box1, ids.box2)
mockOnBeforeChange.mockImplementationOnce(() => {
expect(editor.getBinding(bindingId)?.meta).toEqual({})
})
editor.updateBindings([
{
id: bindingId,
type: 'test',
meta: { foo: 'bar' },
},
])
expect(editor.getBinding(bindingId)?.meta).toEqual({ foo: 'bar' })
expect.assertions(2)
})
test('onAfterChange is called after the binding is updated', () => {
const bindingId = bindShapes(ids.box1, ids.box2)
expect(editor.getBinding(bindingId)?.meta).toEqual({})
mockOnAfterChange.mockImplementationOnce(() => {
expect(editor.getBinding(bindingId)?.meta).toEqual({ foo: 'bar' })
})
editor.updateBindings([
{
id: bindingId,
type: 'test',
meta: { foo: 'bar' },
},
])
expect(editor.getBinding(bindingId)?.meta).toEqual({ foo: 'bar' })
expect.assertions(3)
})
test('onAfterChangeFromShape is called after the from shape is updated', () => {
bindShapes(ids.box1, ids.box2)
expect(editor.getShape(ids.box1)?.meta).toEqual({})
mockOnAfterChangeFromShape.mockImplementationOnce(() => {
expect(editor.getShape(ids.box1)?.meta).toEqual({
foo: 'bar',
})
})
editor.updateShapes([
{
id: ids.box1,
type: 'geo',
meta: { foo: 'bar' },
},
])
expect(editor.getShape(ids.box1)?.meta).toEqual({
foo: 'bar',
})
expect.assertions(3)
})
test('onAfterChangeToShape is called after the to shape is updated', () => {
bindShapes(ids.box1, ids.box2)
expect(editor.getShape(ids.box2)?.meta).toEqual({})
mockOnAfterChangeToShape.mockImplementationOnce(() => {
expect(editor.getShape(ids.box2)?.meta).toEqual({
foo: 'bar',
})
})
editor.updateShapes([
{
id: ids.box2,
type: 'geo',
meta: { foo: 'bar' },
},
])
expect(editor.getShape(ids.box2)?.meta).toEqual({
foo: 'bar',
})
expect.assertions(3)
})