diff --git a/.changeset/pr-449-1600350490.md b/.changeset/pr-449-1600350490.md new file mode 100644 index 000000000..e947a0455 --- /dev/null +++ b/.changeset/pr-449-1600350490.md @@ -0,0 +1,5 @@ + +--- +"fusion-project-portal": minor +--- +Service messages are now specifically tailored to the selected project's application. Only service messages relevant to the chosen application will be displayed, ensuring that users are not confused by messages unrelated to their concerns. diff --git a/client/packages/core/src/apps/hooks/index.ts b/client/packages/core/src/apps/hooks/index.ts new file mode 100644 index 000000000..a32e978d3 --- /dev/null +++ b/client/packages/core/src/apps/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-current-apps'; diff --git a/client/packages/core/src/apps/hooks/use-current-apps.ts b/client/packages/core/src/apps/hooks/use-current-apps.ts new file mode 100644 index 000000000..35842e033 --- /dev/null +++ b/client/packages/core/src/apps/hooks/use-current-apps.ts @@ -0,0 +1,13 @@ +import { useAppGroupsQuery, App } from '@equinor/portal-core'; +import { useMemo } from 'react'; + +export const useCurrentApps = (shouldFilter?: boolean) => { + const { data } = useAppGroupsQuery(); + if (!shouldFilter) return undefined; + return useMemo(() => { + if (!data) return []; + return data.reduce((acc, app) => { + return [...acc, ...app.apps]; + }, [] as App[]); + }, [data]); +}; diff --git a/client/packages/core/src/apps/index.ts b/client/packages/core/src/apps/index.ts new file mode 100644 index 000000000..4cc90d02b --- /dev/null +++ b/client/packages/core/src/apps/index.ts @@ -0,0 +1 @@ +export * from './hooks'; diff --git a/client/packages/core/src/index.ts b/client/packages/core/src/index.ts index e5abc8565..eebc66414 100644 --- a/client/packages/core/src/index.ts +++ b/client/packages/core/src/index.ts @@ -1 +1,2 @@ export * from './user'; +export * from './apps'; diff --git a/client/packages/core/tsconfig.json b/client/packages/core/tsconfig.json index 2336d7a34..ac03e84c6 100644 --- a/client/packages/core/tsconfig.json +++ b/client/packages/core/tsconfig.json @@ -13,7 +13,7 @@ "outDir": "../../dist/out-tsc", }, "files": [], - "include": ["**/*.tsx"] + "include": ["**/*.tsx", "./src"] } diff --git a/client/packages/portal-client/src/components/portal-frame/PortalFrame.tsx b/client/packages/portal-client/src/components/portal-frame/PortalFrame.tsx index 9a538cb5a..858b40480 100644 --- a/client/packages/portal-client/src/components/portal-frame/PortalFrame.tsx +++ b/client/packages/portal-client/src/components/portal-frame/PortalFrame.tsx @@ -9,6 +9,7 @@ import { useBookmarkNavigate } from '@equinor/fusion-framework-react-module-book import { BookmarkProvider } from '@equinor/fusion-framework-react-components-bookmark'; import { HasContext } from '../context/HasContext'; import { css } from '@emotion/css'; +import { ServiceMessageFilter } from '../service-message-filter/ServiceMessageFilter'; const style = css` width: 100vw; @@ -32,6 +33,7 @@ export const PortalFrame = () => {
+ diff --git a/client/packages/portal-client/src/components/service-message-filter/ServiceMessageFilter.tsx b/client/packages/portal-client/src/components/service-message-filter/ServiceMessageFilter.tsx new file mode 100644 index 000000000..669fcf435 --- /dev/null +++ b/client/packages/portal-client/src/components/service-message-filter/ServiceMessageFilter.tsx @@ -0,0 +1,19 @@ +import { useCurrentApps } from '@portal/core'; +import { useEffect } from 'react'; +import { useServiceMessage } from '@equinor/service-message'; + +export const ServiceMessageFilter = () => { + const currentApps = useCurrentApps(true); + const { registerCurrentApps, registerPortals } = useServiceMessage(); + + useEffect(() => { + if (currentApps) { + registerCurrentApps(currentApps.map((app) => app.appKey)); + } + }, [currentApps]); + + useEffect(() => { + registerPortals(['Project execution portal']); + }, []); + return null; +}; diff --git a/client/packages/portal-core/src/queries/hooks/use-app-groups-query.ts b/client/packages/portal-core/src/queries/hooks/use-app-groups-query.ts index 00bce17b1..d17912666 100644 --- a/client/packages/portal-core/src/queries/hooks/use-app-groups-query.ts +++ b/client/packages/portal-core/src/queries/hooks/use-app-groups-query.ts @@ -5,14 +5,14 @@ import { useViewController } from '../../providers'; import { AppGroup } from '../../types'; import { getAppGroups } from '../portal/getAppGroups'; -export const useAppGroupsQuery = (): UseQueryResult<[] | AppGroup[]> => { - const id = useViewController().currentView?.id; - const currentContext = useFrameworkCurrentContext(); +export const useAppGroupsQuery = (): UseQueryResult => { + const id = useViewController().currentView?.id; + const currentContext = useFrameworkCurrentContext(); - const client = usePortalClient(); + const client = usePortalClient(); - return useQuery({ - queryKey: ['appGroups', { id, externalId: currentContext?.externalId }], - queryFn: () => getAppGroups(client, id, currentContext?.externalId), - }); + return useQuery({ + queryKey: ['appGroups', { id, externalId: currentContext?.externalId }], + queryFn: () => getAppGroups(client, id, currentContext?.externalId), + }); }; diff --git a/client/packages/portal-core/src/queries/portal/getAppGroups.ts b/client/packages/portal-core/src/queries/portal/getAppGroups.ts index 3101ea661..08ac3d068 100644 --- a/client/packages/portal-core/src/queries/portal/getAppGroups.ts +++ b/client/packages/portal-core/src/queries/portal/getAppGroups.ts @@ -6,12 +6,12 @@ export async function getAppGroups( client: IHttpClient, viewId?: string, contextExternalId?: string -): Promise { +): Promise { try { if (!viewId || !contextExternalId) return []; const res = await client.fetch(getAppGroupsURI(viewId, contextExternalId)); if (!res.ok) throw res; - const data = (await res.json()) as AppGroup[]; + const data = await res.json(); return data || []; } catch (error) { console.error(error); diff --git a/client/packages/portal-core/src/queries/portal/index.ts b/client/packages/portal-core/src/queries/portal/index.ts index 30567d3ff..d568cf5d1 100644 --- a/client/packages/portal-core/src/queries/portal/index.ts +++ b/client/packages/portal-core/src/queries/portal/index.ts @@ -1,2 +1,3 @@ export * from './getViewById'; export * from './getViews'; +export * from './getAppGroups'; diff --git a/client/packages/service-message/components/NotificationService.tsx b/client/packages/service-message/components/NotificationService.tsx index e44d013b3..506bba87b 100644 --- a/client/packages/service-message/components/NotificationService.tsx +++ b/client/packages/service-message/components/NotificationService.tsx @@ -1,7 +1,7 @@ import { FC, PropsWithChildren } from 'react'; import { useParams } from 'react-router-dom'; -import { useServiceMessage } from '../query/use-service-message'; +import { useServiceMessage } from '../hooks/use-service-message'; import { MessageWrapper } from './MessageWrapper'; import { css } from '@emotion/css'; diff --git a/client/packages/service-message/components/ServiceMessage.tsx b/client/packages/service-message/components/ServiceMessage.tsx index b0a00e54d..c88cad34c 100644 --- a/client/packages/service-message/components/ServiceMessage.tsx +++ b/client/packages/service-message/components/ServiceMessage.tsx @@ -5,11 +5,11 @@ import { useParams } from 'react-router-dom'; import styled from 'styled-components'; import { ServiceMessageList } from './ServiceMessageList'; -import { ServiceMessageCard } from './ServiceMessageCard'; -import { AppServiceMessage } from '../provider/ServiceMessageProvider'; -import { useServiceMessage } from '../query/use-service-message'; + +import { useServiceMessage } from '../hooks/use-service-message'; import { PortalActionProps } from '@equinor/portal-core'; import SideSheet from '@equinor/fusion-react-side-sheet'; +import { AppServiceMessage } from '../types/types'; export function ServiceMessages({ action, onClose, open }: PortalActionProps) { const { appKey } = useParams(); @@ -36,6 +36,11 @@ const StyledWrapper = styled.div` flex-direction: column; `; +const portalNameMapper = (identifier: string) => { + if (identifier === 'Project execution portal') return 'Project Portal'; + return identifier; +}; + export const ServiceMessageWidget: FC = ({ appKey }) => { const { appsMessages, portalMessages, messages } = useServiceMessage(); const [compact] = useState(false); @@ -44,9 +49,16 @@ export const ServiceMessageWidget: FC = ({ appKey }) <> Portal ({portalMessages.length}) - {portalMessages.length > 0 - ? portalMessages.map((message) => ) - : null} + {portalMessages.length > 0 && + portalMessages.map((portal) => ( + + ))} App Status ({messages.filter((a) => a.scope === 'App').length}) diff --git a/client/packages/service-message/components/ServiceMessageIcon.tsx b/client/packages/service-message/components/ServiceMessageIcon.tsx index 287ac8951..d19f4e11e 100644 --- a/client/packages/service-message/components/ServiceMessageIcon.tsx +++ b/client/packages/service-message/components/ServiceMessageIcon.tsx @@ -2,7 +2,7 @@ import { Icon } from '@equinor/eds-core-react'; import { tokens } from '@equinor/eds-tokens'; import { useMemo } from 'react'; import styled from 'styled-components'; -import { useServiceMessage } from '../query/use-service-message'; +import { useServiceMessage } from '../hooks/use-service-message'; import { ServiceMessage } from '../types/types'; const StyledMessageChip = styled.span<{ color: string }>` diff --git a/client/packages/service-message/components/index.ts b/client/packages/service-message/components/index.ts index 4d8d972c7..06b05d60a 100644 --- a/client/packages/service-message/components/index.ts +++ b/client/packages/service-message/components/index.ts @@ -1,2 +1,2 @@ -export * from "./NotificationService" -export * from "./ServiceMessageIcon" \ No newline at end of file +export * from './NotificationService'; +export * from './ServiceMessageIcon'; diff --git a/client/packages/service-message/hooks/use-service-message.ts b/client/packages/service-message/hooks/use-service-message.ts new file mode 100644 index 000000000..24245aa16 --- /dev/null +++ b/client/packages/service-message/hooks/use-service-message.ts @@ -0,0 +1,58 @@ +import { useContext, useEffect, useState } from 'react'; +import { ServiceMessageContext } from '../provider/ServiceMessageProvider'; + +import { AppServiceMessage, PortalServiceMessage, ServiceMessage } from '../types/types'; + +export const useServiceMessage = (appKey?: string) => { + const context = useContext(ServiceMessageContext); + + if (!context) { + throw new Error('ServiceMessageContext context used out of bounds'); + } + + useEffect(() => { + if (appKey) context.serviceMessages.setCurrentApp(appKey); + }, [appKey]); + + const [messages, setMessages] = useState([]); + const [appsMessages, setAppsMessages] = useState([]); + const [portalMessages, setPortalMessages] = useState([]); + const [currentMessages, setCurrentMessages] = useState([]); + + useEffect(() => { + const sub = context.serviceMessages.messages$.subscribe(setMessages); + return () => { + sub.unsubscribe(); + }; + }, [context]); + + useEffect(() => { + const sub = context.serviceMessages.appMessages$.subscribe(setAppsMessages); + return () => { + sub.unsubscribe(); + }; + }, [context]); + + useEffect(() => { + const sub = context.serviceMessages.currentPortalAndAppMessages$.subscribe(setCurrentMessages); + return () => { + sub.unsubscribe(); + }; + }, [context]); + + useEffect(() => { + const sub = context.serviceMessages.portal$.subscribe(setPortalMessages); + return () => { + sub.unsubscribe(); + }; + }, [context]); + + return { + appsMessages, + portalMessages, + currentMessages, + messages, + registerCurrentApps: context.registerCurrentApps, + registerPortals: context.registerPortals, + }; +}; diff --git a/client/packages/service-message/index.ts b/client/packages/service-message/index.ts index 554ca3e8f..664262fee 100644 --- a/client/packages/service-message/index.ts +++ b/client/packages/service-message/index.ts @@ -1,3 +1,4 @@ -export * from "./components/ServiceMessage" -export * from "./components" -export * from "./provider/ServiceMessageProvider" \ No newline at end of file +export * from './components/ServiceMessage'; +export * from './components'; +export * from './provider/ServiceMessageProvider'; +export * from './hooks/use-service-message'; diff --git a/client/packages/service-message/provider/ServiceMessageProvider.tsx b/client/packages/service-message/provider/ServiceMessageProvider.tsx index a35fd77da..c871eef61 100644 --- a/client/packages/service-message/provider/ServiceMessageProvider.tsx +++ b/client/packages/service-message/provider/ServiceMessageProvider.tsx @@ -1,125 +1,38 @@ import { useSignalR } from '@equinor/fusion-framework-react/signalr'; - -import { createContext, FC, PropsWithChildren, useEffect, useLayoutEffect } from 'react'; -import { BehaviorSubject, combineLatestWith, map, Observable } from 'rxjs'; - +import { createContext, FC, PropsWithChildren, useCallback, useEffect, useLayoutEffect } from 'react'; import { useServiceMessageQuery } from '../query/use-service-message-query'; -import { AppReference, ServiceMessage } from '../types/types'; +import { ServiceMessage } from '../types/types'; +import { ServiceMessages } from '../services/service-message'; +import { map } from 'rxjs'; interface IServiceMessageContext { serviceMessages: ServiceMessages; + registerCurrentApps: (apps: string[]) => void; + registerPortals: (portals: string[]) => void; } export const ServiceMessageContext = createContext(null); -export interface AppServiceMessage extends AppReference { - messages: ServiceMessage[]; -} - -class ServiceMessages { - messages$: BehaviorSubject; - - appMessages$: Observable; - - currentAppMessages$: Observable; - - portal$: Observable; - - currentAppKey$: BehaviorSubject; - - constructor(_initial: ServiceMessage[]) { - this.messages$ = new BehaviorSubject(_initial); - this.currentAppKey$ = new BehaviorSubject(''); - this.appMessages$ = this.messages$.pipe(map((messages) => this.#appServiceMessageMapper(messages))); - this.currentAppMessages$ = this.messages$.pipe( - combineLatestWith(this.currentAppKey$), - map(([messages, appKey]) => - messages.filter( - (message) => message.scope === 'Portal' || message.relevantApps?.some((app) => app.key === appKey) - ) - ) - ); - this.portal$ = this.messages$.pipe(map((messages) => messages.filter((message) => message.scope === 'Portal'))); - } - - #appServiceMessageMapper = (serviceMessages: ServiceMessage[]): AppServiceMessage[] => { - const currentAppMessageRecord = serviceMessages - .filter((message) => message.scope === 'App') - .reduce(this.#reduceAppServiceMessage, {}); - return Object.keys(currentAppMessageRecord).map((key) => currentAppMessageRecord[key]); - }; - - // eslint-disable-next-line class-methods-use-this - #sortMessages = (serviceMessages: ServiceMessage[]): ServiceMessage[] => { - return serviceMessages - .sort((a: ServiceMessage, b: ServiceMessage) => { - return new Date(b.timestamp).getUTCDate() - new Date(a.timestamp).getUTCDate(); - }) - .sort((a: ServiceMessage, b: ServiceMessage): number => { - const statusValues = { - Issue: 3, - Maintenance: 2, - Info: 1, - }; - - return statusValues[b.type] - statusValues[a.type]; - }); - }; - - #reduceAppServiceMessage = ( - acc: Record, - message: ServiceMessage - ): Record => { - if (!message.relevantApps) return acc; - message.relevantApps.forEach((app) => { - if (acc[app.key]) { - acc[app.key].messages.push(message); - acc[app.key].messages = this.#sortMessages(acc[app.key].messages); - } else { - acc[app.key] = { - ...app, - messages: [message], - }; - } - }); - return acc; - }; - - next(value: ServiceMessage[]) { - this.messages$.next(value); - } - - setCurrentApp(appKey: string) { - this.currentAppKey$.next(appKey); - } - - get appMessages() { - return this.#appServiceMessageMapper(this.messages$.value); - } - - get currentAppMessages() { - return this.messages$.value.filter((message) => - message.relevantApps?.some((app) => app.key === this.currentAppKey$.value) - ); - } - - get portalMessages() { - return this.messages$.value.filter((message) => message.scope === 'Portal'); - } -} - const serviceMessages = new ServiceMessages([]); export const ServiceMessageProvider: FC = ({ children }) => { const { data } = useServiceMessageQuery(); + const registerCurrentApps = useCallback((apps?: string[]) => { + serviceMessages.registerAppsFilter(apps); + }, []); + + const registerPortals = useCallback((portals?: string[]) => { + serviceMessages.registerPortalFilter(portals); + }, []); + useEffect(() => { - if (data) { - serviceMessages.next(data); - } + if (!data) return; + + serviceMessages.next(data); }, [data]); - const topic = useSignalR('portal', 'portal'); + const topic = useSignalR('portal', 'service-messages'); useLayoutEffect(() => { const sub = topic.pipe(map((x) => x.shift() as ServiceMessage[])).subscribe(serviceMessages); @@ -129,5 +42,9 @@ export const ServiceMessageProvider: FC = ({ children }) => { }; }, [topic]); - return {children}; + return ( + + {children} + + ); }; diff --git a/client/packages/service-message/query/use-service-message-query.ts b/client/packages/service-message/query/use-service-message-query.ts index 37e864760..40b654578 100644 --- a/client/packages/service-message/query/use-service-message-query.ts +++ b/client/packages/service-message/query/use-service-message-query.ts @@ -4,21 +4,17 @@ import { useQuery } from 'react-query'; import { ServiceMessage } from '../types/types'; - - export async function getActiveServiceMessages(client: IHttpClient): Promise { - const res = await client.fetch(`/api/service-messages/active`); - if (!res.ok) throw res; - return (await res.json()) as ServiceMessage[]; + const res = await client.fetch(`/api/service-messages/active`); + if (!res.ok) throw res; + return (await res.json()) as ServiceMessage[]; } - export const useServiceMessageQuery = () => { - const client = useFramework().modules.serviceDiscovery.createClient('portal'); + const client = useFramework().modules.serviceDiscovery.createClient('portal'); - return useQuery({ - queryKey: ['service-message'], - queryFn: async () => - getActiveServiceMessages(await client), - }); + return useQuery({ + queryKey: ['service-message'], + queryFn: async () => getActiveServiceMessages(await client), + }); }; diff --git a/client/packages/service-message/query/use-service-message.ts b/client/packages/service-message/query/use-service-message.ts deleted file mode 100644 index 75fcef369..000000000 --- a/client/packages/service-message/query/use-service-message.ts +++ /dev/null @@ -1,59 +0,0 @@ - -import { useContext, useEffect, useState } from "react"; -import { AppServiceMessage, ServiceMessageContext } from "../provider/ServiceMessageProvider"; - -import { ServiceMessage } from "../types/types"; - -export const useServiceMessage = (appKey?: string) => { - const context = useContext(ServiceMessageContext); - - if (!context) { - throw new Error("ServiceMessageContext context used out of bounds"); - } - - useEffect(() => { - if (appKey) context.serviceMessages.setCurrentApp(appKey) - }, [appKey]) - - const [messages, setMessages] = useState([]); - const [appsMessages, setAppsMessages] = useState([]); - const [portalMessages, setPortalMessages] = useState([]); - const [currentMessages, setCurrentMessages] = useState([]); - - useEffect(() => { - const sub = context.serviceMessages.messages$.subscribe(setMessages); - return () => { - sub.unsubscribe() - } - }, [context]); - - useEffect(() => { - const sub = context.serviceMessages.appMessages$.subscribe(setAppsMessages); - return () => { - sub.unsubscribe() - } - }, [context]); - - useEffect(() => { - const sub = context.serviceMessages.currentAppMessages$.subscribe(setCurrentMessages); - return () => { - sub.unsubscribe() - } - }, [context]); - - - useEffect(() => { - const sub = context.serviceMessages.portal$.subscribe(setPortalMessages); - return () => { - sub.unsubscribe() - } - }, [context]); - - - return { - appsMessages, - portalMessages, - currentMessages, - messages - } -} \ No newline at end of file diff --git a/client/packages/service-message/services/service-message.test.ts b/client/packages/service-message/services/service-message.test.ts new file mode 100644 index 000000000..9ed0d33da --- /dev/null +++ b/client/packages/service-message/services/service-message.test.ts @@ -0,0 +1,218 @@ +import { describe, beforeEach, it, expect } from 'vitest'; +import { ServiceMessages } from './service-message'; +import { AppReference, ServiceMessage } from '../types/types'; + +describe('ServiceMessages', () => { + let serviceMessages: ServiceMessages; + let initialMessages: ServiceMessage[]; + + beforeEach(() => { + initialMessages = [ + { + id: '1', + type: 'Info', + title: 'Initial Message', + content: 'This is an initial message', + scope: 'App', + relevantApps: [{ key: 'appKey', name: 'AppName', shortName: 'AN' }], + timestamp: new Date('2023-01-01T12:00:00Z'), + notifyUser: true, + }, + { + id: '2', + type: 'Issue', + title: 'Another Message', + content: 'This is another message', + scope: 'Portal', + relevantPortals: [{ identifier: 'portal1' }], + timestamp: new Date('2023-01-02T12:00:00Z'), + notifyUser: false, + }, + ]; + + serviceMessages = new ServiceMessages(initialMessages); + }); + + it('should be created', () => { + expect(serviceMessages).toBeTruthy(); + }); + + it('should update messages when next is called', () => { + const newMessages: ServiceMessage[] = [ + { + id: '3', + type: 'Maintenance', + title: 'Updated Message', + content: 'This is an updated message', + scope: 'App', + relevantApps: [{ key: 'appKey', name: 'AppName', shortName: 'AN' }], + timestamp: new Date(), + notifyUser: true, + }, + ]; + + serviceMessages.next(newMessages); + + serviceMessages.messages$.subscribe((messages) => { + expect(messages).toEqual(newMessages); + }); + }); + + it('should filter appMessages based on appsFilter', () => { + const appsFilter: string[] = ['appKey']; + + serviceMessages.registerAppsFilter(appsFilter); + + serviceMessages.appMessages$.subscribe((appMessages) => { + expect(appMessages).toEqual([ + { + key: 'appKey', + name: 'AppName', + shortName: 'AN', + messages: [ + { + id: '1', + type: 'Info', + title: 'Initial Message', + content: 'This is an initial message', + scope: 'App', + relevantApps: [{ key: 'appKey', name: 'AppName', shortName: 'AN' }], + timestamp: new Date('2023-01-01T12:00:00Z'), + notifyUser: true, + }, + ], + }, + ]); + }); + }); + + it('should filter currentPortalAndAppMessages based on currentAppKey', () => { + const newAppKey = 'newAppKey'; + + serviceMessages.setCurrentApp(newAppKey); + + serviceMessages.currentPortalAndAppMessages$.subscribe((messages) => { + expect(messages).toEqual([ + { + id: '2', + type: 'Issue', + title: 'Another Message', + content: 'This is another message', + scope: 'Portal', + relevantPortals: [{ identifier: 'portal1' }], + timestamp: new Date('2023-01-02T12:00:00Z'), + notifyUser: false, + }, + ]); + }); + }); + + it('should filter portalMessages based on portalsFilter', () => { + const portalsFilter: string[] = ['portal1']; + + serviceMessages.registerPortalFilter(portalsFilter); + + serviceMessages.portal$.subscribe((portalMessages) => { + expect(portalMessages).toEqual([ + { + identifier: 'portal1', + messages: [ + { + id: '2', + type: 'Issue', + title: 'Another Message', + content: 'This is another message', + scope: 'Portal', + relevantPortals: [{ identifier: 'portal1' }], + timestamp: new Date('2023-01-02T12:00:00Z'), + notifyUser: false, + }, + ], + }, + ]); + }); + }); + + it('should correctly map app messages using the types', () => { + const appReference: AppReference = { + key: 'appKey', + name: 'AppName', + shortName: 'AN', + }; + + const appMessages: ServiceMessage[] = [ + { + id: '1', + type: 'Info', + title: 'Initial Message', + content: 'This is an initial message', + scope: 'App', + relevantApps: [appReference], + timestamp: new Date('2023-01-01T12:00:00Z'), + notifyUser: true, + }, + { + id: '2', + type: 'Issue', + title: 'Another Message', + content: 'This is another message', + scope: 'Portal', + relevantPortals: [{ identifier: 'portal1' }], + timestamp: new Date('2023-01-02T12:00:00Z'), + notifyUser: false, + }, + { + id: '3', + type: 'Maintenance', + title: 'Updated Message', + content: 'This is an updated message', + scope: 'Portal', + relevantPortals: [{ identifier: 'portal1' }], + timestamp: new Date('2023-01-02T12:00:00Z'), + notifyUser: true, + }, + { + id: '4', + type: 'Info', + title: 'Another App Message', + content: 'This is another app message', + scope: 'App', + relevantApps: [appReference], + timestamp: new Date('2023-01-01T12:00:00Z'), + notifyUser: true, + }, + ]; + + serviceMessages.next(appMessages); + + expect(serviceMessages.appMessages).toEqual([ + { + key: 'appKey', + name: 'AppName', + shortName: 'AN', + messages: [ + { + id: '1', + type: 'Info', + title: 'Initial Message', + content: 'This is an initial message', + scope: 'App', + relevantApps: [appReference], + timestamp: new Date('2023-01-01T12:00:00Z'), + notifyUser: true, + }, + { + id: '4', + type: 'Info', + title: 'Another App Message', + content: 'This is another app message', + scope: 'App', + relevantApps: [appReference], + timestamp: new Date('2023-01-01T12:00:00Z'), + notifyUser: true, + }, + ], + }, + ]); + }); +}); diff --git a/client/packages/service-message/services/service-message.ts b/client/packages/service-message/services/service-message.ts new file mode 100644 index 000000000..907d67dc3 --- /dev/null +++ b/client/packages/service-message/services/service-message.ts @@ -0,0 +1,162 @@ +import { Observable, BehaviorSubject, combineLatestWith, map } from 'rxjs'; +import { ServiceMessage, AppServiceMessage, PortalServiceMessage } from '../types/types'; + +export class ServiceMessages { + messages$: Observable; + + #messages$: BehaviorSubject; + + #portalsFilter$: BehaviorSubject; + + #appsFilter$: BehaviorSubject; + + appMessages$: Observable; + + currentPortalAndAppMessages$: Observable; + + portal$: Observable; + + currentAppKey$: BehaviorSubject; + + constructor(_initial: ServiceMessage[]) { + this.currentAppKey$ = new BehaviorSubject(''); + + this.#portalsFilter$ = new BehaviorSubject(undefined); + this.#appsFilter$ = new BehaviorSubject(undefined); + + this.#messages$ = new BehaviorSubject(_initial); + this.messages$ = this.#messages$ + .pipe( + combineLatestWith(this.#appsFilter$), + map(([messages, apps]) => + messages.filter( + (message) => + message.scope === 'Portal' || + message.relevantApps?.some((app) => (apps ? apps?.includes(app.key) : true)) + ) + ) + ) + .pipe( + combineLatestWith(this.#portalsFilter$), + map(([messages, portals]) => + messages.filter( + (message) => + message.scope === 'App' || + message.relevantPortals?.some((portal) => + portals ? portals?.includes(portal.identifier) : true + ) + ) + ) + ); + + this.appMessages$ = this.messages$.pipe(map((messages) => this.#appServiceMessageMapper(messages))); + + this.currentPortalAndAppMessages$ = this.messages$.pipe( + combineLatestWith(this.currentAppKey$), + map(([messages, appKey]) => + messages.filter( + (message) => message.scope === 'Portal' || message.relevantApps?.some((app) => app.key === appKey) + ) + ) + ); + this.portal$ = this.messages$.pipe(map((messages) => this.#portalServiceMessageMapper(messages))); + } + + #appServiceMessageMapper = (serviceMessages: ServiceMessage[]): AppServiceMessage[] => { + const currentAppMessageRecord = serviceMessages + .filter((message) => message.scope === 'App') + .reduce(this.#reduceAppServiceMessage, {}); + return Object.keys(currentAppMessageRecord).map((key) => currentAppMessageRecord[key]); + }; + + #portalServiceMessageMapper = (serviceMessages: ServiceMessage[]): PortalServiceMessage[] => { + return Object.values( + serviceMessages.filter((message) => message.scope === 'Portal').reduce(this.#reducePortalServiceMessage, {}) + ); + }; + + // eslint-disable-next-line class-methods-use-this + #sortMessages = (serviceMessages: ServiceMessage[]): ServiceMessage[] => { + return serviceMessages + .sort((a: ServiceMessage, b: ServiceMessage) => { + return new Date(b.timestamp).getUTCDate() - new Date(a.timestamp).getUTCDate(); + }) + .sort((a: ServiceMessage, b: ServiceMessage): number => { + const statusValues = { + Issue: 3, + Maintenance: 2, + Info: 1, + }; + + return statusValues[b.type] - statusValues[a.type]; + }); + }; + + #reduceAppServiceMessage = ( + acc: Record, + message: ServiceMessage + ): Record => { + if (!message.relevantApps) return acc; + message.relevantApps.forEach((app) => { + if (acc[app.key]) { + acc[app.key].messages.push(message); + acc[app.key].messages = this.#sortMessages(acc[app.key].messages); + } else { + acc[app.key] = { + ...app, + messages: [message], + }; + } + }); + return acc; + }; + + #reducePortalServiceMessage = ( + acc: Record, + message: ServiceMessage + ): Record => { + if (!message.relevantPortals) return acc; + message.relevantPortals.forEach((portal) => { + if (acc[portal.identifier]) { + acc[portal.identifier].messages.push(message); + acc[portal.identifier].messages = this.#sortMessages(acc[portal.identifier].messages); + } else { + acc[portal.identifier] = { + ...portal, + messages: [message], + }; + } + }); + return acc; + }; + + next(value: ServiceMessage[]) { + this.#messages$.next(value); + } + + registerAppsFilter(value?: string[]) { + this.#appsFilter$.next(value); + } + + registerPortalFilter(value?: string[]) { + this.#portalsFilter$.next(value); + } + + setCurrentApp(appKey: string) { + this.currentAppKey$.next(appKey); + } + + get appMessages() { + return this.#appServiceMessageMapper(this.#messages$.value); + } + + get currentAppMessages() { + return this.#messages$.value.filter((message) => + message.relevantApps?.some((app) => app.key === this.currentAppKey$.value) + ); + } + + get portalMessages() { + return this.#messages$.value.filter((message) => message.scope === 'Portal'); + } +} diff --git a/client/packages/service-message/types/types.ts b/client/packages/service-message/types/types.ts index 605dd4e1a..fe02a7cee 100644 --- a/client/packages/service-message/types/types.ts +++ b/client/packages/service-message/types/types.ts @@ -1,20 +1,31 @@ export type ServiceMessage = { - id: string, - type: "Issue" | "Maintenance" | "Info", - title: string | null, - content: string | null, - scope: "Portal" | "App", - relevantApps?: AppReference[] | null, - timestamp: Date, - appliesFrom?: Date | null, - appliesTo?: Date | null, - notifyUser: boolean -} + id: string; + type: 'Issue' | 'Maintenance' | 'Info'; + title: string | null; + content: string | null; + scope: 'Portal' | 'App'; + relevantApps?: AppReference[] | null; + relevantPortals?: PortalReference[] | null; + timestamp: Date; + appliesFrom?: Date | null; + appliesTo?: Date | null; + notifyUser: boolean; +}; export type AppReference = { - key: string, - name: string | null, - shortName: string | null -} + key: string; + name: string | null; + shortName: string | null; +}; +export type PortalReference = { + identifier: string; +}; + +export type ServiceMessages = Record; -export type ServiceMessages = Record; \ No newline at end of file +export interface AppServiceMessage extends AppReference { + messages: ServiceMessage[]; +} +export interface PortalServiceMessage extends PortalReference { + messages: ServiceMessage[]; +}