Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions components/SystemAlerts/SystemAlert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
*
* Copyright (c) 2025 The Ontario Institute for Cancer Research. All rights reserved
*
* This program and the accompanying materials are made available under the terms of
* the GNU Affero General Public License v3.0. You should have received a copy of the
* GNU Affero General Public License along with this program.
* If not, see <http://www.gnu.org/licenses/>.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT
* SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
* INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
* TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
* OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
* IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
* ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
*/

import { css } from '@emotion/react';

import { default as theme } from '@/components/theme';
import { Error as ErrorIcon, Info, Warning } from '@/components/theme/icons';
import Dismiss from '@/components/theme/icons/dismiss';

export type AlertLevel = 'info' | 'warning' | 'critical';

export type AlertDef = {
level: AlertLevel;
title: string;
message?: string;
dismissible: boolean;
id: string;
};

type Props = {
alert: AlertDef;
onClose: () => void;
};

const alertContainerStyle = (backgroundColor: string, outline: string) => css`
padding: 12px;
display: flex;
justify-content: space-between;
background-color: ${backgroundColor};
border-bottom: 1px solid ${outline};
`;

const contentWrapperStyle = css`
display: flex;
`;

const iconContainerStyle = css`
margin: auto 15px auto auto;
`;

const titleStyle = (textColor: string, hasMessage: boolean) => css`
color: ${textColor};
margin-top: ${hasMessage ? '0px' : '6px'};
${theme.typography.heading};
`;

const messageStyle = (textColor: string) => css`
color: ${textColor};
margin-bottom: 8px;
${theme.typography.regular};
`;

const dismissButtonStyle = css`
cursor: pointer;
`;

const AlertVariants = {
critical: {
backgroundColor: theme.colors.error_2,
icon: <ErrorIcon height={26} width={26} />,
textColor: theme.colors.black,
outline: theme.colors.error_dark,
},
warning: {
backgroundColor: theme.colors.warning_light,
icon: <Warning height={26} width={26} />,
textColor: theme.colors.black,
outline: theme.colors.warning_dark,
},
info: {
backgroundColor: theme.colors.secondary_1,
icon: <Info fill={theme.colors.secondary_accessible} />,
textColor: theme.colors.black,
outline: theme.colors.secondary_dark,
},
};

/**
* Renders a single system alert with appropriate styling based on alert level
* @param alert - Alert definition containing level, title, message, dismissible flag, and id
* @param onClose - Callback function triggered when the alert is dismissed
* @returns JSX element representing the styled alert
*/
export const SystemAlert = ({ alert, onClose }: Props) => {
const { backgroundColor, icon, textColor, outline } = AlertVariants[alert.level];

return (
<div css={alertContainerStyle(backgroundColor, outline)}>
<div css={contentWrapperStyle}>
<div css={iconContainerStyle}>{icon}</div>
<div>
<div css={titleStyle(textColor, !!alert.message)}>{alert.title}</div>
{alert.message && <div css={messageStyle(textColor)} dangerouslySetInnerHTML={{ __html: alert.message }} />}
</div>
</div>
{alert.dismissible && (
<div css={dismissButtonStyle} onClick={onClose}>
<Dismiss height={15} width={15} fill={theme.colors.black} />
</div>
)}
</div>
);
};
91 changes: 91 additions & 0 deletions components/SystemAlerts/SystemAlerts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
*
* Copyright (c) 2025 The Ontario Institute for Cancer Research. All rights reserved
*
* This program and the accompanying materials are made available under the terms of
* the GNU Affero General Public License v3.0. You should have received a copy of the
* GNU Affero General Public License along with this program.
* If not, see <http://www.gnu.org/licenses/>.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT
* SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
* INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
* TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
* OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
* IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
* ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
*/

import { useEffect, useState } from 'react';

import { AlertDef, AlertLevel, SystemAlert } from '@/components/SystemAlerts/SystemAlert';
import { getConfig } from '@/global/config';

export const isAlertLevel = (level: any): level is AlertLevel => {
return level === 'info' || level === 'warning' || level === 'critical';
};

export const isAlertDef = (obj: any): obj is AlertDef => {
return obj.id && obj.title && obj.dismissible !== undefined && isAlertLevel(obj.level);
};

export const isAlertDefs = (obj: any): obj is AlertDef[] => {
return Array.isArray(obj) && obj.every(isAlertDef);
};

const LOCAL_STORAGE_KEY = 'SYSTEM_ALERTS_DISMISSED_IDS';

type Props = {
alerts?: AlertDef[];
};

/**
* Manages and displays a collection of system alerts with dismissal functionality
* @param alerts - Optional array of alert definitions to display (falls back to config if not provided)
* @returns JSX element containing rendered system alerts
*/
export const SystemAlerts = ({ alerts }: Props) => {
const [displayAlerts, setDisplayAlerts] = useState<AlertDef[]>([]);
const [dismissedIds, setDismissedIds] = useState<string[]>([]);

const getParsedSystemAlerts = () => {
try {
const { NEXT_PUBLIC_SYSTEM_ALERTS } = getConfig();
const parsed = JSON.parse(NEXT_PUBLIC_SYSTEM_ALERTS);
if (!isAlertDefs(parsed)) {
throw new Error('System Alert types are invalid!');
}
return parsed;
} catch (e) {
console.error('Failed to parse systems alerts! Using empty array!', e);
return [];
}
};

const systemAlerts = alerts ?? getParsedSystemAlerts();
const systemAlertIds = systemAlerts.map((a) => a.id);

const handleClose = (id: string) => {
const updated = dismissedIds.concat(id).filter((id) => systemAlertIds.includes(id));
setDisplayAlerts(systemAlerts.filter((a) => !updated.includes(a.id)));
setDismissedIds(updated);
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(updated));
};

