Skip to content

Commit

Permalink
feat: responsive object selections (#389)
Browse files Browse the repository at this point in the history
Closes #381
  • Loading branch information
stoffeastrom committed Mar 31, 2020
1 parent d3381b5 commit 63d8cc1
Show file tree
Hide file tree
Showing 10 changed files with 154 additions and 56 deletions.
9 changes: 4 additions & 5 deletions apis/nucleus/src/components/Cell.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable react/jsx-props-no-spreading */
import React, { forwardRef, useImperativeHandle, useEffect, useState, useContext, useReducer, useRef } from 'react';
import React, { forwardRef, useImperativeHandle, useEffect, useState, useContext, useReducer } from 'react';

import { Grid, Paper } from '@material-ui/core';
import { useTheme } from '@nebula.js/ui/theme';
Expand Down Expand Up @@ -168,11 +168,11 @@ const Cell = forwardRef(({ corona, model, initialSnOptions, initialError, onMoun

const { translator, language } = useContext(InstanceContext);
const theme = useTheme();
const cellRef = useRef();
const [cellRef] = useRect();
const [state, dispatch] = useReducer(contentReducer, initialState(initialError));
const [layout, { validating, canCancel, canRetry }, longrunning] = useLayout(model);
const [appLayout] = useAppLayout(app);
const [contentRef, contentRect, , contentNode] = useRect();
const [contentRef, contentRect] = useRect();
const [snOptions, setSnOptions] = useState(initialSnOptions);
const [selections] = useObjectSelections(app, model);

Expand Down Expand Up @@ -296,7 +296,6 @@ const Cell = forwardRef(({ corona, model, initialSnOptions, initialError, onMoun
snOptions={snOptions}
layout={layout}
appLayout={appLayout}
parentNode={contentNode}
/>
);
}
Expand All @@ -321,7 +320,7 @@ const Cell = forwardRef(({ corona, model, initialSnOptions, initialError, onMoun
...(state.longRunningQuery ? { opacity: '0.3' } : {}),
}}
>
<Header layout={layout} sn={state.sn}>
<Header layout={layout} sn={state.sn} anchorEl={cellRef.current}>
&nbsp;
</Header>
<Grid
Expand Down
76 changes: 62 additions & 14 deletions apis/nucleus/src/components/Header.jsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import React, { useEffect, useState } from 'react';

import { makeStyles, Grid, Typography } from '@material-ui/core';
import { makeStyles, Grid, Typography, Popover } from '@material-ui/core';
import SelectionToolbarWithDefault from './SelectionToolbar';
import useRect from '../hooks/useRect';

const ITEM_WIDTH = 32;
const ITEM_SPACING = 4;
const NUMBER_OF_ITEMS = 6;
const MIN_WIDTH = (ITEM_WIDTH + ITEM_SPACING) * NUMBER_OF_ITEMS;

const useStyles = makeStyles(theme => ({
containerStyle: {
flexGrow: 0,
},
containerTitleStyle: {
paddingBottom: theme.spacing(1),
},
itemsStyle: {
Expand All @@ -14,12 +22,14 @@ const useStyles = makeStyles(theme => ({
},
}));

const Header = ({ layout, sn }) => {
const Header = ({ layout, sn, anchorEl }) => {
const showTitle = layout && layout.showTitles && !!layout.title;
const showSubtitle = layout && layout.showTitles && !!layout.subtitle;
const showInSelectionActions = sn && layout && layout.qSelectionInfo && layout.qSelectionInfo.qInSelections;
const [items, setItems] = useState([]);
const { containerStyle, itemsStyle } = useStyles();
const { containerStyle, containerTitleStyle, itemsStyle } = useStyles();
const [containerRef, containerRect] = useRect();
const [shouldShowPopoverToolbar, setShouldShowPopoverToolbar] = useState(false);

useEffect(() => {
if (!sn || !sn.component || !sn.component.isHooked) {
Expand All @@ -28,8 +38,28 @@ const Header = ({ layout, sn }) => {
sn.component.observeActions(actions => setItems(actions));
}, [sn]);

useEffect(() => {
if (!containerRect) return;
const { width } = containerRect;
setShouldShowPopoverToolbar(width < MIN_WIDTH);
}, [containerRect]);

const Toolbar = (
<SelectionToolbarWithDefault
inline
layout={layout}
api={sn && sn.component.selections}
xItems={[...items, ...((sn && sn.selectionToolbar.items) || [])]}
/>
);

const classes = [containerStyle, ...(showTitle ? [containerTitleStyle] : [])];
const showPopoverToolbar =
(!showTitle && showInSelectionActions) || (shouldShowPopoverToolbar && showInSelectionActions);
const showToolbar = showInSelectionActions && !showPopoverToolbar;

return (
<Grid item container wrap="nowrap" className={containerStyle}>
<Grid ref={containerRef} item container wrap="nowrap" className={classes.join(' ')}>
<Grid item zeroMinWidth xs>
<Grid container wrap="nowrap" direction="column">
{showTitle && (
Expand All @@ -44,16 +74,34 @@ const Header = ({ layout, sn }) => {
)}
</Grid>
</Grid>
<Grid item className={itemsStyle}>
{showInSelectionActions && (
<SelectionToolbarWithDefault
inline
layout={layout}
api={sn.component.selections}
xItems={[...items, ...(sn.selectionToolbar.items || [])]}
/>
)}
</Grid>
{showToolbar && (
<Grid item className={itemsStyle}>
{Toolbar}
</Grid>
)}
{showPopoverToolbar && (
<Popover
open={showInSelectionActions}
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'top',
horizontal: 'center',
}}
transformOrigin={{
vertical: 'bottom',
horizontal: 'center',
}}
hideBackdrop
style={{ pointerEvents: 'none' }}
PaperProps={{
style: {
pointerEvents: 'auto',
},
}}
>
{Toolbar}
</Popover>
)}
</Grid>
);
};
Expand Down
11 changes: 8 additions & 3 deletions apis/nucleus/src/components/SelectionToolbar.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import React, { useContext } from 'react';

import { Grid } from '@material-ui/core';

import { close } from '@nebula.js/ui/icons/close';
import { tick } from '@nebula.js/ui/icons/tick';
import { clearSelections } from '@nebula.js/ui/icons/clear-selections';
Expand All @@ -8,11 +11,13 @@ import Item from './SelectionToolbarItem';

const SelectionToolbar = React.forwardRef(({ layout, items }, ref) => {
return (
<>
<Grid container spacing={0} wrap="nowrap">
{items.map((e, ix) => (
<Item key={e.key} layout={layout} item={e} ref={ix === 0 ? ref : null} />
<Grid item key={e.key}>
<Item key={e.key} layout={layout} item={e} ref={ix === 0 ? ref : null} />
</Grid>
))}
</>
</Grid>
);
});

Expand Down
61 changes: 54 additions & 7 deletions apis/nucleus/src/components/__tests__/header.spec.jsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,37 @@
import React from 'react';
import { create, act } from 'react-test-renderer';
import { Typography } from '@material-ui/core';
import { makeStyles, Grid, Typography } from '@material-ui/core';

const Popover = props => props.children;
const SelectionToolbar = () => 'selectiontoolbar';

const [{ default: Header }] = aw.mock(
[[require.resolve('../SelectionToolbar'), () => SelectionToolbar]],
['../Header']
);

describe('<Header />', () => {
let sandbox;
let renderer;
let render;
beforeEach(() => {
let Header;
let rect;
before(() => {
sandbox = sinon.createSandbox();
rect = { width: 900 };
[{ default: Header }] = aw.mock(
[
[require.resolve('../SelectionToolbar'), () => SelectionToolbar],
[require.resolve('../../hooks/useRect'), () => () => [() => {}, rect]],
[
require.resolve('@material-ui/core'),
() => ({
makeStyles,
Grid,
Typography,
Popover,
}),
],
],
['../Header']
);
});
beforeEach(() => {
render = async (layout, sn) => {
await act(async () => {
renderer = create(<Header layout={layout} sn={sn} />);
Expand Down Expand Up @@ -47,4 +64,34 @@ describe('<Header />', () => {
const types = renderer.root.findAllByType(SelectionToolbar);
expect(types).to.have.length(1);
});
it('should not render popover toolbar', async () => {
await render(
{ showTitles: true, title: 'popover', qSelectionInfo: { qInSelections: true } },
{ component: {}, selectionToolbar: {} }
);
expect(() => renderer.root.findByType(Popover)).to.throw();
});
it('should render popover toolbar if no title', async () => {
await render(
{ showTitles: false, title: 'popover', qSelectionInfo: { qInSelections: true } },
{ component: {}, selectionToolbar: {} }
);
renderer.root.findByType(Popover);
});
it('should not render popover toolbar if to small with title', async () => {
sandbox.stub(rect, 'width').value(20);
await render(
{ showTitles: true, title: 'popover', qSelectionInfo: { qInSelections: true } },
{ component: {}, selectionToolbar: {} }
);
renderer.root.findByType(Popover);
});
it('should not render popover toolbar if to small with no title', async () => {
sandbox.stub(rect, 'width').value(20);
await render(
{ showTitles: false, title: 'popover', qSelectionInfo: { qInSelections: true } },
{ component: {}, selectionToolbar: {} }
);
renderer.root.findByType(Popover);
});
});
21 changes: 12 additions & 9 deletions apis/nucleus/src/hooks/__tests__/use-rect.spec.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React, { forwardRef, useImperativeHandle } from 'react';
import { create, act } from 'react-test-renderer';

import useRect from '../useRect';

const TestHook = forwardRef(({ hook, hookProps = [] }, ref) => {
Expand Down Expand Up @@ -41,9 +40,10 @@ describe('useRect - window resize', () => {

it('should set rect', async () => {
await render();
ref.current.result[0]({
ref.current.result[0].current = {
getBoundingClientRect: () => ({ left: 100, top: 200, width: 300, height: 400 }),
});
};
renderer.update(<TestHook ref={ref} hook={useRect} />);
await act(async () => {
global.window.addEventListener.callArg(1);
});
Expand All @@ -52,9 +52,10 @@ describe('useRect - window resize', () => {

it('should cleanup listeners', async () => {
await render();
ref.current.result[0]({
ref.current.result[0].current = {
getBoundingClientRect: () => ({ left: 100, top: 200, width: 300, height: 400 }),
});
};
renderer.update(<TestHook ref={ref} hook={useRect} />);
renderer.unmount();
expect(removeEventListener.callCount).to.equal(1);
});
Expand Down Expand Up @@ -93,18 +94,20 @@ describe('useRect - resize observer', () => {

it('should set rect', async () => {
await render();
ref.current.result[0]({
ref.current.result[0].current = {
getBoundingClientRect: () => ({ left: 100, top: 200, width: 300, height: 400 }),
});
};
renderer.update(<TestHook ref={ref} hook={useRect} />);
handleResize();
expect(ref.current.result[1]).to.deep.equal({ left: 100, top: 200, width: 300, height: 400 });
});

it('should cleanup listeners', async () => {
await render();
ref.current.result[0]({
ref.current.result[0].current = {
getBoundingClientRect: () => ({ left: 100, top: 200, width: 300, height: 400 }),
});
};
renderer.update(<TestHook ref={ref} hook={useRect} />);
renderer.unmount();
expect(observer.unobserve.callCount).to.equal(1);
expect(observer.disconnect.callCount).to.equal(1);
Expand Down
30 changes: 13 additions & 17 deletions apis/nucleus/src/hooks/useRect.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,29 @@
import { useState, useCallback, useLayoutEffect } from 'react';
import { useState, useCallback, useLayoutEffect, useRef } from 'react';

export default function useRect() {
const [node, setNode] = useState();
const [rect, setRect] = useState();
const callbackRef = useCallback(ref => {
if (!ref) {
return;
}
setNode(ref);
}, []);
const handleResize = () => {
const { left, top, width, height } = node.getBoundingClientRect();
const ref = useRef();

const handleResize = useCallback(() => {
const { left, top, width, height } = ref.current.getBoundingClientRect();
setRect({ left, top, width, height });
};
}, [ref.current]);

useLayoutEffect(() => {
if (!node) return undefined;
if (!ref.current) return undefined;
if (typeof ResizeObserver === 'function') {
let resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(node);
resizeObserver.observe(ref.current);
return () => {
resizeObserver.unobserve(node);
resizeObserver.disconnect(node);
resizeObserver.unobserve(ref.current);
resizeObserver.disconnect(ref.current);
resizeObserver = null;
};
}
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [node]);
return [callbackRef, rect, node];
}, [ref.current]);
return [ref, rect, ref.current];
}
2 changes: 1 addition & 1 deletion commands/serve/web/components/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ export default function App({ app, info }) {
<VizContext.Provider value={vizContext}>
{sn ? (
<Grid container wrap="nowrap" style={{ height: '100%' }} spacing={SPACING}>
<Grid item xs>
<Grid item xs zeroMinWidth>
{objectListMode ? (
<Collection cache={currentId} types={[info.supernova.name]} />
) : (
Expand Down
Binary file modified test/mashup/__artifacts__/baseline/snaps-bar.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 63d8cc1

Please sign in to comment.