Display usage tips and promote features available on UI (#1131)

Closes #1108

Signed-off-by: Cintia Sanchez Garcia <cynthiasg@icloud.com>
This commit is contained in:
Cynthia S. Garcia 2021-02-19 18:06:05 +01:00 committed by GitHub
parent 8c589661ec
commit 402de4ca2c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 1036 additions and 27 deletions

View File

@ -7,6 +7,7 @@
"@apidevtools/json-schema-ref-parser": "^9.0.6", "@apidevtools/json-schema-ref-parser": "^9.0.6",
"@types/bootstrap": "^4.5.0", "@types/bootstrap": "^4.5.0",
"@types/classnames": "^2.2.10", "@types/classnames": "^2.2.10",
"@types/crypto-js": "^4.0.1",
"@types/json-schema": "^7.0.6", "@types/json-schema": "^7.0.6",
"@types/json-schema-merge-allof": "^0.6.0", "@types/json-schema-merge-allof": "^0.6.0",
"@types/lodash": "^4.14.161", "@types/lodash": "^4.14.161",
@ -21,6 +22,7 @@
"classnames": "^2.2.6", "classnames": "^2.2.6",
"codemirror": "^5.57.0", "codemirror": "^5.57.0",
"codemirror-rego": "^1.1.0", "codemirror-rego": "^1.1.0",
"crypto-js": "^4.0.0",
"json-schema-merge-allof": "^0.7.0", "json-schema-merge-allof": "^0.7.0",
"lodash": "^4.17.20", "lodash": "^4.17.20",
"moment": "^2.27.0", "moment": "^2.27.0",

View File

@ -32,7 +32,9 @@ type Action =
| { type: 'updateLimit'; limit: number } | { type: 'updateLimit'; limit: number }
| { type: 'updateTheme'; theme: string } | { type: 'updateTheme'; theme: string }
| { type: 'updateEfectiveTheme'; theme: string } | { type: 'updateEfectiveTheme'; theme: string }
| { type: 'enableAutomaticTheme'; enabled: boolean }; | { type: 'enableAutomaticTheme'; enabled: boolean }
| { type: 'enabledDisplayedNotifications'; enabled: boolean }
| { type: 'addNewDisplayedNotification'; id: string };
export const AppCtx = createContext<{ export const AppCtx = createContext<{
ctx: AppState; ctx: AppState;
@ -74,6 +76,14 @@ export function enableAutomaticTheme(enabled: boolean) {
return { type: 'enableAutomaticTheme', enabled }; return { type: 'enableAutomaticTheme', enabled };
} }
export function enabledDisplayedNotifications(enabled: boolean) {
return { type: 'enabledDisplayedNotifications', enabled };
}
export function addNewDisplayedNotification(id: string) {
return { type: 'addNewDisplayedNotification', id };
}
export async function refreshUserProfile(dispatch: React.Dispatch<any>, redirectUrl?: string) { export async function refreshUserProfile(dispatch: React.Dispatch<any>, redirectUrl?: string) {
try { try {
const profile: Profile = await API.getUserProfile(); const profile: Profile = await API.getUserProfile();
@ -133,10 +143,11 @@ function getCurrentSystemActiveTheme(prefs: ThemePrefs): ThemePrefs {
} }
export function appReducer(state: AppState, action: Action) { export function appReducer(state: AppState, action: Action) {
let prefs;
switch (action.type) { switch (action.type) {
case 'signIn': case 'signIn':
const tmpUserPrefs = lsStorage.getPrefs(action.profile.alias); prefs = lsStorage.getPrefs(action.profile.alias);
const userPrefs = { ...tmpUserPrefs, theme: getCurrentSystemActiveTheme(tmpUserPrefs.theme) }; const userPrefs = { ...prefs, theme: getCurrentSystemActiveTheme(prefs.theme) };
updateActiveStyleSheet(userPrefs.theme.efective || userPrefs.theme.configured); updateActiveStyleSheet(userPrefs.theme.efective || userPrefs.theme.configured);
lsStorage.setPrefs(userPrefs, action.profile.alias); lsStorage.setPrefs(userPrefs, action.profile.alias);
lsStorage.setActiveProfile(action.profile.alias); lsStorage.setActiveProfile(action.profile.alias);
@ -146,49 +157,49 @@ export function appReducer(state: AppState, action: Action) {
}; };
case 'unselectOrg': case 'unselectOrg':
const unselectedOrgPrefs = updateSelectedOrg(state.prefs); prefs = updateSelectedOrg(state.prefs);
lsStorage.setPrefs(unselectedOrgPrefs, state.user!.alias); lsStorage.setPrefs(prefs, state.user!.alias);
redirectToControlPanel('user'); redirectToControlPanel('user');
return { return {
...state, ...state,
prefs: unselectedOrgPrefs, prefs: prefs,
}; };
case 'signOut': case 'signOut':
const tmpGuestPrefs = lsStorage.getPrefs(); prefs = lsStorage.getPrefs();
const guestPrefs = { ...tmpGuestPrefs, theme: getCurrentSystemActiveTheme(tmpGuestPrefs.theme) }; const guestPrefs = { ...prefs, theme: getCurrentSystemActiveTheme(prefs.theme) };
lsStorage.setPrefs(guestPrefs); lsStorage.setPrefs(guestPrefs);
lsStorage.setActiveProfile(); lsStorage.setActiveProfile();
updateActiveStyleSheet(guestPrefs.theme.efective || guestPrefs.theme.configured); updateActiveStyleSheet(guestPrefs.theme.efective || guestPrefs.theme.configured);
return { user: null, prefs: guestPrefs }; return { user: null, prefs: guestPrefs };
case 'updateOrg': case 'updateOrg':
const newPrefs = updateSelectedOrg(state.prefs, action.name); prefs = updateSelectedOrg(state.prefs, action.name);
lsStorage.setPrefs(newPrefs, state.user!.alias); lsStorage.setPrefs(prefs, state.user!.alias);
if (isUndefined(state.prefs.controlPanel.selectedOrg) || action.name !== state.prefs.controlPanel.selectedOrg) { if (isUndefined(state.prefs.controlPanel.selectedOrg) || action.name !== state.prefs.controlPanel.selectedOrg) {
redirectToControlPanel('org'); redirectToControlPanel('org');
} }
return { return {
...state, ...state,
prefs: newPrefs, prefs: prefs,
}; };
case 'updateLimit': case 'updateLimit':
const updatedPrefs = { prefs = {
...state.prefs, ...state.prefs,
search: { search: {
...state.prefs.search, ...state.prefs.search,
limit: action.limit, limit: action.limit,
}, },
}; };
lsStorage.setPrefs(updatedPrefs, state.user ? state.user.alias : undefined); lsStorage.setPrefs(prefs, state.user ? state.user.alias : undefined);
return { return {
...state, ...state,
prefs: updatedPrefs, prefs: prefs,
}; };
case 'updateTheme': case 'updateTheme':
const updatedUserPrefs = { prefs = {
...state.prefs, ...state.prefs,
theme: { theme: {
configured: action.theme, configured: action.theme,
@ -196,35 +207,35 @@ export function appReducer(state: AppState, action: Action) {
automatic: false, automatic: false,
}, },
}; };
lsStorage.setPrefs(updatedUserPrefs, state.user ? state.user.alias : undefined); lsStorage.setPrefs(prefs, state.user ? state.user.alias : undefined);
updateActiveStyleSheet(action.theme); updateActiveStyleSheet(action.theme);
return { return {
...state, ...state,
prefs: updatedUserPrefs, prefs: prefs,
}; };
case 'updateEfectiveTheme': case 'updateEfectiveTheme':
const updatedThemePrefs = { prefs = {
...state.prefs, ...state.prefs,
theme: { theme: {
...state.prefs.theme, ...state.prefs.theme,
efective: action.theme, efective: action.theme,
}, },
}; };
lsStorage.setPrefs(updatedThemePrefs, state.user ? state.user.alias : undefined); lsStorage.setPrefs(prefs, state.user ? state.user.alias : undefined);
updateActiveStyleSheet(action.theme); updateActiveStyleSheet(action.theme);
return { return {
...state, ...state,
prefs: updatedThemePrefs, prefs: prefs,
}; };
case 'enableAutomaticTheme': case 'enableAutomaticTheme':
const updatedThemeUserPrefs = updateAutomaticTheme(state.prefs, action.enabled); prefs = updateAutomaticTheme(state.prefs, action.enabled);
lsStorage.setPrefs(updatedThemeUserPrefs, state.user ? state.user.alias : undefined); lsStorage.setPrefs(prefs, state.user ? state.user.alias : undefined);
updateActiveStyleSheet(updatedThemeUserPrefs.theme.efective || updatedThemeUserPrefs.theme.configured); updateActiveStyleSheet(prefs.theme.efective || prefs.theme.configured);
return { return {
...state, ...state,
prefs: updatedThemeUserPrefs, prefs: prefs,
}; };
case 'updateUser': case 'updateUser':
@ -237,6 +248,36 @@ export function appReducer(state: AppState, action: Action) {
...action.user, ...action.user,
}, },
}; };
case 'enabledDisplayedNotifications':
prefs = {
...state.prefs,
notifications: {
...state.prefs.notifications,
enabled: action.enabled,
},
};
lsStorage.setPrefs(prefs, state.user ? state.user.alias : undefined);
return {
...state,
prefs: prefs,
};
case 'addNewDisplayedNotification':
prefs = {
...state.prefs,
notifications: {
...state.prefs.notifications,
displayed: [...state.prefs.notifications.displayed, action.id],
lastDisplayedTime: Date.now(),
},
};
lsStorage.setPrefs(prefs, state.user ? state.user.alias : undefined);
return {
...state,
prefs: prefs,
};
default: default:
return { ...state }; return { ...state };
} }

View File

@ -12,6 +12,7 @@ import detectActiveThemeMode from '../utils/detectActiveThemeMode';
import history from '../utils/history'; import history from '../utils/history';
import lsPreferences from '../utils/localStoragePreferences'; import lsPreferences from '../utils/localStoragePreferences';
import AlertController from './common/AlertController'; import AlertController from './common/AlertController';
import UserNotificationsController from './common/userNotifications';
import ControlPanelView from './controlPanel'; import ControlPanelView from './controlPanel';
import HomeView from './home'; import HomeView from './home';
import BannerMOTD from './navigation/BannerMOTD'; import BannerMOTD from './navigation/BannerMOTD';
@ -58,6 +59,7 @@ export default function App() {
<ScrollMemory /> <ScrollMemory />
<AlertController /> <AlertController />
<BannerMOTD /> <BannerMOTD />
<UserNotificationsController />
<Switch> <Switch>
<Route <Route
path={['/', '/verify-email', '/login', '/accept-invitation', '/oauth-failed', '/reset-password']} path={['/', '/verify-email', '/login', '/accept-invitation', '/oauth-failed', '/reset-password']}

View File

@ -0,0 +1,44 @@
.toast {
opacity: 0;
bottom: 20px;
transition: opacity 0.3s ease-in-out;
width: 700px;
max-width: 80%;
background-color: var(--white);
border: 3px solid var(--color-1-500);
border-radius: 15px;
z-index: 1100;
}
.isVisible {
opacity: 1;
box-shadow: 0px 0px 15px 0 var(--color-black-75);
}
.btn {
font-size: 0.65rem;
line-height: 1rem;
top: -2px;
}
.closeBtn {
top: -5px;
}
.content {
font-size: 0.8rem;
}
.content code {
color: var(--color-1-700);
}
.content *:is(h1, h2, h3, h4, h5, h6) {
font-weight: bold;
font-size: 0.85rem;
line-height: 1rem;
}
.content p {
margin-bottom: 0rem;
}

View File

@ -0,0 +1,93 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`UserNotificationsController creates snapshot 1`] = `
<DocumentFragment>
<div
class="d-none d-md-flex justify-content-center align-items-center w-100"
>
<div
class="position-fixed toast fade toast show isVisible notificationCard"
role="alert"
>
<div
class="toast-body"
data-testid="notificationContent"
>
<div>
<div
class="float-right"
>
<div
class="d-flex flex-row align-items-start"
>
<button
class="btn btn-link text-dark py-0 position-relative btn"
data-testid="disableNotificationsBtn"
type="button"
>
Don't show me more again
</button>
<button
class="close position-relative closeBtn"
type="button"
>
<span
aria-hidden="true"
>
×
</span>
</button>
</div>
</div>
<span>
<div
class="content"
>
<h2
class="text-secondary"
>
Filters
</h2>
<p>
Using the
<strong>
filters
</strong>
on the left column, you can narrow down search results. You can filter by
<em>
kind
</em>
,
<em>
category
</em>
,
<em>
publisher
</em>
,
<em>
repository
</em>
and
<em>
license
</em>
, as well as only showing packages from
<strong>
official repositories
</strong>
or
<strong>
verified publishers
</strong>
.
</p>
</div>
</span>
</div>
</div>
</div>
</div>
</DocumentFragment>
`;

View File

@ -0,0 +1,135 @@
import { fireEvent, render, waitFor } from '@testing-library/react';
import React from 'react';
import { AppCtx } from '../../../context/AppCtx';
import UserNotificationsController from './index';
const mockCtx = {
user: { alias: 'test', email: 'test@test.com' },
prefs: {
controlPanel: {},
search: { limit: 60 },
theme: {
configured: 'light',
automatic: false,
},
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
},
};
describe('UserNotificationsController', () => {
beforeEach(() => {
jest.useFakeTimers();
const mockMath = Object.create(global.Math);
mockMath.random = () => 0;
global.Math = mockMath;
global.window = Object.create(window);
Object.defineProperty(window, 'location', {
value: {
href: 'http://test.com',
pathname: '/packages/search',
},
writable: true,
});
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
jest.resetAllMocks();
});
it('creates snapshot', async () => {
const result = render(
<AppCtx.Provider value={{ ctx: mockCtx, dispatch: jest.fn() }}>
<UserNotificationsController />
</AppCtx.Provider>
);
const component = result.getByRole('alert');
await waitFor(() => {
expect(component).toHaveClass('show isVisible');
});
expect(result.asFragment()).toMatchSnapshot();
await waitFor(() => {
expect(result.queryByTestId('notificationContent')).toBeNull();
});
await waitFor(() => {
expect(component).not.toHaveClass('show isVisible');
});
});
it('renders component', async () => {
const { getByRole, getByTestId, getByText, queryByTestId } = render(
<AppCtx.Provider value={{ ctx: mockCtx, dispatch: jest.fn() }}>
<UserNotificationsController />
</AppCtx.Provider>
);
const component = getByRole('alert');
expect(component).toBeInTheDocument();
expect(component).not.toHaveClass('show');
expect(component).toHaveClass('toast');
expect(component).toBeEmptyDOMElement();
await waitFor(() => {
expect(component).toHaveClass('show isVisible');
expect(getByTestId('disableNotificationsBtn')).toBeInTheDocument();
expect(getByText("Don't show me more again")).toBeInTheDocument();
});
await waitFor(() => {
expect(queryByTestId('notificationContent')).toBeNull();
});
await waitFor(() => {
expect(component).not.toHaveClass('show isVisible');
});
});
it('disabled notifications', async () => {
const { getByRole, getByTestId, queryByTestId } = render(
<AppCtx.Provider value={{ ctx: mockCtx, dispatch: jest.fn() }}>
<UserNotificationsController />
</AppCtx.Provider>
);
const component = getByRole('alert');
await waitFor(() => {
expect(component).toHaveClass('show isVisible');
});
const disableBtn = getByTestId('disableNotificationsBtn');
fireEvent.click(disableBtn);
await waitFor(() => {
expect(queryByTestId('notificationContent')).toBeNull();
});
await waitFor(() => {
expect(component).not.toHaveClass('show isVisible');
});
});
it('does not call userNotificationsDispatcher.start when user is undefined', () => {
const { getByRole } = render(
<AppCtx.Provider value={{ ctx: { ...mockCtx, user: undefined }, dispatch: jest.fn() }}>
<UserNotificationsController />
</AppCtx.Provider>
);
const component = getByRole('alert');
expect(component).not.toHaveClass('show isVisible');
});
});

View File

@ -0,0 +1,140 @@
import classnames from 'classnames';
import { isNull, isUndefined } from 'lodash';
import React, { useContext, useEffect, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import { addNewDisplayedNotification, AppCtx, enabledDisplayedNotifications } from '../../../context/AppCtx';
import useBreakpointDetect from '../../../hooks/useBreakpointDetect';
import { UserNotification } from '../../../types';
import notificationsDispatcher from '../../../utils/userNotificationsDispatcher';
import styles from './UserNotifications.module.css';
interface HeadingProps {
level: number;
title?: string;
children?: JSX.Element[];
}
const Heading: React.ElementType = (data: HeadingProps) => {
const Tag = `h${data.level}` as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
return <Tag className="text-secondary">{data.title || data.children}</Tag>;
};
const ANIMATION_TIME = 300; //300ms
const UserNotificationsController: React.ElementType = () => {
const { dispatch, ctx } = useContext(AppCtx);
const [notification, setNotification] = useState<UserNotification | null>(null);
const [isVisible, setIsVisible] = useState<boolean>(false);
const [addNotificationTimeout, setAddNotificationTimeout] = useState<NodeJS.Timeout | undefined>(undefined);
const [dismissNotificationTimeout, setDismissNotificationTimeout] = useState<NodeJS.Timeout | undefined>(undefined);
const point = useBreakpointDetect();
notificationsDispatcher.subscribe({
updateUserNotificationsWrapper: (notif: UserNotification | null) => {
if (!isNull(notif)) {
setNotification(notif);
setIsVisible(true);
setAddNotificationTimeout(
setTimeout(() => {
dispatch(addNewDisplayedNotification(notif.id));
}, ANIMATION_TIME)
);
} else {
setIsVisible(false);
setDismissNotificationTimeout(
setTimeout(() => {
if (!isNull(notification)) {
setNotification(null);
}
}, ANIMATION_TIME)
);
}
},
});
const onClose = () => {
notificationsDispatcher.dismissNotification();
};
const onChangeNotificationsPrefs = () => {
notificationsDispatcher.dismissNotification();
// Change user prefs
dispatch(enabledDisplayedNotifications(false));
};
useEffect(() => {
if (!isUndefined(ctx.user)) {
notificationsDispatcher.start(ctx.prefs.notifications, point);
}
return () => {
notificationsDispatcher.close();
};
}, [ctx.user]); /* eslint-disable-line react-hooks/exhaustive-deps */
useEffect(() => {
notificationsDispatcher.updateSettings(ctx.prefs.notifications);
}, [ctx.prefs.notifications]);
useEffect(() => {
return () => {
if (addNotificationTimeout) {
clearTimeout(addNotificationTimeout);
}
if (dismissNotificationTimeout) {
clearTimeout(dismissNotificationTimeout);
}
};
}, [addNotificationTimeout, dismissNotificationTimeout]);
return (
<div className="d-none d-md-flex justify-content-center align-items-center w-100">
<div
className={classnames(
'position-fixed toast fade',
styles.toast,
{
[`show ${styles.isVisible}`]: !isNull(notification) && isVisible,
},
'notificationCard'
)}
role="alert"
>
{!isNull(notification) && (
<div className="toast-body" data-testid="notificationContent">
<div>
<div className="float-right">
<div className="d-flex flex-row align-items-start">
<button
data-testid="disableNotificationsBtn"
type="button"
className={`btn btn-link text-dark py-0 position-relative ${styles.btn}`}
onClick={onChangeNotificationsPrefs}
>
Don't show me more again
</button>
<button type="button" className={`close position-relative ${styles.closeBtn}`} onClick={onClose}>
<span aria-hidden="true">&times;</span>
</button>
</div>
</div>
<span>
<ReactMarkdown
className={styles.content}
children={notification.body}
renderers={{
heading: Heading,
}}
linkTarget="_blank"
skipHtml
/>
</span>
</div>
</div>
)}
</div>
</div>
);
};
export default UserNotificationsController;

View File

@ -31,6 +31,11 @@ const mockCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -22,6 +22,11 @@ const mockCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };
@ -36,6 +41,11 @@ const mockOrgCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };
@ -50,6 +60,11 @@ const mockOrgCtx1 = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -29,6 +29,11 @@ const mockCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };
@ -41,6 +46,11 @@ const mockCtxOrgSelected = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -31,6 +31,11 @@ const mockCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -32,6 +32,11 @@ const mockCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -36,6 +36,11 @@ const mockCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -28,6 +28,11 @@ const mockCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -41,6 +41,11 @@ const mockCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -49,6 +49,11 @@ const mockCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -32,6 +32,11 @@ const mockWithoutSelectedOrgCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };
@ -46,6 +51,11 @@ const mockWithSelectedOrgCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -40,6 +40,11 @@ const mockCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -44,6 +44,11 @@ const mockCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -41,6 +41,11 @@ const mockWithoutSelectedOrgCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };
@ -55,6 +60,11 @@ const mockWithSelectedOrgCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -38,6 +38,11 @@ const mockCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -60,6 +60,11 @@ const mockCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };
@ -74,6 +79,11 @@ const mockNotSelectedOrgCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -26,6 +26,11 @@ const mockCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -36,6 +36,11 @@ const mockCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -28,6 +28,11 @@ const mockCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -59,6 +59,11 @@ const mockCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -34,6 +34,11 @@ const mockUserCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };
@ -46,6 +51,11 @@ const mockOrgCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -37,6 +37,11 @@ const mockUserCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };
@ -49,6 +54,11 @@ const mockOrgCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -28,6 +28,11 @@ const mockUserCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };
@ -40,6 +45,11 @@ const mockOrgCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -23,6 +23,11 @@ const mockCtxLoggedIn = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };
@ -35,6 +40,11 @@ const mockCtxNotLoggedIn = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };
@ -47,6 +57,11 @@ const mockUndefinedUser = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -20,6 +20,11 @@ const mockCtxLoggedIn = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };
@ -32,6 +37,11 @@ const mockCtxNotLoggedIn = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };
@ -44,6 +54,11 @@ const mockUndefinedUser = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -14,6 +14,11 @@ const mockCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -14,6 +14,11 @@ const mockCtxLoggedIn = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -39,9 +39,9 @@ const CustomResourceDefinition = (props: Props) => {
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
<div className="d-flex flex-wrap mt-4"> <div className="d-flex flex-wrap mt-4">
{props.resources.map((resourceDefinition: CustomResourcesDefinition) => { {props.resources.map((resourceDefinition: CustomResourcesDefinition, index: number) => {
return ( return (
<div className="col-12 col-lg-6 col-xxl-4 mb-4" key={`resourceDef_${resourceDefinition.kind}`}> <div className="col-12 col-lg-6 col-xxl-4 mb-4" key={`resourceDef_${resourceDefinition.kind}_${index}`}>
<div className="card h-100" data-testid="resourceDefinition"> <div className="card h-100" data-testid="resourceDefinition">
<div className="card-body d-flex flex-column"> <div className="card-body d-flex flex-column">
<h6 className="card-title mb-3">{resourceDefinition.displayName || resourceDefinition.name}</h6> <h6 className="card-title mb-3">{resourceDefinition.displayName || resourceDefinition.name}</h6>

View File

@ -23,6 +23,11 @@ const mockCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -23,6 +23,11 @@ const mockCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };
@ -35,6 +40,11 @@ const mockNotSignedInCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };
@ -47,6 +57,11 @@ const mockUndefinedUserCtx = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}, },
}; };

View File

@ -13,7 +13,7 @@
--color-1-5: rgba(15, 14, 17, 0.95); --color-1-5: rgba(15, 14, 17, 0.95);
--color-1-10: rgba(15, 14, 17, 0.9); --color-1-10: rgba(15, 14, 17, 0.9);
--color-1-20: rgba(15, 14, 17, 0.8); --color-1-20: rgba(15, 14, 17, 0.8);
--color-1-95: rgba(15, 14, 17, 0.95);
--color-2-500: #0f0e11; --color-2-500: #0f0e11;
--color-2-10: rgba(var(--color-2-500), 0.1); --color-2-10: rgba(var(--color-2-500), 0.1);
@ -28,6 +28,7 @@
--color-black-90: rgba(255, 255, 255, 0.9); --color-black-90: rgba(255, 255, 255, 0.9);
--color-white-50: rgba(0, 0, 0, 0.5); --color-white-50: rgba(0, 0, 0, 0.5);
--color-white-95: rgba(0, 0, 0, 0.95);
--logo-font: 'Archivo Black', cursive; --logo-font: 'Archivo Black', cursive;
@ -229,6 +230,10 @@
box-shadow: none !important; box-shadow: none !important;
} }
.notificationCard {
box-shadow: 0px 0px 25px 5px var(--color-black-25);
}
.activeVersion { .activeVersion {
& > span { & > span {
color: var(--hightlighted); color: var(--hightlighted);

View File

@ -13,6 +13,7 @@
--color-1-5: rgba(28, 44, 53, 0.05); --color-1-5: rgba(28, 44, 53, 0.05);
--color-1-10: rgba(28, 44, 53, 0.1); --color-1-10: rgba(28, 44, 53, 0.1);
--color-1-20: rgba(28, 44, 53, 0.2); --color-1-20: rgba(28, 44, 53, 0.2);
--color-1-95: rgba(28, 44, 53, 0.95);
--color-2-500: #b0cee0; --color-2-500: #b0cee0;
--color-2-10: rgba(176, 206, 224, 0.1); --color-2-10: rgba(176, 206, 224, 0.1);
@ -23,8 +24,11 @@
--color-black-5: rgba(0, 0, 0, 0.05); --color-black-5: rgba(0, 0, 0, 0.05);
--color-black-15: rgba(0, 0, 0, 0.15); --color-black-15: rgba(0, 0, 0, 0.15);
--color-black-25: rgba(0, 0, 0, 0.25); --color-black-25: rgba(0, 0, 0, 0.25);
--color-black-75: rgba(0, 0, 0, 0.75);
--color-black-90: rgba(0, 0, 0, 0.9);
--color-white-50: rgba(256, 256, 256, 0.5); --color-white-50: rgba(256, 256, 256, 0.5);
--color-white-95: rgba(256, 256, 256, 0.95);
--logo-font: 'Archivo Black', cursive; --logo-font: 'Archivo Black', cursive;

View File

@ -317,6 +317,12 @@ export interface AvailabilityInfo {
excluded: string[]; excluded: string[];
} }
export interface NotificationsPrefs {
lastDisplayedTime: null | number;
enabled: boolean;
displayed: string[];
}
export interface Prefs { export interface Prefs {
theme: ThemePrefs; theme: ThemePrefs;
controlPanel: { controlPanel: {
@ -325,6 +331,7 @@ export interface Prefs {
search: { search: {
limit: number; limit: number;
}; };
notifications: NotificationsPrefs;
} }
export interface ThemePrefs { export interface ThemePrefs {
@ -552,3 +559,16 @@ export interface ActiveJSONSchemaValue {
options: JSONSchema[]; options: JSONSchema[];
error?: boolean; error?: boolean;
} }
export interface UserNotification {
id: string;
body: string;
linkTip?: string;
}
export enum PathTips {
Home = 'home',
Package = 'package',
Search = 'search',
ControlPanel = 'control-panel',
}

View File

@ -2,6 +2,7 @@ import { createBrowserHistory } from 'history';
import analytics from '../analytics/analytics'; import analytics from '../analytics/analytics';
import updateMetaIndex from './updateMetaIndex'; import updateMetaIndex from './updateMetaIndex';
import notificationsDispatcher from './userNotificationsDispatcher';
const history = createBrowserHistory(); const history = createBrowserHistory();
const detailPath = /^\/packages\/(helm|falco|opa|olm|tbaction|krew|helm-plugin|tekton-task)\//; const detailPath = /^\/packages\/(helm|falco|opa|olm|tbaction|krew|helm-plugin|tekton-task)\//;
@ -12,6 +13,8 @@ history.listen((location) => {
updateMetaIndex(); updateMetaIndex();
} }
analytics.page(); analytics.page();
// Clean notifications to change location
notificationsDispatcher.dismissNotification();
}); });
export default history; export default history;

View File

@ -7,7 +7,13 @@ const defaultPrefs = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}; };
const initialUserPrefs = { const initialUserPrefs = {
controlPanel: {}, controlPanel: {},
search: { limit: 60 }, search: { limit: 60 },
@ -15,6 +21,11 @@ const initialUserPrefs = {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}; };
describe('localStoragePreferences', () => { describe('localStoragePreferences', () => {
@ -40,6 +51,11 @@ describe('localStoragePreferences', () => {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}; };
lsPreferences.setPrefs(userPrefs, 'user1'); lsPreferences.setPrefs(userPrefs, 'user1');
expect(lsPreferences.getPrefs('user1')).toStrictEqual(userPrefs); expect(lsPreferences.getPrefs('user1')).toStrictEqual(userPrefs);
@ -57,6 +73,11 @@ describe('localStoragePreferences', () => {
configured: 'light', configured: 'light',
automatic: false, automatic: false,
}, },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}; };
lsPreferences.setPrefs(userPrefs, 'user2'); lsPreferences.setPrefs(userPrefs, 'user2');
expect(lsPreferences.getPrefs('user2')).toStrictEqual(userPrefs); expect(lsPreferences.getPrefs('user2')).toStrictEqual(userPrefs);

View File

@ -8,10 +8,16 @@ const LS_ITEM = 'prefs';
export const DEFAULT_SEARCH_LIMIT = 20; export const DEFAULT_SEARCH_LIMIT = 20;
const DEFAULT_THEME = 'light'; const DEFAULT_THEME = 'light';
const LS_ACTIVE_PROFILE = 'activeProfile'; const LS_ACTIVE_PROFILE = 'activeProfile';
const DEFAULT_PREFS: Prefs = { const DEFAULT_PREFS: Prefs = {
search: { limit: DEFAULT_SEARCH_LIMIT }, search: { limit: DEFAULT_SEARCH_LIMIT },
controlPanel: {}, controlPanel: {},
theme: { configured: DEFAULT_THEME, automatic: false }, theme: { configured: DEFAULT_THEME, automatic: false },
notifications: {
lastDisplayedTime: null,
enabled: true,
displayed: [],
},
}; };
export class LocalStoragePreferences { export class LocalStoragePreferences {

View File

@ -0,0 +1,44 @@
{
"notifications": [
{
"body": "## Light and dark themes\nYou can switch between the **light** and **dark** themes from the icon in the *top right in the navigation bar*. It is also possible to use the **automatic** mode, that will rely on your system setting when available.",
"linkTip": "home"
},
{
"body": "## Filters\nUsing the **filters** on the left column, you can narrow down search results. You can filter by *kind*, *category*, *publisher*, *repository* and *license*, as well as only showing packages from **official repositories** or **verified publishers**.",
"linkTip": "search"
},
{
"body": "## Search tip\nUse **multiple words** to refine your search. Example: *kafka operator*.",
"linkTip": "search"
},
{
"body": "## Search tip\nUse **-** to exclude words from your search. Example: *apache -solr -hadoop*.",
"linkTip": "search"
},
{
"body": "## Search tip\nPut a phrase inside **double quotes** for an exact match. Example: *\"monitoring system\"*.",
"linkTip": "search"
},
{
"body": "## Search tip\nUse **or** to combine multiple searches. Example: *postgresql or mysql*.",
"linkTip": "search"
},
{
"body": "## New release notifications\nUsing the bell icon on the *top right* of this screen, you can subscribe to receive **email notifications** when a new version of the packages you are interested in is released. You can manage all your subscriptions from the control panel.",
"linkTip": "package"
},
{
"body": "## Webhooks notifications\nFrom the control panel, you can setup **webhooks** to notify *external services* when certain events happen. This could be, for example, posting a notification to Slack when a new version of certain packages is released.",
"linkTip": "package"
},
{
"body": "## Star your favorite packages\nBy starring your favorite packages, you help them **stand out in search results**, helping users when deciding among multiple alternatives.",
"linkTip": "package"
},
{
"body": "## Related packages\nOn the *right column*, below the package summary section, you will find some packages related to the one you are currently viewing.",
"linkTip": "package"
}
]
}

View File

@ -0,0 +1,67 @@
import { waitFor } from '@testing-library/react';
import { UserNotification } from '../types';
import notificationsDispatcher from './userNotificationsDispatcher';
const updateUserNotificationMock = jest.fn();
const notificationSample = {
id: 'id',
body: 'Lorem ipsum',
};
describe('notificationsDispatcher', () => {
beforeEach(() => {
jest.useFakeTimers();
const mockMath = Object.create(global.Math);
mockMath.random = () => 0;
global.Math = mockMath;
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
jest.resetAllMocks();
});
it('starts dispatcher', async () => {
notificationsDispatcher.subscribe({
updateUserNotificationsWrapper: (notification: UserNotification | null) =>
updateUserNotificationMock(notification),
});
notificationsDispatcher.start({
lastDisplayedTime: null,
enabled: true,
displayed: [],
});
await waitFor(() => {
expect(updateUserNotificationMock).toHaveBeenCalledTimes(3);
expect(updateUserNotificationMock).toHaveBeenLastCalledWith(null);
});
});
it('dismiss notification', async () => {
notificationsDispatcher.subscribe({
updateUserNotificationsWrapper: (notification: UserNotification | null) =>
updateUserNotificationMock(notification),
});
notificationsDispatcher.updateSettings({
lastDisplayedTime: null,
enabled: true,
displayed: [],
});
notificationsDispatcher.postNotification(notificationSample);
notificationsDispatcher.dismissNotification();
await waitFor(() => {
expect(updateUserNotificationMock).toHaveBeenCalledTimes(2);
expect(updateUserNotificationMock).toHaveBeenCalledWith(notificationSample);
expect(updateUserNotificationMock).toHaveBeenLastCalledWith(null);
});
});
});

View File

@ -0,0 +1,157 @@
import md5 from 'crypto-js/md5';
import { groupBy, isNull, isUndefined } from 'lodash';
import moment from 'moment';
import { NotificationsPrefs, PathTips, UserNotification } from '../types';
const DEFAULT_START_TIME = 3 * 1000; //3s
const DEFAULT_DISMISS_TIME = 20 * 1000; //20s
export interface UserNotificationsUpdatesHandler {
updateUserNotificationsWrapper(notification: UserNotification | null): void;
}
const detailPkgPath = /^\/packages\/(helm|falco|opa|olm|tbaction|krew|helm-plugin|tekton-task)\//;
const getNotifications = (): UserNotification[] => {
const list = require('./notifications.json').notifications;
return list.map((notif: any) => {
const notification: UserNotification = {
...notif,
id: md5(notif.body).toString(),
};
return notification;
});
};
const getCurrentLocationPath = (): PathTips | undefined => {
const currentLocation = window.location.pathname;
if (currentLocation.startsWith('/control-panel')) {
return PathTips.ControlPanel;
} else if (detailPkgPath.test(currentLocation)) {
return PathTips.Package;
} else {
switch (currentLocation) {
case '/':
return PathTips.Home;
case '/packages/search':
return PathTips.Search;
default:
return;
}
}
};
export class UserNotificationsDispatcher {
private updatesHandler?: UserNotificationsUpdatesHandler;
private activeNotification: UserNotification | null = null;
private startTimeout?: NodeJS.Timeout;
private dismissTimeout?: NodeJS.Timeout;
private settings: NotificationsPrefs | null = null;
private notifications: UserNotification[] = getNotifications();
private breakpoint: string | undefined;
public start(prefs: NotificationsPrefs, breakpoint?: string) {
this.settings = prefs;
this.breakpoint = breakpoint;
this.startTimeout = setTimeout(() => {
// We don't display notifications for small devices
if (isUndefined(this.breakpoint) || !['xs', 'sm'].includes(this.breakpoint)) {
this.get(true);
}
}, DEFAULT_START_TIME);
}
public get(dateLimit: boolean) {
this.dismissNotification();
const notif = this.getRandomNotification(dateLimit);
if (!isNull(notif)) {
this.postNotification(notif);
}
}
public subscribe(updatesHandler: UserNotificationsUpdatesHandler) {
this.updatesHandler = updatesHandler;
}
public postNotification(notification: UserNotification | null) {
this.activeNotification = notification;
if (this.updatesHandler) {
this.updatesHandler.updateUserNotificationsWrapper(this.activeNotification);
}
if (!isNull(notification)) {
this.dismissTimeout = setTimeout(() => {
this.dismissNotification();
}, DEFAULT_DISMISS_TIME);
} else {
this.cleanTimeouts();
}
}
public dismissNotification() {
this.postNotification(null);
}
private cleanTimeouts() {
if (this.startTimeout) {
clearTimeout(this.startTimeout);
}
if (this.dismissTimeout) {
clearTimeout(this.dismissTimeout);
}
}
public close() {
this.cleanTimeouts();
}
public updateSettings(settings: NotificationsPrefs) {
this.settings = settings;
}
private getRandomNotification(dateLimit: boolean): UserNotification | null {
if (!isNull(this.settings)) {
if (
this.settings.enabled &&
this.settings.displayed.length < this.notifications.length &&
// Only display a maximun of 1 notification per day when dateLimit is true
(!dateLimit ||
isNull(this.settings.lastDisplayedTime) ||
moment(this.settings.lastDisplayedTime).diff(Date.now(), 'day') > 0)
) {
let notDisplayedNotifications = groupBy(
this.notifications.filter((notif: UserNotification) => !this.settings!.displayed.includes(notif.id)),
'linkTip'
);
const currentLocationTip = getCurrentLocationPath();
const getCommonTip = (): UserNotification | null => {
return notDisplayedNotifications.hasOwnProperty('undefined')
? (notDisplayedNotifications.undefined[
Math.floor(Math.random() * notDisplayedNotifications.undefined.length)
] as UserNotification)
: null;
};
if (currentLocationTip) {
if (
notDisplayedNotifications.hasOwnProperty(currentLocationTip) &&
notDisplayedNotifications[currentLocationTip].length > 0
) {
return notDisplayedNotifications[currentLocationTip][
Math.floor(Math.random() * notDisplayedNotifications[currentLocationTip].length)
] as UserNotification;
} else {
return getCommonTip();
}
} else {
return getCommonTip();
}
}
}
return null;
}
}
const notificationsDispatcher = new UserNotificationsDispatcher();
export default notificationsDispatcher;

View File

@ -1677,6 +1677,11 @@
resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.11.tgz#2521cc86f69d15c5b90664e4829d84566052c1cf" resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.11.tgz#2521cc86f69d15c5b90664e4829d84566052c1cf"
integrity sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw== integrity sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw==
"@types/crypto-js@^4.0.1":
version "4.0.1"
resolved "https://registry.yarnpkg.com/@types/crypto-js/-/crypto-js-4.0.1.tgz#3a4bd24518b0e6c5940da4e2659eeb2ef0806963"
integrity sha512-6+OPzqhKX/cx5xh+yO8Cqg3u3alrkhoxhE5ZOdSEv0DOzJ13lwJ6laqGU0Kv6+XDMFmlnGId04LtY22PsFLQUw==
"@types/eslint-visitor-keys@^1.0.0": "@types/eslint-visitor-keys@^1.0.0":
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d" resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d"
@ -3783,6 +3788,11 @@ crypto-browserify@^3.11.0:
randombytes "^2.0.0" randombytes "^2.0.0"
randomfill "^1.0.3" randomfill "^1.0.3"
crypto-js@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.0.0.tgz#2904ab2677a9d042856a2ea2ef80de92e4a36dcc"
integrity sha512-bzHZN8Pn+gS7DQA6n+iUmBfl0hO5DJq++QP3U6uTucDtk/0iGpXd/Gg7CGR0p8tJhofJyaKoWBuJI4eAO00BBg==
css-blank-pseudo@^0.1.4: css-blank-pseudo@^0.1.4:
version "0.1.4" version "0.1.4"
resolved "https://registry.yarnpkg.com/css-blank-pseudo/-/css-blank-pseudo-0.1.4.tgz#dfdefd3254bf8a82027993674ccf35483bfcb3c5" resolved "https://registry.yarnpkg.com/css-blank-pseudo/-/css-blank-pseudo-0.1.4.tgz#dfdefd3254bf8a82027993674ccf35483bfcb3c5"