Fix Today button when all items in the past

fixes ADMIN-1150

test plan:
  - planner student with no items
  - planner student with only past items
    > expect the most recent item to be scrolled into view
      and get focus and a flash alert saying what happened
  - planner student with only future items
    > expect the soonest due item to be scrolled into view
      and get focus and a flash alert saying what happened
  - planner student with only today items
    > expect the first item today to be scrolled into view
      and get focus and no flash message
  - planner student with completed item in the future and
    an item due the day after
    > expect the completed item to be scrolled into view and
      given focus and a flash message saying what happened
  - planner student with completed items yesterday and not
    completed items the day before that
    > expect the completeditem to be scrolled into view and
      given focus and a flash message saying what happened

Change-Id: I05a0edf3dd173aabe857bfff35f672e8e5313472
Reviewed-on: https://gerrit.instructure.com/153915
Tested-by: Jenkins
Reviewed-by: Jon Willesen <jonw+gerrit@instructure.com>
QA-Review: Jon Willesen <jonw+gerrit@instructure.com>
Product-Review: Ed Schiebel <eschiebel@instructure.com>
This commit is contained in:
Ed Schiebel 2018-06-14 18:05:06 -04:00
parent a2e318ab7b
commit 1cfddf1adc
11 changed files with 297 additions and 42 deletions

View File

