Implementing blocking announcements in smallcase frontend

Photo by Thomas Ciszewski on Unsplash

Overview

At smallcase, we are on a mission to change the way India invests. As is obvious from the mission statement, the most critical bit here is to make sure that the user is able to invest. This requires the smallcase platform to be highly available and reliable during market timings, so that users can transact (buy/sell stocks) successfully through their broker accounts. If for some reason (technical or otherwise) users are not able to invest / transact from the platform, we need to fix that on priority. If we can’t fix it right away because the reasons are beyond our immediate control, we need to keep the users informed about the issue to reduce user frustration. These issues are infrequent, but possible. Some examples of things that we do not have immediate control over are:

  • the exchange apis are down for some reason
  • the broker apis are down due to a technical glitch on their side
  • the broker login is not working with smallcase due to a problem on their side

We decided that one of the first steps to fix this experience would be to enable persistent announcements on the platform UI, so that the user is informed about any ongoing issues at any point in time, while they are using the application. (Sending app notifications, or whatsapp / email notifications was initially not considered because we feel that this info is only important to the user while they are on the platform, with an intent to transact, and hence there isn’t a major need to inform them through email / whatsapp)

This post talks about the frontend implementation of those persistent / blocking announcements.

An example of how announcements looks like

Background:

To enable transactions on the smallcase platform, we integrate with a lot of different brokers, so that their users can login to smallcase using their broker credentials and transact using their broker accounts. To enable this, we have a multi tenant codebase, where we deploy a separate build from the same codebase for each individual broker, and it is hosted at a broker owned url. So each broker platform is customised for a specific broker, and is hosted at a separate url.

This understanding will come in handy when we talk about broker specific announcements further in the post.

Product functionality requirements and assumptions:

  • These issues are temporary and would be resolved in a day or two, even if they are outside of our control. This assumption is generally valid, as we would be able to fix any major glitches on our side in a day or two, and when the reason is out of our control, there are a lot of other entities (brokers, exchanges) which would be impacted, for whom fixing the issue would be as important as us.
  • The immediate need is to make sure that we can show an urgent announcement about transactions being impacted, so there is no need to have a capability to show multiple announcements at a time. This is generally valid because this setup is supposed to be used for critical, transaction related announcements, and there can be nothing more important than that.
  • There is no easy way to auto detect or predict these kind of issues, so as part of the first iteration, we would be updating these announcements manually.
  • We should not need to re-deploy the codebase to enable the announcements, so these announcements have to fetched at runtime somehow.
  • These announcements have to be controlled by product / business and not devs, as product people would be the ones aware of such a thing happening, much earlier than devs. So, there should be an easy interface for the product team to enable / disable these announcements or to customize the text of each announcement.
  • We decided that announcements can broadly be of two types
    Common Announcements which will be common across all brokers. This can happen if the exchange is facing some issues and hence all the brokers are impacted, or if we have some technical glitch that impacts all the brokers.
    Broker Specific announcements which will be specific to one broker and will be shown on respective Broker Platform only. This can happen if a particular broker’s apis are not working, or login is not working.
  • Broker specific announcements will have more priority over common announcements, so in case if both broker specific and common announcements are enabled through the data, Broker specific announcement will be shown to user until its visibility is turned off.
  • For now, we decided that blocking announcements will be shown as part of the page, at the top, and it would be scrollable, not sticky. This was done to make sure that users don’t see the announcement as the one single alarming thing on every page, because even though transactions aren’t working, there are a lot of other functionalities like tracking investments, checking out previous orders etc which would still be working, and we do not want to deter users from being able to do those things.

As discussed above, we decided to implement some kind of UI announcements. We went ahead with implementing a blocking announcement on the frontend. These announcements would be shown on every page by default (though we can control the routes where it has to be shown). These announcements would be persistent / non dismissible, as this info is critical for the user to see any time they are on the platform. This should get auto dismissed once the issue is resolved.

For example, Announcements on platform to inform user about announcements regarding market halt, maintenance breaks or corp action issues which are temporary but might effect the user for a day or two.

There were some challenges with this feature which we wanted to solve in terms of the frontend implementation, data fetching and code organization

Where to get the data from:

As previously discussed, these announcements are dynamic, and we wanted these announcements to be controlled from outside the frontend codebase, by non tech people. We had various options to do so:

  1. DB + api: DB was one of the sources from where we could have fetched this data and shown it to users. But updating the DB is not easy and non tech folks couldn’t have been used this feature directly. Also this puts additional load on DB as we need to add / update and removed the announcements, which was not the first thing we want.
  2. Using a static hosted file: Using a static hosted file was one another option available, where we could have hosted a json file in a s3 bucket and fetched the data from there and shown the data to users. But again this requires effort from non tech folks to update the json correctly and maintain the structure and as the json gets big it becomes tough to maintain, this was not a very easy solution for non tech folks.
  3. CMS: The another option available was to use our content management system. This provides UI benefits and maintain the JSON data structure on its own and non tech folks can easily update the data without caring about data structure and can manage the configs easily. And this eliminates the risk of updating the DB and is easy to maintain because of the UI benefits. So we decided to choose the CMS for the data requirement of announcements

Business logic:

There are 2–3 different factors based on which we decide whether to show announcements, and if yes, which announcement to show if there are multiple. Note that the current UI implementation on the frontend only handles showing 1 announcement, so at a time there can only be 1 announcement shown.

  • The primary deciding factor is if the visibility of announcements is set as true or not.
  • In the announcements data object if the visible key is set as true then this means the announcement should be visible.
  • If one announcement is broker specific and one announcement is of type common announcement then the broker specific announcement take more priority and is shown to user first.
  • If the multiple announcement of same type have visible as true then we show the announcement which comes first in the array of announcements.

Technical Implementation

These are broadly the things that we need to consider in the implementation:

  • We have to fetch the announcements using an api call to our CMS,
  • we need to transform it according to requirements to be exposed to the UI component
  • Create the required UI Components
  • Render the UI components at appropriate slots on the UI

Fetching and transforming announcements:

We decided the schema for the announcements after discussions with the product team and set the schema in CMS so that product team can add or update the announcements from CMS and the same can be reflected on broker platform frontend.

The easiest way to fetch these announcements would have been to create an Announcement component, fetch the required data on mount, transform it as required, and render the component UI accordingly, all within the component.

The above approach assumes that the announcement data is only required by that one component, which is true in our case, right now, but we could easily see this requirement coming up in future where 2 different UI components might want to consume the announcement data. So we wanted to decouple the data fetching from the component UI. We created a react context to encapsulate the data fetching, the context can make this data available anywhere across the tree.

Now for the transformation, we could have kept the transformation within the context, and only exposed the transformed data as required by the component, but again, we could see if another UI component was to be added, it would probably not need the data transformed in the same way as this component. So we needed to decouple the data transformation from the data fetching. Whoever wants to consume this data, can write their own transformer function which reads the raw data from the context and provides it to the component.

This is how the final setup for fetching and transforming data works:

The react context provider sits at the top level of the App, and when the app is rendered, it fetches the announcement data. It simply makes the data available anywhere in the tree, but does not transform the data in any way.

We created a separate hook which reads this data from context and transforms it according to our current Announcement component requirement. The transformation could have happened inside the Announcement component itself, but we realized it would still be better to decouple the UI from the transformation logic.

We make two api calls here one to fetch the common announcements and one to fetch the broker specific announcements. Since we don’t want to affect our TTL and performance, and we wanted them both to be available quickly, we used Promise.all here to fetch the announcements. We implemented it in a way that if one promise fails we make sure that entire promise isn't rejected instead if one promise fails it should fetch other one and store the data in common state.

