diff --git a/CHANGELOG.md b/CHANGELOG.md index 375a72b21..74ae3bb54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ * Unpin `moment` to reflect what is provided in platforms. Refs STCOR-706, STRIPES-678. * Expose additional functionality so it can be consumed via other modules. Refs STCOR-711. * Apps icons replaced with blue rectangle on apps menu. Refs STCOR-707. +* Display consortium active affiliation in the profile dropdown trigger. Refs STCOR-689. +* Support switch consortium active affiliation. Refs STCOR-690. ## [9.0.0](https://github.com/folio-org/stripes-core/tree/v9.0.0) (2023-01-30) [Full Changelog](https://github.com/folio-org/stripes-core/compare/v8.3.0...v9.0.0) diff --git a/index.js b/index.js index 5e26badc1..812e7453b 100644 --- a/index.js +++ b/index.js @@ -9,7 +9,7 @@ export { useModules } from './src/ModulesContext'; export { withModule, withModules } from './src/components/Modules'; export { default as stripesConnect } from './src/stripesConnect'; export { default as Pluggable } from './src/Pluggable'; -export { updateUser } from './src/loginServices'; +export { updateUser, updateTenant } from './src/loginServices'; export { default as coreEvents } from './src/events'; export { default as useOkapiKy } from './src/useOkapiKy'; export { default as withOkapiKy } from './src/withOkapiKy'; diff --git a/src/components/HandlerManager/HandlerManager.js b/src/components/HandlerManager/HandlerManager.js index 6d2d29f72..731485b33 100644 --- a/src/components/HandlerManager/HandlerManager.js +++ b/src/components/HandlerManager/HandlerManager.js @@ -20,6 +20,7 @@ class HandlerManager extends React.Component { constructor(props) { super(props); const { event, stripes, modules, data } = props; + this.components = getEventHandlers(event, stripes, modules.handler, data); } diff --git a/src/components/MainNav/ProfileDropdown/ProfileDropdown.css b/src/components/MainNav/ProfileDropdown/ProfileDropdown.css index bacdb4553..aff227d6f 100644 --- a/src/components/MainNav/ProfileDropdown/ProfileDropdown.css +++ b/src/components/MainNav/ProfileDropdown/ProfileDropdown.css @@ -24,9 +24,16 @@ .button__label { margin: 0 0.35rem; - display: inline-block; + display: inline-flex; + flex-direction: column; + align-items: start; max-width: 15rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + + & span { + width: 100%; + text-align: start; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } } diff --git a/src/components/MainNav/ProfileDropdown/ProfileDropdown.js b/src/components/MainNav/ProfileDropdown/ProfileDropdown.js index 29d82d69e..190a18820 100644 --- a/src/components/MainNav/ProfileDropdown/ProfileDropdown.js +++ b/src/components/MainNav/ProfileDropdown/ProfileDropdown.js @@ -1,6 +1,5 @@ import React, { Component } from 'react'; import { isFunction, kebabCase } from 'lodash'; -import get from 'lodash/get'; import { compose } from 'redux'; import { withRouter } from 'react-router'; import PropTypes from 'prop-types'; @@ -60,11 +59,7 @@ class ProfileDropdown extends Component { this.createHandlerComponent = this.createHandlerComponent.bind(this); this.navigateByUrl = this.navigateByUrl.bind(this); - const modulesWithLinks = this.getModulesWithLinks(); - this.userLinks = modulesWithLinks.reduce((acc, m) => { - const links = m.links.userDropdown.map((link, index) => this.createLink(link, index, m)); - return acc.concat(links); - }, []); + this.userLinks = this.getDropdownMenuLinks(); } setInitialState(callback) { @@ -73,9 +68,24 @@ class ProfileDropdown extends Component { }, callback); } - getModulesWithLinks() { + getDropdownMenuLinks = () => { + const modulesWithLinks = this.getModulesWithLinks(); + + return modulesWithLinks.reduce((acc, m) => { + const links = m.links.userDropdown.map((link, index) => this.createLink(link, index, m)); + return acc.concat(links); + }, []); + } + + getModulesWithLinks = () => { const { modules } = this.props; - return ([].concat(...Object.values(modules))) + return Object.values(modules) + .flat() + .filter((module, index, self) => { + return index === self.findIndex((m) => ( + m.module === module.module + )); + }) .filter(({ links }) => links && Array.isArray(links.userDropdown)); } @@ -122,6 +132,9 @@ class ProfileDropdown extends Component { } toggleDropdown() { + // Get items after rechecking for item visibility + this.userLinks = this.getDropdownMenuLinks(); + this.setState(({ dropdownOpen }) => ({ dropdownOpen: !dropdownOpen })); @@ -145,7 +158,8 @@ class ProfileDropdown extends Component { alt={user.name} ariaLabel={user.name} className={css.avatar} - />); + /> + ); } navigateByUrl(link) { @@ -168,7 +182,7 @@ class ProfileDropdown extends Component { * if setting is active in stripes config */ let perms = null; - if (stripes.config && stripes.config.showPerms) { + if (stripes.config?.showPerms) { perms = ( { @@ -209,7 +223,7 @@ class ProfileDropdown extends Component { { - (!stripes.config || !stripes.config.showHomeLink) ? + (!stripes.config?.showHomeLink) ? null : @@ -228,7 +242,6 @@ class ProfileDropdown extends Component { renderProfileTrigger = ({ getTriggerProps, open }) => { const { intl } = this.props; - const servicePointName = get(this.getUserData(), 'curServicePoint.name', null); return ( - - {servicePointName} - - - - ) : null} + label={this.renderProfileTriggerLabel({ open })} {...getTriggerProps()} /> ); } + renderProfileTriggerLabel = ({ open }) => { + const { okapi } = this.props.stripes; + const userData = this.getUserData(); + const servicePointName = userData?.curServicePoint?.name; + const tenantName = userData?.tenants?.find(({ id }) => id === okapi.tenant)?.name; + + const hasLabel = Boolean(servicePointName || tenantName); + + return ( + hasLabel ? ( + <> + + {tenantName && {tenantName}} + {servicePointName && {servicePointName}} + + + + ) : null + ); + } + renderProfileMenu = ({ open }) => ( {this.getDropdownContent()} diff --git a/src/components/MainNav/ProfileDropdown/ProfileDropdown.test.js b/src/components/MainNav/ProfileDropdown/ProfileDropdown.test.js new file mode 100644 index 000000000..fd024ccb5 --- /dev/null +++ b/src/components/MainNav/ProfileDropdown/ProfileDropdown.test.js @@ -0,0 +1,83 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +import { ModulesContext } from '../../../ModulesContext'; +import TestComponent from './ProfileDropdown'; + +jest.unmock('@folio/stripes-components'); +jest.mock('currency-codes/data', () => ({ filter: () => [] })); + +const checkAction = jest.fn(() => true); +const eventHandler = jest.fn(() => 'Handler content'); +const modules = { + app: [ + { + displayName: 'Test app', + handlerName: 'eventHandler', + route: '/test', + links: { + userDropdown: [{ + event: 'TEST_EVENT', + caption: 'Profile dropdown action', + check: 'checkAction', + }] + }, + getModule: jest.fn(() => ({ + checkAction, + eventHandler, + })), + }, + ], +}; + +const tenant = 'test'; +const stripes = { + user: { + user: { + id: 'user-id', + tenants: [{ + id: tenant, + name: 'Central office', + }] + }, + }, + okapi: { + tenant, + }, +}; + +const defaultProps = { + onLogout: jest.fn(), + stripes, +}; + +const wrapper = ({ children }) => ( + + + {children} + + +); + +const renderProfileDropdown = (props = {}) => render( + , + { wrapper }, +); + +describe('ProfileDropdown', () => { + it('should display current consortium (if enabled) in the dropdown trigger', () => { + renderProfileDropdown(); + + expect(screen.getByText('Central office')).toBeInTheDocument(); + }); + + it('should display module profile dropdown item', () => { + renderProfileDropdown(); + + expect(checkAction).toBeCalled(); + expect(screen.getByText('Profile dropdown action')).toBeInTheDocument(); + }); +}); diff --git a/src/loginServices.js b/src/loginServices.js index a38334323..cb6ed41fe 100644 --- a/src/loginServices.js +++ b/src/loginServices.js @@ -362,10 +362,13 @@ export function createOkapiSession(okapiUrl, store, tenant, token, data) { // permission-names for keys and `true` for values const perms = Object.assign({}, ...data.permissions.permissions.map(p => ({ [p.permissionName]: true }))); store.dispatch(setCurrentPerms(perms)); + + const sessionTenant = data.tenant || tenant; const okapiSess = { token, user, perms, + tenant: sessionTenant, }; return localforage.setItem('loginResponse', data) @@ -390,13 +393,14 @@ export function createOkapiSession(okapiUrl, store, tenant, token, data) { * @returns {Promise} */ export function validateUser(okapiUrl, store, tenant, session) { - return fetch(`${okapiUrl}/bl-users/_self`, { headers: getHeaders(tenant, session.token) }).then((resp) => { + const { token, user, perms, tenant: sessionTenant = tenant } = session; + + return fetch(`${okapiUrl}/bl-users/_self`, { headers: getHeaders(sessionTenant, token) }).then((resp) => { if (resp.ok) { - const { token, user, perms } = session; return resp.json().then((data) => { store.dispatch(setLoginData(data)); - store.dispatch(setSessionData({ token, user, perms })); - return loadResources(okapiUrl, store, tenant, user.id); + store.dispatch(setSessionData({ token, user, perms, tenant: sessionTenant })); + return loadResources(okapiUrl, store, sessionTenant, user.id); }); } else { store.dispatch(clearCurrentUser()); @@ -623,3 +627,21 @@ export function updateUser(store, data) { store.dispatch(updateCurrentUser(data)); }); } + +/** + * updateTenant + * 1. concat the given data onto local-storage tenant and save it + * 2. update full user info based on new tenant + * @param {string} okapiUrl okapi url + * @param {redux-store} store redux store + * @param {object} data + * + * @returns {Promise} + */ +export async function updateTenant(okapi, store, tenant) { + const okapiSess = await localforage.getItem('okapiSess'); + + await localforage.setItem('okapiSess', { ...okapiSess, tenant }); + + await requestUserWithPerms(okapi.url, store, tenant, okapi.token); +} diff --git a/src/loginServices.test.js b/src/loginServices.test.js index 545ee351d..a696634e7 100644 --- a/src/loginServices.test.js +++ b/src/loginServices.test.js @@ -1,4 +1,4 @@ -// import localforage from 'localforage'; +import localforage from 'localforage'; import { createOkapiSession, @@ -8,6 +8,7 @@ import { supportedLocales, supportedNumberingSystems, updateUser, + updateTenant, validateUser, } from './loginServices'; @@ -46,6 +47,7 @@ const mockFetchSuccess = (data) => { Promise.resolve({ ok: true, json: () => Promise.resolve(data), + headers: new Map(), }) )); }; @@ -244,11 +246,12 @@ describe('validateUser', () => { mockFetchCleanUp(); }); - it('handles valid user', async () => { + it('handles valid user with empty tenant in session', async () => { const store = { dispatch: jest.fn(), }; + const tenant = 'tenant'; const data = { monkey: 'bagel' }; const token = 'token'; const user = { id: 'id' }; @@ -261,9 +264,36 @@ describe('validateUser', () => { mockFetchSuccess(data); - await validateUser('url', store, 'tenant', session); + await validateUser('url', store, tenant, session); expect(store.dispatch).toHaveBeenCalledWith(setLoginData(data)); - expect(store.dispatch).toHaveBeenCalledWith(setSessionData({ token, user, perms })); + expect(store.dispatch).toHaveBeenCalledWith(setSessionData({ token, user, perms, tenant })); + + mockFetchCleanUp(); + }); + + it('handles valid user with tenant in session', async () => { + const store = { + dispatch: jest.fn(), + }; + + const tenant = 'tenant'; + const sessionTenant = 'sessionTenant'; + const data = { monkey: 'bagel' }; + const token = 'token'; + const user = { id: 'id' }; + const perms = []; + const session = { + token, + user, + perms, + tenant: sessionTenant, + }; + + mockFetchSuccess(data); + + await validateUser('url', store, tenant, session); + expect(store.dispatch).toHaveBeenCalledWith(setLoginData(data)); + expect(store.dispatch).toHaveBeenCalledWith(setSessionData({ token, user, perms, tenant: sessionTenant })); mockFetchCleanUp(); }); @@ -294,3 +324,44 @@ describe('updateUser', () => { expect(store.dispatch).toHaveBeenCalledWith(updateCurrentUser(data)); }); }); + +describe('updateTenant', () => { + const okapi = { + currentPerms: {}, + }; + const store = { + dispatch: jest.fn(), + getState: jest.fn().mockReturnValue({ okapi }), + }; + const tenant = 'test'; + const data = { + user: {}, + permissions: { + permissions: [], + }, + }; + + beforeEach(() => { + localforage.setItem.mockClear(); + store.dispatch.mockClear(); + }); + + it('should set tenant in session', async () => { + mockFetchSuccess(data); + await updateTenant(okapi, store, tenant); + mockFetchCleanUp(); + + expect(localforage.setItem).toHaveBeenCalledWith('okapiSess', { + user: {}, + tenant, + }); + }); + + it('should "relogin" user with new tenant', async () => { + mockFetchSuccess(data); + await updateTenant(okapi, store, tenant); + mockFetchCleanUp(); + + expect(store.dispatch).toHaveBeenCalledWith(setLoginData(data)); + }); +}); diff --git a/src/okapiReducer.js b/src/okapiReducer.js index 2c2f72168..aaa34563f 100644 --- a/src/okapiReducer.js +++ b/src/okapiReducer.js @@ -25,8 +25,10 @@ export default function okapiReducer(state = {}, action) { case 'CLEAR_CURRENT_USER': return Object.assign({}, state, { currentUser: {}, currentPerms: {} }); case 'SET_SESSION_DATA': { - const { perms, user, token } = action.session; - return { ...state, currentUser: user, currentPerms: perms, token }; + const { perms, user, token, tenant } = action.session; + const sessionTenant = tenant || state.tenant; + + return { ...state, currentUser: user, currentPerms: perms, token, tenant: sessionTenant }; } case 'SET_AUTH_FAILURE': return Object.assign({}, state, { authFailure: action.message }); diff --git a/src/okapiReducer.test.js b/src/okapiReducer.test.js index 7d84ff9a8..fc67ace6e 100644 --- a/src/okapiReducer.test.js +++ b/src/okapiReducer.test.js @@ -13,4 +13,32 @@ describe('okapiReducer', () => { const o = okapiReducer(initialState, { type: 'UPDATE_CURRENT_USER', data }); expect(o).toMatchObject({ ...initialState, currentUser: { ...data } }); }); + + it('SET_SESSION_DATA', () => { + const initialState = { + perms: [], + user: {}, + token: 'qwerty', + tenant: 'central', + }; + const session = { + perms: ['users.collection.get'], + user: { + user: { + id: 'userId', + username: 'admin', + } + }, + token: 'ytrewq', + tenant: 'institutional', + }; + const o = okapiReducer(initialState, { type: 'SET_SESSION_DATA', session }); + const { user, perms, ...rest } = session; + expect(o).toMatchObject({ + ...initialState, + ...rest, + currentUser: user, + currentPerms: perms, + }); + }); });