Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/layers-graph-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@joint/react": minor
---

<GraphProvider /> - add `initialLayers`, controlled `layers` and `onLayersChange`
5 changes: 5 additions & 0 deletions .changeset/layers-mvc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@joint/react": minor
---

LayerModel - add the layer model preset registered for plain graph layers
5 changes: 5 additions & 0 deletions .changeset/layers-namespace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@joint/react": minor
---

<GraphProvider /> - add `layerNamespace` prop to register custom layer types
5 changes: 5 additions & 0 deletions .changeset/layers-record.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@joint/react": minor
---

LayerRecord - add the layer record type; `visible: false` hides a layer without unmounting its cells
5 changes: 5 additions & 0 deletions .changeset/layers-setters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@joint/react": minor
---

useGraph - add `setLayers()` and `setLayer()` to declare, reorder, update and hide layers
5 changes: 5 additions & 0 deletions .changeset/layers-use-layer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@joint/react": minor
---

useLayer - add a hook reading one layer by id with an optional selector
5 changes: 5 additions & 0 deletions .changeset/layers-use-layers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@joint/react": minor
---

useLayers - add a hook returning the graph's layers in paint order, generic over the layer id union
5 changes: 5 additions & 0 deletions .changeset/layers-view.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@joint/react": minor
---

LayerView - add the layer view preset that applies a layer's `visible` attribute
5 changes: 5 additions & 0 deletions .changeset/react-layer-callback-commit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@joint/react": patch
---

<GraphProvider /> - refresh layer callbacks before descendant layout effects
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { act, render, waitFor } from '@testing-library/react';
import { GraphProvider } from '../graph-provider';
import { useGraphStore } from '../../../hooks/use-graph-store';
import { useGraph } from '../../../hooks/use-graph';
import type { GraphStore } from '../../../store';
import type { CellRecord, ElementRecord } from '../../../types/cell.types';
import type { LayerRecord } from '../../../types/layer.types';
import { ELEMENT_MODEL_TYPE } from '../../../mvc/element-model';

const element = (layer: string) => ({
id: 'node',
type: ELEMENT_MODEL_TYPE,
layer,
position: { x: 0, y: 0 },
size: { width: 10, height: 10 },
} satisfies ElementRecord);
const INITIAL_LAYERS: readonly LayerRecord[] = [{ id: 'notes' }];
const REMAINING_LAYERS: readonly LayerRecord[] = [];
const INITIAL_CELLS: readonly CellRecord[] = [element('notes')];
const MOVED_CELLS: readonly CellRecord[] = [element('cells')];
const NO_CELLS: readonly CellRecord[] = [];
interface Captured { store?: GraphStore; api?: ReturnType<typeof useGraph> }
function Capture({ captured }: Readonly<{ captured: Captured }>) {
captured.store = useGraphStore();
captured.api = useGraph();
return null;
}

it.each(['move', 'delete'] as const)('prunes a retained layer after a controlled cells-only %s with the same layers array', async (mode) => {
const captured: Captured = {};
const onLayersChange = jest.fn();
const warn = jest.spyOn(console, 'warn').mockImplementation(jest.fn());
const tree = (cells: readonly CellRecord[], layers: readonly LayerRecord[]) => (
<GraphProvider cells={cells} layers={layers} onLayersChange={onLayersChange}>
<Capture captured={captured} />
</GraphProvider>
);
const view = render(tree(INITIAL_CELLS, INITIAL_LAYERS));
try {
await waitFor(() => expect(captured.store?.graph.hasLayer('notes')).toBe(true));
onLayersChange.mockClear();
await act(async () => {
view.rerender(tree(INITIAL_CELLS, REMAINING_LAYERS));
});
expect(captured.store?.graph.hasLayer('notes')).toBe(true);

await act(async () => {
view.rerender(tree(mode === 'move' ? MOVED_CELLS : NO_CELLS, REMAINING_LAYERS));
});

if (mode === 'move') expect(captured.store?.graph.getCell('node').get('layer')).toBe('cells');
else expect(captured.store?.graph.getCell('node')).toBeUndefined();
expect(captured.store?.graph.hasLayer('notes')).toBe(false);
expect(captured.store?.graphProjection.layers.getLayer('notes')).toBeUndefined();
expect(onLayersChange).not.toHaveBeenCalled();
} finally {
view.unmount();
warn.mockRestore();
}
});