export const BlockingAnnouncementContext = React.createContext();
// This is done to get better debugging experience in dev tools
BlockingAnnouncementContext.displayName = 'BlockingAnnouncementContext';
function commonAnnouncementPromise() {
return fetchHandler('GET', CMS_URL + apiMap.COMMON_BLOCKING_ANNOUNCEMENTS)
.catch((err) => {
captureException(err, {
level: Severity.ERROR,
});
// don't throw as we do not want the promise to be rejected
return { hasError: true, err, data: [] };
});
}
function brokerSpecificAnnouncementPromise() {
return fetchHandler('GET', CMS_URL + apiMap.BROKER_SPECIFIC_BLOCKING_ANNOUNCEMENT)
.catch((err) => {
captureException(err, {
level: Severity.ERROR,
});
return { hasError: true, err, data: [] };
});
}
export function BlockingAnnouncementProvider(props) {
const [announcements, dispatch] = React.useReducer(
blockingAnnouncementReducer, blockingAnnouncementInitialState,
);
React.useEffect(() => {
Promise.all([commonAnnouncementPromise(), brokerSpecificAnnouncementPromise()])
.then(response => response.map(eachResponse => eachResponse.data))
.then(([commonAnnouncements, brokerSpecificAnnouncements]) => {
if (commonAnnouncements.hasError && brokerSpecificAnnouncements.hasError) {
throw Error([commonAnnouncements.err, brokerSpecificAnnouncements.err]);
} else {
dispatch({
type: actionsMap.SUCCESS,
payload: { data: { commonAnnouncements, brokerSpecificAnnouncements } },
});
}
})
.catch((err) => {
captureException(err, {
level: Severity.ERROR,
});
dispatch({ type: actionsMap.ERROR });
});
}, []);
return (
<BlockingAnnouncementContext.Provider
value={announcements}
>
{ props.children }
</BlockingAnnouncementContext.Provider>
);
}
BlockingAnnouncementProvider.propTypes = {
/**
* to render childrens with blocking announcement context
*/
children: PropTypes.node.isRequired,
};

Here is how the hook works

import * as React from 'react';
import { BlockingAnnouncementContext } from '~/context/BlockingAnnouncementContext';
import { announcementsReducer, initialState } from './utils';
import { actionsMap } from './constants';
function findFirstVisibleAnnouncement(announcementsArray = []) {
const announcement = announcementsArray.find(
eachAnnouncement => eachAnnouncement.visible,
) || null;
return announcement;
}
/**
* custom hook to fetch the data from BlockingAnnouncementContext and find out
* the announcement which should be visible to user according to priority and
* availability.
*
* Note :- Broker Specific announcements have more priority over common announcements
* and hence will be visible to user first when both type of announcements are visible.
*/
export default function useBlockingAnnouncement() {
const { loading, error, data } = React.useContext(BlockingAnnouncementContext);
const [announcementsData, setAnnouncementsData] = React.useReducer(
announcementsReducer, initialState,
);
const getAnnouncement = React.useCallback(() => {
const announcementsArray = [...data.brokerSpecificAnnouncements, ...data.commonAnnouncements];
const announcement = findFirstVisibleAnnouncement(announcementsArray);
return announcement;
}, [data]);
React.useEffect(() => {
if (!loading && !error) {
const announcement = getAnnouncement();
setAnnouncementsData({ type: actionsMap.SUCCESS, payload: { announcement } });
} else if (!loading && error) {
setAnnouncementsData({ type: actionsMap.ERROR });
}
}, [loading, error, getAnnouncement]);
return announcementsData;
}

UI Development

The next step was to make this data available to the Announcement component. We created a dumb UI component which gets data from props and display the UI accordingly. We added a wrapper around this dumb UI component which reads the data from the hook, and passes it on to the dumb component. This solved our purpose of consuming these announcements in the Announcement component, but we still need to render this component at the appropriate place on the screen.

Problem 1:

If announcements are not visible, then in some cases, the UI screen space used by the announcements would be used by another UI component.

Solution:

Fortunately the other component is also global and does not depend on any info from particular page that it is shown on. To cater to this, we made sure that the wrapper component also takes care of deciding whether to show the announcement, or another UI component based on some conditions.

This wrapper component was effectively responsible for deciding which UI component would be rendered at the top of each page / scene / route, so we aptly named it as SceneHeaderManager.

