console: notifications bug fixes (#6067)

https://github.com/hasura/graphql-engine/pull/6067
This commit is contained in:
Sameer Kolhar 2020-11-06 20:23:18 +05:30 committed by GitHub
parent 4e4e3f3fd3
commit 31d07cb976
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 430 additions and 210 deletions

View File

@ -20,8 +20,9 @@ const Endpoints = {
hasuractlMetadata: `${hasuractlUrl}/apis/metadata`,
hasuractlMigrateSettings: `${hasuractlUrl}/apis/migrate/settings`,
telemetryServer: 'wss://telemetry.hasura.io/v1/ws',
consoleNotificationsStg: 'https://data.hasura-stg.hasura-app.io/v1/query',
consoleNotificationsProd: 'https://data.hasura.io/v1/query',
consoleNotificationsStg:
'https://notifications.hasura-stg.hasura-app.io/v1/graphql',
consoleNotificationsProd: 'https://notifications.hasura.io/v1/graphql',
};
const globalCookiePolicy = 'same-origin';

View File

@ -795,47 +795,34 @@ export const getConsoleNotificationQuery = (
time: Date | string | number,
userType?: Nullable<ConsoleScope>
) => {
let consoleUserScope = {
$ilike: `%${userType}%`,
};
let consoleUserScopeVar = `%${userType}%`;
if (!userType) {
consoleUserScope = {
$ilike: '%OSS%',
};
consoleUserScopeVar = '%OSS%';
}
return {
args: {
table: 'console_notification',
columns: ['*'],
where: {
$or: [
{
expiry_date: {
$gte: time,
},
},
{
expiry_date: {
$eq: null,
},
},
],
scope: consoleUserScope,
start_date: { $lte: time },
},
order_by: [
{
type: 'asc',
nulls: 'last',
column: 'priority',
},
{
type: 'desc',
column: 'start_date',
},
],
},
type: 'select',
const query = `query fetchNotifications($currentTime: timestamptz, $userScope: String) {
console_notifications(
where: {start_date: {_lte: $currentTime}, scope: {_ilike: $userScope}, _or: [{expiry_date: {_gte: $currentTime}}, {expiry_date: {_eq: null}}]},
order_by: {priority: asc_nulls_last, start_date: desc}
) {
content
created_at
external_link
expiry_date
id
is_active
priority
scope
start_date
subject
type
}
}`;
const variables = {
userScope: consoleUserScopeVar,
currentTime: time,
};
return { query, variables };
};

View File

@ -79,8 +79,12 @@ const fetchConsoleNotifications = () => (dispatch, getState) => {
const consoleId = window.__env.consoleId;
const consoleScope = getConsoleScope(serverVersion, consoleId);
let userType = 'admin';
if (headers.hasOwnProperty(HASURA_COLLABORATOR_TOKEN)) {
const collabToken = headers[HASURA_COLLABORATOR_TOKEN];
const headerHasCollabToken = Object.keys(headers).find(
header => header.toLowerCase() === HASURA_COLLABORATOR_TOKEN
);
if (headerHasCollabToken) {
const collabToken = headers[headerHasCollabToken];
userType = getUserType(collabToken);
}
@ -94,12 +98,14 @@ const fetchConsoleNotifications = () => (dispatch, getState) => {
}
const now = new Date().toISOString();
const query = getConsoleNotificationQuery(now, consoleScope);
const payload = getConsoleNotificationQuery(now, consoleScope);
const options = {
body: JSON.stringify(query),
body: JSON.stringify(payload),
method: 'POST',
headers: {
'content-type': 'application/json',
// temp. change until Auth is added
'x-hasura-role': 'user',
},
};
@ -108,94 +114,131 @@ const fetchConsoleNotifications = () => (dispatch, getState) => {
const lastSeenNotifications = JSON.parse(
window.localStorage.getItem('notifications:lastSeen')
);
if (!data.length) {
dispatch({ type: FETCH_CONSOLE_NOTIFICATIONS_SET_DEFAULT });
dispatch(
updateConsoleNotificationsState({
read: 'default',
date: now,
showBadge: false,
})
);
if (!lastSeenNotifications) {
if (data.data.console_notifications) {
const fetchedData = data.data.console_notifications;
if (!fetchedData.length) {
dispatch({ type: FETCH_CONSOLE_NOTIFICATIONS_SET_DEFAULT });
dispatch(
updateConsoleNotificationsState({
read: 'default',
date: now,
showBadge: false,
})
);
if (!lastSeenNotifications) {
window.localStorage.setItem(
'notifications:lastSeen',
JSON.stringify(0)
);
}
return;
}
// NOTE: these 2 steps may not be required if the table in the DB
// enforces the usage of `enums` and we're sure that the notification scope
// is only from the allowed permutations of scope. We aren't doing that yet
// because within the GQL query, I can't be using the `_ilike` operator during
// filtering. Hence I'm keeping it here since this is a new feature and
// mistakes can happen while adding data into the DB.
// TODO: is to remove these once things are more streamlined
const uppercaseScopedData = makeUppercaseScopes(fetchedData);
let filteredData = filterScope(uppercaseScopedData, consoleScope);
if (
lastSeenNotifications &&
lastSeenNotifications > filteredData.length
) {
window.localStorage.setItem(
'notifications:lastSeen',
JSON.stringify(0)
JSON.stringify(filteredData.length)
);
}
if (previousRead) {
if (!consoleStateDB.console_notifications) {
dispatch(
updateConsoleNotificationsState({
read: [],
date: now,
showBadge: true,
})
);
} else {
let newReadValue;
if (previousRead === 'default' || previousRead === 'error') {
newReadValue = [];
toShowBadge = false;
} else if (previousRead === 'all') {
const previousList = JSON.parse(
localStorage.getItem('notifications:data')
);
if (!previousList) {
// we don't have a record of the IDs that were marked as read previously
newReadValue = [];
toShowBadge = true;
} else if (previousList.length) {
const readNotificationsDiff = filteredData.filter(
newNotif =>
!previousList.find(oldNotif => oldNotif.id === newNotif.id)
);
if (!readNotificationsDiff.length) {
// since the data hasn't changed since the last call
newReadValue = previousRead;
toShowBadge = false;
} else {
newReadValue = [...previousList.map(notif => `${notif.id}`)];
toShowBadge = true;
filteredData = [...readNotificationsDiff, ...previousList];
}
}
} else {
newReadValue = previousRead;
if (
previousRead.length &&
lastSeenNotifications >= filteredData.length
) {
toShowBadge = false;
} else if (lastSeenNotifications < filteredData.length) {
toShowBadge = true;
}
}
dispatch(
updateConsoleNotificationsState({
read: newReadValue,
date: consoleStateDB.console_notifications[userType].date,
showBadge: toShowBadge,
})
);
}
}
dispatch({
type: FETCH_CONSOLE_NOTIFICATIONS_SUCCESS,
data: filteredData,
});
// update/set the lastSeen value upon data is set
if (
!lastSeenNotifications ||
lastSeenNotifications !== filteredData.length
) {
window.localStorage.setItem(
'notifications:lastSeen',
JSON.stringify(filteredData.length)
);
}
return;
}
const uppercaseScopedData = makeUppercaseScopes(data);
let filteredData = filterScope(uppercaseScopedData, consoleScope);
if (
!lastSeenNotifications ||
lastSeenNotifications !== filteredData.length
) {
window.localStorage.setItem(
'notifications:lastSeen',
JSON.stringify(filteredData.length)
);
}
if (previousRead) {
if (!consoleStateDB.console_notifications) {
dispatch(
updateConsoleNotificationsState({
read: [],
date: now,
showBadge: true,
})
);
} else {
let newReadValue;
if (previousRead === 'default' || previousRead === 'error') {
newReadValue = [];
toShowBadge = false;
} else if (previousRead === 'all') {
const previousList = JSON.parse(
localStorage.getItem('notifications:data')
);
if (previousList.length) {
const resDiff = filteredData.filter(
newNotif =>
!previousList.find(oldNotif => oldNotif.id === newNotif.id)
);
if (!resDiff.length) {
// since the data hasn't changed since the last call
newReadValue = previousRead;
toShowBadge = false;
} else {
newReadValue = [...previousList.map(notif => `${notif.id}`)];
toShowBadge = true;
filteredData = [...resDiff, ...previousList];
}
}
} else {
newReadValue = previousRead;
if (
previousRead.length &&
lastSeenNotifications === filteredData.length
) {
toShowBadge = false;
}
}
dispatch(
updateConsoleNotificationsState({
read: newReadValue,
date: consoleStateDB.console_notifications[userType].date,
showBadge: toShowBadge,
})
);
}
}
dispatch({
type: FETCH_CONSOLE_NOTIFICATIONS_SUCCESS,
data: filteredData,
});
dispatch({ type: FETCH_CONSOLE_NOTIFICATIONS_ERROR });
dispatch(
updateConsoleNotificationsState({
read: 'error',
date: now,
showBadge: false,
})
);
})
.catch(err => {
console.error(err);

View File

@ -31,7 +31,6 @@ export type ConsoleNotification = {
scope?: NotificationScope;
};
// FIXME? : we may have to remove this
export const defaultNotification: ConsoleNotification = {
subject: 'No updates available at the moment',
created_at: Date.now(),

View File

@ -35,10 +35,40 @@ import {
setProClickState,
getLoveConsentState,
setLoveConsentState,
getUserType,
} from './utils';
import { getSchemaBaseRoute } from '../Common/utils/routesUtils';
import LoveSection from './LoveSection';
import { Help, ProPopup } from './components/';
import { HASURA_COLLABORATOR_TOKEN } from '../../constants';
import { UPDATE_CONSOLE_NOTIFICATIONS } from '../../telemetry/Actions';
const updateRequestHeaders = props => {
const { requestHeaders, dispatch } = props;
const collabTokenKey = Object.keys(requestHeaders).find(
hdr => hdr.toLowerCase() === HASURA_COLLABORATOR_TOKEN
);
if (collabTokenKey) {
const userID = getUserType(requestHeaders[collabTokenKey]);
if (props.console_opts && props.console_opts.console_notifications) {
if (!props.console_opts.console_notifications[userID]) {
dispatch({
type: UPDATE_CONSOLE_NOTIFICATIONS,
data: {
...props.console_opts.console_notifications,
[userID]: {
read: [],
date: null,
showBadge: true,
},
},
});
}
}
}
};
class Main extends React.Component {
constructor(props) {
@ -57,6 +87,7 @@ class Main extends React.Component {
componentDidMount() {
const { dispatch } = this.props;
updateRequestHeaders(this.props);
dispatch(loadServerVersion()).then(() => {
dispatch(featureCompatibilityInit());
@ -74,6 +105,18 @@ class Main extends React.Component {
dispatch(fetchServerConfig);
}
componentDidUpdate(prevProps) {
const prevHeaders = Object.keys(prevProps.requestHeaders);
const currHeaders = Object.keys(this.props.requestHeaders);
if (
prevHeaders.length !== currHeaders.length ||
prevHeaders.filter(hdr => !currHeaders.includes(hdr)).length
) {
updateRequestHeaders(this.props);
}
}
toggleProPopup = () => {
const { dispatch } = this.props;
dispatch(emitProClickedEvent({ open: !this.state.isPopUpOpen }));
@ -368,6 +411,7 @@ const mapStateToProps = (state, ownProps) => {
currentSchema: state.tables.currentSchema,
metadata: state.metadata,
console_opts: state.telemetry.console_opts,
requestHeaders: state.tables.dataHeaders,
};
};

View File

@ -1367,7 +1367,7 @@
position: absolute;
width: 17px;
top: 16px;
right: 8px;
right: 0.8rem;
border-radius: 50%;
user-select: none;
visibility: visible;
@ -1536,10 +1536,6 @@
.secureSectionText {
display: none;
}
.shareSection {
display: none;
}
}
@media (max-width: 1050px) {

View File

@ -511,15 +511,19 @@ const HasuraNotifications: React.FC<
let userType = 'admin';
if (dataHeaders?.[HASURA_COLLABORATOR_TOKEN]) {
const collabToken = dataHeaders[HASURA_COLLABORATOR_TOKEN];
const headerHasCollabToken = Object.keys(dataHeaders).find(
header => header.toLowerCase() === HASURA_COLLABORATOR_TOKEN
);
if (headerHasCollabToken) {
const collabToken = dataHeaders[headerHasCollabToken];
userType = getUserType(collabToken);
}
const previouslyReadState = React.useMemo(
() =>
console_opts?.console_notifications &&
console_opts?.console_notifications[userType].read,
console_opts?.console_notifications[userType]?.read,
[console_opts?.console_notifications, userType]
);
const showBadge = React.useMemo(
@ -639,7 +643,7 @@ const HasuraNotifications: React.FC<
useOnClickOutside([dropDownRef, wrapperRef], onClickOutside);
const onClickShareSection = () => {
const onClickNotificationButton = () => {
if (showBadge) {
if (console_opts?.console_notifications) {
let updatedState = {};
@ -718,11 +722,11 @@ const HasuraNotifications: React.FC<
return (
<>
<div
className={`${styles.shareSection} ${
className={`${styles.shareSection} ${styles.headerRightNavbarBtn} ${
isDropDownOpen ? styles.opened : ''
} dropdown-toggle`}
aria-expanded="false"
onClick={onClickShareSection}
onClick={onClickNotificationButton}
ref={wrapperRef}
>
<i className={`fa fa-bell ${styles.bellIcon}`} />
@ -750,7 +754,7 @@ const HasuraNotifications: React.FC<
<Button
title="Mark all as read"
onClick={onClickMarkAllAsRead}
disabled={!numberNotifications}
disabled={!numberNotifications || !consoleNotifications.length}
className={styles.markAllAsReadBtn}
>
mark all as read

View File

@ -149,81 +149,168 @@ const setPreReleaseNotificationOptOutInDB = () => (
return dispatch(setConsoleOptsInDB(options, successCb, errorCb));
};
// TODO: We could fetch the latest `read` state from the DB everytime we
// open the notifications dropdown. That way we can reach a more consistent behavior on notifications.
// OR another option would be to provide a refresh button so that users can use it to refresh state
const updateConsoleNotificationsState = (updatedState: NotificationsState) => {
return (
dispatch: ThunkDispatch<ReduxState, unknown, AnyAction>,
getState: GetReduxState
) => {
const url = Endpoints.schemaChange;
const currentNotifications = getState().main.consoleNotifications;
const restState = getState().telemetry.console_opts;
const headers = dataHeaders(getState);
let userType = 'admin';
if (headers?.[HASURA_COLLABORATOR_TOKEN]) {
const collabToken = headers[HASURA_COLLABORATOR_TOKEN];
userType = getUserType(collabToken);
}
let composedUpdatedState: ConsoleState['console_opts'] = {
...restState,
console_notifications: {
[userType]: updatedState,
},
};
if (userType !== 'admin') {
const currentState = restState?.console_notifications;
if (Object.keys(currentState ?? {}).length > 1) {
composedUpdatedState = {
...restState,
console_notifications: {
...currentState,
[userType]: updatedState,
},
};
}
}
if (currentNotifications && Array.isArray(currentNotifications)) {
if (isUpdateIDsEqual(currentNotifications, updatedState.read)) {
composedUpdatedState = {
...restState,
console_notifications: {
...restState?.console_notifications,
[userType]: {
read: 'all',
showBadge: false,
date: updatedState.date,
},
},
};
// update the localStorage var with all the notifications
// since all the notifications were clicked on read state
window.localStorage.setItem(
'notifications:data',
JSON.stringify(currentNotifications)
);
}
}
const updatedReadNotifications = getUpdateConsoleStateQuery(
composedUpdatedState
);
const options: RequestInit = {
credentials: globalCookiePolicy,
const getStateURL = Endpoints.query;
const getStateOptions: RequestInit = {
method: 'POST',
headers,
body: JSON.stringify(updatedReadNotifications),
body: JSON.stringify(getConsoleOptsQuery()),
headers: dataHeaders(getState),
credentials: globalCookiePolicy,
};
return dispatch(requestAction(url, options))
.then((data: any) => {
dispatch({
type: UPDATE_CONSOLE_NOTIFICATIONS,
data: data.returning[0].console_state.console_notifications,
});
// make a query to get the latest state from db prior to updating the read state for a user
return dispatch(requestAction(getStateURL, getStateOptions))
.then((data: Telemetry[]) => {
if (data?.length) {
const { console_state: current_console_state } = data[0];
let composedUpdatedState: ConsoleState['console_opts'] = {
...current_console_state,
console_notifications: {
...current_console_state?.console_notifications,
},
};
const url = Endpoints.query;
const currentNotifications = getState().main.consoleNotifications;
const headers = dataHeaders(getState);
let userType = 'admin';
const headerHasAdminToken = Object.keys(headers).find(
header => header.toLowerCase() === HASURA_COLLABORATOR_TOKEN
);
if (headerHasAdminToken) {
const collabToken = headers[headerHasAdminToken];
userType = getUserType(collabToken);
}
const dbReadState =
current_console_state?.console_notifications?.[userType]?.read;
let combinedReadState: NotificationsState['read'] = [];
if (
!dbReadState ||
dbReadState === 'default' ||
dbReadState === 'error'
) {
composedUpdatedState = {
...current_console_state,
console_notifications: {
...current_console_state?.console_notifications,
[userType]: updatedState,
},
};
} else if (dbReadState === 'all') {
if (updatedState.read === 'all') {
composedUpdatedState = {
...current_console_state,
console_notifications: {
...current_console_state?.console_notifications,
[userType]: {
read: 'all',
date: updatedState.date,
showBadge: false,
},
},
};
} else {
composedUpdatedState = {
...current_console_state,
console_notifications: {
...current_console_state?.console_notifications,
[userType]: updatedState,
},
};
}
} else {
if (typeof updatedState.read === 'string') {
combinedReadState = updatedState.read;
} else if (Array.isArray(updatedState.read)) {
// this is being done to ensure that there is a consistency between the read
// state of the users and the data present in the DB
combinedReadState = dbReadState
.concat(updatedState.read)
.reduce((acc: string[], val: string) => {
if (!acc.includes(val)) {
return [...acc, val];
}
return acc;
}, []);
}
composedUpdatedState = {
...current_console_state,
console_notifications: {
...current_console_state?.console_notifications,
[userType]: {
...updatedState,
read: combinedReadState,
},
},
};
}
if (
currentNotifications &&
Array.isArray(currentNotifications) &&
Array.isArray(combinedReadState)
) {
if (isUpdateIDsEqual(currentNotifications, combinedReadState)) {
composedUpdatedState = {
...current_console_state,
console_notifications: {
...current_console_state?.console_notifications,
[userType]: {
read: 'all',
showBadge: false,
date: updatedState.date,
},
},
};
// update the localStorage var with all the notifications
// since all the notifications were clicked on read state
window.localStorage.setItem(
'notifications:data',
JSON.stringify(currentNotifications)
);
}
}
const updatedReadNotifications = getUpdateConsoleStateQuery(
composedUpdatedState
);
const options: RequestInit = {
credentials: globalCookiePolicy,
method: 'POST',
headers,
body: JSON.stringify(updatedReadNotifications),
};
return dispatch(requestAction(url, options))
.then((retData: any) => {
dispatch({
type: UPDATE_CONSOLE_NOTIFICATIONS,
data: retData.returning[0].console_state.console_notifications,
});
})
.catch(error => {
console.error(
'There was an error in updating the read console notifications.',
error
);
return error;
});
}
})
.catch(error => {
.catch(err => {
console.error(
'There was an error in updating the console notifications.',
error
'There was an error in fetching the latest state from the DB.',
err
);
return error;
});
};
};
@ -242,12 +329,18 @@ const loadConsoleOpts = () => {
body: JSON.stringify(getConsoleOptsQuery()),
};
let userType = 'admin';
if (headers?.[HASURA_COLLABORATOR_TOKEN]) {
userType = headers[HASURA_COLLABORATOR_TOKEN];
const headerHasAdminToken = Object.keys(headers).find(
header => header.toLowerCase() === HASURA_COLLABORATOR_TOKEN
);
if (headerHasAdminToken) {
const collabToken = headers[headerHasAdminToken];
userType = getUserType(collabToken);
}
return dispatch(requestAction(url, options) as any).then(
return dispatch(requestAction(url, options)).then(
(data: Telemetry[]) => {
if (data.length) {
if (data?.length) {
const { hasura_uuid, console_state } = data[0];
dispatch({
@ -274,6 +367,20 @@ const loadConsoleOpts = () => {
},
},
});
} else if (
console_state.console_notifications &&
!console_state.console_notifications[userType]
) {
dispatch({
type: UPDATE_CONSOLE_NOTIFICATIONS,
data: {
[userType]: {
read: [],
date: null,
showBadge: true,
},
},
});
}
return Promise.resolve();
@ -353,7 +460,10 @@ const telemetryReducer = (
...state,
console_opts: {
...state.console_opts,
console_notifications: action.data,
console_notifications: {
...state.console_opts?.console_notifications,
...action.data,
},
},
};
default:
@ -369,4 +479,5 @@ export {
setPreReleaseNotificationOptOutInDB,
setTelemetryNotificationShownInDB,
updateConsoleNotificationsState,
UPDATE_CONSOLE_NOTIFICATIONS,
};

View File

@ -27,6 +27,40 @@ export type ConsoleState = {
hasura_uuid: string;
};
export type ApiExplorer = {
authApiExpanded: string;
currentTab: number;
headerFocus: boolean;
loading: boolean;
mode: string;
modalState: Record<string, string>;
explorerData: Record<string, string>;
displayedApi: DisplayedApiState;
};
export type DisplayedApiState = {
details: Record<string, string>;
id: string;
request: ApiExplorerRequest;
};
export type ApiExplorerRequest = {
bodyType: string;
headers: ApiExplorerHeader[];
headersInitialised: boolean;
method: string;
params: string;
url: string;
};
export type ApiExplorerHeader = {
key: string;
value: string;
isActive: boolean;
isNewHeader: boolean;
isDisabled: boolean;
};
// Redux Utils
export type ReduxState = {
tables: {
@ -43,6 +77,7 @@ export type ReduxState = {
consoleNotifications: ConsoleNotification[];
};
telemetry: ConsoleState;
apiexplorer: ApiExplorer;
};
export type ReduxAction = RAEvents | RouterAction;