diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9f3d2a5a41..1c4c3156ea 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,6 +25,9 @@ jobs: - name: Run linter run: make lint-ui + - name: Run tests + run: make test-ui + - name: Build production image run: docker build -t alephdata/aleph-ui-production:${GITHUB_SHA} -f ui/Dockerfile.production ui diff --git a/aleph.env.tmpl b/aleph.env.tmpl index 9c29ba9087..26968433dd 100644 --- a/aleph.env.tmpl +++ b/aleph.env.tmpl @@ -13,6 +13,15 @@ ALEPH_APP_TITLE=Aleph ALEPH_APP_NAME=aleph ALEPH_UI_URL=http://localhost:8080/ +# Set a static, app-wide message banner displayed at the top of every page. +# This can be useful to inform users about planned downtime etc. +# ALEPH_APP_BANNER="This is an app-wide message." + +# Instead of defining a static message using the `ALEPH_APP_BANNER` variable, +# you can also define a JSON endpoint that Aleph will use to fetch app-wide +# messages to display at the top of every page. +# ALEPH_APP_MESSAGES_URL=https://example.org/messages.json + # ALEPH_URL_SCHEME=https # ALEPH_FAVICON=https://investigativedashboard.org/static/favicon.ico # ALEPH_LOGO=http://assets.pudo.org/img/logo_bigger.png diff --git a/aleph/settings.py b/aleph/settings.py index 70d5a01fa5..a96710ba92 100644 --- a/aleph/settings.py +++ b/aleph/settings.py @@ -37,6 +37,7 @@ # Show a system-wide banner in the user interface. APP_BANNER = env.get("ALEPH_APP_BANNER") +APP_MESSAGES_URL = env.get("ALEPH_APP_MESSAGES_URL", None) # Force HTTPS here: FORCE_HTTPS = True if APP_UI_URL.lower().startswith("https") else False diff --git a/aleph/views/base_api.py b/aleph/views/base_api.py index 43d7a3b5ef..e6b1a2815d 100644 --- a/aleph/views/base_api.py +++ b/aleph/views/base_api.py @@ -51,6 +51,7 @@ def _metadata_locale(locale): "version": __version__, "banner": settings.APP_BANNER, "ui_uri": settings.APP_UI_URL, + "messages_url": settings.APP_MESSAGES_URL, "publish": archive.can_publish, "logo": app_logo, "favicon": settings.APP_FAVICON, diff --git a/ui/package.json b/ui/package.json index 4e98ae7abe..8ac51333d0 100644 --- a/ui/package.json +++ b/ui/package.json @@ -16,6 +16,8 @@ "@formatjs/intl-pluralrules": "^5.0.2", "@formatjs/intl-relativetimeformat": "^11.0.1", "@formatjs/intl-utils": "^3.8.4", + "@testing-library/jest-dom": "^5.16.4", + "@testing-library/react": "^12.1.5", "@types/jest": "^28.1.4", "@types/node": "^17.0.8", "@types/react": "^17.0.1", @@ -72,6 +74,9 @@ "eslintConfig": { "extends": "react-app" }, + "jest": { + "transformIgnorePatterns": [] + }, "browserslist": { "production": [ ">0.2%", diff --git a/ui/src/actions/index.js b/ui/src/actions/index.js index d7a3a13d04..8b27d15cc1 100644 --- a/ui/src/actions/index.js +++ b/ui/src/actions/index.js @@ -58,6 +58,7 @@ export { fetchProfileTags, pairwiseJudgement, } from './profileActions'; +export { fetchMessages } from './messagesActions'; export { fetchMetadata, fetchStatistics, diff --git a/ui/src/actions/messagesActions.js b/ui/src/actions/messagesActions.js new file mode 100644 index 0000000000..e281b349df --- /dev/null +++ b/ui/src/actions/messagesActions.js @@ -0,0 +1,10 @@ +import axios from 'axios'; +import asyncActionCreator from 'actions/asyncActionCreator'; + +export const fetchMessages = asyncActionCreator( + (endpoint) => async () => { + const response = await axios.get(endpoint); + return { messages: response.data }; + }, + { name: 'FETCH_MESSAGES' } +); diff --git a/ui/src/app/App.scss b/ui/src/app/App.scss index 0fb1361840..8fd6ca66c1 100644 --- a/ui/src/app/App.scss +++ b/ui/src/app/App.scss @@ -40,7 +40,6 @@ body { margin: 0; padding: 0; font-family: $pt-font-family; - font-weight: 300; font-size: $aleph-font-size; display: flex; diff --git a/ui/src/app/Router.jsx b/ui/src/app/Router.jsx index 72a0b460c6..62a1fc8418 100644 --- a/ui/src/app/Router.jsx +++ b/ui/src/app/Router.jsx @@ -3,9 +3,15 @@ import { Route, Navigate, Routes } from 'react-router-dom'; import { connect } from 'react-redux'; import { Spinner } from '@blueprintjs/core'; -import { fetchMetadata } from 'actions'; -import { selectSession, selectMetadata } from 'selectors'; +import { fetchMetadata, fetchMessages } from 'actions'; +import { + selectSession, + selectMetadata, + selectMessages, + selectPinnedMessage, +} from 'selectors'; import Navbar from 'components/Navbar/Navbar'; +import MessageBanner from 'components/MessageBanner/MessageBanner'; import NotFoundScreen from 'screens/NotFoundScreen/NotFoundScreen'; import OAuthScreen from 'screens/OAuthScreen/OAuthScreen'; @@ -34,24 +40,55 @@ import ExportsScreen from 'src/screens/ExportsScreen/ExportsScreen'; import './Router.scss'; +const MESSAGES_INTERVAL = 15 * 60 * 1000; // every 15 minutes + class Router extends Component { componentDidMount() { this.fetchIfNeeded(); + this.setMessagesInterval(); } componentDidUpdate() { this.fetchIfNeeded(); } + componentWillUnmount() { + this.clearMessagesInterval(); + } + fetchIfNeeded() { - const { metadata } = this.props; + const { metadata, messages } = this.props; + if (metadata.shouldLoad) { this.props.fetchMetadata(); } + + if (messages.shouldLoad) { + this.fetchMessages(); + } + } + + fetchMessages() { + const { metadata } = this.props; + + if (metadata?.app?.messages_url) { + this.props.fetchMessages(metadata.app.messages_url); + } + } + + setMessagesInterval() { + const id = setInterval(() => this.fetchMessages(), MESSAGES_INTERVAL); + this.setState(() => ({ messagesInterval: id })); + } + + clearMessagesInterval() { + if (this.state?.messagesInterval) { + clearInterval(this.state.messagesInterval); + } } render() { - const { metadata, session } = this.props; + const { metadata, session, pinnedMessage } = this.props; const isLoaded = metadata && metadata.app && session; const Loading = ( @@ -61,6 +98,7 @@ class Router extends Component { ); + if (!isLoaded) { return Loading; } @@ -68,6 +106,7 @@ class Router extends Component { return ( <> + } /> @@ -160,7 +199,12 @@ class Router extends Component { const mapStateToProps = (state) => ({ metadata: selectMetadata(state), + messages: selectMessages(state), + pinnedMessage: selectPinnedMessage(state), session: selectSession(state), }); -export default connect(mapStateToProps, { fetchMetadata })(Router); +export default connect(mapStateToProps, { + fetchMetadata, + fetchMessages, +})(Router); diff --git a/ui/src/components/Dashboard/Dashboard.scss b/ui/src/components/Dashboard/Dashboard.scss index 8c0e4607d8..ca37e00971 100644 --- a/ui/src/components/Dashboard/Dashboard.scss +++ b/ui/src/components/Dashboard/Dashboard.scss @@ -1,10 +1,8 @@ @import 'app/variables.scss'; @import 'app/mixins.scss'; -$dashboard-padding: $aleph-grid-size * 2.5; - .Dashboard { - padding: $dashboard-padding; + padding: $aleph-content-padding; &__inner-container { display: flex; @@ -24,8 +22,18 @@ $dashboard-padding: $aleph-grid-size * 2.5; 1px solid $aleph-border-color, null ); - @include rtlSupportInvertedProp(margin, right, $dashboard-padding, null); - @include rtlSupportInvertedProp(padding, right, $dashboard-padding, null); + @include rtlSupportInvertedProp( + margin, + right, + $aleph-content-padding, + null + ); + @include rtlSupportInvertedProp( + padding, + right, + $aleph-content-padding, + null + ); min-width: 235px; @media screen and (max-width: $aleph-screen-sm-max-width) { @@ -76,7 +84,7 @@ $dashboard-padding: $aleph-grid-size * 2.5; } &__title-container { - margin: $dashboard-padding 0; + margin: $aleph-content-padding 0; } &__title { diff --git a/ui/src/components/Entity/EntityHeading.jsx b/ui/src/components/Entity/EntityHeading.jsx index d53698c5be..9bbcf2adde 100644 --- a/ui/src/components/Entity/EntityHeading.jsx +++ b/ui/src/components/Entity/EntityHeading.jsx @@ -1,8 +1,6 @@ import React from 'react'; import { FormattedMessage } from 'react-intl'; -import { selectUnit } from '@formatjs/intl-utils'; -import { FormattedRelativeTime } from 'react-intl'; -import { Entity, Schema } from 'components/common'; +import { Entity, Schema, RelativeTime } from 'components/common'; import 'components/common/ItemOverview.scss'; @@ -12,7 +10,6 @@ class EntityHeading extends React.PureComponent { const lastViewedDate = entity.lastViewed ? new Date(parseInt(entity.lastViewed, 10)) : Date.now(); - const { value, unit } = selectUnit(lastViewedDate, Date.now()); return ( <> @@ -36,17 +33,7 @@ class EntityHeading extends React.PureComponent { - ), - }} + values={{ time: }} /> )} diff --git a/ui/src/components/Exports/Export.jsx b/ui/src/components/Exports/Export.jsx index f8d1b232f5..6a2982969d 100644 --- a/ui/src/components/Exports/Export.jsx +++ b/ui/src/components/Exports/Export.jsx @@ -1,10 +1,12 @@ import React, { PureComponent } from 'react'; -import { selectUnit } from '@formatjs/intl-utils'; -import { FormattedRelativeTime } from 'react-intl'; import c from 'classnames'; -import { Skeleton, ExportLink, FileSize } from 'src/components/common'; -import convertUTCDateToLocalDate from 'util/convertUTCDateToLocalDate'; +import { + Skeleton, + ExportLink, + FileSize, + RelativeTime, +} from 'src/components/common'; import './Export.scss'; @@ -35,8 +37,6 @@ class Export extends PureComponent { const { id, expires_at: expiresAt, export_status: status } = export_; - const expiryDate = convertUTCDateToLocalDate(new Date(expiresAt)); - const { value, unit } = selectUnit(expiryDate); return ( @@ -47,13 +47,7 @@ class Export extends PureComponent { {export_.status} - + ); diff --git a/ui/src/components/MessageBanner/MessageBanner.jsx b/ui/src/components/MessageBanner/MessageBanner.jsx new file mode 100644 index 0000000000..fe5757b278 --- /dev/null +++ b/ui/src/components/MessageBanner/MessageBanner.jsx @@ -0,0 +1,59 @@ +import { Callout, Intent, Classes } from '@blueprintjs/core'; +import { injectIntl } from 'react-intl'; +import { RelativeTime } from 'components/common'; + +import './MessageBanner.scss'; + +const MESSAGE_INTENTS = { + info: Intent.PRIMARY, + warning: Intent.WARNING, + error: Intent.DANGER, + success: Intent.SUCCESS, +}; + +function Wrapper({ children }) { + return ( +
+ {children} +
+ ); +} + +function MessageBanner({ message }) { + if (!message) { + return ; + } + + const intent = MESSAGE_INTENTS[message.level] || Intent.WARNING; + const updates = message.updates || []; + + const latestUpdate = + updates.length > 0 ? updates[updates.length - 1] : message; + + return ( + + +

+ {message.title && ( + <> + {message.title} +
+ + )} + + + + {latestUpdate.createdAt && ( + + + + )} +

+
+
+ ); +} + +export default injectIntl(MessageBanner); diff --git a/ui/src/components/MessageBanner/MessageBanner.scss b/ui/src/components/MessageBanner/MessageBanner.scss new file mode 100644 index 0000000000..4ac4c01514 --- /dev/null +++ b/ui/src/components/MessageBanner/MessageBanner.scss @@ -0,0 +1,24 @@ +@import '../../app/variables.scss'; + +.MessageBanner__callout.MessageBanner__callout { + padding: 0.5 * $aleph-content-padding $aleph-content-padding; +} + +.MessageBanner p { + margin: 0; +} + +.MessageBanner a, +.MessageBanner a:hover { + color: inherit; + text-decoration: underline; + font-weight: 600; +} + +.MessageBanner__meta { + opacity: 0.6; +} + +.MessageBanner__meta::before { + content: ' — '; +} diff --git a/ui/src/components/MessageBanner/MessageBanner.test.jsx b/ui/src/components/MessageBanner/MessageBanner.test.jsx new file mode 100644 index 0000000000..dda43d4cf6 --- /dev/null +++ b/ui/src/components/MessageBanner/MessageBanner.test.jsx @@ -0,0 +1,87 @@ +import { render, screen } from 'testUtils'; +import MessageBanner from './MessageBanner'; + +const getCallout = (container, intent) => { + return container.querySelector( + `[class*="-callout"][class*="-intent-${intent}"]` + ); +}; + +const getTime = (container, date) => { + return container.querySelector(`time[datetime="${date}"]`); +}; + +it('renders empty wrapper without message', () => { + // This ensures that the ARIA live region is already present in the DOM, + // even if no message is displayed. Required by some screenreaders so that + // they announce new messages correctly. + render(); + expect(screen.getByRole('status')).toBeInTheDocument(); +}); + +it('renders level, title, body, date', () => { + const message = { + title: 'Degraded ingest performance', + safeHtmlBody: + 'Processing ingested files currently takes longer than usual.', + }; + + render(); + expect(screen.getByText('Degraded ingest performance')).toBeInTheDocument(); + expect(screen.getByText(message.safeHtmlBody)).toBeInTheDocument(); +}); + +it('renders HTML body', () => { + const message = { + safeHtmlBody: 'Read more', + }; + + render(); + expect(screen.getByText('Read more')).toBeInTheDocument(); + expect(screen.getByText('Read more').closest('a')).toHaveAttribute( + 'href', + 'https://example.org' + ); +}); + +it('renders correct intent', () => { + const { container } = render(); + expect(getCallout(container, 'primary')).toBeInTheDocument(); +}); + +it('uses warning intent by default', () => { + const { container } = render(); + expect(getCallout(container, 'warning')).toBeInTheDocument(); +}); + +it('renders date', () => { + const message = { + safeHtmlBody: 'Hello World!', + createdAt: '2022-01-01T00:00:00.000Z', + }; + + const { container } = render(); + expect(getTime(container, message.createdAt)).toBeInTheDocument(); +}); + +it('renders successfully with only a body', () => { + render(); + expect(screen.getByRole('status')).toBeInTheDocument(); +}); + +it('renders latest update', () => { + const message = { + safeHtmlBody: 'Aleph will be down for maintenance on Sunday.', + createdAt: '2022-01-01T00:00:00.000Z', + updates: [ + { + createdAt: '2022-01-02T00:00:00.000Z', + safeHtmlBody: 'We’re back online!', + }, + ], + }; + + const { container } = render(); + expect(screen.getByRole('status')).toHaveTextContent('We’re back online!'); + expect(getTime(container, message.createdAt)); +}); diff --git a/ui/src/components/Notification/Notification.jsx b/ui/src/components/Notification/Notification.jsx index 42696bd2b1..456eb8d956 100644 --- a/ui/src/components/Notification/Notification.jsx +++ b/ui/src/components/Notification/Notification.jsx @@ -1,6 +1,4 @@ import React, { PureComponent } from 'react'; -import { selectUnit } from '@formatjs/intl-utils'; -import { FormattedRelativeTime } from 'react-intl'; import { Collection, @@ -10,8 +8,8 @@ import { Role, Skeleton, ExportLink, + RelativeTime, } from 'src/components/common'; -import convertUTCDateToLocalDate from 'util/convertUTCDateToLocalDate'; import './Notification.scss'; @@ -94,19 +92,11 @@ class Notification extends PureComponent { } }); - const createdDate = convertUTCDateToLocalDate(new Date(createdAt)); - const { value, unit } = selectUnit(createdDate, Date.now()); return (
  • {message}
    - +
  • ); diff --git a/ui/src/components/Screen/Screen.jsx b/ui/src/components/Screen/Screen.jsx index 1fb946a0db..9f7cfc85d3 100644 --- a/ui/src/components/Screen/Screen.jsx +++ b/ui/src/components/Screen/Screen.jsx @@ -63,11 +63,6 @@ export class Screen extends React.Component { )} - {hasMetadata && !!metadata.app.banner && ( -
    - {metadata.app.banner} -
    - )} {!forceAuth && ( <>
    {this.props.children}
    diff --git a/ui/src/components/common/RelativeTime.jsx b/ui/src/components/common/RelativeTime.jsx new file mode 100644 index 0000000000..2e9b9ec2a6 --- /dev/null +++ b/ui/src/components/common/RelativeTime.jsx @@ -0,0 +1,22 @@ +import convertUTCDateToLocalDate from 'util/convertUTCDateToLocalDate'; +import { selectUnit } from '@formatjs/intl-utils'; +import { FormattedRelativeTime } from 'react-intl'; + +export default function ({ date, utcDate }) { + const dateObj = utcDate + ? convertUTCDateToLocalDate(new Date(utcDate)) + : new Date(date); + + const { value, unit } = selectUnit(dateObj, Date.now()); + + return ( + + ); +} diff --git a/ui/src/components/common/index.jsx b/ui/src/components/common/index.jsx index 3e2195f180..ba81af1536 100644 --- a/ui/src/components/common/index.jsx +++ b/ui/src/components/common/index.jsx @@ -37,6 +37,7 @@ import Statistics from './Statistics'; import Summary from './Summary'; import QueryText from './QueryText'; import QueryInfiniteLoad from './QueryInfiniteLoad'; +import RelativeTime from './RelativeTime'; import ResultCount from './ResultCount'; import ResultText from './ResultText'; import SelectWrapper from './SelectWrapper'; @@ -86,6 +87,7 @@ export { QueryText, QueryInfiniteLoad, QuickLinks, + RelativeTime, ResultCount, ResultText, SelectWrapper, diff --git a/ui/src/reducers/index.js b/ui/src/reducers/index.js index 4ab3c80c88..58ad972469 100644 --- a/ui/src/reducers/index.js +++ b/ui/src/reducers/index.js @@ -1,5 +1,6 @@ import { combineReducers } from 'redux'; +import messages from './messages'; import metadata from './metadata'; import mutation from './mutation'; import session from './session'; @@ -20,6 +21,7 @@ import systemStatus from './systemStatus'; import exports from './exports'; const rootReducer = combineReducers({ + messages, metadata, mutation, session, diff --git a/ui/src/reducers/messages.js b/ui/src/reducers/messages.js new file mode 100644 index 0000000000..a96737ae9f --- /dev/null +++ b/ui/src/reducers/messages.js @@ -0,0 +1,14 @@ +import { createReducer } from 'redux-act'; +import { fetchMessages } from 'actions'; +import { loadState, loadStart, loadError, loadComplete } from 'reducers/util'; + +const initialState = loadState(); + +export default createReducer( + { + [fetchMessages.START]: (state) => loadStart(state), + [fetchMessages.ERROR]: (state, { error }) => loadError(state, error), + [fetchMessages.COMPLETE]: (state, messages) => loadComplete(messages), + }, + initialState +); diff --git a/ui/src/screens/HomeScreen/HomeScreen.jsx b/ui/src/screens/HomeScreen/HomeScreen.jsx index 5e7c0554df..a8e679e127 100644 --- a/ui/src/screens/HomeScreen/HomeScreen.jsx +++ b/ui/src/screens/HomeScreen/HomeScreen.jsx @@ -7,7 +7,6 @@ import queryString from 'query-string'; import { compose } from 'redux'; import { connect } from 'react-redux'; import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; -import { Callout, Intent } from '@blueprintjs/core'; import withRouter from 'app/withRouter'; import { @@ -80,8 +79,7 @@ export class HomeScreen extends Component { } const appHomePage = metadata.pages.find((page) => page.home); - const { description, samples, title, warning_title, warning_body } = - appHomePage; + const { description, samples, title } = appHomePage; const samplesList = wordList(samples, ', ').join(''); return ( @@ -97,15 +95,6 @@ export class HomeScreen extends Component { {description && (

    {description}

    )} - {(warning_title || warning_body) && ( - - {warning_body} - - )}
    { + return !displayUntil || Date.now() <= new Date(displayUntil); + }); + + if (activeMessages.length <= 0) { + return null; + } + + return activeMessages[0]; +} + export function selectPages(state) { return selectMetadata(state).pages; } diff --git a/ui/src/setupTests.js b/ui/src/setupTests.js new file mode 100644 index 0000000000..7b0828bfa8 --- /dev/null +++ b/ui/src/setupTests.js @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/ui/src/testUtils.jsx b/ui/src/testUtils.jsx new file mode 100644 index 0000000000..0ddee27764 --- /dev/null +++ b/ui/src/testUtils.jsx @@ -0,0 +1,13 @@ +import { render as rtlRender } from '@testing-library/react'; +import { IntlProvider } from 'react-intl'; + +function render(ui, { locale = 'en', ...renderOptions } = {}) { + const Wrapper = ({ children }) => { + return {children}; + }; + + return rtlRender(ui, { wrapper: Wrapper, ...renderOptions }); +} + +export * from '@testing-library/react'; +export { render };