import * as React from 'react';
import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router';
import useBlockingAnnouncement from '~/hooks/useBlockingAnnouncements';
import { setOverrideDefaultPageMargin } from '~/root-store/actions/uiFlagsActions';
import {
BlockingAnnouncementUi,
BlockingAnnouncementErrorBoundary,
} from '../BlockingAnnouncementComponents';
// Constants & utils
import {
shouldShowHoldingsAuthBanner,
shouldShowBlockingAnnouncement,
} from './utils';
const componentMap = {
LOADING: 'LOADING',
ERROR: 'ERROR',
BLOCKING_ANNOUNCEMENT_UI: 'BlockingAnnouncementUI',
HOLDINGS_AUTH_BANNER: 'HoldingsAuthBanner',
};
/**
* helper function to return the component which needs to be re-render
* for blocking Announcements.
*
* @param {boolean} loading - tells whether the data is fetched or being fetched
* @param {boolean} error - denotes the whether an error as happened during fetch or not.
* @param {( import('~/types/announcementsType').BlockingAnnouncement | null )} announcement -
* announcement object to detect if announcement is available
* or not.
* @param {string} pathname - pathname of the current page to check whether to show holdings
* auth banner or not
* @returns {String} type of the component to render
*/
function getComponentToRender(loading, error, announcement, pathName) {
if (loading) {
return componentMap.LOADING;
}
if (error) {
return componentMap.ERROR;
}
if (shouldShowBlockingAnnouncement(announcement, pathName)) {
return componentMap.BLOCKING_ANNOUNCEMENT_UI;
}
...
//show other components
...
...
return null;
}
/**
* SceneHeaderManager component for deciding which component needs to be rendered
* for scene header either to render the blocking announcement or holdings auth banner.
*
* @returns {(JSX.Element | null)} returns the component to render or null
* if no component needs to be rendered.
*/
function SceneHeaderManager() {
const [component, setComponent] = React.useState(null);
const { loading, error, announcement } = useBlockingAnnouncement();
const dispatch = useDispatch();
const location = useLocation();
React.useEffect(() => {
const componentToRender = getComponentToRender(
loading,
error,
announcement,
location.pathname,
);
if (
componentToRender === componentMap.BLOCKING_ANNOUNCEMENT_UI
) {
dispatch(setOverrideDefaultPageMargin(true));
setComponent(componentToRender);
} else {
dispatch(setOverrideDefaultPageMargin(false));
setComponent(componentToRender);
}
}, [loading, error, announcement, dispatch, location.pathname]);
switch (component) {
case componentMap.BLOCKING_ANNOUNCEMENT_UI:
return (
<BlockingAnnouncementErrorBoundary>
<BlockingAnnouncementUi
title={announcement.title}
description={announcement.description}
src={announcement.src?.url}
primaryCta={announcement.primaryCta}
secondaryCta={announcement.secondaryCta}
/>
</BlockingAnnouncementErrorBoundary>
);
...
...
default:
return null;
}
}
export default SceneHeaderManager;

The next step was to actually integrate the SceneHeaderManager component on the UI, so that announcements start getting shown. There were two ways we could have done this:

  • Render this component in every page. Although this was as simple as doing <SceneHeaderManager /> at the top of every page, this was not efficient as we would have to remember to add this with every new page we create.
  • The other way was to integrate it at the root / router level, so that it gets rendered before the page in the DOM. This makes more sense semantically as announcements are global, and individual pages shouldn’t need to care about them.

We took the second approach and integrated it at the root level of our scenes which was of-course our router.

But there was an even bigger problem.

Problem: Issues with margin

Our header is absolutely positioned and has a fixed height of 56px, this is done to make the header sticky on scroll. All the individual pages get around this limitation by having a global or local class applied which adds a margin-top of at least 56px to them (individual pages might have specific top margin values as well), so that they don’t need to care about the fixed header, and nothing gets hidden behind the header.