@ -17,6 +17,7 @@
*/
import React, { Component } from 'react';
import classnames from 'classnames';
import { momentObj } from 'react-moment-proptypes';
import themeable from '@instructure/ui-themeable/lib';
import ToggleDetails from '@instructure/ui-toggle-details/lib/components/ToggleDetails';
import Pill from '@instructure/ui-elements/lib/components/Pill';
@ -41,6 +42,7 @@ export class CompletedItemsFacade extends Component {
registerAnimatable: func,
deregisterAnimatable: func,
notificationBadge: oneOf(['none', 'newActivity', 'missing']),
date: momentObj, // the scroll-to-today animation requires a date on each component in the planner
};
static defaultProps = {
badges: [],

View File

@ -17,6 +17,7 @@
*/
import React from 'react';
import { shallow, mount } from 'enzyme';
import moment from 'moment-timezone';
import {Grouping} from '../index';
const getDefaultProps = () => ({
@ -24,7 +25,7 @@ const getDefaultProps = () => ({
id: "5",
uniqueId: "five",
title: 'San Juan',
date: '2017-04-25T05:06:07-08:00',
date: moment.tz('2017-04-25T05:06:07-08:00', "America/Denver"),
context: {
url: 'example.com',
color: "#5678",
@ -34,7 +35,7 @@ const getDefaultProps = () => ({
}, {
id: "6",
uniqueId: "six",
date: '2017-04-25T05:06:07-08:00',
date: moment.tz('2017-04-25T05:06:07-08:00', "America/Denver"),
title: 'Roll for the Galaxy',
context: {
color: "#5678",
@ -73,7 +74,7 @@ it('renders to do items correctly', () => {
id: "700",
uniqueId: "seven hundred",
title: 'To Do 700',
date: '2017-06-16T05:06:07-06:00',
date: moment.tz('2017-06-16T05:06:07-06:00', "America/Denver"),
context: null,
}],
timeZone: "America/Denver",

View File

@ -158,6 +158,7 @@ exports[`renders a CompletedItemsFacade when completed items are present by defa
]
}
badges={Array []}
date={"2017-04-25T06:00:00.000Z"}
itemCount={1}
notificationBadge="none"
onClick={[Function]}

View File

@ -163,6 +163,8 @@ export class Grouping extends Component {
renderFacade (completedItems, animatableIndex) {
const showNotificationBadgeOnItem = this.getLayout() !== 'large';
if (!this.state.showCompletedItems && completedItems.length > 0) {
const theDay = completedItems[0].date.clone();
theDay.startOf('day');
let missing = false;
let newActivity = false;
const completedItemIds = completedItems.map(item => {
@ -194,6 +196,7 @@ export class Grouping extends Component {
theme={{
labelColor: this.props.color
}}
date={theDay}
/>
</li>
);

View File

@ -58,6 +58,21 @@ describe('PlannerApp', () => {
expect(wrapper).toMatchSnapshot();
});
it('always renders today and tomorrow when only items are in the future', () => {
let days = [moment.tz(TZ).add(+5, 'day')];
days = days.map(d => [d.format('YYYY-MM-DD'), [{dateBucketMoment: d}]]);
const wrapper = shallow(<PlannerApp {...getDefaultValues({days})} />);
expect(wrapper).toMatchSnapshot();
});
it('only renders today when the only item is today', () => {
// because we don't know if we have all the items for tomorrow yet.
let days = [moment.tz(TZ)];
days = days.map(d => [d.format('YYYY-MM-DD'), [{dateBucketMoment: d}]]);
const wrapper = shallow(<PlannerApp {...getDefaultValues({days})} />);
expect(wrapper).toMatchSnapshot();
});
it('shows only the loading component when the isLoading prop is true', () => {
const wrapper = shallow(
<PlannerApp

View File

@ -1,5 +1,71 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PlannerApp always renders today and tomorrow when only items are in the future 1`] = `
<div
className="PlannerApp large"
>
<View
as="div"
display="auto"
textAlign="center"
>
<ShowOnFocusButton
buttonProps={
Object {
"onClick": undefined,
}
}
buttonRef={[Function]}
>
Load prior dates
</ShowOnFocusButton>
</View>
<LoadingPastIndicator
allPastItemsLoaded={false}
loadingPast={false}
/>
<Animatable(Day)
animatableIndex={1}
day="2017-04-24"
itemsForDay={Array []}
timeZone="Asia/Tokyo"
/>
<Animatable(Day)
animatableIndex={2}
day="2017-04-25"
itemsForDay={Array []}
timeZone="Asia/Tokyo"
/>
<EmptyDays
animatableIndex={3}
day="2017-04-26"
endday="2017-04-28"
timeZone="Asia/Tokyo"
/>
<Animatable(Day)
animatableIndex={4}
day="2017-04-29"
itemsForDay={
Array [
Object {
"dateBucketMoment": "2017-04-28T15:00:00.000Z",
},
]
}
timeZone="Asia/Tokyo"
/>
<div
id="planner-app-fixed-element"
/>
<LoadingFutureIndicator
allFutureItemsLoaded={false}
loadingFuture={false}
onLoadMore={[Function]}
plannerActive={[Function]}
/>
</div>
`;
exports[`PlannerApp empty day calculation always renders yesterday, today and tomorrow 1`] = `
<div
className="PlannerApp large"
@ -366,6 +432,54 @@ exports[`PlannerApp empty day calculation renders 2 consecutive empty days in th
</div>
`;
exports[`PlannerApp only renders today when the only item is today 1`] = `
<div
className="PlannerApp large"
>
<View
as="div"
display="auto"
textAlign="center"
>
<ShowOnFocusButton
buttonProps={
Object {
"onClick": undefined,
}
}
buttonRef={[Function]}
>
Load prior dates
</ShowOnFocusButton>
</View>
<LoadingPastIndicator
allPastItemsLoaded={false}
loadingPast={false}
/>
<Animatable(Day)
animatableIndex={1}
day="2017-04-24"
itemsForDay={
Array [
Object {
"dateBucketMoment": "2017-04-23T15:00:00.000Z",
},
]
}
timeZone="Asia/Tokyo"
/>
<div
id="planner-app-fixed-element"
/>
<LoadingFutureIndicator
allFutureItemsLoaded={false}
loadingFuture={false}
onLoadMore={[Function]}
plannerActive={[Function]}
/>
</div>
`;
exports[`PlannerApp renders base component 1`] = `
<div
className="PlannerApp large"

View File

@ -331,16 +331,17 @@ export class PlannerApp extends Component {
// ending at the last props.days (or today, whichever is later)
// step a day at a time.
// if the day is before yesterday, emit a <Day> only it if it has items
// always render yesterday, today, and tomorrow
// always render yesterday (if loaded), today, and tomorrow
// starting with the day after tomorrow:
// if a day has items, emit a <Day>
// if we find a string of < 3 empty days, emit a <Day> for each
// if we find a string of 3 or more empty days, emit an <EmptyDays> for the interval
renderDays () {
const children = [];
let workingDay = moment.tz(this.props.days[0][0], this.props.timeZone);
let lastDay = moment.tz(this.props.days[this.props.days.length-1][0], this.props.timeZone);
const today = moment.tz(this.props.timeZone).startOf('day');
let workingDay = moment.tz(this.props.days[0][0], this.props.timeZone);
if (workingDay.isAfter(today)) workingDay = today;
let lastDay = moment.tz(this.props.days[this.props.days.length-1][0], this.props.timeZone);
let tomorrow = today.clone().add(1, 'day');
const dayBeforeYesterday = today.clone().add(-2, 'day');
if (lastDay.isBefore(today)) lastDay = today;

View File

@ -23,11 +23,14 @@ import {ScrollToToday} from '../scroll-to-today';
import {createAnimation, mockRegistryEntry} from './test-utils';
const TZ = 'Asia/Tokyo';
const successalert = jest.fn();
const pastMessage = 'Nothing planned today. Selecting most recent item.';
const futureMessage = 'Nothing planned today. Selecting next item.';
beforeAll(() => {
MockDate.set('2018-04-15', TZ);
initialize({
visualSuccessCallback: jest.fn(),
visualSuccessCallback: successalert,
visualErrorCallback: jest.fn(),
srAlertCallback: jest.fn()
});
@ -35,36 +38,111 @@ beforeAll(() => {
afterAll(() => {
MockDate.reset();
});
it('scrolls when today is in the DOM', () => {
const today_elem = {};
const {animation, animator, store, registry, manager} = createAnimation(ScrollToToday);
manager.getDocument().querySelector = function () {return today_elem;};
const mockRegistryEntries = [
mockRegistryEntry('some-item', 'i1', moment.tz(TZ)),
];
mockRegistryEntries[0].component.getScrollable.mockReturnValue(today_elem);
registry.getAllItemsSorted.mockReturnValue(mockRegistryEntries);
animation.uiDidUpdate();
expect(animator.scrollTo.mock.calls[0][0]).toEqual(today_elem);
expect(animator.scrollToTop).not.toHaveBeenCalled();
expect(store.dispatch).not.toHaveBeenCalled();
beforeEach(() => {
successalert.mockReset();
});
it('scrolls to the top when it cannot find today', () => {
const {animation, animator, registry} = createAnimation(ScrollToToday);
const mockRegistryEntries = [
mockRegistryEntry('some-item', 'i1', moment.tz(TZ)),
];
registry.getAllItemsSorted.mockReturnValue(mockRegistryEntries);
describe('items are in the planner', () => {
it('scrolls when today is in the DOM', () => {
const today_elem = {};
const {animation, animator, store, registry, manager} = createAnimation(ScrollToToday);
manager.getDocument().querySelector = function () {return today_elem;};
const mockRegistryEntries = [
mockRegistryEntry('some-item', 'i1', moment.tz(TZ)),
];
mockRegistryEntries[0].component.getScrollable.mockReturnValue(today_elem);
registry.getAllItemsSorted.mockReturnValue(mockRegistryEntries);
animation.uiDidUpdate();
expect(animator.scrollTo.mock.calls[0][0]).toEqual(today_elem);
expect(animator.scrollToTop).not.toHaveBeenCalled();
expect(store.dispatch).not.toHaveBeenCalled();
});
it('scrolls to the top when it cannot find today', () => {
const {animation, animator, registry} = createAnimation(ScrollToToday);
const mockRegistryEntries = [
mockRegistryEntry('some-item', 'i1', moment.tz(TZ)),
];
registry.getAllItemsSorted.mockReturnValue(mockRegistryEntries);
animation.uiDidUpdate();
expect(animator.scrollTo).not.toHaveBeenCalled();
expect(animator.scrollToTop).toHaveBeenCalled();
});
it('focuses on next item if none today', () => {
const today_elem = {};
const {animation, animator, store, registry, manager} = createAnimation(ScrollToToday);
manager.getDocument().querySelector = function () {return today_elem;};
const mockRegistryEntries = [
mockRegistryEntry('some-item', 'i1', moment.tz('2018-04-16', TZ)), // in the future
];
mockRegistryEntries[0].component.getScrollable.mockReturnValue(today_elem);
registry.getAllItemsSorted.mockReturnValue(mockRegistryEntries);
animation.uiDidUpdate();
expect(successalert).toHaveBeenCalledWith(futureMessage)
expect(animator.scrollTo).toHaveBeenCalledTimes(2);
expect(animator.focusElement).toHaveBeenCalledWith('i1-focusable');
});
it('focuses on previous item if none today or after', () => {
const today_elem = {};
const {animation, animator, store, registry, manager} = createAnimation(ScrollToToday);
manager.getDocument().querySelector = function () {return today_elem;};
const mockRegistryEntries = [
mockRegistryEntry('some-item', 'i1', moment.tz('2018-04-13', TZ)), // in the past
];
mockRegistryEntries[0].component.getScrollable.mockReturnValue(today_elem);
registry.getAllItemsSorted.mockReturnValue(mockRegistryEntries);
animation.uiDidUpdate();
expect(successalert).toHaveBeenCalledWith(pastMessage);
expect(animator.scrollTo).toHaveBeenCalledTimes(2);
expect(animator.focusElement).toHaveBeenCalledWith('i1-focusable');
});
it('focuses on future item even if past item is closer', () => {
const some_elem = {};
const {animation, animator, store, registry, manager} = createAnimation(ScrollToToday);
manager.getDocument().querySelector = function () {return some_elem;};
const mockRegistryEntries = [
mockRegistryEntry('past-item', 'p1', moment.tz('2018-04-13', TZ)), // in the past
mockRegistryEntry('some-item', 'f1', moment.tz('2018-06-16', TZ)), // way in the future
];
mockRegistryEntries[0].component.getScrollable.mockReturnValue(some_elem);
mockRegistryEntries[1].component.getScrollable.mockReturnValue(some_elem);
registry.getAllItemsSorted.mockReturnValue(mockRegistryEntries);
animation.uiDidUpdate();
expect(successalert).toHaveBeenCalledWith(futureMessage)
expect(animator.scrollTo).toHaveBeenCalledTimes(2);
expect(animator.focusElement).toHaveBeenCalledWith('f1-focusable');
});
it('ignores items w/o a date', () => {
successalert.mockReset();
const some_elem = {};
const {animation, animator, store, registry, manager} = createAnimation(ScrollToToday);
manager.getDocument().querySelector = function () {return some_elem;};
const mockRegistryEntries = [
mockRegistryEntry('past-item', 'p1', moment.tz('2018-04-13', TZ)), // in the past
mockRegistryEntry('some-item', 'f1', undefined),
];
mockRegistryEntries[0].component.getScrollable.mockReturnValue(some_elem);
mockRegistryEntries[1].component.getScrollable.mockReturnValue(some_elem);
registry.getAllItemsSorted.mockReturnValue(mockRegistryEntries);
animation.uiDidUpdate();
expect(successalert).toHaveBeenCalledWith(pastMessage)
expect(animator.scrollTo).toHaveBeenCalledTimes(2);
expect(animator.focusElement).toHaveBeenCalledWith('p1-focusable');
});
animation.uiDidUpdate();
expect(animator.scrollTo).not.toHaveBeenCalled();
expect(animator.scrollToTop).toHaveBeenCalled();
});
describe('items require requires loading', () => {
describe('items require loading', () => {
it('scrolls to top and dispatches loadPastUntilToday', () => {
const {animation, animator, store} = createAnimation(ScrollToToday);

View File

@ -62,7 +62,7 @@ export function mockAnimator () {
focusElement: jest.fn(),
elementPositionMemo: jest.fn(),
maintainViewportPositionFromMemo: jest.fn(),
scrollTo: jest.fn(),
scrollTo: jest.fn(((scrollable, offset, callback) => {callback && callback()})),
scrollToTop: jest.fn(),
isAboveScreen: jest.fn(),
isBelowScreen: jest.fn(),

View File

@ -35,16 +35,18 @@ export class ScrollToToday extends Animation {
}
export function scrollAndFocusTodayItem (manager, todayElem) {
const {component, isToday} = findTodayOrNext(manager.getRegistry());
const {component, when} = findTodayOrNearest(manager.getRegistry());
if (component) {
if (component.getScrollable()) {
// scroll Today into view
manager.getAnimator().scrollTo(todayElem, manager.totalOffset(), () => {
// then, if necessary, scroll today's or next todo item into view but not all the way to the top
manager.getAnimator().scrollTo(component.getScrollable(), manager.totalOffset() + todayElem.offsetHeight, () => {
if (!isToday) {
if (when === 'after') {
// tell the user where we wound up
alert(formatMessage("Nothing planned today. Next item loaded."));
alert(formatMessage("Nothing planned today. Selecting next item."));
} else if (when === 'before') {
alert(formatMessage("Nothing planned today. Selecting most recent item."));
}
// finally, focus the item
if (component.getFocusable()) {
@ -59,11 +61,48 @@ export function scrollAndFocusTodayItem (manager, todayElem) {
}
}
function findTodayOrNext (registry) {
// Find an item that's due that's
// 1. the first item due today, and if there isn't one
// 2. the next item due after today, and if there isn't one
// 3. the most recent item still due from the past
function findTodayOrNearest (registry) {
const today = moment().startOf('day');
const todayOrNextItem = registry.getAllItemsSorted().find(item => {
return item.component.props.date >= today;
});
const component = todayOrNextItem && todayOrNextItem.component;
return {component, isToday: component.props.date.isSame(today, 'day')};
const allItems = registry.getAllItemsSorted();
let before = {
diff: Number.MIN_SAFE_INTEGER,
component: null
};
let after = {
diff: Number.MAX_SAFE_INTEGER,
component: null
};
// find the before and after today items due closest to today
for (let i = 0; i < allItems.length; ++i) {
const item = allItems[i];
if (item.component && item.component.props.date) {
const diff = item.component.props.date.diff(today, 'seconds');
if (diff < 0 && diff > before.diff) {
before.diff = diff;
before.component = item.component;
} else if (diff >= 0 && diff < after.diff) {
after.diff = diff;
after.component = item.component;
}
}
}
// if there's an item in the future, prefer it
const component = after.component ? after.component : before.component;
let when = 'never';
if (after.component) {
if (component.props.date.isSame(today, 'day')) {
when = 'today';
} else {
when = 'after';
}
} else if (before.component) {
when = 'before';
}
return {component, when};
}

View File

@ -378,6 +378,7 @@ describe "student planner" do
end
it "edits a completed To Do", priority: "1" do
skip("build breaking, for some reason, it won't click a:contains('Title Text') 12 lines down.")
@student1.planner_notes.create!(todo_date: 2.days.from_now, title: "Title Text")
go_to_list_view