diff --git a/src/Main.tsx b/src/Main.tsx index 5a7f03881..51bb18a14 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -66,7 +66,9 @@ export class Container extends React.Component { - + + {this.props.context.isAuthenticated && } + {this.props.context.isAuthenticated && } diff --git a/src/components/sidekick/index.test.tsx b/src/components/sidekick/index.test.tsx index c19e4fa20..b036502a3 100644 --- a/src/components/sidekick/index.test.tsx +++ b/src/components/sidekick/index.test.tsx @@ -4,18 +4,28 @@ import { shallow } from 'enzyme'; import { Container } from '.'; import { IfAuthenticated } from '../authentication/if-authenticated'; import { MessengerList } from '../messenger/list'; +import { SidekickTabs } from './types'; describe('Sidekick', () => { const subject = (props: any = {}) => { const allProps = { className: '', - updateLayout: () => undefined, + updateSidekick: jest.fn(), + setActiveSidekickTab: jest.fn(), + syncSidekickState: jest.fn(), ...props, }; return shallow(); }; + it('sync state', () => { + const syncSidekickState = jest.fn(); + subject({ syncSidekickState }); + + expect(syncSidekickState).toHaveBeenCalled(); + }); + it('adds className', () => { const wrapper = subject({ className: 'todo' }); @@ -24,6 +34,22 @@ describe('Sidekick', () => { expect(ifAuthenticated.find('.todo').exists()).toBe(true); }); + it('renders sidekick with class animation in', () => { + const wrapper = subject({ isOpen: true }); + + const sidekick = wrapper.find('.sidekick'); + + expect(sidekick.hasClass('sidekick--slide-in')).toBe(true); + }); + + it('it should not render out class animation', () => { + const wrapper = subject({ isOpen: false }); + + const sidekick = wrapper.find('.sidekick'); + + expect(sidekick.hasClass('sidekick--slide-out')).toBe(false); + }); + it('renders sidekick panel', () => { const wrapper = subject(); @@ -33,42 +59,62 @@ describe('Sidekick', () => { }); it('renders sidekick when panel tab is clicked', () => { - const updateLayout = jest.fn(); - const wrapper = subject(updateLayout); + const updateSidekick = jest.fn(); + const wrapper = subject({ updateSidekick, isOpen: false }); - const ifAuthenticated = wrapper.find(IfAuthenticated).find({ showChildren: true }); - ifAuthenticated.find('.app-sidekick-panel__target').simulate('click'); + expect(wrapper.find('.sidekick').hasClass('sidekick--slide-out')).toBe(false); - expect(ifAuthenticated.find('.sidekick__slide-out').exists()).toBe(false); - }); + wrapper.find('.app-sidekick-panel__target').simulate('click'); - it('renders default active tab', () => { - const wrapper = subject(); - - expect(wrapper.find(MessengerList).exists()).toBe(true); + expect(updateSidekick).toHaveBeenCalledWith({ isOpen: true }); }); it('handle network tab content', () => { - const wrapper = subject(); + const setActiveSidekickTab = jest.fn(); + const wrapper = subject({ setActiveSidekickTab }); wrapper.find('.sidekick__tabs-network').simulate('click'); - expect(wrapper.find('.sidekick__tab-content--network').exists()).toBe(true); + expect(setActiveSidekickTab).toHaveBeenCalledWith({ activeTab: SidekickTabs.NETWORK }); }); it('handle messages tab content', () => { - const wrapper = subject(); + const setActiveSidekickTab = jest.fn(); + const wrapper = subject({ setActiveSidekickTab }); wrapper.find('.sidekick__tabs-messages').simulate('click'); - expect(wrapper.find('.sidekick__tab-content--messages').exists()).toBe(true); + expect(setActiveSidekickTab).toHaveBeenCalledWith({ activeTab: SidekickTabs.MESSAGES }); }); it('handle notifications tab content', () => { - const wrapper = subject(); + const setActiveSidekickTab = jest.fn(); + const wrapper = subject({ setActiveSidekickTab }); + wrapper.find('.sidekick__tabs-notifications').simulate('click'); + + expect(setActiveSidekickTab).toHaveBeenCalledWith({ activeTab: SidekickTabs.NOTIFICATIONS }); + }); + + it('render notifications tab content', () => { + const wrapper = subject({ activeTab: SidekickTabs.NOTIFICATIONS }); wrapper.find('.sidekick__tabs-notifications').simulate('click'); expect(wrapper.find('.sidekick__tab-content--notifications').exists()).toBe(true); }); + it('render messages tab content', () => { + const wrapper = subject({ activeTab: SidekickTabs.MESSAGES }); + wrapper.find('.sidekick__tabs-messages').simulate('click'); + + expect(wrapper.find(MessengerList).exists()).toBe(true); + expect(wrapper.find('.sidekick__tab-content--messages').exists()).toBe(true); + }); + + it('render network tab content', () => { + const wrapper = subject({ activeTab: SidekickTabs.NETWORK }); + wrapper.find('.sidekick__tabs-network').simulate('click'); + + expect(wrapper.find('.sidekick__tab-content--network').exists()).toBe(true); + }); + it('renders total unread messages', () => { const wrapper = subject({ countAllUnreadMessages: 10 }); diff --git a/src/components/sidekick/index.tsx b/src/components/sidekick/index.tsx index a2f9ab333..5c512824b 100644 --- a/src/components/sidekick/index.tsx +++ b/src/components/sidekick/index.tsx @@ -5,78 +5,92 @@ import { IfAuthenticated } from '../authentication/if-authenticated'; import { IconButton, Icons } from '@zer0-os/zos-component-library'; import classNames from 'classnames'; import { AuthenticationState } from '../../store/authentication/types'; -import { AppLayout, update as updateLayout } from '../../store/layout'; +import { + UpdateSidekickPayload, + updateSidekick, + setActiveSidekickTab, + SetActiveSidekickTabPayload, + syncSidekickState, +} from '../../store/layout'; import { MessengerList } from '../messenger/list'; import { denormalize } from '../../store/channels'; +import { SidekickTabs as Tabs } from './types'; import './styles.scss'; -enum Tabs { - NETWORK, - MESSAGES, - NOTIFICATIONS, -} - interface PublicProperties { className?: string; } export interface Properties extends PublicProperties { user: AuthenticationState['user']; - updateLayout: (layout: Partial) => void; + updateSidekick: (action: UpdateSidekickPayload) => void; + setActiveSidekickTab: (action: SetActiveSidekickTabPayload) => void; + syncSidekickState: () => void; countAllUnreadMessages: number; + isOpen: boolean; + activeTab: Tabs; } export interface State { - isOpen: boolean; - activeTab: Tabs; + canStartAnimation: boolean; } export class Container extends React.Component { - state = { isOpen: true, activeTab: Tabs.MESSAGES }; + state = { canStartAnimation: false }; + defaultProps: { + activeTab: Tabs.MESSAGES; + }; static mapState(state: RootState): Partial { const directMessages = denormalize(state.channelsList.value, state).filter((channel) => Boolean(channel.isChannel)); + const countAllUnreadMessages = directMessages.reduce( (count, directMessage) => count + directMessage.unreadCount, 0 ); + const { authentication: { user }, + layout: { value }, } = state; return { user, countAllUnreadMessages, + isOpen: value.isSidekickOpen, + activeTab: value.activeSidekickTab, }; } static mapActions(_props: Properties): Partial { - return { updateLayout }; + return { updateSidekick, setActiveSidekickTab, syncSidekickState }; } - slideAnimationEnded = (): void => { - if (!this.state.isOpen) { - this.setState({ isOpen: false }); - } - }; + componentDidMount() { + this.props.syncSidekickState(); + } + + get isOpen() { + return this.props.isOpen; + } clickTab(tab: Tabs): void { - this.setState({ + this.props.setActiveSidekickTab({ activeTab: tab, }); } - handleSidekickPanel = (): void => { - this.props.updateLayout({ isSidekickOpen: !this.state.isOpen }); - this.setState({ isOpen: !this.state.isOpen }); + toggleSidekickPanel = (): void => { + this.setState({ canStartAnimation: true }); + this.props.updateSidekick({ isOpen: !this.isOpen }); }; renderSidekickPanel(): JSX.Element { return (
{ } renderTabContent(): JSX.Element { - switch (this.state.activeTab) { + switch (this.props.activeTab) { case Tabs.NETWORK: return
NETWORK
; case Tabs.MESSAGES: @@ -155,8 +169,12 @@ export class Container extends React.Component { return (
{this.renderSidekickPanel()}
{this.renderTabs()}
diff --git a/src/components/sidekick/styles.scss b/src/components/sidekick/styles.scss index 2194de82c..4203e3e2b 100644 --- a/src/components/sidekick/styles.scss +++ b/src/components/sidekick/styles.scss @@ -8,7 +8,14 @@ background-color: theme.$background-color-tertiary-hover; width: $width-sidekick; height: 100%; - animation: sidekick-slide-in animation.$animation-duration-double ease-in forwards; + margin-right: -$width-sidekick; + + &--slide-in { + animation: sidekick-slide-in animation.$animation-duration-double ease-in forwards; + } + &--slide-out { + animation: sidekick-slide-out animation.$animation-duration-double ease-out forwards; + } &__tabs { position: absolute; @@ -41,10 +48,6 @@ } } - &__slide-out { - animation: sidekick-slide-out animation.$animation-duration-double ease-out forwards; - } - .scroll-container__gradient { background: linear-gradient(to bottom, transparent, theme.$background-color-tertiary-hover 100%); } @@ -85,6 +88,7 @@ height: 34px; width: 34px; border-radius: 50%; + cursor: pointer; } } diff --git a/src/components/sidekick/types.ts b/src/components/sidekick/types.ts new file mode 100644 index 000000000..3e0bc790c --- /dev/null +++ b/src/components/sidekick/types.ts @@ -0,0 +1,5 @@ +export enum SidekickTabs { + NETWORK = 'network', + MESSAGES = 'messages', + NOTIFICATIONS = 'notifications', +} diff --git a/src/lib/storage/index.test.ts b/src/lib/storage/index.test.ts new file mode 100644 index 000000000..86b3aa2a5 --- /dev/null +++ b/src/lib/storage/index.test.ts @@ -0,0 +1,31 @@ +import { resolveFromLocalStorageAsBoolean } from './'; + +describe('storage', () => { + describe('resolveFromLocalStorage', () => { + it('should use the key to get the data', () => { + const storageKey = 'key-store'; + + resolveFromLocalStorageAsBoolean(storageKey); + expect(global.localStorage.getItem).toHaveBeenCalledWith(storageKey); + }); + + it('returns false in case no data is saved', () => { + const value = resolveFromLocalStorageAsBoolean('key'); + expect(value).toEqual(false); + }); + + it('returns false', () => { + global.localStorage.getItem = jest.fn().mockReturnValue('data hjere'); + + const value = resolveFromLocalStorageAsBoolean('key'); + expect(value).toEqual(false); + }); + + it('returns true', () => { + global.localStorage.getItem = jest.fn().mockReturnValue('true'); + + const value = resolveFromLocalStorageAsBoolean('key'); + expect(value).toEqual(true); + }); + }); +}); diff --git a/src/lib/storage/index.ts b/src/lib/storage/index.ts new file mode 100644 index 000000000..3ac0de21e --- /dev/null +++ b/src/lib/storage/index.ts @@ -0,0 +1,5 @@ +export function resolveFromLocalStorageAsBoolean(key) { + const value = localStorage.getItem(key); + + return value === null || value === 'true'; +} diff --git a/src/setupTests.ts b/src/setupTests.ts index 2fa390a15..ba72c30fb 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -3,3 +3,14 @@ import { configure } from 'enzyme'; import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; configure({ adapter: new Adapter() }); + +const localStorageMock = { + setItem: jest.fn(), + getItem: jest.fn(), + removeItem: jest.fn(), + length: 0, + clear: jest.fn(), + key: jest.fn(), +}; + +global.localStorage = localStorageMock; diff --git a/src/store/layout/constants.ts b/src/store/layout/constants.ts new file mode 100644 index 000000000..85e569faa --- /dev/null +++ b/src/store/layout/constants.ts @@ -0,0 +1,7 @@ +import { SidekickTabs } from './../../components/sidekick/types'; + +export const SIDEKICK_OPEN_STORAGE = 'isSidekickOpen'; + +export const SIDEKICK_TAB_KEY = 'sidekick-tab'; + +export const DEFAULT_ACTIVE_TAB = SidekickTabs.MESSAGES; diff --git a/src/store/layout/index.test.ts b/src/store/layout/index.test.ts index c9b4a8677..cf6171895 100644 --- a/src/store/layout/index.test.ts +++ b/src/store/layout/index.test.ts @@ -4,8 +4,9 @@ describe('layout reducer', () => { const initialExistingState: LayoutState = { value: { isContextPanelOpen: false, - isSidekickOpen: true, + isSidekickOpen: false, hasContextPanel: false, + activeSidekickTab: null, }, }; @@ -13,8 +14,9 @@ describe('layout reducer', () => { expect(reducer(undefined, { type: 'unknown' })).toEqual({ value: { isContextPanelOpen: false, - isSidekickOpen: true, + isSidekickOpen: false, hasContextPanel: false, + activeSidekickTab: null, }, }); }); diff --git a/src/store/layout/index.ts b/src/store/layout/index.ts index 14f1724ec..09376577d 100644 --- a/src/store/layout/index.ts +++ b/src/store/layout/index.ts @@ -1,20 +1,22 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { createSlice, PayloadAction, createAction } from '@reduxjs/toolkit'; +import type { AppLayout, LayoutState, UpdateSidekickPayload, SetActiveSidekickTabPayload } from './types'; -export interface AppLayout { - hasContextPanel: boolean; - isContextPanelOpen: boolean; - isSidekickOpen: boolean; +export enum SagaActionTypes { + updateSidekick = 'layout/saga/updateSidekick', + setActiveSidekickTab = 'layout/saga/setActiveSidekickTab', + syncSidekickState = 'layout/saga/syncSidekickState', } -export interface LayoutState { - value: AppLayout; -} +export const updateSidekick = createAction(SagaActionTypes.updateSidekick); +export const setActiveSidekickTab = createAction(SagaActionTypes.setActiveSidekickTab); +export const syncSidekickState = createAction(SagaActionTypes.syncSidekickState); const initialState: LayoutState = { value: { isContextPanelOpen: false, - isSidekickOpen: true, hasContextPanel: false, + isSidekickOpen: false, + activeSidekickTab: null, }, }; @@ -33,3 +35,4 @@ const slice = createSlice({ export const { update } = slice.actions; export const { reducer } = slice; +export { AppLayout, LayoutState, UpdateSidekickPayload, SetActiveSidekickTabPayload }; diff --git a/src/store/layout/saga.test.ts b/src/store/layout/saga.test.ts new file mode 100644 index 000000000..405d7a483 --- /dev/null +++ b/src/store/layout/saga.test.ts @@ -0,0 +1,62 @@ +import { SidekickTabs } from './../../components/sidekick/types'; +import { expectSaga } from 'redux-saga-test-plan'; +import { updateSidekick as updateSidekickSaga, updateActiveSidekickTab, syncSidekickState } from './saga'; + +import { reducer } from '.'; + +describe('layout saga', () => { + const state = { + authentication: { + user: { + data: { + id: 'user-id', + }, + }, + }, + }; + it('should store sidekick', async () => { + const { storeState } = await expectSaga(updateSidekickSaga, { payload: { isOpen: true } }) + .withReducer(reducer, state as any) + .run(); + + expect(storeState).toMatchObject({ + value: { + isSidekickOpen: true, + }, + }); + + expect(global.localStorage.setItem).toHaveBeenCalled(); + }); + + it('should store active tab', async () => { + const { storeState } = await expectSaga(updateActiveSidekickTab, { payload: { activeTab: SidekickTabs.MESSAGES } }) + .withReducer(reducer, state as any) + .run(); + + expect(storeState).toMatchObject({ + value: { + activeSidekickTab: SidekickTabs.MESSAGES, + }, + }); + + expect(global.localStorage.setItem).toHaveBeenCalledWith('user-id-sidekick-tab', 'messages'); + }); + + it('should sync sidekick state', async () => { + global.localStorage.getItem = jest.fn().mockReturnValue('true'); + + const { storeState } = await expectSaga(syncSidekickState) + .withReducer(reducer, state as any) + .run(); + + expect(storeState).toMatchObject({ + value: { + activeSidekickTab: SidekickTabs.MESSAGES, + isSidekickOpen: true, + }, + }); + + expect(global.localStorage.getItem).toHaveBeenNthCalledWith(1, 'user-id-isSidekickOpen'); + expect(global.localStorage.getItem).toHaveBeenNthCalledWith(2, 'user-id-sidekick-tab'); + }); +}); diff --git a/src/store/layout/saga.ts b/src/store/layout/saga.ts new file mode 100644 index 000000000..c258309e4 --- /dev/null +++ b/src/store/layout/saga.ts @@ -0,0 +1,69 @@ +import getDeepProperty from 'lodash.get'; +import { SIDEKICK_OPEN_STORAGE, SIDEKICK_TAB_KEY } from './constants'; +import { put, takeLatest, select } from 'redux-saga/effects'; +import { update, SagaActionTypes } from './'; +import { resolveFromLocalStorageAsBoolean } from '../../lib/storage'; +import { resolveActiveTab } from './utils'; + +export const getKeyWithUserId = (key: string) => (state) => { + const user = getDeepProperty(state, 'authentication.user.data', null); + + if (user) { + return `${user.id}-${key}`; + } +}; + +export function* updateSidekick(action) { + const { isOpen } = action.payload; + + const sidekickOpenStorageWithUserId = yield select(getKeyWithUserId(SIDEKICK_OPEN_STORAGE)); + + if (sidekickOpenStorageWithUserId) { + localStorage.setItem(sidekickOpenStorageWithUserId, isOpen); + + yield put( + update({ + isSidekickOpen: isOpen, + }) + ); + } +} + +export function* updateActiveSidekickTab(action) { + const { activeTab } = action.payload; + + const sidekickTabKeyWithUserId = yield select(getKeyWithUserId(SIDEKICK_TAB_KEY)); + + if (sidekickTabKeyWithUserId) { + localStorage.setItem(sidekickTabKeyWithUserId, activeTab); + + yield put( + update({ + activeSidekickTab: activeTab, + }) + ); + } +} + +export function* syncSidekickState() { + const sidekickTabKeyWithUserId = yield select(getKeyWithUserId(SIDEKICK_TAB_KEY)); + const sidekickOpenStorageWithUserId = yield select(getKeyWithUserId(SIDEKICK_OPEN_STORAGE)); + + if (sidekickTabKeyWithUserId && sidekickOpenStorageWithUserId) { + const isSidekickOpen = resolveFromLocalStorageAsBoolean(sidekickOpenStorageWithUserId); + const activeSidekickTab = resolveActiveTab(sidekickTabKeyWithUserId); + + yield put( + update({ + isSidekickOpen, + activeSidekickTab, + }) + ); + } +} + +export function* saga() { + yield takeLatest(SagaActionTypes.updateSidekick, updateSidekick); + yield takeLatest(SagaActionTypes.setActiveSidekickTab, updateActiveSidekickTab); + yield takeLatest(SagaActionTypes.syncSidekickState, syncSidekickState); +} diff --git a/src/store/layout/types.ts b/src/store/layout/types.ts new file mode 100644 index 000000000..021d5c0a9 --- /dev/null +++ b/src/store/layout/types.ts @@ -0,0 +1,20 @@ +import { SidekickTabs } from './../../components/sidekick/types'; + +export interface AppLayout { + hasContextPanel: boolean; + isContextPanelOpen: boolean; + isSidekickOpen: boolean; + activeSidekickTab: SidekickTabs; +} + +export interface LayoutState { + value: AppLayout; +} + +export interface UpdateSidekickPayload { + isOpen: boolean; +} + +export interface SetActiveSidekickTabPayload { + activeTab: SidekickTabs; +} diff --git a/src/store/layout/utils.test.ts b/src/store/layout/utils.test.ts new file mode 100644 index 000000000..213b12eb1 --- /dev/null +++ b/src/store/layout/utils.test.ts @@ -0,0 +1,34 @@ +import { SidekickTabs } from './../../components/sidekick/types'; +import { resolveActiveTab } from './utils'; + +describe('layout.utils', () => { + describe('resolveActiveTab', () => { + it('should use the key to get the data', () => { + const key = 'key'; + resolveActiveTab(key); + expect(global.localStorage.getItem).toHaveBeenCalledWith(key); + }); + + it('returns default active tab', () => { + const key = 'key'; + const activeTab = resolveActiveTab(key); + expect(activeTab).toEqual(SidekickTabs.MESSAGES); + }); + + it('returns stored active tab', () => { + const key = 'key'; + global.localStorage.getItem = jest.fn().mockReturnValue('notifications'); + + const activeTab = resolveActiveTab(key); + expect(activeTab).toEqual(SidekickTabs.NOTIFICATIONS); + }); + + it('returns default in case stored tab is not matching any tab', () => { + const key = 'key'; + global.localStorage.getItem = jest.fn().mockReturnValue('data here'); + + const activeTab = resolveActiveTab(key); + expect(activeTab).toEqual(SidekickTabs.MESSAGES); + }); + }); +}); diff --git a/src/store/layout/utils.ts b/src/store/layout/utils.ts new file mode 100644 index 000000000..347819505 --- /dev/null +++ b/src/store/layout/utils.ts @@ -0,0 +1,12 @@ +import { SidekickTabs } from '../../components/sidekick/types'; +import { DEFAULT_ACTIVE_TAB } from './constants'; + +export function resolveActiveTab(key: string) { + const activeTab = localStorage.getItem(key) as SidekickTabs; + + if (Object.values(SidekickTabs).includes(activeTab)) { + return activeTab; + } + + return DEFAULT_ACTIVE_TAB; +} diff --git a/src/store/saga.ts b/src/store/saga.ts index 2d9d60453..6ec49fe9f 100644 --- a/src/store/saga.ts +++ b/src/store/saga.ts @@ -9,6 +9,7 @@ import { saga as channels } from './channels/saga'; import { saga as authentication } from './authentication/saga'; import { saga as chat } from './chat/saga'; import { saga as theme } from './theme/saga'; +import { saga as layout } from './layout/saga'; export function* rootSaga() { const allSagas = { @@ -21,6 +22,7 @@ export function* rootSaga() { authentication, chat, theme, + layout, }; yield all(