mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[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:
parent
4d295c7923
commit
1e7740ae4a
18 changed files with 811 additions and 394 deletions
|
@ -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(),
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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} />;
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
25
x-pack/plugins/canvas/public/lib/clone_subgraphs.js
Normal file
25
x-pack/plugins/canvas/public/lib/clone_subgraphs.js
Normal 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;
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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: [],
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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(),
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
{}
|
||||
|
|
|
@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue