/** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import sinon from 'sinon'; import { ActionCreators as UndoActionCreators } from 'redux-undo'; import { UPDATE_COMPONENTS, updateComponents, DELETE_COMPONENT, deleteComponent, CREATE_COMPONENT, CREATE_TOP_LEVEL_TABS, createTopLevelTabs, DELETE_TOP_LEVEL_TABS, deleteTopLevelTabs, resizeComponent, MOVE_COMPONENT, handleComponentDrop, updateDashboardTitle, undoLayoutAction, redoLayoutAction, } from 'src/dashboard/actions/dashboardLayout'; import { setUnsavedChanges } from 'src/dashboard/actions/dashboardState'; import * as dashboardFilters from 'src/dashboard/actions/dashboardFilters'; import { addWarningToast, ADD_TOAST } from 'src/messageToasts/actions'; import { DASHBOARD_GRID_TYPE, ROW_TYPE, CHART_TYPE, TABS_TYPE, TAB_TYPE, } from 'src/dashboard/util/componentTypes'; import { DASHBOARD_HEADER_ID, DASHBOARD_GRID_ID, DASHBOARD_ROOT_ID, NEW_COMPONENTS_SOURCE_ID, NEW_ROW_ID, } from 'src/dashboard/util/constants'; describe('dashboardLayout actions', () => { const mockState = { dashboardState: { hasUnsavedChanges: true, // don't dispatch setUnsavedChanges() after every action }, dashboardInfo: {}, dashboardLayout: { past: [], present: {}, future: {}, }, }; function setup(stateOverrides) { const state = { ...mockState, ...stateOverrides }; const getState = sinon.spy(() => state); const dispatch = sinon.spy(); return { getState, dispatch, state }; } beforeEach(() => { sinon.spy(dashboardFilters, 'updateLayoutComponents'); }); afterEach(() => { dashboardFilters.updateLayoutComponents.restore(); }); describe('updateComponents', () => { it('should dispatch an updateLayout action', () => { const { getState, dispatch } = setup(); const nextComponents = { 1: {} }; const thunk = updateComponents(nextComponents); thunk(dispatch, getState); expect(dispatch.callCount).toBe(1); expect(dispatch.getCall(0).args[0]).toEqual({ type: UPDATE_COMPONENTS, payload: { nextComponents }, }); // update component should not trigger action for dashboardFilters expect(dashboardFilters.updateLayoutComponents.callCount).toEqual(0); }); it('should dispatch a setUnsavedChanges action if hasUnsavedChanges=false', () => { const { getState, dispatch } = setup({ dashboardState: { hasUnsavedChanges: false }, }); const nextComponents = { 1: {} }; const thunk = updateComponents(nextComponents); thunk(dispatch, getState); expect(dispatch.getCall(1).args[0]).toEqual(setUnsavedChanges(true)); // update component should not trigger action for dashboardFilters expect(dashboardFilters.updateLayoutComponents.callCount).toEqual(0); }); }); describe('deleteComponents', () => { it('should dispatch an deleteComponent action', () => { const { getState, dispatch } = setup(); const thunk = deleteComponent('id', 'parentId'); thunk(dispatch, getState); expect(dispatch.getCall(0).args[0]).toEqual({ type: DELETE_COMPONENT, payload: { id: 'id', parentId: 'parentId' }, }); // delete components should trigger action for dashboardFilters expect(dashboardFilters.updateLayoutComponents.callCount).toEqual(1); }); it('should dispatch a setUnsavedChanges action if hasUnsavedChanges=false', () => { const { getState, dispatch } = setup({ dashboardState: { hasUnsavedChanges: false }, }); const thunk = deleteComponent('id', 'parentId'); thunk(dispatch, getState); expect(dispatch.getCall(2).args[0]).toEqual(setUnsavedChanges(true)); // delete components should trigger action for dashboardFilters expect(dashboardFilters.updateLayoutComponents.callCount).toEqual(1); }); }); describe('updateDashboardTitle', () => { it('should dispatch an updateComponent action for the header component', () => { const { getState, dispatch } = setup(); const thunk1 = updateDashboardTitle('new text'); thunk1(dispatch, getState); const thunk2 = dispatch.getCall(0).args[0]; thunk2(dispatch, getState); expect(dispatch.getCall(1).args[0]).toEqual({ type: UPDATE_COMPONENTS, payload: { nextComponents: { [DASHBOARD_HEADER_ID]: { meta: { text: 'new text' }, }, }, }, }); // update dashboard title should not trigger action for dashboardFilters expect(dashboardFilters.updateLayoutComponents.callCount).toEqual(0); }); }); describe('createTopLevelTabs', () => { it('should dispatch a createTopLevelTabs action', () => { const { getState, dispatch } = setup(); const dropResult = {}; const thunk = createTopLevelTabs(dropResult); thunk(dispatch, getState); expect(dispatch.getCall(0).args[0]).toEqual({ type: CREATE_TOP_LEVEL_TABS, payload: { dropResult }, }); // create top level tabs should trigger action for dashboardFilters expect(dashboardFilters.updateLayoutComponents.callCount).toEqual(1); }); it('should dispatch a setUnsavedChanges action if hasUnsavedChanges=false', () => { const { getState, dispatch } = setup({ dashboardState: { hasUnsavedChanges: false }, }); const dropResult = {}; const thunk = createTopLevelTabs(dropResult); thunk(dispatch, getState); expect(dispatch.getCall(2).args[0]).toEqual(setUnsavedChanges(true)); // create top level tabs should trigger action for dashboardFilters expect(dashboardFilters.updateLayoutComponents.callCount).toEqual(1); }); }); describe('deleteTopLevelTabs', () => { it('should dispatch a deleteTopLevelTabs action', () => { const { getState, dispatch } = setup(); const dropResult = {}; const thunk = deleteTopLevelTabs(dropResult); thunk(dispatch, getState); expect(dispatch.getCall(0).args[0]).toEqual({ type: DELETE_TOP_LEVEL_TABS, payload: {}, }); // delete top level tabs should trigger action for dashboardFilters expect(dashboardFilters.updateLayoutComponents.callCount).toEqual(1); }); it('should dispatch a setUnsavedChanges action if hasUnsavedChanges=false', () => { const { getState, dispatch } = setup({ dashboardState: { hasUnsavedChanges: false }, }); const dropResult = {}; const thunk = deleteTopLevelTabs(dropResult); thunk(dispatch, getState); expect(dispatch.getCall(2).args[0]).toEqual(setUnsavedChanges(true)); // delete top level tabs should trigger action for dashboardFilters expect(dashboardFilters.updateLayoutComponents.callCount).toEqual(1); }); }); describe('resizeComponent', () => { const dashboardLayout = { ...mockState.dashboardLayout, present: { 1: { id: 1, children: [], meta: { width: 1, height: 1, }, }, }, }; it('should update the size of the component', () => { const { getState, dispatch } = setup({ dashboardLayout, }); const thunk1 = resizeComponent({ id: 1, width: 10, height: 3 }); thunk1(dispatch, getState); const thunk2 = dispatch.getCall(0).args[0]; thunk2(dispatch, getState); expect(dispatch.callCount).toBe(2); expect(dispatch.getCall(1).args[0]).toEqual({ type: UPDATE_COMPONENTS, payload: { nextComponents: { 1: { id: 1, children: [], meta: { width: 10, height: 3, }, }, }, }, }); expect(dispatch.callCount).toBe(2); }); it('should dispatch a setUnsavedChanges action if hasUnsavedChanges=false', () => { const { getState, dispatch } = setup({ dashboardState: { hasUnsavedChanges: false }, dashboardLayout, }); const thunk1 = resizeComponent({ id: 1, width: 10, height: 3 }); thunk1(dispatch, getState); const thunk2 = dispatch.getCall(0).args[0]; thunk2(dispatch, getState); expect(dispatch.callCount).toBe(3); // resize components should not trigger action for dashboardFilters expect(dashboardFilters.updateLayoutComponents.callCount).toEqual(0); }); }); describe('handleComponentDrop', () => { it('should create a component if it is new', () => { const { getState, dispatch } = setup(); const dropResult = { source: { id: NEW_COMPONENTS_SOURCE_ID }, destination: { id: DASHBOARD_GRID_ID, type: DASHBOARD_GRID_TYPE }, dragging: { id: NEW_ROW_ID, type: ROW_TYPE }, }; const handleComponentDropThunk = handleComponentDrop(dropResult); handleComponentDropThunk(dispatch, getState); const createComponentThunk = dispatch.getCall(0).args[0]; createComponentThunk(dispatch, getState); expect(dispatch.getCall(1).args[0]).toEqual({ type: CREATE_COMPONENT, payload: { dropResult, }, }); // create components should trigger action for dashboardFilters expect(dashboardFilters.updateLayoutComponents.callCount).toEqual(1); }); it('should move a component if the component is not new', () => { const { getState, dispatch } = setup({ dashboardLayout: { // if 'dragging' is not only child will dispatch deleteComponent thunk present: { id: { type: ROW_TYPE, children: ['_'] } }, }, }); const dropResult = { source: { id: 'id', index: 0, type: ROW_TYPE }, destination: { id: DASHBOARD_GRID_ID, type: DASHBOARD_GRID_TYPE }, dragging: { id: 'dragging', type: ROW_TYPE }, }; const handleComponentDropThunk = handleComponentDrop(dropResult); handleComponentDropThunk(dispatch, getState); const moveComponentThunk = dispatch.getCall(0).args[0]; moveComponentThunk(dispatch, getState); expect(dispatch.getCall(1).args[0]).toEqual({ type: MOVE_COMPONENT, payload: { dropResult, }, }); // create components should trigger action for dashboardFilters expect(dashboardFilters.updateLayoutComponents.callCount).toEqual(1); }); it('should dispatch a toast if the drop overflows the destination', () => { const { getState, dispatch } = setup({ dashboardLayout: { present: { source: { type: ROW_TYPE }, destination: { type: ROW_TYPE, children: ['rowChild'] }, dragging: { type: CHART_TYPE, meta: { width: 1 } }, rowChild: { type: CHART_TYPE, meta: { width: 12 } }, }, }, }); const dropResult = { source: { id: 'source', type: ROW_TYPE }, destination: { id: 'destination', type: ROW_TYPE }, dragging: { id: 'dragging', type: CHART_TYPE }, }; const thunk = handleComponentDrop(dropResult); thunk(dispatch, getState); expect(dispatch.getCall(0).args[0].type).toEqual( addWarningToast('').type, ); expect(dispatch.callCount).toBe(1); }); it('should delete a parent Row or Tabs if the moved child was the only child', () => { const { getState, dispatch } = setup({ dashboardLayout: { present: { parentId: { id: 'parentId', children: ['tabsId'] }, tabsId: { id: 'tabsId', type: TABS_TYPE, children: [] }, [DASHBOARD_GRID_ID]: { id: DASHBOARD_GRID_ID, type: DASHBOARD_GRID_TYPE, }, tabId: { id: 'tabId', type: TAB_TYPE }, }, }, }); const dropResult = { source: { id: 'tabsId', type: TABS_TYPE }, destination: { id: DASHBOARD_GRID_ID, type: DASHBOARD_GRID_TYPE }, dragging: { id: 'tabId', type: TAB_TYPE }, }; const moveThunk = handleComponentDrop(dropResult); moveThunk(dispatch, getState); // first call is move action which is not a thunk const deleteThunk = dispatch.getCall(1).args[0]; deleteThunk(dispatch, getState); expect(dispatch.getCall(2).args[0]).toEqual({ type: DELETE_COMPONENT, payload: { id: 'tabsId', parentId: 'parentId', }, }); }); it('should create top-level tabs if dropped on root', () => { const { getState, dispatch } = setup(); const dropResult = { source: { id: NEW_COMPONENTS_SOURCE_ID }, destination: { id: DASHBOARD_ROOT_ID }, dragging: { id: NEW_ROW_ID, type: ROW_TYPE }, }; const thunk1 = handleComponentDrop(dropResult); thunk1(dispatch, getState); const thunk2 = dispatch.getCall(0).args[0]; thunk2(dispatch, getState); expect(dispatch.getCall(1).args[0]).toEqual({ type: CREATE_TOP_LEVEL_TABS, payload: { dropResult, }, }); }); it('should dispatch a toast if drop top-level tab into nested tab', () => { const { getState, dispatch } = setup({ dashboardLayout: { present: { [DASHBOARD_ROOT_ID]: { children: ['TABS-ROOT_TABS'], id: DASHBOARD_ROOT_ID, type: 'ROOT', }, 'TABS-ROOT_TABS': { children: ['TAB-iMppmTOQy', 'TAB-rt1y8cQ6K9', 'TAB-X_pnCIwPN'], id: 'TABS-ROOT_TABS', meta: {}, parents: ['ROOT_ID'], type: TABS_TYPE, }, 'TABS-ROW_TABS': { children: [ 'TAB-dKIDBT03bQ', 'TAB-PtxY5bbTe', 'TAB-Wc2P-yGMz', 'TAB-U-xe_si7i', ], id: 'TABS-ROW_TABS', meta: {}, parents: ['ROOT_ID', 'TABS-ROOT_TABS', 'TAB-X_pnCIwPN'], type: TABS_TYPE, }, }, }, }); const dropResult = { source: { id: 'TABS-ROOT_TABS', index: 1, type: TABS_TYPE, }, destination: { id: 'TABS-ROW_TABS', index: 1, type: TABS_TYPE, }, dragging: { id: 'TAB-rt1y8cQ6K9', meta: { text: 'New Tab' }, type: 'TAB', }, }; const thunk1 = handleComponentDrop(dropResult); thunk1(dispatch, getState); const thunk2 = dispatch.getCall(0).args[0]; thunk2(dispatch, getState); expect(dispatch.getCall(1).args[0].type).toEqual(ADD_TOAST); }); }); describe('undoLayoutAction', () => { it('should dispatch a redux-undo .undo() action ', () => { const { getState, dispatch } = setup({ dashboardLayout: { past: ['non-empty'] }, }); const thunk = undoLayoutAction(); thunk(dispatch, getState); expect(dispatch.callCount).toBe(1); expect(dispatch.getCall(0).args[0]).toEqual(UndoActionCreators.undo()); }); it('should dispatch a setUnsavedChanges(false) action history length is zero', () => { const { getState, dispatch } = setup({ dashboardLayout: { past: [] }, }); const thunk = undoLayoutAction(); thunk(dispatch, getState); expect(dispatch.callCount).toBe(2); expect(dispatch.getCall(1).args[0]).toEqual(setUnsavedChanges(false)); }); }); describe('redoLayoutAction', () => { it('should dispatch a redux-undo .redo() action ', () => { const { getState, dispatch } = setup(); const thunk = redoLayoutAction(); thunk(dispatch, getState); expect(dispatch.getCall(0).args[0]).toEqual(UndoActionCreators.redo()); // redo/undo should trigger action for dashboardFilters expect(dashboardFilters.updateLayoutComponents.callCount).toEqual(1); }); it('should dispatch a setUnsavedChanges(true) action if hasUnsavedChanges=false', () => { const { getState, dispatch } = setup({ dashboardState: { hasUnsavedChanges: false }, }); const thunk = redoLayoutAction(); thunk(dispatch, getState); expect(dispatch.getCall(2).args[0]).toEqual(setUnsavedChanges(true)); // redo/undo should trigger action for dashboardFilters expect(dashboardFilters.updateLayoutComponents.callCount).toEqual(1); }); }); });