it.each(['api move', 'native move', 'native delete'] as const)(
'prunes a retained controlled layer after an uncontrolled %s without a parent update',
async (mode) => {
const captured: Captured = {};
const warn = jest.spyOn(console, 'warn').mockImplementation(jest.fn());
const tree = (layers: readonly LayerRecord[]) => (
<GraphProvider initialCells={INITIAL_CELLS} layers={layers}>
<Capture captured={captured} />
</GraphProvider>
);
const view = render(tree(INITIAL_LAYERS));
try {
await waitFor(() => expect(captured.store?.graph.hasLayer('notes')).toBe(true));
await act(async () => {
view.rerender(tree(REMAINING_LAYERS));
});
const { store, api } = captured;
if (!store || !api) throw new Error('graph not ready');
expect(store.graph.hasLayer('notes')).toBe(true);

await act(async () => {
if (mode === 'api move') api.setCell(element('cells'));
else if (mode === 'native move') store.graph.getCell('node').layer('cells');
else store.graph.removeCells([store.graph.getCell('node')]);
await new Promise<void>((resolve) => queueMicrotask(resolve));
});

expect(store.graph.hasLayer('notes')).toBe(false);
expect(store.graphProjection.layers.getLayer('notes')).toBeUndefined();
if (mode === 'native delete') expect(store.graph.getCell('node')).toBeUndefined();
else expect(store.graph.getCell('node').layer()).toBe('cells');
} finally {
view.unmount();
warn.mockRestore();
}
}
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { dia } from '@joint/core';
import { act, render } from '@testing-library/react';
import { useLayoutEffect } from 'react';
import { GraphProvider } from '../graph-provider';
import { useGraph } from '../../../hooks/use-graph';
import type { GraphApi } from '../../../hooks/use-graph';
import type { LayerRecord } from '../../../types/layer.types';

const UPDATED_LAYERS = [{ id: 'cells' }, { id: 'notes', name: 'Updated' }];
const INITIAL_LAYERS: readonly LayerRecord[] = [{ id: 'cells' }, { id: 'notes' }];

function MountMutation({ mutate }: { readonly mutate: (api: GraphApi) => void }) {
const api = useGraph();
useLayoutEffect(() => {
mutate(api);
}, [api, mutate]);
return null;
}

const hideNotes = (api: GraphApi) => api.setLayer('notes', { visible: false });
const addLayer = (api: GraphApi) => {
if (!api.graph.hasLayer('child')) api.graph.addLayer({ id: 'child' });
};

describe('GraphProvider layer subscription ownership', () => {
it.each([
['setLayer', hideNotes, { id: 'notes', visible: false }],
['graph.addLayer', addLayer, { id: 'child' }],
] as const)('reports %s from a child mount layout effect', (_name, mutate, expected) => {
const onLayersChange = jest.fn();
render(
<GraphProvider initialLayers={INITIAL_LAYERS} onLayersChange={onLayersChange}>
<MountMutation mutate={mutate} />
</GraphProvider>
);

expect(onLayersChange).toHaveBeenCalledTimes(1);
expect(onLayersChange).toHaveBeenLastCalledWith(expect.arrayContaining([expected]));
});

it('restores controlled layers changed by a child mount layout effect without a handler', async () => {
const graph = new dia.Graph();
render(
<GraphProvider graph={graph} layers={INITIAL_LAYERS}>
<MountMutation mutate={hideNotes} />
</GraphProvider>
);

await act(async () => {
await Promise.resolve();
});
expect(graph.getLayer('notes').get('visible')).not.toBe(false);
});

it('does not revert external graph layers after the provider unmounts', async () => {
const graph = new dia.Graph();
const { unmount } = render(<GraphProvider graph={graph} layers={INITIAL_LAYERS} />);

act(() => {
graph.getLayer('notes').set('visible', false);
});
expect(graph.getLayer('notes').get('visible')).toBe(false);
unmount();

await act(async () => {
await Promise.resolve();
});
expect(graph.getLayer('notes').get('visible')).toBe(false);
});

it('uses the latest callback and does not echo controlled prop updates', () => {
const graph = new dia.Graph();
const initialHandler = jest.fn();
const nextHandler = jest.fn();
const { rerender } = render(
<GraphProvider graph={graph} layers={INITIAL_LAYERS} onLayersChange={initialHandler} />
);

rerender(<GraphProvider graph={graph} layers={UPDATED_LAYERS} onLayersChange={nextHandler} />);
expect(initialHandler).not.toHaveBeenCalled();
expect(nextHandler).not.toHaveBeenCalled();

act(() => {
graph.getLayer('notes').set('visible', false);
});
expect(initialHandler).not.toHaveBeenCalled();
expect(nextHandler).toHaveBeenCalledTimes(1);
expect(nextHandler).toHaveBeenLastCalledWith(
expect.arrayContaining([{ id: 'notes', name: 'Updated', visible: false }])
);
});
});


function CommitLayerChange({ visible }: { readonly visible: boolean }) {
const { setLayer } = useGraph();
useLayoutEffect(() => { setLayer('notes', { visible }); }, [setLayer, visible]);
return null;
}

it('uses the replacement handler for a child layout-effect write in the same commit', () => {
const oldHandler = jest.fn();
const nextHandler = jest.fn();
const { rerender } = render(
<GraphProvider initialLayers={INITIAL_LAYERS} onLayersChange={oldHandler}>
<CommitLayerChange visible />
</GraphProvider>
);
oldHandler.mockClear();
rerender(
<GraphProvider initialLayers={INITIAL_LAYERS} onLayersChange={nextHandler}>
<CommitLayerChange visible={false} />
</GraphProvider>
);
expect(oldHandler).not.toHaveBeenCalled();
expect(nextHandler).toHaveBeenCalledTimes(1);
expect(nextHandler).toHaveBeenLastCalledWith(
expect.arrayContaining([{ id: 'notes', visible: false }])
);
});
Loading
Loading