useEffect(() => {
const stored = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) || '[]');
setDismissedIds(stored);
setDisplayAlerts(systemAlerts.filter((a) => !stored.includes(a.id)));
}, []);

return (
<>
{displayAlerts.map((alert) => (
<SystemAlert key={alert.id} alert={alert} onClose={() => handleClose(alert.id)} />
))}
</>
);
};
1 change: 1 addition & 0 deletions components/theme/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ const error = {
};

const warning = {
warning_light: '#ffff758c',
warning: '#f2d021',
warning_dark: '#e6c104',
};
Expand Down
2 changes: 2 additions & 0 deletions components/theme/icons/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import Checkmark from './checkmark';
import Spinner from './spinner';
import Error from './error';
import Warning from './warning';
import Info from './info';

export {
GoogleLogo,
Expand All @@ -55,4 +56,5 @@ export {
Spinner,
Error,
Warning,
Info,
};
52 changes: 52 additions & 0 deletions components/theme/icons/info.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
*
* Copyright (c) 2025 The Ontario Institute for Cancer Research. All rights reserved
*
* This program and the accompanying materials are made available under the terms of
* the GNU Affero General Public License v3.0. You should have received a copy of the
* GNU Affero General Public License along with this program.
* If not, see <http://www.gnu.org/licenses/>.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT
* SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
* INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
* TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
* OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
* IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
* ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
*/

import { ReactElement } from 'react';
import { css } from '@emotion/react';

import { IconProps } from './types';

const Info = ({ fill, size = 30, style }: IconProps): ReactElement => {
return (
<svg
css={css`
${style};
height: ${size}px;
width: ${size}px;
`}
width={size}
height={size}
viewBox="0 0 30 30"
>
<g fill="none" fillRule="evenodd">
<g fill={fill}>
<path d="M15 0c8.284 0 15 6.716 15 15 0 8.284-6.716 15-15 15-8.284 0-15-6.716-15-15C0 6.716 6.716 0 15 0z" />
<path
d="M14.62 10.985c1.104 0 2.019-.888 2.019-1.992S15.724 7 14.619 7c-1.103 0-2.019.889-2.019 1.993s.916 1.992 2.02 1.992zM12.6 21.083c0 1.023.916 1.858 2.02 1.858s2.019-.835 2.019-1.858v-7.217c0-1.023-.915-1.858-2.02-1.858-1.103 0-2.019.835-2.019 1.858v7.217z"
fill="white"
/>
</g>
</g>
</svg>
);
};

export default Info;
2 changes: 1 addition & 1 deletion components/theme/icons/warning.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { css } from '@emotion/react';
import { IconProps } from './types';
import theme from '../';

const Warning = ({ height, width, style, fill = theme.colors.error_dark }: IconProps) => {
const Warning = ({ height, width, style, fill = theme.colors.warning_dark }: IconProps) => {
return (
<svg
css={css`
Expand Down
5 changes: 3 additions & 2 deletions global/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,16 @@ export const getConfig = () => {
`-----BEGIN PUBLIC KEY-----\r\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0lOqMuPLCVusc6szklNXQL1FHhSkEgR7An+8BllBqTsRHM4bRYosseGFCbYPn8r8FsWuMDtxp0CwTyMQR2PCbJ740DdpbE1KC6jAfZxqcBete7gP0tooJtbvnA6X4vNpG4ukhtUoN9DzNOO0eqMU0Rgyy5HjERdYEWkwTNB30i9I+nHFOSj4MGLBSxNlnuo3keeomCRgtimCx+L/K3HNo0QHTG1J7RzLVAchfQT0lu3pUJ8kB+UM6/6NG+fVyysJyRZ9gadsr4gvHHckw8oUBp2tHvqBEkEdY+rt1Mf5jppt7JUV7HAPLB/qR5jhALY2FX/8MN+lPLmb/nLQQichVQIDAQAB\r\n-----END PUBLIC KEY-----`,
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID: publicConfig.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID || '',
NEXT_PUBLIC_KEYCLOAK_HOST: publicConfig.NEXT_PUBLIC_KEYCLOAK_HOST || '',
NEXT_PUBLIC_KEYCLOAK_PERMISSION_AUDIENCE:
publicConfig.NEXT_PUBLIC_KEYCLOAK_PERMISSION_AUDIENCE || '',
NEXT_PUBLIC_KEYCLOAK_PERMISSION_AUDIENCE: publicConfig.NEXT_PUBLIC_KEYCLOAK_PERMISSION_AUDIENCE || '',
NEXT_PUBLIC_KEYCLOAK_REALM: publicConfig.NEXT_PUBLIC_KEYCLOAK_REALM || '',
NEXT_PUBLIC_LAB_NAME: publicConfig.NEXT_PUBLIC_LAB_NAME || 'Overture Stage UI',
NEXT_PUBLIC_LOGO_FILENAME: publicConfig.NEXT_PUBLIC_LOGO_FILENAME,
NEXT_PUBLIC_SSO_PROVIDERS: publicConfig.NEXT_PUBLIC_SSO_PROVIDERS || '',
NEXT_PUBLIC_UI_VERSION: publicConfig.NEXT_PUBLIC_UI_VERSION || '',
SESSION_ENCRYPTION_SECRET: process.env.SESSION_ENCRYPTION_SECRET || '',
NEXT_PUBLIC_SYSTEM_ALERTS: process.env.NEXT_PUBLIC_SYSTEM_ALERTS || '',
} as {
NEXT_PUBLIC_SYSTEM_ALERTS: string;
ACCESSTOKEN_ENCRYPTION_SECRET: string;
KEYCLOAK_CLIENT_SECRET: string;
NEXT_PUBLIC_ADMIN_EMAIL: string;
Expand Down