mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-26 23:43:40 +00:00
Migrated from 4-position handle system (top/right/bottom/left) to React Flow's easy-connect pattern where the entire node surface is connectable and edges dynamically route to the nearest point on the node border. Key changes: - Migration utility removes old 4-position handle references for backwards compatibility - Full-coverage invisible handles on CustomNode and GroupNode (maximized state) - Floating edges use node.measured dimensions and node.internals.positionAbsolute - useInternalNode hook for correct absolute positioning of nodes in groups - All edges now omit handle fields, allowing dynamic border calculations This improves UX by making nodes easier to connect (whole surface vs tiny handles) and edges intelligently route to optimal connection points. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
659 lines
20 KiB
TypeScript
659 lines
20 KiB
TypeScript
import { create } from 'zustand';
|
|
import { addEdge as rfAddEdge } from '@xyflow/react';
|
|
import type {
|
|
Actor,
|
|
Relation,
|
|
Group,
|
|
NodeTypeConfig,
|
|
EdgeTypeConfig,
|
|
LabelConfig,
|
|
TangibleConfig,
|
|
RelationData,
|
|
GroupData,
|
|
GraphActions
|
|
} from '../types';
|
|
import { MINIMIZED_GROUP_WIDTH, MINIMIZED_GROUP_HEIGHT } from '../constants';
|
|
import { migrateTangibleConfigs } from '../utils/tangibleMigration';
|
|
import { migrateRelationHandlesArray } from '../utils/handleMigration';
|
|
|
|
/**
|
|
* ⚠️ IMPORTANT: DO NOT USE THIS STORE DIRECTLY IN COMPONENTS ⚠️
|
|
*
|
|
* This is the low-level graph store. All mutation operations should go through
|
|
* the `useGraphWithHistory` hook to ensure undo/redo history is tracked.
|
|
*
|
|
* ✅ CORRECT: Use `useGraphWithHistory()` in components
|
|
* ❌ WRONG: Use `useGraphStore()` directly (bypasses history tracking)
|
|
*
|
|
* Exception: Read-only access in presentation components (CustomNode, CustomEdge)
|
|
* is acceptable.
|
|
*
|
|
* See: src/hooks/useGraphWithHistory.ts
|
|
*/
|
|
interface GraphStore {
|
|
nodes: Actor[];
|
|
edges: Relation[];
|
|
groups: Group[];
|
|
nodeTypes: NodeTypeConfig[];
|
|
edgeTypes: EdgeTypeConfig[];
|
|
labels: LabelConfig[];
|
|
tangibles: TangibleConfig[];
|
|
}
|
|
|
|
// Default node types with semantic shape assignments
|
|
const defaultNodeTypes: NodeTypeConfig[] = [
|
|
{ id: 'person', label: 'Person', color: '#3b82f6', shape: 'circle', icon: 'Person', description: 'Individual person' },
|
|
{ id: 'organization', label: 'Organization', color: '#10b981', shape: 'rectangle', icon: 'Business', description: 'Company or group' },
|
|
{ id: 'system', label: 'System', color: '#f59e0b', shape: 'roundedRectangle', icon: 'Computer', description: 'Technical system' },
|
|
{ id: 'concept', label: 'Concept', color: '#8b5cf6', shape: 'roundedRectangle', icon: 'Lightbulb', description: 'Abstract concept' },
|
|
];
|
|
|
|
// Default edge types
|
|
const defaultEdgeTypes: EdgeTypeConfig[] = [
|
|
{ id: 'collaborates', label: 'Collaborates', color: '#3b82f6', style: 'solid' },
|
|
{ id: 'reports-to', label: 'Reports To', color: '#10b981', style: 'solid' },
|
|
{ id: 'depends-on', label: 'Depends On', color: '#f59e0b', style: 'dashed' },
|
|
{ id: 'influences', label: 'Influences', color: '#8b5cf6', style: 'dotted' },
|
|
];
|
|
|
|
// Initial state - starts empty, documents are loaded by workspaceStore
|
|
const initialState: GraphStore = {
|
|
nodes: [],
|
|
edges: [],
|
|
groups: [],
|
|
nodeTypes: defaultNodeTypes,
|
|
edgeTypes: defaultEdgeTypes,
|
|
labels: [],
|
|
tangibles: [],
|
|
};
|
|
|
|
export const useGraphStore = create<GraphStore & GraphActions>((set) => ({
|
|
nodes: initialState.nodes,
|
|
edges: initialState.edges,
|
|
groups: initialState.groups,
|
|
nodeTypes: initialState.nodeTypes,
|
|
edgeTypes: initialState.edgeTypes,
|
|
labels: initialState.labels,
|
|
tangibles: initialState.tangibles,
|
|
|
|
// Node operations
|
|
addNode: (node: Actor) =>
|
|
set((state) => ({
|
|
nodes: [...state.nodes, node],
|
|
})),
|
|
|
|
updateNode: (id: string, updates: Partial<Actor>) =>
|
|
set((state) => {
|
|
// Validate and filter labels if present
|
|
let validatedData = updates.data;
|
|
if (updates.data?.labels) {
|
|
const validLabelIds = new Set(state.labels.map((l) => l.id));
|
|
const filteredLabels = updates.data.labels.filter((labelId) =>
|
|
validLabelIds.has(labelId)
|
|
);
|
|
validatedData = {
|
|
...updates.data,
|
|
labels: filteredLabels.length > 0 ? filteredLabels : undefined,
|
|
};
|
|
}
|
|
|
|
return {
|
|
nodes: state.nodes.map((node) =>
|
|
node.id === id
|
|
? {
|
|
...node,
|
|
...updates,
|
|
data: validatedData ? { ...node.data, ...validatedData } : node.data,
|
|
}
|
|
: node
|
|
),
|
|
};
|
|
}),
|
|
|
|
deleteNode: (id: string) =>
|
|
set((state) => ({
|
|
nodes: state.nodes.filter((node) => node.id !== id),
|
|
edges: state.edges.filter(
|
|
(edge) => edge.source !== id && edge.target !== id
|
|
),
|
|
})),
|
|
|
|
// Edge operations
|
|
addEdge: (edge: Relation) =>
|
|
set((state) => ({
|
|
edges: rfAddEdge(edge, state.edges) as Relation[],
|
|
})),
|
|
|
|
updateEdge: (id: string, data: Partial<RelationData>) =>
|
|
set((state) => {
|
|
// Validate and filter labels if present
|
|
let validatedData = data;
|
|
if (data.labels) {
|
|
const validLabelIds = new Set(state.labels.map((l) => l.id));
|
|
const filteredLabels = data.labels.filter((labelId) =>
|
|
validLabelIds.has(labelId)
|
|
);
|
|
validatedData = {
|
|
...data,
|
|
labels: filteredLabels.length > 0 ? filteredLabels : undefined,
|
|
};
|
|
}
|
|
|
|
return {
|
|
edges: state.edges.map((edge) =>
|
|
edge.id === id
|
|
? { ...edge, data: { ...edge.data, ...validatedData } as RelationData }
|
|
: edge
|
|
),
|
|
};
|
|
}),
|
|
|
|
deleteEdge: (id: string) =>
|
|
set((state) => ({
|
|
edges: state.edges.filter((edge) => edge.id !== id),
|
|
})),
|
|
|
|
// Node type operations
|
|
addNodeType: (nodeType: NodeTypeConfig) =>
|
|
set((state) => ({
|
|
nodeTypes: [...state.nodeTypes, nodeType],
|
|
})),
|
|
|
|
updateNodeType: (id: string, updates: Partial<Omit<NodeTypeConfig, 'id'>>) =>
|
|
set((state) => ({
|
|
nodeTypes: state.nodeTypes.map((type) =>
|
|
type.id === id ? { ...type, ...updates } : type
|
|
),
|
|
})),
|
|
|
|
deleteNodeType: (id: string) =>
|
|
set((state) => {
|
|
// Remove node type ID from tangible filters.actorTypes arrays
|
|
const updatedTangibles = state.tangibles.map((tangible) => {
|
|
if (tangible.mode === 'filter' && tangible.filters?.actorTypes) {
|
|
return {
|
|
...tangible,
|
|
filters: {
|
|
...tangible.filters,
|
|
actorTypes: tangible.filters.actorTypes.filter((typeId) => typeId !== id),
|
|
},
|
|
};
|
|
}
|
|
return tangible;
|
|
});
|
|
|
|
return {
|
|
nodeTypes: state.nodeTypes.filter((type) => type.id !== id),
|
|
tangibles: updatedTangibles,
|
|
};
|
|
}),
|
|
|
|
// Edge type operations
|
|
addEdgeType: (edgeType: EdgeTypeConfig) =>
|
|
set((state) => ({
|
|
edgeTypes: [...state.edgeTypes, edgeType],
|
|
})),
|
|
|
|
updateEdgeType: (id: string, updates: Partial<Omit<EdgeTypeConfig, 'id'>>) =>
|
|
set((state) => ({
|
|
edgeTypes: state.edgeTypes.map((type) =>
|
|
type.id === id ? { ...type, ...updates } : type
|
|
),
|
|
})),
|
|
|
|
deleteEdgeType: (id: string) =>
|
|
set((state) => {
|
|
// Remove edge type ID from tangible filters.relationTypes arrays
|
|
const updatedTangibles = state.tangibles.map((tangible) => {
|
|
if (tangible.mode === 'filter' && tangible.filters?.relationTypes) {
|
|
return {
|
|
...tangible,
|
|
filters: {
|
|
...tangible.filters,
|
|
relationTypes: tangible.filters.relationTypes.filter((typeId) => typeId !== id),
|
|
},
|
|
};
|
|
}
|
|
return tangible;
|
|
});
|
|
|
|
return {
|
|
edgeTypes: state.edgeTypes.filter((type) => type.id !== id),
|
|
tangibles: updatedTangibles,
|
|
};
|
|
}),
|
|
|
|
// Label operations
|
|
addLabel: (label: LabelConfig) =>
|
|
set((state) => ({
|
|
labels: [...state.labels, label],
|
|
})),
|
|
|
|
updateLabel: (id: string, updates: Partial<Omit<LabelConfig, 'id'>>) =>
|
|
set((state) => ({
|
|
labels: state.labels.map((label) =>
|
|
label.id === id ? { ...label, ...updates } : label
|
|
),
|
|
})),
|
|
|
|
deleteLabel: (id: string) =>
|
|
set((state) => {
|
|
// Remove label from all nodes and edges
|
|
const updatedNodes = state.nodes.map((node) => ({
|
|
...node,
|
|
data: {
|
|
...node.data,
|
|
labels: node.data.labels?.filter((labelId) => labelId !== id),
|
|
},
|
|
}));
|
|
|
|
const updatedEdges = state.edges.map((edge) => ({
|
|
...edge,
|
|
data: edge.data
|
|
? {
|
|
...edge.data,
|
|
labels: edge.data.labels?.filter((labelId) => labelId !== id),
|
|
}
|
|
: edge.data,
|
|
}));
|
|
|
|
// Remove label from tangible filterLabels arrays (old format) and filters.labels (new format)
|
|
const updatedTangibles = state.tangibles.map((tangible) => {
|
|
if (tangible.mode === 'filter') {
|
|
const updates: Partial<typeof tangible> = {};
|
|
|
|
// Handle old format
|
|
if (tangible.filterLabels) {
|
|
updates.filterLabels = tangible.filterLabels.filter((labelId) => labelId !== id);
|
|
}
|
|
|
|
// Handle new format
|
|
if (tangible.filters?.labels) {
|
|
updates.filters = {
|
|
...tangible.filters,
|
|
labels: tangible.filters.labels.filter((labelId) => labelId !== id),
|
|
};
|
|
}
|
|
|
|
return { ...tangible, ...updates };
|
|
}
|
|
return tangible;
|
|
});
|
|
|
|
return {
|
|
labels: state.labels.filter((label) => label.id !== id),
|
|
nodes: updatedNodes,
|
|
edges: updatedEdges,
|
|
tangibles: updatedTangibles,
|
|
};
|
|
}),
|
|
|
|
// Tangible operations
|
|
addTangible: (tangible: TangibleConfig) =>
|
|
set((state) => ({
|
|
tangibles: [...state.tangibles, tangible],
|
|
})),
|
|
|
|
updateTangible: (id: string, updates: Partial<Omit<TangibleConfig, 'id'>>) =>
|
|
set((state) => ({
|
|
tangibles: state.tangibles.map((tangible) =>
|
|
tangible.id === id ? { ...tangible, ...updates } : tangible
|
|
),
|
|
})),
|
|
|
|
deleteTangible: (id: string) =>
|
|
set((state) => ({
|
|
tangibles: state.tangibles.filter((tangible) => tangible.id !== id),
|
|
})),
|
|
|
|
setTangibles: (tangibles: TangibleConfig[]) =>
|
|
set({
|
|
tangibles,
|
|
}),
|
|
|
|
// Group operations
|
|
addGroup: (group: Group) =>
|
|
set((state) => ({
|
|
groups: [...state.groups, group],
|
|
})),
|
|
|
|
updateGroup: (id: string, updates: Partial<GroupData>) =>
|
|
set((state) => ({
|
|
groups: state.groups.map((group) =>
|
|
group.id === id
|
|
? { ...group, data: { ...group.data, ...updates } }
|
|
: group
|
|
),
|
|
})),
|
|
|
|
deleteGroup: (id: string, ungroupActors = true) =>
|
|
set((state) => {
|
|
if (ungroupActors) {
|
|
// Remove group and unparent actors (keep them at their current absolute positions)
|
|
// Note: parentId is a React Flow v11+ property for parent-child relationships
|
|
const group = state.groups.find((g) => g.id === id);
|
|
|
|
const updatedNodes = state.nodes.map((node) => {
|
|
const nodeWithParent = node as Actor & { parentId?: string; extent?: 'parent' };
|
|
if (nodeWithParent.parentId === id && group) {
|
|
// Convert relative position to absolute position
|
|
return {
|
|
...node,
|
|
parentId: undefined,
|
|
extent: undefined,
|
|
position: {
|
|
x: group.position.x + node.position.x,
|
|
y: group.position.y + node.position.y,
|
|
}
|
|
};
|
|
}
|
|
return node;
|
|
});
|
|
|
|
return {
|
|
groups: state.groups.filter((group) => group.id !== id),
|
|
nodes: updatedNodes,
|
|
};
|
|
} else {
|
|
// Delete group AND all actors inside
|
|
const nodeWithParent = (node: Actor) => node as Actor & { parentId?: string };
|
|
const updatedNodes = state.nodes.filter((node) => nodeWithParent(node).parentId !== id);
|
|
|
|
// Delete all edges connected to deleted actors
|
|
const deletedNodeIds = new Set(
|
|
state.nodes.filter((node) => nodeWithParent(node).parentId === id).map((node) => node.id)
|
|
);
|
|
const updatedEdges = state.edges.filter(
|
|
(edge) => !deletedNodeIds.has(edge.source) && !deletedNodeIds.has(edge.target)
|
|
);
|
|
|
|
return {
|
|
groups: state.groups.filter((group) => group.id !== id),
|
|
nodes: updatedNodes,
|
|
edges: updatedEdges,
|
|
};
|
|
}
|
|
}),
|
|
|
|
addActorToGroup: (actorId: string, groupId: string) =>
|
|
set((state) => {
|
|
const group = state.groups.find((g) => g.id === groupId);
|
|
const actor = state.nodes.find((n) => n.id === actorId);
|
|
if (!group || !actor) return state;
|
|
|
|
// Calculate new group bounds to include the actor
|
|
const actorWidth = 150; // Approximate node width
|
|
const actorHeight = 80; // Approximate node height
|
|
const padding = 20;
|
|
|
|
const actorAbsX = actor.position.x;
|
|
const actorAbsY = actor.position.y;
|
|
|
|
// Current group bounds
|
|
const groupX = group.position.x;
|
|
const groupY = group.position.y;
|
|
const groupWidth = typeof group.style?.width === 'number' ? group.style.width : 200;
|
|
const groupHeight = typeof group.style?.height === 'number' ? group.style.height : 200;
|
|
|
|
// Calculate new bounds
|
|
const newMinX = Math.min(groupX, actorAbsX - padding);
|
|
const newMinY = Math.min(groupY, actorAbsY - padding);
|
|
const newMaxX = Math.max(groupX + groupWidth, actorAbsX + actorWidth + padding);
|
|
const newMaxY = Math.max(groupY + groupHeight, actorAbsY + actorHeight + padding);
|
|
|
|
const newGroupX = newMinX;
|
|
const newGroupY = newMinY;
|
|
const newGroupWidth = newMaxX - newMinX;
|
|
const newGroupHeight = newMaxY - newMinY;
|
|
|
|
// Calculate position delta for existing child nodes
|
|
const deltaX = groupX - newGroupX;
|
|
const deltaY = groupY - newGroupY;
|
|
|
|
// Update actor to be child of group with relative position
|
|
const updatedNodes = state.nodes.map((node) => {
|
|
const nodeWithParent = node as Actor & { parentId?: string; extent?: 'parent' };
|
|
|
|
if (node.id === actorId) {
|
|
// New actor: convert to relative position
|
|
return {
|
|
...node,
|
|
parentId: groupId,
|
|
extent: 'parent' as const,
|
|
position: {
|
|
x: actorAbsX - newGroupX,
|
|
y: actorAbsY - newGroupY,
|
|
},
|
|
};
|
|
} else if (nodeWithParent.parentId === groupId) {
|
|
// Existing child: adjust position due to group position change
|
|
return {
|
|
...node,
|
|
position: {
|
|
x: node.position.x + deltaX,
|
|
y: node.position.y + deltaY,
|
|
},
|
|
};
|
|
}
|
|
return node;
|
|
});
|
|
|
|
// Update group's position, size, and actorIds
|
|
const updatedGroups = state.groups.map((g) =>
|
|
g.id === groupId
|
|
? {
|
|
...g,
|
|
position: { x: newGroupX, y: newGroupY },
|
|
style: {
|
|
...g.style,
|
|
width: newGroupWidth,
|
|
height: newGroupHeight,
|
|
},
|
|
data: {
|
|
...g.data,
|
|
actorIds: [...g.data.actorIds, actorId],
|
|
},
|
|
}
|
|
: g
|
|
);
|
|
|
|
return {
|
|
nodes: updatedNodes,
|
|
groups: updatedGroups,
|
|
};
|
|
}),
|
|
|
|
removeActorFromGroup: (actorId: string, groupId: string) =>
|
|
set((state) => {
|
|
const group = state.groups.find((g) => g.id === groupId);
|
|
|
|
// Update actor to remove parent and convert to absolute position
|
|
const updatedNodes = state.nodes.map((node) => {
|
|
if (node.id === actorId && group) {
|
|
return {
|
|
...node,
|
|
parentId: undefined,
|
|
extent: undefined,
|
|
position: {
|
|
x: group.position.x + node.position.x,
|
|
y: group.position.y + node.position.y,
|
|
},
|
|
};
|
|
}
|
|
return node;
|
|
});
|
|
|
|
// Update group's actorIds
|
|
const updatedGroups = state.groups.map((g) =>
|
|
g.id === groupId
|
|
? {
|
|
...g,
|
|
data: {
|
|
...g.data,
|
|
actorIds: g.data.actorIds.filter((id) => id !== actorId),
|
|
},
|
|
}
|
|
: g
|
|
);
|
|
|
|
return {
|
|
nodes: updatedNodes,
|
|
groups: updatedGroups,
|
|
};
|
|
}),
|
|
|
|
toggleGroupMinimized: (groupId: string) =>
|
|
set((state) => {
|
|
const group = state.groups.find((g) => g.id === groupId);
|
|
if (!group) return state;
|
|
|
|
const isMinimized = !group.data.minimized;
|
|
|
|
// Update group's minimized state and child nodes
|
|
const updatedGroups = state.groups.map((g) => {
|
|
if (g.id !== groupId) return g;
|
|
|
|
if (isMinimized) {
|
|
// Minimizing: store original dimensions in metadata
|
|
const currentWidth = typeof g.style?.width === 'number' ? g.style.width : 300;
|
|
const currentHeight = typeof g.style?.height === 'number' ? g.style.height : 200;
|
|
|
|
return {
|
|
...g,
|
|
data: {
|
|
...g.data,
|
|
minimized: true,
|
|
metadata: {
|
|
...g.data.metadata,
|
|
originalWidth: currentWidth,
|
|
originalHeight: currentHeight,
|
|
},
|
|
},
|
|
style: {
|
|
...g.style,
|
|
width: MINIMIZED_GROUP_WIDTH,
|
|
height: MINIMIZED_GROUP_HEIGHT,
|
|
},
|
|
};
|
|
} else {
|
|
// Maximizing: restore original dimensions from metadata
|
|
const originalWidth = (g.data.metadata?.originalWidth as number) || 300;
|
|
const originalHeight = (g.data.metadata?.originalHeight as number) || 200;
|
|
|
|
// Remove the stored dimensions from metadata
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const { originalWidth: _, originalHeight: __, ...restMetadata } = g.data.metadata || {};
|
|
|
|
return {
|
|
...g,
|
|
data: {
|
|
...g.data,
|
|
minimized: false,
|
|
metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined,
|
|
},
|
|
style: {
|
|
...g.style,
|
|
width: originalWidth,
|
|
height: originalHeight,
|
|
},
|
|
};
|
|
}
|
|
});
|
|
|
|
// When minimizing, hide child nodes; when maximizing, show them
|
|
const updatedNodes = state.nodes.map((node) => {
|
|
const nodeWithParent = node as Actor & { parentId?: string };
|
|
if (nodeWithParent.parentId === groupId) {
|
|
return {
|
|
...node,
|
|
hidden: isMinimized,
|
|
};
|
|
}
|
|
return node;
|
|
});
|
|
|
|
return {
|
|
groups: updatedGroups,
|
|
nodes: updatedNodes,
|
|
};
|
|
}),
|
|
|
|
// Utility operations
|
|
clearGraph: () =>
|
|
set({
|
|
nodes: [],
|
|
edges: [],
|
|
groups: [],
|
|
}),
|
|
|
|
setNodes: (nodes: Actor[]) =>
|
|
set({
|
|
nodes,
|
|
}),
|
|
|
|
setEdges: (edges: Relation[]) =>
|
|
set({
|
|
edges,
|
|
}),
|
|
|
|
setGroups: (groups: Group[]) =>
|
|
set({
|
|
groups,
|
|
}),
|
|
|
|
setNodeTypes: (nodeTypes: NodeTypeConfig[]) =>
|
|
set({
|
|
nodeTypes,
|
|
}),
|
|
|
|
setEdgeTypes: (edgeTypes: EdgeTypeConfig[]) =>
|
|
set({
|
|
edgeTypes,
|
|
}),
|
|
|
|
setLabels: (labels: LabelConfig[]) =>
|
|
set({
|
|
labels,
|
|
}),
|
|
|
|
// NOTE: exportToFile and importFromFile have been removed
|
|
// Import/export is now handled by the workspace-level system
|
|
// See: workspaceStore.importDocumentFromFile() and workspaceStore.exportDocument()
|
|
|
|
loadGraphState: (data) => {
|
|
// Build set of valid group IDs to check for orphaned parentId references
|
|
const validGroupIds = new Set((data.groups || []).map((g) => g.id));
|
|
|
|
// Sanitize nodes - remove parentId if the referenced group doesn't exist
|
|
// This handles timeline states created before groups feature was implemented
|
|
const sanitizedNodes = data.nodes.map((node) => {
|
|
const nodeWithParent = node as Actor & { parentId?: string; extent?: 'parent' };
|
|
if (nodeWithParent.parentId && !validGroupIds.has(nodeWithParent.parentId)) {
|
|
// Remove orphaned parent reference
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const { parentId, extent, ...cleanNode } = nodeWithParent;
|
|
return cleanNode as Actor;
|
|
}
|
|
return node;
|
|
});
|
|
|
|
// Apply tangible migration for backward compatibility
|
|
const migratedTangibles = data.tangibles
|
|
? migrateTangibleConfigs(data.tangibles)
|
|
: [];
|
|
|
|
// Apply handle migration for backward compatibility (remove old 4-position handles)
|
|
const migratedEdges = migrateRelationHandlesArray(data.edges);
|
|
|
|
// Atomic update: all state changes happen in a single set() call
|
|
set({
|
|
nodes: sanitizedNodes,
|
|
edges: migratedEdges,
|
|
groups: data.groups || [],
|
|
nodeTypes: data.nodeTypes,
|
|
edgeTypes: data.edgeTypes,
|
|
labels: data.labels || [],
|
|
tangibles: migratedTangibles,
|
|
});
|
|
},
|
|
}));
|