} />
@@ -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 };