diff --git a/package.json b/package.json index 523fc4e..b47212f 100644 --- a/package.json +++ b/package.json @@ -79,8 +79,8 @@ "test:coverage": "npm run test -- --watchAll=false --coverage", "lint": "eslint --quiet .", "lint:fix": "eslint . --fix", - "prettier": "./node_modules/.bin/prettier --config .prettierrc --check 'src/**/*.js'", - "prettier:fix": "./node_modules/.bin/prettier --config .prettierrc --write 'src/**/*.js'", + "prettier": "./node_modules/.bin/prettier --config .prettierrc --check src/**/*.js", + "prettier:fix": "./node_modules/.bin/prettier --config .prettierrc --write src/**/*.js", "eject": "react-scripts eject", "push:pre": "npm-run-all --parallel test:ci lint prettier", "publish:clean": "rm -rf ./template/src ./template/server ./template/.vscode ./template/public && rm -f ./template/.editorconfig ./template/.env ./template/.eslintignore ./template/.eslintrc.js ./template/.prettierrc ./template/commitlint.config.js ./template/jest.config.json ./template/PULL_REQUEST_TEMPLATE.md ./template/README.md ./template/webpack.config.js", diff --git a/src/App.js b/src/App.js index cd83f68..cf61721 100644 --- a/src/App.js +++ b/src/App.js @@ -6,9 +6,10 @@ import { Helmet } from 'react-helmet'; import ErrorBoundary from './pages/Fallback/ErrorBoundary'; import Shell from './components/Shell/Shell'; import Routes from './routes/Routes'; - import './App.css'; import CenteredContent from './components/Layout/CenteredContent'; +import SessionTimeoutDialog from './components/SessionTimeout/SessionTimeoutDialog'; +import InformationZone from './components/InformationZone/InformationZone'; const style = { emptySpace: { @@ -29,6 +30,9 @@ function App() { + + + ); } diff --git a/src/components/InformationDialog/InformationDialog.js b/src/components/InformationDialog/InformationDialog.js new file mode 100644 index 0000000..d7753b6 --- /dev/null +++ b/src/components/InformationDialog/InformationDialog.js @@ -0,0 +1,132 @@ +import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { spacing } from '@ui5/webcomponents-react-base'; +import { Icon } from '@ui5/webcomponents-react/lib/Icon'; +import { Button } from '@ui5/webcomponents-react/lib/Button'; +import { ButtonDesign } from '@ui5/webcomponents-react/lib/ButtonDesign'; +import { Dialog } from '@ui5/webcomponents-react/lib/Dialog'; +import { FlexBox } from '@ui5/webcomponents-react/lib/FlexBox'; +import { FlexBoxDirection } from '@ui5/webcomponents-react/lib/FlexBoxDirection'; +import { FlexBoxAlignItems } from '@ui5/webcomponents-react/lib/FlexBoxAlignItems'; +import { FlexBoxJustifyContent } from '@ui5/webcomponents-react/lib/FlexBoxJustifyContent'; +import { Text } from '@ui5/webcomponents-react/lib/Text'; + +const KEYBOARD_KEYS = { + ESCAPE: 27, +}; + +const style = { + warning: { + width: '1.5rem', + height: '1.5rem', + color: '#feb60a', + }, + error: { + width: '1.5rem', + height: '1.5rem', + color: '#ff5254', + }, + information: { + width: '1.5rem', + height: '1.5rem', + color: 'black', + }, + text: { + lineHeight: '20px', + }, +}; + +const _getHeaderIcon = (type) => { + switch (type) { + case Type.Warning: + return _getHeaderWarningIcon(); + case Type.Error: + return _getHeaderErrorIcon(); + default: + return _getHeaderInfoIcon(); + } +}; + +const _getHeaderWarningIcon = () => { + return ; +}; + +const _getHeaderErrorIcon = () => { + return ; +}; + +const _getHeaderInfoIcon = () => { + return ; +}; + +const _handleAvoidEscapeClosing = (avoidEscapeClose) => { + document.addEventListener( + 'keydown', + (e) => { + if (e.keyCode === KEYBOARD_KEYS.ESCAPE && avoidEscapeClose) { + e.stopPropagation(); + } + }, + true, + ); +}; + +const InformationDialog = ({ dialogRef, avoidEscapeClose, headerText, innerText, closeButtonText, children, onClose, type }) => { + const { t } = useTranslation(); + + useEffect(() => { + _handleAvoidEscapeClosing(avoidEscapeClose); + }); + + const _onClose = () => { + onClose && onClose(); + if (dialogRef.current) { + dialogRef.current.close(); + } + }; + + const _getFooter = () => { + return ( + + + + ); + }; + + const _getHeader = () => { + return ( + + {_getHeaderIcon(type)} + + {headerText} + + + ); + }; + + return ( + +
+ + {innerText ? ( + + {innerText} + + ) : ( + children + )} + +
+
+ ); +}; + +export default InformationDialog; + +export const Type = { + Warning: 'WARNING', + Error: 'ERROR', + Info: 'INFO', +}; diff --git a/src/components/InformationDialog/InformationDialog.test.js b/src/components/InformationDialog/InformationDialog.test.js new file mode 100644 index 0000000..6a9c548 --- /dev/null +++ b/src/components/InformationDialog/InformationDialog.test.js @@ -0,0 +1,25 @@ +import React from 'react'; + +import '@testing-library/jest-dom/extend-expect'; +import { render, screen } from '../../util/TestSetup'; +import InformationDialog, { Type } from './InformationDialog'; + +describe('InformationDialog.js Test Suite', () => { + test('should render', () => { + const dialog = ; + render(dialog); + const infoDialog = screen.getByTestId('information-dialog'); + expect(infoDialog).toBeInTheDocument(); + }); + + test('should render child when not inner text is passed', () => { + const dialog = ( + +
+
+ ); + render(dialog); + const child = screen.getByTestId('information-dialog-child'); + expect(child).toBeInTheDocument(); + }); +}); diff --git a/src/components/InformationZone/InformationZone.js b/src/components/InformationZone/InformationZone.js new file mode 100644 index 0000000..2b423ee --- /dev/null +++ b/src/components/InformationZone/InformationZone.js @@ -0,0 +1,3 @@ +export default function InformationZone({ children }) { + return children; +} diff --git a/src/components/SessionTimeout/SessionTimeoutDialog.js b/src/components/SessionTimeout/SessionTimeoutDialog.js new file mode 100644 index 0000000..f53e19c --- /dev/null +++ b/src/components/SessionTimeout/SessionTimeoutDialog.js @@ -0,0 +1,70 @@ +import React, { useEffect, useState, useRef } from 'react'; +import InformationDialog, { Type } from '../InformationDialog/InformationDialog'; +import i18n from '../../util/i18n'; + +const SESSION = { + TIMEOUT_INTERVAL: 60000, + REFRESH_LIMIT: 15, + REFRESH_WARNING: 13, +}; + +const TIMEOUT_MODE = { + type: Type.Error, + headerText: i18n.t('session.expired'), + closeButtonText: i18n.t('session.expired.button.reload'), + innerText: i18n.t('session.expired.text'), + onClose: () => window.location.reload(), +}; + +const WARNING_MODE = { + type: Type.Warning, + headerText: i18n.t('session.warning'), + closeButtonText: i18n.t('app.generics.close'), + innerText: i18n.t('session.warning.text'), + onClose: null, +}; + +const SessionTimeoutDialog = ({ timeoutScale = SESSION.TIMEOUT_INTERVAL, hasExpiredLimit = SESSION.REFRESH_LIMIT, isExpiringLimit = SESSION.REFRESH_WARNING }) => { + const dialogRef = useRef(null); + const ACTIVITY_EVENTS = ['click', 'focus', 'blur', 'keyup', 'keydown', 'mousemove', 'scroll']; + const [sessionIntervalCount, setSessionIntervalCount] = useState(1); + const [options, setOptions] = useState(WARNING_MODE); + + useEffect(() => { + let sessionIntervalFinder = setInterval(() => { + if (sessionIntervalCount >= isExpiringLimit && sessionIntervalCount < hasExpiredLimit) { + setOptions(WARNING_MODE); + dialogRef.current && dialogRef.current.open(); + } else if (sessionIntervalCount >= hasExpiredLimit) { + setOptions(TIMEOUT_MODE); + dialogRef.current && dialogRef.current.open(); + } + + setSessionIntervalCount(sessionIntervalCount + 1); + }, timeoutScale); + handleUserActivity(sessionIntervalFinder); + }); + + const handleUserActivity = (sessionIntervalFinder) => { + ACTIVITY_EVENTS.forEach((EVENT) => { + window.addEventListener(EVENT, () => { + setSessionIntervalCount(0); + clearInterval(sessionIntervalFinder); + }); + }); + }; + + return ( + + ); +}; + +export default SessionTimeoutDialog; diff --git a/src/components/SessionTimeout/SessionTimeoutDialog.test.js b/src/components/SessionTimeout/SessionTimeoutDialog.test.js new file mode 100644 index 0000000..fe4cccb --- /dev/null +++ b/src/components/SessionTimeout/SessionTimeoutDialog.test.js @@ -0,0 +1,17 @@ +import React from 'react'; + +import '@testing-library/jest-dom/extend-expect'; +import { render, screen, waitFor } from '../../util/TestSetup'; +import SessionTimeoutDialog from './SessionTimeoutDialog'; + +describe('SessionTimeoutDialog.js Test Suite', () => { + beforeEach(() => { + render(); + }); + + test('should render, wait 13 cycles and see the text “Session Almost Expiring”', async () => { + const text = 'Session Almost Expiring'; + const warning = await waitFor(() => screen.getByText(text)); + expect(warning).toBeInTheDocument(); + }); +}); diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index ed0f792..c99666b 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -8,5 +8,11 @@ "page.error.alt": "Error", "page.notfound.text": "Hmmm, we could find this URL", "page.notfound.alt": "Not Found", - "page.fallback.reload.text": "Reload this page" + "page.fallback.reload.text": "Reload this page", + "session.expired": "Session Expired", + "session.expired.button.reload": "Reload", + "session.expired.text": "Your session has expired. Please reload to continue.", + "session.warning": "Session Almost Expiring", + "session.warning.text": "If you not perform an action within 2 minutes, your session will expire.", + "app.generics.close": "Close" } diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index 3db8eca..e3ab8c5 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -8,5 +8,11 @@ "page.error.alt": "Erro", "page.notfound.text": "Hmmm, não conseguimos encontrar esta página", "page.notfound.alt": "Não Encontrado", - "page.fallback.reload.text": "Recarregar" + "page.fallback.reload.text": "Recarregar", + "session.expired": "Sessão expirada", + "session.expired.button.reload": "Recarregar", + "session.expired.text": "Sua sessão expirou. Recarregue para continuar.", + "session.warning": "Sessão quase expirada", + "session.warning.text": "Se você não efetuar uma ação nos próximos 2 minutos, a sua sessão irá expirar.", + "app.generics.close": "Fechar" } diff --git a/src/pages/Todo/List/TodoList.js b/src/pages/Todo/List/TodoList.js index 5b881e8..ea757a4 100644 --- a/src/pages/Todo/List/TodoList.js +++ b/src/pages/Todo/List/TodoList.js @@ -1,14 +1,19 @@ -import React from 'react'; -import { Helmet } from 'react-helmet'; +import React, { useRef } from 'react'; import { useHistory } from 'react-router-dom'; +import { Helmet } from 'react-helmet'; import { MobileView, BrowserView, IEView, isMobile, isTablet, isDesktop, isIE, isChrome, isOpera } from 'react-device-detect'; import HyperLink from '../../../components/HyperLink/HyperLink'; import BrowserURL from '../../../util/BrowserURL'; import ComponentValidator from '../../../auth/Components/Validator'; +import InformationDialog, { Type } from '../../../components/InformationDialog/InformationDialog'; export default function TodoList() { + const dialogRef = useRef(null); const history = useHistory(); + const openInformationDialog = () => { + dialogRef.current.open(); + }; return ( <> @@ -23,6 +28,9 @@ export default function TodoList() {

Component Validator

Drop Application (this is a restricted text and you should not see unless you have access)

+
+ +

Device Detect