Merge pull request #8336 from strapi/front/application-settings

Add application details page
This commit is contained in:
ELABBASSI Hicham 2020-10-15 17:32:40 +02:00 committed by GitHub
commit e26b45e6be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 619 additions and 179 deletions

View File

@ -1,6 +1,7 @@
import styled from 'styled-components';
const A = styled.a`
display: flex;
position: relative;
padding-top: 0.7rem;
padding-bottom: 0.2rem;
@ -10,7 +11,6 @@ const A = styled.a`
cursor: pointer;
color: ${props => props.theme.main.colors.leftMenu['link-color']};
text-decoration: none;
display: block;
-webkit-font-smoothing: antialiased;
&:hover {
@ -31,6 +31,8 @@ const A = styled.a`
}
&.linkActive {
padding-right: 2.3rem;
color: white !important;
border-left: 0.3rem solid ${props => props.theme.main.colors.strapi.blue};
}

View File

@ -10,10 +10,10 @@ import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import styled from 'styled-components';
import { Link, withRouter } from 'react-router-dom';
import en from '../../../translations/en.json';
import LeftMenuIcon from './LeftMenuIcon';
import A from './A';
import NotificationCount from './NotificationCount';
const LinkLabel = styled.span`
display: inline-block;
@ -23,7 +23,7 @@ const LinkLabel = styled.span`
`;
// TODO: refacto this file
const LeftMenuLinkContent = ({ destination, iconName, label, location }) => {
const LeftMenuLinkContent = ({ destination, iconName, label, location, notificationsCount }) => {
const isLinkActive = startsWith(
location.pathname.replace('/admin', '').concat('/'),
destination.concat('/')
@ -67,6 +67,7 @@ const LeftMenuLinkContent = ({ destination, iconName, label, location }) => {
>
<LeftMenuIcon icon={iconName} />
{content}
{notificationsCount > 0 && <NotificationCount count={notificationsCount} />}
</A>
);
};
@ -78,6 +79,7 @@ LeftMenuLinkContent.propTypes = {
location: PropTypes.shape({
pathname: PropTypes.string,
}).isRequired,
notificationsCount: PropTypes.number.isRequired,
};
export default withRouter(LeftMenuLinkContent);

View File

@ -0,0 +1,31 @@
import React from 'react';
import styled from 'styled-components';
import PropTypes from 'prop-types';
import { Text } from '@buffetjs/core';
const NotificationWrapper = styled.div`
height: 14px;
margin-top: 4px;
padding: 0px 4px;
background-color: #383d49;
border-radius: 2px;
font-size: 11px;
`;
const NotificationCount = ({ count }) => (
<NotificationWrapper>
<Text fontWeight="bold" fontSize="xs" lineHeight="14px" color="#919bae">
{count}
</Text>
</NotificationWrapper>
);
NotificationCount.defaultProps = {
count: 0,
};
NotificationCount.propTypes = {
count: PropTypes.number,
};
export default NotificationCount;

View File

@ -9,13 +9,14 @@ import PropTypes from 'prop-types';
import LeftMenuLinkContent from './LeftMenuLinkContent';
const LeftMenuLink = ({ destination, iconName, label, location }) => {
const LeftMenuLink = ({ destination, iconName, label, location, notificationsCount }) => {
return (
<LeftMenuLinkContent
destination={destination}
iconName={iconName}
label={label}
location={location}
notificationsCount={notificationsCount}
/>
);
};
@ -27,6 +28,7 @@ LeftMenuLink.propTypes = {
location: PropTypes.shape({
pathname: PropTypes.string,
}).isRequired,
notificationsCount: PropTypes.number.isRequired,
};
LeftMenuLink.defaultProps = {

View File

@ -3,7 +3,6 @@ import styled from 'styled-components';
const LeftMenuListLink = styled.div`
max-height: 180px;
margin-bottom: 19px;
margin-right: 28px;
overflow: auto;
`;

View File

@ -46,6 +46,7 @@ const LeftMenuLinksSection = ({
iconName={link.icon}
label={link.label}
destination={link.destination}
notificationsCount={link.notificationsCount || 0}
/>
))
) : (

View File

@ -5,12 +5,20 @@
*/
import {
GET_STRAPI_LATEST_RELEASE_SUCCEEDED,
GET_USER_PERMISSIONS,
GET_USER_PERMISSIONS_ERROR,
GET_USER_PERMISSIONS_SUCCEEDED,
SET_APP_ERROR,
} from './constants';
export function getStrapiLatestReleaseSucceeded(latestStrapiReleaseTag) {
return {
type: GET_STRAPI_LATEST_RELEASE_SUCCEEDED,
latestStrapiReleaseTag,
};
}
export function getUserPermissions() {
return {
type: GET_USER_PERMISSIONS,

View File

@ -5,6 +5,8 @@
*/
export const SET_APP_ERROR = 'StrapiAdmin/Admin/SET_APP_ERROR';
export const GET_STRAPI_LATEST_RELEASE_SUCCEEDED =
'StrapiAdmin/Admin/GET_STRAPI_LATEST_RELEASE_SUCCEEDED';
export const GET_USER_PERMISSIONS = 'StrapiAdmin/Admin/GET_USER_PERMISSIONS';
export const GET_USER_PERMISSIONS_ERROR = 'StrapiAdmin/Admin/GET_USER_PERMISSIONS_ERROR';
export const GET_USER_PERMISSIONS_SUCCEEDED = 'StrapiAdmin/Admin/GET_USER_PERMISSIONS_SUCCEEDED';

View File

@ -23,7 +23,7 @@ import {
CheckPagePermissions,
request,
} from 'strapi-helper-plugin';
import { SETTINGS_BASE_URL, SHOW_TUTORIALS } from '../../config';
import { SETTINGS_BASE_URL, SHOW_TUTORIALS, STRAPI_UPDATE_NOTIF } from '../../config';
import adminPermissions from '../../permissions';
import Header from '../../components/Header/index';
@ -42,10 +42,12 @@ import Logout from './Logout';
import {
disableGlobalOverlayBlocker,
enableGlobalOverlayBlocker,
getInfosDataSucceeded,
updatePlugin,
} from '../App/actions';
import makeSelecApp from '../App/selectors';
import {
getStrapiLatestReleaseSucceeded,
getUserPermissions,
getUserPermissionsError,
getUserPermissionsSucceeded,
@ -67,7 +69,7 @@ export class Admin extends React.Component {
componentDidMount() {
this.emitEvent('didAccessAuthenticatedAdministration');
this.fetchUserPermissions(true);
this.initApp();
}
shouldComponentUpdate(prevProps) {
@ -108,6 +110,59 @@ export class Admin extends React.Component {
}
};
fetchAppInfo = async () => {
try {
const { data } = await request('/admin/information', { method: 'GET' });
this.props.getInfosDataSucceeded(data);
} catch (err) {
console.error(err);
strapi.notification.error('notification.error');
}
};
fetchStrapiLatestRelease = async () => {
const {
global: { strapiVersion },
getStrapiLatestReleaseSucceeded,
} = this.props;
if (!STRAPI_UPDATE_NOTIF) {
return;
}
try {
const {
data: { tag_name },
} = await axios.get('https://api.github.com/repos/strapi/strapi/releases/latest');
getStrapiLatestReleaseSucceeded(tag_name);
const showUpdateNotif = !JSON.parse(localStorage.getItem('STRAPI_UPDATE_NOTIF'));
if (!showUpdateNotif) {
return;
}
if (`v${strapiVersion}` !== tag_name) {
strapi.notification.toggle({
type: 'info',
message: { id: 'notification.version.update.message' },
link: {
url: `https://github.com/strapi/strapi/releases/tag/${tag_name}`,
label: {
id: 'notification.version.update.link',
},
},
blockTransition: true,
onClose: () => localStorage.setItem('STRAPI_UPDATE_NOTIF', true),
});
}
} catch (err) {
// Silent
}
};
fetchUserPermissions = async (resetState = false) => {
const { getUserPermissions, getUserPermissionsError, getUserPermissionsSucceeded } = this.props;
@ -134,6 +189,12 @@ export class Admin extends React.Component {
return !Object.keys(plugins).every(plugin => plugins[plugin].isReady === true);
};
initApp = async () => {
await this.fetchAppInfo();
await this.fetchStrapiLatestRelease();
await this.fetchUserPermissions(true);
};
/**
* Display the app loader until the app is ready
* @returns {Boolean}
@ -170,7 +231,7 @@ export class Admin extends React.Component {
render() {
const {
admin: { isLoading, userPermissions },
admin: { isLoading, latestStrapiReleaseTag, userPermissions },
global: {
autoReload,
blockApp,
@ -211,14 +272,21 @@ export class Admin extends React.Component {
enableGlobalOverlayBlocker={enableGlobalOverlayBlocker}
fetchUserPermissions={this.fetchUserPermissions}
formatMessage={formatMessage}
latestStrapiReleaseTag={latestStrapiReleaseTag}
menu={this.menuRef.current}
plugins={plugins}
settingsBaseURL={SETTINGS_BASE_URL || '/settings'}
strapiVersion={strapiVersion}
updatePlugin={updatePlugin}
>
<UserProvider value={userPermissions}>
<Wrapper>
<LeftMenu version={strapiVersion} plugins={plugins} ref={this.menuRef} />
<LeftMenu
latestStrapiReleaseTag={latestStrapiReleaseTag}
version={strapiVersion}
plugins={plugins}
ref={this.menuRef}
/>
<NavTopRightWrapper>
{/* Injection zone not ready yet */}
<Logout />
@ -275,10 +343,13 @@ Admin.propTypes = {
admin: PropTypes.shape({
appError: PropTypes.bool,
isLoading: PropTypes.bool,
latestStrapiReleaseTag: PropTypes.string.isRequired,
userPermissions: PropTypes.array,
}).isRequired,
disableGlobalOverlayBlocker: PropTypes.func.isRequired,
enableGlobalOverlayBlocker: PropTypes.func.isRequired,
getInfosDataSucceeded: PropTypes.func.isRequired,
getStrapiLatestReleaseSucceeded: PropTypes.func.isRequired,
getUserPermissions: PropTypes.func.isRequired,
getUserPermissionsError: PropTypes.func.isRequired,
getUserPermissionsSucceeded: PropTypes.func.isRequired,
@ -311,6 +382,8 @@ export function mapDispatchToProps(dispatch) {
{
disableGlobalOverlayBlocker,
enableGlobalOverlayBlocker,
getInfosDataSucceeded,
getStrapiLatestReleaseSucceeded,
getUserPermissions,
getUserPermissionsError,
getUserPermissionsSucceeded,

View File

@ -5,17 +5,21 @@
*/
import produce from 'immer';
import packageJSON from '../../../../package.json';
import {
GET_STRAPI_LATEST_RELEASE_SUCCEEDED,
GET_USER_PERMISSIONS,
GET_USER_PERMISSIONS_ERROR,
GET_USER_PERMISSIONS_SUCCEEDED,
SET_APP_ERROR,
} from './constants';
const packageVersion = packageJSON.version;
const initialState = {
appError: false,
isLoading: true,
latestStrapiReleaseTag: `v${packageVersion}`,
userPermissions: [],
};
@ -23,6 +27,10 @@ const reducer = (state = initialState, action) =>
// eslint-disable-next-line consistent-return
produce(state, draftState => {
switch (action.type) {
case GET_STRAPI_LATEST_RELEASE_SUCCEEDED: {
draftState.latestStrapiReleaseTag = action.latestStrapiReleaseTag;
break;
}
case GET_USER_PERMISSIONS: {
draftState.isLoading = true;
break;

View File

@ -19,10 +19,13 @@ describe('<Admin />', () => {
props = {
admin: {
appError: false,
latestStrapiReleaseTag: '3',
},
disableGlobalOverlayBlocker: jest.fn(),
emitEvent: jest.fn(),
enableGlobalOverlayBlocker: jest.fn(),
getInfosDataSucceeded: jest.fn(),
getStrapiLatestReleaseSucceeded: jest.fn(),
getUserPermissions: jest.fn(),
getUserPermissionsError: jest.fn(),
getUserPermissionsSucceeded: jest.fn(),
@ -42,6 +45,7 @@ describe('<Admin />', () => {
intl: {
formatMessage: jest.fn(),
},
location: {},
setAppError: jest.fn(),
showGlobalAppBlocker: jest.fn(),

View File

@ -1,4 +1,5 @@
import produce from 'immer';
import packageJSON from '../../../../../package.json';
import {
setAppError,
getUserPermissions,
@ -14,6 +15,7 @@ describe('adminReducer', () => {
state = {
appError: false,
isLoading: true,
latestStrapiReleaseTag: `v${packageJSON.version}`,
userPermissions: [],
};
});

View File

@ -8,12 +8,12 @@ import {
DISABLE_GLOBAL_OVERLAY_BLOCKER,
ENABLE_GLOBAL_OVERLAY_BLOCKER,
FREEZE_APP,
GET_INFOS_DATA_SUCCEEDED,
GET_DATA_SUCCEEDED,
LOAD_PLUGIN,
PLUGIN_DELETED,
PLUGIN_LOADED,
UNFREEZE_APP,
UNSET_HAS_USERS_PLUGIN,
UPDATE_PLUGIN,
} from './constants';
@ -36,6 +36,13 @@ export function freezeApp(data) {
};
}
export function getInfosDataSucceeded(data) {
return {
type: GET_INFOS_DATA_SUCCEEDED,
data,
};
}
export function getDataSucceeded(data) {
return {
type: GET_DATA_SUCCEEDED,
@ -70,12 +77,6 @@ export function unfreezeApp() {
};
}
export function unsetHasUserPlugin() {
return {
type: UNSET_HAS_USERS_PLUGIN,
};
}
export function updatePlugin(pluginId, updatedKey, updatedValue) {
return {
type: UPDATE_PLUGIN,

View File

@ -9,9 +9,10 @@ export const LOAD_PLUGIN = 'app/App/LOAD_PLUGIN';
export const PLUGIN_LOADED = 'app/App/PLUGIN_LOADED';
export const PLUGIN_DELETED = 'app/App/PLUGIN_DELETED';
export const UNFREEZE_APP = 'app/App/UNFREEZE_APP';
export const UNSET_HAS_USERS_PLUGIN = 'app/App/UNSET_HAS_USERS_PLUGIN';
export const UPDATE_PLUGIN = 'app/App/UPDATE_PLUGIN';
export const DISABLE_GLOBAL_OVERLAY_BLOCKER =
'app/App/OverlayBlocker/DISABLE_GLOBAL_OVERLAY_BLOCKER';
export const ENABLE_GLOBAL_OVERLAY_BLOCKER = 'app/App/OverlayBlocker/ENABLE_GLOBAL_OVERLAY_BLOCKER';
export const GET_DATA_SUCCEEDED = 'app/App/GET_DATA_SUCCEEDED';
export const GET_INFOS_DATA_SUCCEEDED = 'admin/App/GET_INFOS_DATA_SUCCEEDED';

View File

@ -35,25 +35,31 @@ function App(props) {
getDataRef.current = props.getDataSucceeded;
useEffect(() => {
const getData = async () => {
const currentToken = auth.getToken();
const currentToken = auth.getToken();
if (currentToken) {
try {
const {
data: { token },
} = await request('/admin/renew-token', {
method: 'POST',
body: { token: currentToken },
});
auth.updateToken(token);
} catch (err) {
// Refresh app
auth.clearAppStorage();
window.location.reload();
}
const renewToken = async () => {
try {
const {
data: { token },
} = await request('/admin/renew-token', {
method: 'POST',
body: { token: currentToken },
});
auth.updateToken(token);
} catch (err) {
// Refresh app
auth.clearAppStorage();
window.location.reload();
}
};
if (currentToken) {
renewToken();
}
}, []);
useEffect(() => {
const getData = async () => {
try {
const { data } = await request('/admin/init', { method: 'GET' });
@ -87,7 +93,7 @@ function App(props) {
};
getData();
}, [getDataRef]);
}, []);
if (isLoading) {
return <LoadingIndicatorPage />;

View File

@ -6,16 +6,17 @@ import {
DISABLE_GLOBAL_OVERLAY_BLOCKER,
ENABLE_GLOBAL_OVERLAY_BLOCKER,
FREEZE_APP,
GET_INFOS_DATA_SUCCEEDED,
GET_DATA_SUCCEEDED,
PLUGIN_DELETED,
PLUGIN_LOADED,
UNFREEZE_APP,
UNSET_HAS_USERS_PLUGIN,
UPDATE_PLUGIN,
} from './constants';
const packageVersion = packageJSON.version;
const initialState = fromJS({
appInfos: {},
autoReload: false,
blockApp: false,
currentEnvironment: 'development',
@ -43,24 +44,27 @@ function appReducer(state = initialState, action) {
return null;
});
case GET_DATA_SUCCEEDED: {
const {
data: { hasAdmin, uuid, currentEnvironment, autoReload, strapiVersion },
} = action;
if (strapiVersion !== state.get('strapiVersion')) {
case GET_INFOS_DATA_SUCCEEDED: {
if (action.data.strapiVersion !== state.get('strapiVersion')) {
console.error(
`It seems that the built version ${packageVersion} is different than your project's one (${strapiVersion})`
`It seems that the built version ${packageVersion} is different than your project's one (${action.data.strapiVersion})`
);
console.error('Please delete your `.cache` and `build` folders and restart your app');
}
return (
state
.update('appInfos', () => action.data)
// Keep this for plugins legacy
.update('autoReload', () => action.data.autoReload)
.update('currentEnvironment', () => action.data.currentEnvironment)
);
}
case GET_DATA_SUCCEEDED: {
return state
.update('isLoading', () => false)
.update('hasAdminUser', () => hasAdmin)
.update('uuid', () => uuid)
.update('autoReload', () => autoReload)
.update('currentEnvironment', () => currentEnvironment);
.update('hasAdminUser', () => action.data.hasAdmin)
.update('uuid', () => action.data.uuid);
}
case PLUGIN_LOADED:
return state.setIn(['plugins', action.plugin.id], fromJS(action.plugin));
@ -73,8 +77,7 @@ function appReducer(state = initialState, action) {
return state.deleteIn(['plugins', action.plugin]);
case UNFREEZE_APP:
return state.set('blockApp', false).set('overlayBlockerData', null);
case UNSET_HAS_USERS_PLUGIN:
return state.set('hasUserPlugin', false);
default:
return state;
}

View File

@ -9,47 +9,22 @@ const selectApp = () => state => state.get('app');
* Select the language locale
*/
const selectPlugins = () =>
createSelector(
selectApp(),
appState => appState.get('plugins')
);
const selectPlugins = () => createSelector(selectApp(), appState => appState.get('plugins'));
const makeSelectApp = () =>
createSelector(
selectApp(),
appState => appState.toJS()
);
const makeSelectApp = () => createSelector(selectApp(), appState => appState.toJS());
const selectHasUserPlugin = () =>
createSelector(
selectApp(),
appState => appState.get('hasUserPlugin')
);
createSelector(selectApp(), appState => appState.get('hasUserPlugin'));
const makeSelectShowGlobalAppBlocker = () =>
createSelector(
selectApp(),
appState => appState.get('showGlobalAppBlocker')
);
createSelector(selectApp(), appState => appState.get('showGlobalAppBlocker'));
const makeSelectBlockApp = () =>
createSelector(
selectApp(),
appState => appState.get('blockApp')
);
const makeSelectBlockApp = () => createSelector(selectApp(), appState => appState.get('blockApp'));
const makeSelectOverlayBlockerProps = () =>
createSelector(
selectApp(),
appState => appState.get('overlayBlockerData')
);
createSelector(selectApp(), appState => appState.get('overlayBlockerData'));
const makeSelectUuid = () =>
createSelector(
selectApp(),
appState => appState.get('uuid')
);
const makeSelectUuid = () => createSelector(selectApp(), appState => appState.get('uuid'));
export default makeSelectApp;
export {

View File

@ -1,19 +1,21 @@
import {
FREEZE_APP,
GET_DATA_SUCCEEDED,
GET_INFOS_DATA_SUCCEEDED,
LOAD_PLUGIN,
PLUGIN_DELETED,
PLUGIN_LOADED,
UNFREEZE_APP,
UNSET_HAS_USERS_PLUGIN,
UPDATE_PLUGIN,
} from '../constants';
import {
freezeApp,
loadPlugin,
getInfosDataSucceeded,
getDataSucceeded,
pluginDeleted,
pluginLoaded,
unfreezeApp,
unsetHasUserPlugin,
updatePlugin,
} from '../actions';
@ -40,6 +42,30 @@ describe('<App /> actions', () => {
});
});
describe('getDataSucceeded', () => {
it('shoudl return the correct type and the passed data', () => {
const data = { ok: true };
const expected = {
type: GET_DATA_SUCCEEDED,
data,
};
expect(getDataSucceeded(data)).toEqual(expected);
});
});
describe('getInfosDataSucceeded', () => {
it('shoudl return the correct type and the passed data', () => {
const data = { ok: true };
const expected = {
type: GET_INFOS_DATA_SUCCEEDED,
data,
};
expect(getInfosDataSucceeded(data)).toEqual(expected);
});
});
describe('loadPlugin', () => {
it('should return the correct type and the passed data', () => {
const plugin = {
@ -82,16 +108,6 @@ describe('<App /> actions', () => {
});
});
describe('unsetHasUserPlugin', () => {
it('should return the correct type', () => {
const expected = {
type: UNSET_HAS_USERS_PLUGIN,
};
expect(unsetHasUserPlugin()).toEqual(expected);
});
});
describe('updatePlugin', () => {
it('should return the correct type and the passed data', () => {
const pluginId = 'content-manager';
@ -104,9 +120,7 @@ describe('<App /> actions', () => {
updatedValue,
};
expect(updatePlugin(pluginId, updatedKey, updatedValue)).toEqual(
expected
);
expect(updatePlugin(pluginId, updatedKey, updatedValue)).toEqual(expected);
});
});
});

View File

@ -4,10 +4,11 @@ import {
disableGlobalOverlayBlocker,
enableGlobalOverlayBlocker,
freezeApp,
getDataSucceeded,
getInfosDataSucceeded,
pluginDeleted,
pluginLoaded,
unfreezeApp,
unsetHasUserPlugin,
updatePlugin,
} from '../actions';
import appReducer from '../reducer';
@ -17,6 +18,7 @@ describe('<App /> reducer', () => {
beforeEach(() => {
state = fromJS({
appInfos: {},
autoReload: false,
blockApp: false,
currentEnvironment: 'development',
@ -96,9 +98,34 @@ describe('<App /> reducer', () => {
expect(appReducer(state, unfreezeApp())).toEqual(expectedResult);
});
it('should handle the unsetHasUserPlugin action correclty', () => {
const expectedResult = state.set('hasUserPlugin', false);
describe('GET_INFOS_DATA_SUCCEEDED', () => {
it('should handle the set the data correctly', () => {
const data = {
autoReload: true,
communityEdition: false,
currentEnvironment: 'test',
nodeVersion: 'v12.14.1',
strapiVersion: '3.2.1',
};
const expected = state
.set('appInfos', data)
.set('autoReload', true)
.set('currentEnvironment', 'test');
expect(appReducer(state, unsetHasUserPlugin())).toEqual(expectedResult);
expect(appReducer(state, getInfosDataSucceeded(data))).toEqual(expected);
});
});
describe('GET_DATA_SUCCEEDED', () => {
it('should handle the set the data correctly', () => {
const expected = state
.set('hasAdminUser', true)
.set('uuid', 'true')
.set('isLoading', false);
expect(appReducer(state, getDataSucceeded({ hasAdmin: true, uuid: 'true' }))).toEqual(
expected
);
});
});
});

View File

@ -0,0 +1,30 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Text } from '@buffetjs/core';
import InfoText from '../InfoText';
import Link from '../Link';
import Wrapper from '../Wrapper';
const Detail = ({ content, link, title }) => {
return (
<Wrapper>
<Text fontSize="xs" color="grey" fontWeight="bold">
{title}
</Text>
<InfoText content={content} />
{link && <Link {...link} />}
</Wrapper>
);
};
Detail.defaultProps = {
link: null,
};
Detail.propTypes = {
content: PropTypes.string.isRequired,
link: PropTypes.object,
title: PropTypes.string.isRequired,
};
export default Detail;

View File

@ -0,0 +1,19 @@
import React from 'react';
import { Padded, Text } from '@buffetjs/core';
import PropTypes from 'prop-types';
const InfoText = ({ content }) => {
return (
<Padded top size="xs">
<Text fontWeight="semiBold" lineHeight="13px">
{content}
</Text>
</Padded>
);
};
InfoText.propTypes = {
content: PropTypes.string.isRequired,
};
export default InfoText;

View File

@ -0,0 +1,22 @@
import styled from 'styled-components';
import { Text } from '@buffetjs/core';
import { Arrow } from '@buffetjs/icons';
const LinkText = styled(Text)`
color: ${({ theme }) => theme.main.colors.mediumBlue};
> a {
&:hover {
color: ${({ theme }) => theme.main.colors.mediumBlue};
text-decoration: none;
}
}
`;
export const LinkArrow = styled(Arrow)`
transform: rotate(45deg);
margin-top: 2px;
margin-left: 10px;
color: ${({ theme }) => theme.main.colors.blue};
`;
export default LinkText;

View File

@ -0,0 +1,26 @@
import React from 'react';
import { Padded } from '@buffetjs/core';
import PropTypes from 'prop-types';
import BaselineAlignement from '../../../../components/BaselineAlignement';
import LinkText, { LinkArrow } from './components';
const Link = ({ href, label }) => {
return (
<Padded top size="smd">
<BaselineAlignement top size="1px" />
<LinkText fontWeight="semiBold">
<a href={href} target="_blank" rel="noopener noreferrer">
{label}
<LinkArrow />
</a>
</LinkText>
</Padded>
);
};
Link.propTypes = {
href: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
};
export default Link;

View File

@ -0,0 +1,7 @@
import styled from 'styled-components';
const Wrapper = styled.div`
width: 50%;
`;
export default Wrapper;

View File

@ -0,0 +1,4 @@
export { default as Detail } from './Detail';
export { default as InfoText } from './InfoText';
export { default as Link } from './Link';
export { default as Wrapper } from './Wrapper';

View File

@ -0,0 +1,86 @@
import React, { memo, useMemo } from 'react';
import { Header } from '@buffetjs/custom';
import { Flex, Padded, Text } from '@buffetjs/core';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { useIntl } from 'react-intl';
import BaselineAlignement from '../../components/BaselineAlignement';
import Bloc from '../../components/Bloc';
import PageTitle from '../../components/SettingsPageTitle';
import makeSelectApp from '../App/selectors';
import makeSelectAdmin from '../Admin/selectors';
import { Detail, InfoText } from './components';
const makeSelectAppInfos = () => createSelector(makeSelectApp(), appState => appState.appInfos);
const makeSelectLatestRelease = () =>
createSelector(makeSelectAdmin(), adminState => adminState.latestStrapiReleaseTag);
const ApplicationInfosPage = () => {
const { formatMessage } = useIntl();
const selectAppInfos = useMemo(makeSelectAppInfos, []);
const selectLatestRealase = useMemo(makeSelectLatestRelease, []);
const appInfos = useSelector(state => selectAppInfos(state));
const latestStrapiReleaseTag = useSelector(state => selectLatestRealase(state));
const currentPlan = appInfos.communityEdition
? 'app.components.UpgradePlanModal.text-ce'
: 'app.components.UpgradePlanModal.text-ee';
const headerProps = {
title: { label: formatMessage({ id: 'Settings.application.title' }) },
content: formatMessage({
id: 'Settings.application.description',
}),
};
const pricingLabel = formatMessage({ id: 'Settings.application.link-pricing' });
const upgradeLabel = formatMessage({ id: 'Settings.application.link-upgrade' });
const strapiVersion = formatMessage({ id: 'Settings.application.strapi-version' });
const nodeVersion = formatMessage({ id: 'Settings.application.node-version' });
const editionTitle = formatMessage({ id: 'Settings.application.edition-title' });
const shouldShowUpgradeLink = `v${appInfos.strapiVersion}` !== latestStrapiReleaseTag;
/* eslint-disable indent */
const upgradeLink = shouldShowUpgradeLink
? {
label: upgradeLabel,
href: `https://github.com/strapi/strapi/releases/tag/${latestStrapiReleaseTag}`,
}
: null;
/* eslint-enable indent */
return (
<div>
<PageTitle name="Infos" />
<Header {...headerProps} />
<BaselineAlignement top size="3px" />
<Bloc>
<Padded left right top size="smd">
<Padded left right top size="xs">
<Flex justifyContent="space-between">
<Detail
link={upgradeLink}
title={strapiVersion}
content={`v${appInfos.strapiVersion}`}
/>
<Detail
link={{ label: pricingLabel, href: 'https://strapi.io/pricing' }}
title={editionTitle}
content={formatMessage({ id: currentPlan })}
/>
</Flex>
<Padded top size="lg">
<Text fontSize="xs" color="grey" fontWeight="bold">
{nodeVersion}
</Text>
<InfoText content={appInfos.nodeVersion} />
</Padded>
</Padded>
</Padded>
<BaselineAlignement top size="60px" />
</Bloc>
</div>
);
};
export default memo(ApplicationInfosPage);

View File

@ -4,16 +4,12 @@
*
*/
/* eslint-disable */
import React, { memo, useMemo, useEffect } from 'react';
import React, { memo, useMemo } from 'react';
import { FormattedMessage } from 'react-intl';
import { get, isNil, upperFirst } from 'lodash';
import { get, upperFirst } from 'lodash';
import { auth, LoadingIndicatorPage } from 'strapi-helper-plugin';
import axios from 'axios';
import { useSelector } from 'react-redux';
import PageTitle from '../../components/PageTitle';
import { useModels } from '../../hooks';
import { STRAPI_UPDATE_NOTIF } from '../../config';
import useFetch from './hooks';
import { ALink, Block, Container, LinkWrapper, P, Wave, Separator } from './components';
@ -65,54 +61,7 @@ const SOCIAL_LINKS = [
},
];
const HomePage = ({ global: { strapiVersion }, history: { push } }) => {
const notifications = useSelector(state => state.get('newNotification').notifications);
useEffect(() => {
const getStrapiLatestRelease = async () => {
try {
const notificationAlreadyExist =
notifications.findIndex(notification => notification.uid === 'STRAPI_UPDATE_NOTIF') != -1;
const showUpdateNotif =
STRAPI_UPDATE_NOTIF &&
!JSON.parse(localStorage.getItem('STRAPI_UPDATE_NOTIF')) &&
!notificationAlreadyExist;
if (showUpdateNotif) {
const res = await fetch('https://api.github.com/repos/strapi/strapi/releases/latest');
const data = await res.json();
if (strapiVersion !== data.name.split('v').join('')) {
strapi.notification.toggle({
type: 'info',
message: { id: 'notification.version.update.message' },
link: {
url: `https://github.com/strapi/strapi/releases/tag/${data.name}`,
label: {
id: 'notification.version.update.link',
},
},
blockTransition: true,
// Used to check if the notification is already displayed
// to avoid multiple notifications each time the user goes back to the home page.
uid: 'STRAPI_UPDATE_NOTIF',
onClose: () => localStorage.setItem('STRAPI_UPDATE_NOTIF', true),
});
}
}
} catch (e) {
strapi.notification.toggle({
type: 'warning',
message: { id: 'notification.error' },
});
}
};
getStrapiLatestRelease();
}, []);
const HomePage = ({ history: { push } }) => {
const { error, isLoading, posts } = useFetch();
// Temporary until we develop the menu API
const { collectionTypes, singleTypes, isLoading: isLoadingForModels } = useModels();

View File

@ -30,10 +30,19 @@ import reducer, { initialState } from './reducer';
import Loader from './Loader';
import Wrapper from './Wrapper';
const LeftMenu = forwardRef(({ version, plugins }, ref) => {
const LeftMenu = forwardRef(({ latestStrapiReleaseTag, version, plugins }, ref) => {
const location = useLocation();
const permissions = useContext(UserContext);
const { menu: settingsMenu } = useSettingsMenu(true);
// TODO: this needs to be added to the settings API in the v4
const settingsLinkNotificationCount = useMemo(() => {
if (`v${version}` !== latestStrapiReleaseTag) {
return 1;
}
return 0;
}, [latestStrapiReleaseTag, version]);
const [
{
collectionTypesSectionLinks,
@ -43,7 +52,9 @@ const LeftMenu = forwardRef(({ version, plugins }, ref) => {
singleTypesSectionLinks,
},
dispatch,
] = useReducer(reducer, initialState, () => init(initialState, plugins, settingsMenu));
] = useReducer(reducer, initialState, () =>
init(initialState, plugins, settingsMenu, settingsLinkNotificationCount)
);
const generalSectionLinksFiltered = useMemo(() => filterLinks(generalSectionLinks), [
generalSectionLinks,
]);
@ -197,6 +208,7 @@ const LeftMenu = forwardRef(({ version, plugins }, ref) => {
});
LeftMenu.propTypes = {
latestStrapiReleaseTag: PropTypes.string.isRequired,
version: PropTypes.string.isRequired,
plugins: PropTypes.object.isRequired,
};

View File

@ -3,7 +3,7 @@ import { SETTINGS_BASE_URL } from '../../config';
import { sortLinks } from '../../utils';
import { getSettingsMenuLinksPermissions } from './utils';
const init = (initialState, plugins = {}, settingsMenu = []) => {
const init = (initialState, plugins = {}, settingsMenu = [], settingsLinkNotificationCount = 0) => {
const settingsLinkPermissions = getSettingsMenuLinksPermissions(settingsMenu);
const pluginsLinks = Object.values(plugins).reduce((acc, current) => {
@ -21,8 +21,10 @@ const init = (initialState, plugins = {}, settingsMenu = []) => {
if (!settingsLinkPermissions.filter(perm => perm === null).length && settingsLinkIndex !== -1) {
const permissionsPath = ['generalSectionLinks', settingsLinkIndex, 'permissions'];
const notificationPath = ['generalSectionLinks', settingsLinkIndex, 'notificationsCount'];
set(initialState, permissionsPath, settingsLinkPermissions);
set(initialState, notificationPath, settingsLinkNotificationCount);
}
if (sortedLinks.length) {

View File

@ -13,6 +13,7 @@ const initialState = {
destination: '/list-plugins',
isDisplayed: false,
permissions: adminPermissions.marketplace.main,
notificationsCount: 0,
},
{
icon: 'shopping-basket',
@ -20,6 +21,7 @@ const initialState = {
destination: '/marketplace',
isDisplayed: false,
permissions: adminPermissions.marketplace.main,
notificationsCount: 0,
},
{
icon: 'cog',
@ -29,6 +31,7 @@ const initialState = {
// Permissions of this link are retrieved in the init phase
// using the settings menu
permissions: [],
notificationsCount: 0,
},
],
singleTypesSectionLinks: [],

View File

@ -112,6 +112,7 @@ describe('ADMIN | LeftMenu | init', () => {
title: 'Settings.webhooks.title',
to: '/settings/webhooks',
name: 'webhooks',
permissions: [
{ action: 'admin::webhook.create', subject: null },
{ action: 'admin::webhook.read', subject: null },
@ -223,6 +224,7 @@ describe('ADMIN | LeftMenu | init', () => {
label: 'app.components.LeftMenuLinkContainer.settings',
isDisplayed: false,
destination: SETTINGS_BASE_URL,
notificationsCount: 0,
permissions: [
// webhooks
{ action: 'admin::webhook.create', subject: null },

View File

@ -0,0 +1,13 @@
import React from 'react';
import styled from 'styled-components';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
const LeftMenuIcon = styled(({ ...props }) => <FontAwesomeIcon {...props} icon="circle" />)`
position: absolute;
top: calc(50% - 0.25rem);
left: 1.5rem;
font-size: 0.5rem;
color: ${props => props.theme.main.colors.leftMenu['link-color']};
`;
export default LeftMenuIcon;

View File

@ -0,0 +1,25 @@
import styled from 'styled-components';
import { NavLink } from 'react-router-dom';
const Link = styled(NavLink)`
display: flex;
justify-content: space-between;
position: relative;
padding-left: 30px;
height: 34px;
border-radius: 2px;
&.active {
background-color: #e9eaeb;
> p {
font-weight: 600;
}
> svg {
color: #2d3138;
}
}
&:hover {
text-decoration: none;
}
`;
export default Link;

View File

@ -0,0 +1,17 @@
import styled from 'styled-components';
const Notif = styled.div`
margin: auto;
margin-right: 15px;
&:before {
content: '';
display: flex;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #007dff;
}
`;
export default Notif;

View File

@ -0,0 +1,9 @@
import styled from 'styled-components';
const Wrapper = styled.div`
position: relative;
margin-bottom: -5px;
padding: 25px 20px 0 20px;
`;
export default Wrapper;

View File

@ -0,0 +1,27 @@
import React, { memo } from 'react';
import { Text } from '@buffetjs/core';
import { FormattedMessage } from 'react-intl';
import { useGlobalContext } from 'strapi-helper-plugin';
import Icon from './Icon';
import Link from './Link';
import Notif from './Notif';
import Wrapper from './Wrapper';
const ApplicationDetailLink = () => {
const { latestStrapiReleaseTag, strapiVersion } = useGlobalContext();
const showNotif = `v${strapiVersion}` !== latestStrapiReleaseTag;
return (
<Wrapper>
<Link to="/settings/application-infos">
<Icon />
<Text lineHeight="34px">
<FormattedMessage id="Settings.application.title" />
</Text>
{showNotif && <Notif />}
</Link>
</Wrapper>
);
};
export default memo(ApplicationDetailLink);

View File

@ -0,0 +1,8 @@
import styled from 'styled-components';
// background-color: red;
const MenuWrapper = styled.div`
background-color: ${props => props.theme.main.colors.mediumGrey};
`;
export default MenuWrapper;

View File

@ -2,7 +2,7 @@ import React, { memo } from 'react';
import { useGlobalContext } from 'strapi-helper-plugin';
import { get } from 'lodash';
import { useParams } from 'react-router-dom';
import PageTitle from '../../components/SettingsPageTitle';
import PageTitle from '../../../../components/SettingsPageTitle';
const SettingDispatcher = () => {
const { plugins } = useGlobalContext();

View File

@ -0,0 +1,5 @@
export { default as ApplicationDetailLink } from './ApplicationDetailLink';
export { default as MenuWrapper } from './MenuWrapper';
export { default as SettingDispatcher } from './SettingDispatcher';
export { default as StyledLeftMenu } from './StyledLeftMenu';
export { default as Wrapper } from './Wrapper';

View File

@ -24,23 +24,28 @@ import HeaderSearch from '../../components/HeaderSearch';
import PageTitle from '../../components/PageTitle';
import { useSettingsMenu } from '../../hooks';
import { retrieveGlobalLinks } from '../../utils';
import ApplicationInfosPage from '../ApplicationInfosPage';
import SettingsSearchHeaderProvider from '../SettingsHeaderSearchContextProvider';
import UsersEditPage from '../Users/ProtectedEditPage';
import UsersListPage from '../Users/ProtectedListPage';
import RolesEditPage from '../Roles/ProtectedEditPage';
import WebhooksCreateView from '../Webhooks/ProtectedCreateView';
import WebhooksEditView from '../Webhooks/ProtectedEditView';
import WebhooksListView from '../Webhooks/ProtectedListView';
import {
ApplicationDetailLink,
MenuWrapper,
SettingDispatcher,
StyledLeftMenu,
Wrapper,
} from './components';
import {
createRoute,
findFirstAllowedEndpoint,
createPluginsLinksRoutes,
makeUniqueRoutes,
getSectionsToDisplay,
} from './utils';
import WebhooksCreateView from '../Webhooks/ProtectedCreateView';
import WebhooksEditView from '../Webhooks/ProtectedEditView';
import WebhooksListView from '../Webhooks/ProtectedListView';
import SettingDispatcher from './SettingDispatcher';
import LeftMenu from './StyledLeftMenu';
import Wrapper from './Wrapper';
function SettingsPage() {
const { settingId } = useParams();
@ -50,14 +55,6 @@ function SettingsPage() {
const { isLoading, menu } = useSettingsMenu();
const { formatMessage } = useIntl();
const pluginsGlobalLinks = useMemo(() => retrieveGlobalLinks(plugins), [plugins]);
const firstAvailableEndpoint = useMemo(() => {
// Don't need to compute while permissions are being checked
if (isLoading) {
return '';
}
return findFirstAllowedEndpoint(menu);
}, [menu, isLoading]);
// Create all the <Route /> that needs to be created by the plugins
// For instance the upload plugin needs to create a <Route />
@ -95,8 +92,8 @@ function SettingsPage() {
return <LoadingIndicatorPage />;
}
if (!settingId && firstAvailableEndpoint) {
return <Redirect to={firstAvailableEndpoint} />;
if (!settingId) {
return <Redirect to={`${settingsBaseURL}/application-infos`} />;
}
const settingTitle = formatMessage({ id: 'app.components.LeftMenuLinkContainer.settings' });
@ -109,14 +106,23 @@ function SettingsPage() {
<div className="row">
<div className="col-md-3">
<LeftMenu>
{filteredMenu.map(item => {
return <LeftMenuList {...item} key={item.id} />;
})}
</LeftMenu>
<MenuWrapper>
<ApplicationDetailLink />
<StyledLeftMenu>
{filteredMenu.map(item => {
return <LeftMenuList {...item} key={item.id} />;
})}
</StyledLeftMenu>
</MenuWrapper>
</div>
<div className="col-md-9">
<Switch>
<Route
exact
path={`${settingsBaseURL}/application-infos`}
component={ApplicationInfosPage}
/>
<Route exact path={`${settingsBaseURL}/roles`} component={ProtectedRolesListPage} />
<Route
exact

View File

@ -66,6 +66,13 @@
"Roles.RoleRow.user-count.plural": "{number} users",
"Roles.RoleRow.user-count.singular": "{number} user",
"Roles.components.List.empty.withSearch": "There is no role corresponding to the search ({search})...",
"Settings.application.title": "Application",
"Settings.application.description": "See your project's details",
"Settings.application.link-pricing": "See all pricing",
"Settings.application.link-upgrade": "Upgrade your project",
"Settings.application.strapi-version": "STRAPI VERSION",
"Settings.application.node-version": "NODE VERSION",
"Settings.application.edition-title": "CURRENT PLAN",
"Settings.PageTitle": "Settings - {name}",
"Settings.error": "Error",
"Settings.global": "Global Settings",