[Canvas][Layout Engine] Persistent grouping (#25854)

* Canvas element grouping (squashed)
fix: don't allow a tilted group
feat: allow rotation as long as it's a multiple of 90 degrees
same as previous but recursively
minor refactor - predicate
minor refactor - removed if
minor refactor - extracted groupedShape
minor refactor - extracted out magic; temporarily only enable 0 degree orientations
minor refactor - recurse
minor refactor - ignore annotations
minor refactor - simplify recursion
minor refactor - simplify recursion 2
removed key gestures
remove ancestors 1
remove ancestors 2
remove ancestors 3
remove ancestors 4

* lint

* separate elements and groups in storage

* renamed `element...` to `node...` (except exported names and action payload props, for now)

* be able to remove a group

* fixing group deletion

* not re-persisting unnecessarily

* persist / unpersist group right on the keyboard action

* solving inverted cascading for backward compatibility

* fix failing test case

* page cloning with group trees of arbitrary depth

* extracted out cloneSubgraphs

* basic copy-paste that handles a grouping tree of arbitrary depth

* fix: when legacy dataset doesn't have `groups`, it should avoid an `undefined`

* PR feedback: regularize group IDs

* lint: curlies

* schemaVersion bump

* copy/paste: restore selection and 10px offset of newly pasted element

* - fix regression with ad hoc groups
- fix copy/paste offsetting

* PR feedback: don't persist node `type` and group `expression`

* chore: remove commented out code

* chore: switch Object.entries to Object.keys

* fix: handle undefined value

this might be caused by a race condition or something. this fix is probably just covering up some other bug :(
This commit is contained in:
Robert Monfera 2018-12-19 00:25:02 +01:00 committed by Joe Fleming
parent 4d295c7923
commit 1e7740ae4a
18 changed files with 811 additions and 394 deletions

View file

@ -95,7 +95,7 @@ const handleMouseDown = (commit, e, isEditable) => {
const keyCode = key => (key === 'Meta' ? 'MetaLeft' : 'Key' + key.toUpperCase());
const isNotTextInput = ({ tagName, type }) => {
const isTextInput = ({ tagName, type }) => {
// input types that aren't variations of text input
const nonTextInputs = [
'button',
@ -111,11 +111,11 @@ const isNotTextInput = ({ tagName, type }) => {
switch (tagName.toLowerCase()) {
case 'input':
return nonTextInputs.includes(type);
return !nonTextInputs.includes(type);
case 'textarea':
return false;
default:
return true;
default:
return false;
}
};
@ -125,7 +125,7 @@ const handleKeyDown = (commit, e, isEditable, remove) => {
const { key, target } = e;
if (isEditable) {
if (isNotTextInput(target) && (key === 'Backspace' || key === 'Delete')) {
if ((key === 'Backspace' || key === 'Delete') && !isTextInput(target)) {
e.preventDefault();
remove();
} else if (!modifierKey(key)) {
@ -137,6 +137,16 @@ const handleKeyDown = (commit, e, isEditable, remove) => {
}
};
const handleKeyPress = (commit, e, isEditable) => {
const { key, target } = e;
const upcaseKey = key && key.toUpperCase();
if (isEditable && !isTextInput(target) && 'GU'.indexOf(upcaseKey) !== -1) {
commit('actionEvent', {
event: upcaseKey === 'G' ? 'group' : 'ungroup',
});
}
};
const handleKeyUp = (commit, { key }, isEditable) => {
if (isEditable && !modifierKey(key)) {
commit('keyboardEvent', {
@ -150,6 +160,7 @@ export const withEventHandlers = withHandlers({
onMouseDown: props => e => handleMouseDown(props.commit, e, props.isEditable),
onMouseMove: props => e => handleMouseMove(props.commit, e, props.isEditable),
onKeyDown: props => e => handleKeyDown(props.commit, e, props.isEditable, props.remove),
onKeyPress: props => e => handleKeyPress(props.commit, e, props.isEditable),
onKeyUp: props => e => handleKeyUp(props.commit, e, props.isEditable),
onWheel: props => e => handleWheel(props.commit, e, props.isEditable),
resetHandler: () => () => resetHandler(),

View file

@ -10,23 +10,25 @@ import { compose, withState, withProps } from 'recompose';
import { notify } from '../../lib/notify';
import { aeroelastic } from '../../lib/aeroelastic_kibana';
import { setClipboardData, getClipboardData } from '../../lib/clipboard';
import { removeElements, duplicateElement } from '../../state/actions/elements';
import { cloneSubgraphs } from '../../lib/clone_subgraphs';
import { removeElements, rawDuplicateElement } from '../../state/actions/elements';
import { getFullscreen, canUserWrite } from '../../state/selectors/app';
import { getElements, isWriteable } from '../../state/selectors/workpad';
import { getNodes, isWriteable } from '../../state/selectors/workpad';
import { flatten } from '../../lib/aeroelastic/functional';
import { withEventHandlers } from './event_handlers';
import { WorkpadPage as Component } from './workpad_page';
const mapStateToProps = (state, ownProps) => {
return {
isEditable: !getFullscreen(state) && isWriteable(state) && canUserWrite(state),
elements: getElements(state, ownProps.page.id),
elements: getNodes(state, ownProps.page.id),
};
};
const mapDispatchToProps = dispatch => {
return {
duplicateElement: pageId => selectedElement =>
dispatch(duplicateElement(selectedElement, pageId)),
rawDuplicateElement: pageId => (selectedElement, root) =>
dispatch(rawDuplicateElement(selectedElement, pageId, root)),
removeElements: pageId => elementIds => dispatch(removeElements(elementIds, pageId)),
};
};
@ -78,14 +80,35 @@ export const WorkpadPage = compose(
setUpdateCount,
page,
elements: pageElements,
rawDuplicateElement,
removeElements,
duplicateElement,
}) => {
const { shapes, selectedLeafShapes = [], cursor } = aeroelastic.getStore(
const { shapes, selectedPrimaryShapes = [], cursor } = aeroelastic.getStore(
page.id
).currentScene;
const elementLookup = new Map(pageElements.map(element => [element.id, element]));
const selectedElementIds = selectedLeafShapes;
const recurseGroupTree = shapeId => {
return [
shapeId,
...flatten(
shapes
.filter(s => s.parent === shapeId && s.type !== 'annotation')
.map(s => s.id)
.map(recurseGroupTree)
),
];
};
const selectedPrimaryShapeObjects = selectedPrimaryShapes.map(id =>
shapes.find(s => s.id === id)
);
const selectedPersistentPrimaryShapes = flatten(
selectedPrimaryShapeObjects.map(shape =>
shape.subtype === 'adHocGroup'
? shapes.filter(s => s.parent === shape.id && s.type !== 'annotation').map(s => s.id)
: [shape.id]
)
);
const selectedElementIds = flatten(selectedPersistentPrimaryShapes.map(recurseGroupTree));
const selectedElements = [];
const elements = shapes.map(shape => {
let element = null;
@ -114,21 +137,25 @@ export const WorkpadPage = compose(
},
copyElements: () => {
if (selectedElements.length) {
setClipboardData(selectedElements);
setClipboardData({ selectedElements, rootShapes: selectedPrimaryShapes });
notify.success('Copied element to clipboard');
}
},
cutElements: () => {
if (selectedElements.length) {
setClipboardData(selectedElements);
setClipboardData({ selectedElements, rootShapes: selectedPrimaryShapes });
removeElements(page.id)(selectedElementIds);
notify.success('Copied element to clipboard');
}
},
pasteElements: () => {
const elements = JSON.parse(getClipboardData());
if (elements) {
elements.map(element => duplicateElement(page.id)(element));
const { selectedElements, rootShapes } = JSON.parse(getClipboardData());
const indices = rootShapes.map(r => selectedElements.findIndex(s => s.id === r));
const clonedElements = selectedElements && cloneSubgraphs(selectedElements);
if (clonedElements) {
clonedElements.map((element, index) =>
rawDuplicateElement(page.id)(element, indices.indexOf(index) >= 0)
);
}
},
};

View file

@ -65,6 +65,7 @@ export class WorkpadPage extends PureComponent {
isEditable,
onDoubleClick,
onKeyDown,
onKeyPress,
onKeyUp,
onMouseDown,
onMouseMove,
@ -108,6 +109,7 @@ export class WorkpadPage extends PureComponent {
onMouseUp={onMouseUp}
onMouseDown={onMouseDown}
onKeyDown={onKeyDown}
onKeyPress={onKeyPress}
onKeyUp={onKeyUp}
onDoubleClick={onDoubleClick}
onAnimationEnd={onAnimationEnd}
@ -150,7 +152,7 @@ export class WorkpadPage extends PureComponent {
default:
return [];
}
} else if (element.subtype !== 'adHocGroup') {
} else if (element.type !== 'group') {
return <ElementWrapper key={element.id} element={element} />;
}
})

View file

@ -17,13 +17,17 @@ const groupName = 'group';
const groupResize = true;
const guideDistance = 3;
const hoverAnnotationName = 'hoverAnnotation';
const hoverLift = 100;
const intraGroupManipulation = false;
const intraGroupSnapOnly = false;
const persistentGroupName = 'persistentGroup';
const resizeAnnotationOffset = 0;
const resizeAnnotationOffsetZ = 0.1; // causes resize markers to be slightly above the shape plane
const resizeAnnotationSize = 10;
const resizeAnnotationConnectorOffset = 0; //resizeAnnotationSize //+ 2
const resizeConnectorName = 'resizeConnector';
const rotateAnnotationOffset = 12;
const rotationEpsilon = 0.001;
const rotationHandleName = 'rotationHandle';
const rotationHandleSize = 14;
const resizeHandleName = 'resizeHandle';
@ -43,8 +47,11 @@ module.exports = {
groupResize,
guideDistance,
hoverAnnotationName,
hoverLift,
intraGroupManipulation,
intraGroupSnapOnly,
minimumElementSize,
persistentGroupName,
resizeAnnotationOffset,
resizeAnnotationOffsetZ,
resizeAnnotationSize,
@ -52,6 +59,7 @@ module.exports = {
resizeConnectorName,
resizeHandleName,
rotateAnnotationOffset,
rotationEpsilon,
rotateSnapInPixels,
rotationHandleName,
rotationHandleSize,

View file

@ -87,13 +87,21 @@ const not = fun => (...args) => !fun(...args);
const removeDuplicates = (idFun, a) =>
a.filter((d, i) => a.findIndex(s => idFun(s) === idFun(d)) === i);
const epsilon = 1 / 1000;
const applyTolerance = d => Math.round(d / epsilon) * epsilon;
const arrayToMap = a => Object.assign({}, ...a.map(d => ({ [d]: true })));
const subMultitree = (pk, fk, elements, roots) => {
const getSubgraphs = roots => {
const children = flatten(roots.map(r => elements.filter(e => fk(e) === pk(r))));
return [...roots, ...(children.length && getSubgraphs(children, elements))];
};
return getSubgraphs(roots);
};
module.exports = {
applyTolerance,
arrayToMap,
disjunctiveUnion,
flatten,
subMultitree,
identity,
log,
map,

View file

@ -85,8 +85,7 @@ const shapesAt = (shapes, { x, y }) =>
const getExtremum = (transformMatrix, a, b) =>
matrix.normalize(matrix.mvMultiply(transformMatrix, [a, b, 0, 1]));
const landmarkPoint = ({ localTransformMatrix, a, b }, k, l) =>
getExtremum(localTransformMatrix, k * a, l * b);
const landmarkPoint = (a, b, transformMatrix, k, l) => getExtremum(transformMatrix, k * a, l * b);
module.exports = {
landmarkPoint,

View file

@ -47,10 +47,6 @@ const metaHeld = select(appleKeyboard ? e => e.metaKey : e => e.altKey)(keyFromM
const optionHeld = select(appleKeyboard ? e => e.altKey : e => e.ctrlKey)(keyFromMouse);
const shiftHeld = select(e => e.shiftKey)(keyFromMouse);
// retaining this for now to avoid removing dependent inactive code `keyTransformGesture` from layout.js
// todo remove this, and `keyTransformGesture` from layout.js and do accessibility outside the layout engine
const pressedKeys = () => ({});
const cursorPosition = selectReduce((previous, position) => position || previous, { x: 0, y: 0 })(
rawCursorPosition
);
@ -75,7 +71,12 @@ const mouseIsDown = selectReduce(
false
)(mouseButtonEvent);
const gestureEnd = select(next => next && next.event === 'mouseUp')(mouseButtonEvent);
const gestureEnd = select(
action =>
action &&
(action.type === 'actionEvent' ||
(action.type === 'mouseEvent' && action.payload.event === 'mouseUp'))
)(primaryUpdate);
/**
* mouseButtonStateTransitions
@ -138,7 +139,12 @@ const dragVector = select(({ buttonState, downX, downY }, { x, y }) => ({
y1: y,
}))(mouseButtonState, cursorPosition);
const actionEvent = select(action => (action.type === 'actionEvent' ? action.payload : null))(
primaryUpdate
);
module.exports = {
actionEvent,
dragging,
dragVector,
cursorPosition,
@ -148,6 +154,5 @@ module.exports = {
mouseDowned,
mouseIsDown,
optionHeld,
pressedKeys,
shiftHeld,
};

File diff suppressed because it is too large Load diff

View file

@ -311,11 +311,10 @@ const applyTransforms = (transforms, previousTransformMatrix) =>
const clamp = (low, high, value) => Math.min(high, Math.max(low, value));
// todo turn it into returning radians rather than degrees
const matrixToAngle = transformMatrix => {
// clamping is needed, otherwise inevitable floating point inaccuracies can cause NaN
const z0 = (Math.acos(clamp(-1, 1, transformMatrix[0])) * 180) / Math.PI;
const z1 = (Math.asin(clamp(-1, 1, transformMatrix[1])) * 180) / Math.PI;
const z0 = Math.acos(clamp(-1, 1, transformMatrix[0]));
const z1 = Math.asin(clamp(-1, 1, transformMatrix[1]));
return z1 > 0 ? z0 : -z0;
};

View file

@ -26,6 +26,7 @@ export const aeroelastic = {
shapeAdditions: nextScene.shapes,
primaryUpdate,
currentScene: nextScene,
configuration: nextScene.configuration,
}))(aero.layout.nextScene, aero.layout.primaryUpdate);
stores.get(page).setUpdater(updateScene);

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { arrayToMap } from './aeroelastic/functional';
import { getId } from './get_id';
export const cloneSubgraphs = nodes => {
const idMap = arrayToMap(nodes.map(n => n.id));
// We simultaneously provide unique id values for all elements (across all pages)
// AND ensure that parent-child relationships are retained (via matching id values within page)
Object.keys(idMap).forEach(key => (idMap[key] = getId(key.split('-')[0]))); // new group names to which we can map
// must return elements in the same order, for several reasons
const newNodes = nodes.map(element => ({
...element,
id: idMap[element.id],
position: {
...element.position,
parent: element.position.parent ? idMap[element.position.parent] : null,
},
}));
return newNodes;
};

View file

@ -10,11 +10,12 @@ import { createThunk } from 'redux-thunks';
import { set, del } from 'object-path-immutable';
import { get, pick, cloneDeep, without } from 'lodash';
import { toExpression, safeElementFromExpression } from '@kbn/interpreter/common';
import { getPages, getElementById, getSelectedPageIndex } from '../selectors/workpad';
import { getPages, getNodeById, getNodes, getSelectedPageIndex } from '../selectors/workpad';
import { getValue as getResolvedArgsValue } from '../selectors/resolved_args';
import { getDefaultElement } from '../defaults';
import { notify } from '../../lib/notify';
import { runInterpreter } from '../../lib/run_interpreter';
import { subMultitree } from '../../lib/aeroelastic/functional';
import { selectElement } from './transient';
import * as args from './resolved_args';
@ -213,12 +214,48 @@ export const duplicateElement = createThunk(
}
);
export const rawDuplicateElement = createThunk(
'rawDuplicateElement',
({ dispatch, type }, element, pageId, root) => {
const newElement = cloneDeep(element);
// move the root element so users can see that it was added
newElement.position.top = newElement.position.top + 10;
newElement.position.left = newElement.position.left + 10;
const _rawDuplicateElement = createAction(type);
dispatch(_rawDuplicateElement({ pageId, element: newElement }));
// refresh all elements if there's a filter, otherwise just render the new element
if (element.filter) {
dispatch(fetchAllRenderables());
} else {
dispatch(fetchRenderable(newElement));
}
// select the new element
if (root) {
window.setTimeout(() => dispatch(selectElement(newElement.id)));
}
}
);
export const removeElements = createThunk(
'removeElements',
({ dispatch, getState }, elementIds, pageId) => {
({ dispatch, getState }, rootElementIds, pageId) => {
const state = getState();
// todo consider doing the group membership collation in aeroelastic, or the Redux reducer, when adding templates
const allElements = getNodes(state, pageId);
const allRoots = rootElementIds.map(id => allElements.find(e => id === e.id));
if (allRoots.indexOf(undefined) !== -1) {
throw new Error('Some of the elements to be deleted do not exist');
}
const elementIds = subMultitree(e => e.id, e => e.position.parent, allElements, allRoots).map(
e => e.id
);
const shouldRefresh = elementIds.some(elementId => {
const element = getElementById(getState(), elementId, pageId);
const filterIsApplied = element.filter != null && element.filter.length > 0;
const element = getNodeById(state, elementId, pageId);
const filterIsApplied = element.filter && element.filter.length > 0;
return filterIsApplied;
});
@ -253,7 +290,7 @@ function setExpressionFn({ dispatch, getState }, expression, elementId, pageId,
dispatch(_setExpression({ expression, elementId, pageId }));
// read updated element from state and fetch renderable
const updatedElement = getElementById(getState(), elementId, pageId);
const updatedElement = getNodeById(getState(), elementId, pageId);
if (doRender === true) {
dispatch(fetchRenderable(updatedElement));
}
@ -369,7 +406,7 @@ export const deleteArgumentAtIndex = createThunk('deleteArgumentAtIndex', ({ dis
payload: element defaults. Eg {expression: 'foo'}
*/
export const addElement = createThunk('addElement', ({ dispatch }, pageId, element) => {
const newElement = { ...getDefaultElement(), ...getBareElement(element) };
const newElement = { ...getDefaultElement(), ...getBareElement(element, true) };
if (element.width) {
newElement.position.width = element.width;
}

View file

@ -16,6 +16,7 @@ export const getDefaultElement = () => {
height: 300,
width: 500,
angle: 0,
type: 'element',
},
expression: `
demodata
@ -34,6 +35,7 @@ export const getDefaultPage = () => {
},
transition: {},
elements: [],
groups: [],
};
};

View file

@ -22,11 +22,11 @@ export const getInitialState = path => {
// values in resolvedArgs should live under a unique index so they can be looked up.
// The ID of the element is a great example.
// In there will live an object with a status (string), value (any), and error (Error) property.
// If the state is 'error', the error proprty will be the error object, the value will not change
// If the state is 'error', the error property will be the error object, the value will not change
// See the resolved_args reducer for more information.
},
persistent: {
schemaVersion: 1,
schemaVersion: 2,
workpad: getDefaultWorkpad(),
},
};

View file

@ -7,11 +7,13 @@
import { shallowEqual } from 'recompose';
import { aeroelastic as aero } from '../../lib/aeroelastic_kibana';
import { matrixToAngle } from '../../lib/aeroelastic/matrix';
import { identity } from '../../lib/aeroelastic/functional';
import { arrayToMap, identity } from '../../lib/aeroelastic/functional';
import defaultConfiguration from '../../lib/aeroelastic/config';
import {
addElement,
removeElements,
duplicateElement,
rawDuplicateElement,
elementLayer,
setMultiplePositions,
fetchAllRenderables,
@ -21,7 +23,9 @@ import { selectElement } from '../actions/transient';
import { addPage, removePage, duplicatePage } from '../actions/pages';
import { appReady } from '../actions/app';
import { setWorkpad } from '../actions/workpad';
import { getElements, getPages, getSelectedPage, getSelectedElement } from '../selectors/workpad';
import { getNodes, getPages, getSelectedPage, getSelectedElement } from '../selectors/workpad';
const isGroupId = id => id.startsWith(defaultConfiguration.groupName);
/**
* elementToShape
@ -56,16 +60,31 @@ const elementToShape = (element, i) => {
aero.matrix.translate(cx, cy, z),
aero.matrix.rotateZ(angleRadians)
);
const isGroup = isGroupId(element.id);
const parent = (element.position && element.position.parent) || null; // reserved for hierarchical (tree shaped) grouping
return {
id: element.id,
parent: null, // reserved for hierarchical (tree shaped) grouping,
localTransformMatrix: transformMatrix,
type: isGroup ? 'group' : 'rectangleElement',
subtype: isGroup ? 'persistentGroup' : '',
parent,
transformMatrix,
a, // we currently specify half-width, half-height as it leads to
b, // more regular math (like ellipsis radii rather than diameters)
};
};
const shapeToElement = shape => {
return {
left: shape.transformMatrix[12] - shape.a,
top: shape.transformMatrix[13] - shape.b,
width: shape.a * 2,
height: shape.b * 2,
angle: Math.round((matrixToAngle(shape.transformMatrix) * 180) / Math.PI),
parent: shape.parent || null,
type: shape.type === 'group' ? 'group' : 'element',
};
};
const updateGlobalPositions = (setMultiplePositions, { shapes, gestureEnd }, unsortedElements) => {
const ascending = (a, b) => (a.id < b.id ? -1 : 1);
const relevant = s => s.type !== 'annotation' && s.subtype !== 'adHocGroup';
@ -84,16 +103,12 @@ const updateGlobalPositions = (setMultiplePositions, { shapes, gestureEnd }, uns
width: elemPos.width,
height: elemPos.height,
angle: Math.round(elemPos.angle),
type: elemPos.type,
parent: elemPos.parent || null,
};
// cast shape into element-like object to compare
const newProps = {
left: shape.transformMatrix[12] - shape.a,
top: shape.transformMatrix[13] - shape.b,
width: shape.a * 2,
height: shape.b * 2,
angle: Math.round(matrixToAngle(shape.transformMatrix)),
};
const newProps = shapeToElement(shape);
if (1 / newProps.angle === -Infinity) {
newProps.angle = 0;
@ -111,6 +126,22 @@ const updateGlobalPositions = (setMultiplePositions, { shapes, gestureEnd }, uns
};
const id = element => element.id;
// check for duplication
const deduped = a => a.filter((d, i) => a.indexOf(d) === i);
const idDuplicateCheck = groups => {
if (deduped(groups.map(g => g.id)).length !== groups.length) {
throw new Error('Duplicate element encountered');
}
};
const missingParentCheck = groups => {
const idMap = arrayToMap(groups.map(g => g.id));
groups.forEach(g => {
if (g.parent && !idMap[g.parent]) {
g.parent = null;
}
});
};
export const aeroelastic = ({ dispatch, getState }) => {
// When aeroelastic updates an element, we need to dispatch actions to notify redux of the changes
@ -124,41 +155,96 @@ export const aeroelastic = ({ dispatch, getState }) => {
// read current data out of redux
const page = getSelectedPage(getState());
const elements = getElements(getState(), page);
const elements = getNodes(getState(), page);
const selectedElement = getSelectedElement(getState());
const shapes = nextScene.shapes;
const persistableGroups = shapes.filter(s => s.subtype === 'persistentGroup');
const persistedGroups = elements.filter(e => isGroupId(e.id));
idDuplicateCheck(persistableGroups);
idDuplicateCheck(persistedGroups);
persistableGroups.forEach(g => {
if (
!persistedGroups.find(p => {
if (!p.id) {
throw new Error('Element has no id');
}
return p.id === g.id;
})
) {
const partialElement = {
id: g.id,
filter: undefined,
expression: 'shape fill="rgba(255,255,255,0)" | render',
position: {
...shapeToElement(g),
},
};
dispatch(addElement(page, partialElement));
}
});
const elementsToRemove = persistedGroups.filter(
// list elements for removal if they're not in the persistable set, or if there's no longer an associated element
// the latter of which shouldn't happen, so it's belts and braces
p =>
!persistableGroups.find(g => p.id === g.id) ||
!elements.find(e => e.position.parent === p.id)
);
updateGlobalPositions(
positions => dispatch(setMultiplePositions(positions.map(p => ({ ...p, pageId: page })))),
nextScene,
elements
);
if (elementsToRemove.length) {
// remove elements for groups that were ungrouped
dispatch(removeElements(elementsToRemove.map(e => e.id), page));
}
// set the selected element on the global store, if one element is selected
const selectedShape = nextScene.selectedPrimaryShapes[0];
if (nextScene.selectedShapes.length === 1) {
if (selectedShape && selectedShape !== selectedElement) {
if (nextScene.selectedShapes.length === 1 && !isGroupId(selectedShape)) {
if (selectedShape !== (selectedElement && selectedElement.id)) {
dispatch(selectElement(selectedShape));
}
} else {
// otherwise, clear the selected element state
dispatch(selectElement(null));
// even for groups - TODO add handling for groups, esp. persistent groups - common styling etc.
if (selectedElement) {
const shape = shapes.find(s => s.id === selectedShape);
// don't reset if eg. we're in the middle of converting an ad hoc group into a persistent one
if (!shape || shape.subtype !== 'adHocGroup') {
dispatch(selectElement(null));
}
}
}
};
const createStore = page =>
aero.createStore(
{ shapeAdditions: [], primaryUpdate: null, currentScene: { shapes: [] } },
{
shapeAdditions: [],
primaryUpdate: null,
currentScene: { shapes: [] },
configuration: defaultConfiguration,
},
onChangeCallback,
page
);
const populateWithElements = page =>
aero.commit(
page,
'restateShapesEvent',
{ newShapes: getElements(getState(), page).map(elementToShape) },
{ silent: true }
);
const populateWithElements = page => {
const newShapes = getNodes(getState(), page)
.map(elementToShape)
// filtering to eliminate residual element of a possible group that had been deleted in Redux
.filter((d, i, a) => !isGroupId(d.id) || a.find(s => s.parent === d.id));
idDuplicateCheck(newShapes);
missingParentCheck(newShapes);
return aero.commit(page, 'restateShapesEvent', { newShapes }, { silent: true });
};
const selectShape = (page, id) => {
aero.commit(page, 'shapeSelect', { shapes: [id] });
@ -171,7 +257,7 @@ export const aeroelastic = ({ dispatch, getState }) => {
return next => action => {
// get information before the state is changed
const prevPage = getSelectedPage(getState());
const prevElements = getElements(getState(), prevPage);
const prevElements = getNodes(getState(), prevPage);
if (action.type === setWorkpad.toString()) {
const pages = action.payload.pages;
@ -247,10 +333,11 @@ export const aeroelastic = ({ dispatch, getState }) => {
case removeElements.toString():
case addElement.toString():
case duplicateElement.toString():
case rawDuplicateElement.toString():
case elementLayer.toString():
case setMultiplePositions.toString():
const page = getSelectedPage(getState());
const elements = getElements(getState(), page);
const elements = getNodes(getState(), page);
// TODO: add a better check for elements changing, including their position, ids, etc.
const shouldResetState =

View file

@ -9,44 +9,51 @@ import { assign, push, del, set } from 'object-path-immutable';
import { get } from 'lodash';
import * as actions from '../actions/elements';
const getLocation = type => (type === 'group' ? 'groups' : 'elements');
const getLocationFromIds = (workpadState, pageId, nodeId) => {
const page = workpadState.pages.find(p => p.id === pageId);
const groups = page == null ? [] : page.groups || [];
return groups.find(e => e.id === nodeId) ? 'groups' : 'elements';
};
function getPageIndexById(workpadState, pageId) {
return get(workpadState, 'pages', []).findIndex(page => page.id === pageId);
}
function getElementIndexById(page, elementId) {
return page.elements.findIndex(element => element.id === elementId);
function getNodeIndexById(page, nodeId, location) {
return page[location].findIndex(node => node.id === nodeId);
}
function assignElementProperties(workpadState, pageId, elementId, props) {
function assignNodeProperties(workpadState, pageId, nodeId, props) {
const pageIndex = getPageIndexById(workpadState, pageId);
const elementsPath = ['pages', pageIndex, 'elements'];
const elementIndex = get(workpadState, elementsPath, []).findIndex(
element => element.id === elementId
);
const location = getLocationFromIds(workpadState, pageId, nodeId);
const nodesPath = ['pages', pageIndex, location];
const nodeIndex = get(workpadState, nodesPath, []).findIndex(node => node.id === nodeId);
if (pageIndex === -1 || elementIndex === -1) {
if (pageIndex === -1 || nodeIndex === -1) {
return workpadState;
}
// remove any AST value from the element caused by https://github.com/elastic/kibana-canvas/issues/260
// TODO: remove this after a bit of time
const cleanWorkpadState = del(workpadState, elementsPath.concat([elementIndex, 'ast']));
const cleanWorkpadState = del(workpadState, nodesPath.concat([nodeIndex, 'ast']));
return assign(cleanWorkpadState, elementsPath.concat(elementIndex), props);
return assign(cleanWorkpadState, nodesPath.concat(nodeIndex), props);
}
function moveElementLayer(workpadState, pageId, elementId, movement) {
function moveNodeLayer(workpadState, pageId, nodeId, movement, location) {
const pageIndex = getPageIndexById(workpadState, pageId);
const elementIndex = getElementIndexById(workpadState.pages[pageIndex], elementId);
const elements = get(workpadState, ['pages', pageIndex, 'elements']);
const from = elementIndex;
const nodeIndex = getNodeIndexById(workpadState.pages[pageIndex], nodeId, location);
const nodes = get(workpadState, ['pages', pageIndex, location]);
const from = nodeIndex;
const to = (function() {
if (movement < Infinity && movement > -Infinity) {
return elementIndex + movement;
return nodeIndex + movement;
}
if (movement === Infinity) {
return elements.length - 1;
return nodes.length - 1;
}
if (movement === -Infinity) {
return 0;
@ -54,52 +61,88 @@ function moveElementLayer(workpadState, pageId, elementId, movement) {
throw new Error('Invalid element layer movement');
})();
if (to > elements.length - 1 || to < 0) {
if (to > nodes.length - 1 || to < 0) {
return workpadState;
}
// Common
const newElements = elements.slice(0);
newElements.splice(to, 0, newElements.splice(from, 1)[0]);
const newNodes = nodes.slice(0);
newNodes.splice(to, 0, newNodes.splice(from, 1)[0]);
return set(workpadState, ['pages', pageIndex, 'elements'], newElements);
return set(workpadState, ['pages', pageIndex, location], newNodes);
}
const trimPosition = ({ left, top, width, height, angle, parent }) => ({
left,
top,
width,
height,
angle,
parent,
});
const trimElement = ({ id, position, expression, filter }) => ({
id,
position: trimPosition(position),
...(position.type !== 'group' && { expression }),
...(filter !== void 0 && { filter }),
});
export const elementsReducer = handleActions(
{
// TODO: This takes the entire element, which is not necessary, it could just take the id.
[actions.setExpression]: (workpadState, { payload }) => {
const { expression, pageId, elementId } = payload;
return assignElementProperties(workpadState, pageId, elementId, { expression });
return assignNodeProperties(workpadState, pageId, elementId, { expression });
},
[actions.setFilter]: (workpadState, { payload }) => {
const { filter, pageId, elementId } = payload;
return assignElementProperties(workpadState, pageId, elementId, { filter });
return assignNodeProperties(workpadState, pageId, elementId, { filter });
},
[actions.setMultiplePositions]: (workpadState, { payload }) =>
payload.repositionedElements.reduce(
(previousWorkpadState, { position, pageId, elementId }) =>
assignElementProperties(previousWorkpadState, pageId, elementId, { position }),
assignNodeProperties(previousWorkpadState, pageId, elementId, {
position: trimPosition(position),
}),
workpadState
),
[actions.elementLayer]: (workpadState, { payload: { pageId, elementId, movement } }) => {
return moveElementLayer(workpadState, pageId, elementId, movement);
const location = getLocationFromIds(workpadState, pageId, elementId);
return moveNodeLayer(workpadState, pageId, elementId, movement, location);
},
[actions.addElement]: (workpadState, { payload: { pageId, element } }) => {
const pageIndex = getPageIndexById(workpadState, pageId);
if (pageIndex < 0) {
return workpadState;
}
return push(workpadState, ['pages', pageIndex, 'elements'], element);
return push(
workpadState,
['pages', pageIndex, getLocation(element.position.type)],
trimElement(element)
);
},
[actions.duplicateElement]: (workpadState, { payload: { pageId, element } }) => {
const pageIndex = getPageIndexById(workpadState, pageId);
if (pageIndex < 0) {
return workpadState;
}
return push(workpadState, ['pages', pageIndex, 'elements'], element);
return push(
workpadState,
['pages', pageIndex, getLocation(element.position.type)],
trimElement(element)
);
},
[actions.rawDuplicateElement]: (workpadState, { payload: { pageId, element } }) => {
const pageIndex = getPageIndexById(workpadState, pageId);
if (pageIndex < 0) {
return workpadState;
}
return push(
workpadState,
['pages', pageIndex, getLocation(element.position.type)],
trimElement(element)
);
},
[actions.removeElements]: (workpadState, { payload: { pageId, elementIds } }) => {
const pageIndex = getPageIndexById(workpadState, pageId);
@ -107,14 +150,19 @@ export const elementsReducer = handleActions(
return workpadState;
}
const elementIndices = elementIds
.map(elementId => getElementIndexById(workpadState.pages[pageIndex], elementId))
.sort((a, b) => b - a); // deleting from end toward beginning, otherwise indices will become off - todo fuse loops!
const nodeIndices = elementIds
.map(nodeId => {
const location = getLocationFromIds(workpadState, pageId, nodeId);
return {
location,
index: getNodeIndexById(workpadState.pages[pageIndex], nodeId, location),
};
})
.sort((a, b) => b.index - a.index); // deleting from end toward beginning, otherwise indices will become off - todo fuse loops!
return elementIndices.reduce(
(state, nextElementIndex) => del(state, ['pages', pageIndex, 'elements', nextElementIndex]),
workpadState
);
return nodeIndices.reduce((state, { location, index }) => {
return del(state, ['pages', pageIndex, location, index]);
}, workpadState);
},
},
{}

View file

@ -6,6 +6,7 @@
import { handleActions } from 'redux-actions';
import { set, del, insert } from 'object-path-immutable';
import { cloneSubgraphs } from '../../lib/clone_subgraphs';
import { getId } from '../../lib/get_id';
import { routerProvider } from '../../lib/router_provider';
import { getDefaultPage } from '../defaults';
@ -29,10 +30,15 @@ function addPage(workpadState, payload, srcIndex = workpadState.pages.length - 1
function clonePage(page) {
// TODO: would be nice if we could more reliably know which parameters need to get a unique id
// this makes a pretty big assumption about the shape of the page object
const elements = page.elements;
const groups = page.groups;
const nodes = elements.concat(groups);
const newNodes = cloneSubgraphs(nodes);
return {
...page,
id: getId('page'),
elements: page.elements.map(element => ({ ...element, id: getId('element') })),
groups: newNodes.filter(n => n.position.type === 'group'),
elements: newNodes.filter(n => n.position.type !== 'group'),
};
}

View file

@ -102,7 +102,42 @@ export function getElements(state, pageId, withAst = true) {
return [];
}
// explicitely strip the ast, basically a fix for corrupted workpads
// explicitly strip the ast, basically a fix for corrupted workpads
// due to https://github.com/elastic/kibana-canvas/issues/260
// TODO: remove this once it's been in the wild a bit
if (!withAst) {
return elements.map(el => omit(el, ['ast']));
}
return elements.map(appendAst);
}
const augment = type => n => ({
...n,
position: { ...n.position, type },
...(type === 'group' && { expression: 'shape fill="rgba(255,255,255,0)" | render' }), // fixme unify with mw/aeroelastic
});
const getNodesOfPage = page =>
get(page, 'elements')
.map(augment('element'))
.concat((get(page, 'groups') || []).map(augment('group')));
// todo unify or DRY up with `getElements`
export function getNodes(state, pageId, withAst = true) {
const id = pageId || getSelectedPage(state);
if (!id) {
return [];
}
const page = getPageById(state, id);
const elements = getNodesOfPage(page);
if (!elements) {
return [];
}
// explicitly strip the ast, basically a fix for corrupted workpads
// due to https://github.com/elastic/kibana-canvas/issues/260
// TODO: remove this once it's been in the wild a bit
if (!withAst) {
@ -113,12 +148,21 @@ export function getElements(state, pageId, withAst = true) {
}
export function getElementById(state, id, pageId) {
// do we need to pass a truthy empty array instead of `true`?
const element = getElements(state, pageId, []).find(el => el.id === id);
if (element) {
return appendAst(element);
}
}
export function getNodeById(state, id, pageId) {
// do we need to pass a truthy empty array instead of `true`?
const group = getNodes(state, pageId, []).find(el => el.id === id);
if (group) {
return appendAst(group);
}
}
export function getResolvedArgs(state, elementId, path) {
if (!elementId) {
return;