Since this component was rendered at the router level, outside the individual page components, it somehow has to take care of the margin required based on the fixed header. And if it is rendered, then every page, which by default has a margin top because of the global class, should not apply their default margin of ≥56px, instead they should only apply margin wrt this announcement component.

This is how the pages look generally, with their default margin, respecting the fixed header.

This is how it was looking like, with our default handling for announcements rendering. Note that

  • the announcement is hidden behind the header, as the header is absolute,
  • and the margin between the announcement and the page is ≥56px, which shouldn’t be, because that margin is only applicable when there is no other element rendered between page container and the header.

To solve this, we break down the problem to two pieces;

  • [Problem 3a] to fix the margin between header and banner and
  • [Problem 3b] to fix margin between banner and page. Because we want the blocking announcements banner to look like the part of page.

Potential Approach 1

Remove absolute positioning of the header, remove the default margins from all pages, and add some margin bottom to the header. This will make sure that whatever comes after the header in the DOM would be correctly spaced because of bottom margin of the header. To make sure that the header remains sticky, we could use position: sticky. But there were some problems with this.

  1. Position sticky is not well supported by all browsers
  2. Some pages do not need a margin at all, and they need to overlap the header for showing their specific UI. If we make the header static, then we would need to make these page specific headers absolute, so that they can overlap the header. This isn’t bad, but not as efficient.

Potential Approach 2

Instead of adding margin-top on each page we can add it at the router level, remove the current margin from every page and based on whether announcements are visible we can override the margin in router. The router would be the central place which handles the margin, and since it knows about announcements being visible or not, it can change the margin based on that.

But the problem with this approach is that some pages have custom margin and the router would not be able to determine which page should have what margin and how to apply that, unless we maintain a global map of routes — margin, which seems inefficient.

Potential Approach 3:

We agreed that it is better to let individual pages handle their margin, as it makes sense to co-locate the margin handling where it is required, instead of at a central place. Note that this is semantically different from the announcement handling, which makes sense to be centralized. We would still need to inform the page about whether or not they should use the default margin.

Somehow tell every page that announcements are visible so that they can change the margin based on this info, but this defeats the whole purpose of individual pages being unaware of the central announcement functionality, as well as we would have required to do it in every page. We did not want to directly expose this info to the page, because the page only needs to know when to not apply the default margin, but they don’t necessarily need to know whether it should be done because announcements are being shown, or because some other component is being shown.

We used redux here to communicate between pages that we have a blocking announcement visible and pages have to adjust their margins. And since we did not want each page to listen to the redux state individually, we created a common component which will replace the top level container on each page and adjust the margin accordingly. So, anyone creating a new page in the future, would just need to use this layout element as the container of the page. The component just makes decision about whether or not to override the default page margin, otherwise it does nothing, which means that whatever default margin the page has applied would remain applicable. Note that we did not expose anything about announcements to this component, but rather about whether to override margin or not. Today the margin override is only dependent on announcements, but it may be done for other functionalities as well, so we kept this understanding decoupled.

Solution 3a: Margin between header and announcement

The SceneHeaderManager is aware of whether the announcement is visible or not, and it just sets a flag in redux when announcement is visible. This flag can be consumed by any other component to know that announcements are visible.

We created a SceneHeader component which listens to that redux key and renders a margin-top for the SceneHeaderManager (which is responsible for rendering the announcement UI). This separate component was only added for clarity, but practically this could have been done inside the SceneHeaderManager component itself.

import { useSelector } from 'react-redux';
import SceneHeaderManager from '../SceneHeaderManager';
import './SceneHeader.css';/**
* Header Wrapper Component for blocking announcement. It is used to display
* the announcements on header with correct margin or no margin when no announcement is visible.
*/
export default function SceneHeader() {
const { overrideDefaultPageMargin } = useSelector(state => state.uiFlags);
return (
<div styleName={`${overrideDefaultPageMargin ? 'header-wrapper' : ''}`}>
<SceneHeaderManager />
</div>
);
}

Solution 3b: margin between announcement and the page container

We created a layout component, which would be the default layout container for each page. Each page can pass the margin that it wants to have from the header as props to this component, assuming the announcement is not visible. This layout wrapper component, would subscribe to redux to understand if the announcement is visible, in which case, it will just render a margin-top: 0, as the margin between announcement and page container would be take care by announcement margin-bottom, and if announcement is not visible, it will render the margin as passed by the page. This requires that we replace the container component of every page with this layout wrapper. This was an acceptable compromise, even though this has the same problem that any page we add in future needs to make sure that it uses this layout wrapper as its container component.

...
...
/**
* Layout wrapper component for scenes. This component is responsible
* for overriding the default page container classes if
* an announcement is rendered between the header and the page.
*
*/
function SceneWrapper(props) {
const { overrideDefaultPageMargin } = useSelector(state => state.uiFlags);
return (
<div
styleName={`${overrideDefaultPageMargin ? 'override-page-margin' : ''}`}
className={props.className}
>
{props.children}
</div>
);
}
export default SceneWrapper;

now the final output was looking like this which was what we wanted

Error handling

  • If the api call fails, we log the error in our error tracking system, and the component does not render. Nothing breaks.
  • Since this data is received from the cms at runtime, there is a slight chance that data may be received in wrong format if the schema is configured incorrectly by mistake. If such a thing happens this will break our entire app because this component is rendered at the router level. It would not make sense to validate the whole schema on fronted, and we wouldn’t want to render half broken UI for announcement in case some required field is missing in the api response. So we added a component level ErrorBoundary to handle such errors whenever they happen and prevent the entire app from crashing. The error boundary is simple and it renders nothing if anything breaks and simply logs the error to our error tracking mechanism.

Learnings from past experiences with the configs

Caching Issue

  • As already discussed above, this config had to be controlled by the product at runtime, so we decided to use our existing CMS (strapi) setup for this. The browser makes an api call to the CMS to fetch this data at runtime. Browser will cache the response heuristically if we don’t apply any cache control headers to the response.
  • In the past we have faced this problem with other configs that we use in platform where the configs were cached by browser and then whenever we update them it doesn’t reflect to the end user. We wanted to avoid this browser caching issue so that the user don’t have to see the announcement unnecessarily even when it was removed.
  • We started to look for possible solutions for this problem. Since our cms is hosted by us we wanted the cms server to actually add those headers, but that was not convenient, as the CMS code had to be changed to accommodate this, every time a new api call was added, and there was no easy way to centralise this handling in the CMS. So we decided to handle this at the CDN layer which is on top of the CMS. The CMS is behind the cloudfront to prevent extra load on server.
  • What we finally did was we added a lambda at edge which adds these headers to our server response sent by the cms server and these responses are cached at the cloudfront and whenever the request from frontend hits the cloudfront, it passes those headers to the browser and browser don’t cache these api calls.
  • Note: Our lambda at edge is configured at server’s response means whenever the request from cloudfront goes to server, and when server responds our lambda modifies those response and apply cache control headers to that response and pass it to cloudfront, so the lambda is optimized to run only when required.

Issues with the current implementation and future plans

  • We already have an announcements framework which takes care of global / page level / component level announcements. The current implementation for blocking announcements does not comply to the existing framework, because the considerations while building these announcements was way different then our existing framework, we are trying to figure out how to make these announcements a part of framework.
  • The entire CMS setup is behind a Cloudfront and whenever there is a change in existing announcements we have to invalidate the cloudfront cache manually so that the updated config can be fetched by the platform and served to users. We need to automate this.
  • There is no easy way to configure the cache control headers for the CMS response. So we have a whole lambda at edge setup which can add cache headers so that we don’t face issues with default browser caching. But this is an additional thing that we have to handle and if we change our CDN setup then we have to find some alternative of this setup as well.
  • Automate the setup in some way so that manual intervention to enable the announcement, invalidate the cache etc is not required
  • Explore how we can speed up the manual process if we can’t automate it
  • Explore other ways of informing users (email, whatsapp, app notifications) if required

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store