Skip to content

Commit 53be186

Browse files
committed
feat(analytics): add analytics module
Also add cookbook for portal-analytics Ref AB#65764 Ref #3611 Ref #3612
1 parent 3522425 commit 53be186

39 files changed

+2355
-1
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/otel-logs/log.txt

cookbooks/portal-analytics/CHANGELOG.md

Whitespace-only changes.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Cookbook Portal for Analytics
2+
3+
This cookbook demonstrates the analytics module usage in a portal.
4+
5+
It has a click collector, a collector for context change and a adapter to emit
6+
the events to a log file - via the dev-server proxy.
7+
8+
Logs will be displayed in the portal or it can be viewed in `otel-logs/log.txt`.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { defineDevServerConfig, processServices } from '@equinor/fusion-framework-cli/dev-server';
2+
import { appendFileContents, readBody, readFileContents } from './middleware/logs';
3+
4+
const LOG_FILE = './otel-logs/log.txt';
5+
6+
export default defineDevServerConfig(() => {
7+
return {
8+
api: {
9+
processServices: (data, args) => {
10+
const existingServices = processServices(data, args);
11+
12+
const routes = existingServices.routes || [];
13+
routes.push(
14+
{
15+
match: '/logs',
16+
middleware: async (_req, res) => {
17+
res.setHeader('Content-Type', 'text/plain');
18+
res.end(await readFileContents(LOG_FILE));
19+
},
20+
},
21+
{
22+
match: '/api/logs',
23+
middleware: async (req, res) => {
24+
const body = await readBody(req);
25+
try {
26+
appendFileContents(LOG_FILE, body);
27+
} catch (err) {
28+
res.statusCode = 500;
29+
res.end(err);
30+
}
31+
res.end(JSON.stringify({ partialSuccess: {} }));
32+
},
33+
},
34+
);
35+
36+
existingServices.routes = routes;
37+
38+
return existingServices;
39+
},
40+
},
41+
};
42+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { readFile, appendFile } from 'node:fs/promises';
2+
import { existsSync } from 'node:fs';
3+
import { resolve } from 'node:path';
4+
import type { IncomingMessage } from 'node:http';
5+
6+
export const readFileContents = async (filePath: string): Promise<string> => {
7+
const absolutePath = resolve(filePath);
8+
try {
9+
if (!existsSync(absolutePath)) {
10+
throw new Error(`File does not exist: ${absolutePath}`);
11+
}
12+
const data = await readFile(absolutePath, 'utf-8');
13+
return data;
14+
} catch (error) {
15+
if (error instanceof Error) {
16+
throw new Error(`Failed to read file: ${error.message}`);
17+
}
18+
throw new Error('Unknown error occurred');
19+
}
20+
};
21+
22+
export const readBody = async (req: IncomingMessage): Promise<string> => {
23+
return new Promise((resolve, reject) => {
24+
if (req.method !== 'POST' || !req.headers['content-type']?.includes('application/json')) {
25+
resolve('');
26+
return;
27+
}
28+
29+
const chunks: Buffer[] = [];
30+
req.on('data', (chunk: Buffer) => chunks.push(chunk));
31+
req.on('end', () => resolve(Buffer.concat(chunks).toString()));
32+
req.on('error', reject);
33+
});
34+
};
35+
36+
export const appendFileContents = async (filePath: string, content: string): Promise<void> => {
37+
const absolutePath = resolve(filePath);
38+
39+
try {
40+
if (!existsSync(absolutePath)) {
41+
throw new Error(`File does not exist: ${absolutePath}`);
42+
}
43+
await appendFile(absolutePath, `${content}\n`, 'utf8');
44+
} catch (error) {
45+
if (error instanceof Error) {
46+
throw new Error(`Failed to write to file: ${error.message}`);
47+
}
48+
throw new Error('Unknown error occurred');
49+
}
50+
};
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"name": "portal-analytics",
3+
"version": "0.1.3",
4+
"description": "",
5+
"main": "dist/index.js",
6+
"private": true,
7+
"scripts": {
8+
"dev": "fusion-framework-cli portal dev",
9+
"build": "fusion-framework-cli portal build",
10+
"schema": "fusion-framework-cli portal schema",
11+
"pack:portals": "fusion-framework-cli portal pack",
12+
"publish:portals": "fusion-framework-cli portal publish"
13+
},
14+
"keywords": [],
15+
"author": "",
16+
"license": "ISC",
17+
"dependencies": {
18+
"@equinor/eds-core-react": "^0.49.0",
19+
"@equinor/eds-icons": "^0.22.0",
20+
"@equinor/fusion-framework": "workspace:*",
21+
"@equinor/fusion-framework-app": "workspace:*",
22+
"@equinor/fusion-framework-cli": "workspace:*",
23+
"@equinor/fusion-framework-module-app": "workspace:*",
24+
"@equinor/fusion-framework-module-context": "workspace:*",
25+
"@equinor/fusion-framework-module-analytics": "workspace:*",
26+
"@equinor/fusion-framework-module-navigation": "workspace:*",
27+
"@equinor/fusion-framework-react": "workspace:*",
28+
"@equinor/fusion-framework-react-module-context": "workspace:^",
29+
"@equinor/fusion-observable": "workspace:*",
30+
"@equinor/fusion-query": "^6.0.0",
31+
"@equinor/fusion-react-context-selector": "^1.0.6",
32+
"@equinor/fusion-react-date": "^1.0.0",
33+
"@equinor/fusion-react-side-sheet": "1.3.11",
34+
"react": "^18.2.0",
35+
"react-dom": "^18.2.0",
36+
"rxjs": "^7.8.1",
37+
"styled-components": "^6.0.7",
38+
"uuid": "^13.0.0",
39+
"zod": "^4.1.12"
40+
},
41+
"devDependencies": {
42+
"@types/react": "^18.2.0",
43+
"@types/react-dom": "^18.2.0"
44+
}
45+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { useCallback, useEffect, useId, useMemo } from 'react';
2+
import {
3+
ContextProvider,
4+
ContextSearch,
5+
type ContextSearchProps,
6+
type ContextSelectEvent,
7+
ContextClearEvent,
8+
} from '@equinor/fusion-react-context-selector';
9+
import { useContextResolver } from './useContextResolver';
10+
11+
/**
12+
* See fusion-react-component storybook for available attributes
13+
* @link https://equinor.github.io/fusion-react-components/?path=/docs/data-contextselector--component
14+
* @returns JSX element
15+
*/
16+
export const ContextSelector = (props: ContextSearchProps): JSX.Element | null => {
17+
const contextSelectorId = useId();
18+
const {
19+
resolver,
20+
provider,
21+
currentContext: [selectedContextItem],
22+
} = useContextResolver();
23+
24+
/** callback handler for context selector, when context is changed or cleared */
25+
const onContextSelect = useCallback(
26+
(e: Event | ContextSelectEvent) => {
27+
if (provider) {
28+
if (e.type === 'select') {
29+
const ev = e as unknown as ContextSelectEvent;
30+
if (ev.nativeEvent.detail.selected.length) {
31+
provider.contextClient.setCurrentContext(ev.nativeEvent.detail.selected[0].id);
32+
}
33+
} else {
34+
provider.clearCurrentContext();
35+
}
36+
}
37+
},
38+
[provider],
39+
);
40+
41+
/**
42+
* Clears context when ctx has been cleared outside the selector.
43+
*/
44+
const clearEvent = useMemo(() => new ContextClearEvent({ date: Date.now() }), []);
45+
useEffect(() => {
46+
if (!selectedContextItem) {
47+
document.dispatchEvent(clearEvent);
48+
}
49+
}, [clearEvent, selectedContextItem]);
50+
51+
if (!resolver) return null;
52+
53+
return (
54+
<div style={{ flex: 1, maxWidth: '480px' }}>
55+
<ContextProvider resolver={resolver}>
56+
<ContextSearch
57+
id={contextSelectorId}
58+
placeholder={props.placeholder ?? 'Search for context'}
59+
initialText={props.initialText ?? 'Start typing to search'}
60+
dropdownHeight={props.dropdownHeight ?? '300px'}
61+
variant={props.variant ?? 'header'}
62+
onSelect={(e: ContextSelectEvent) => onContextSelect(e)}
63+
selectTextOnFocus={true}
64+
previewItem={selectedContextItem}
65+
onClearContext={onContextSelect}
66+
/>
67+
</ContextProvider>
68+
</div>
69+
);
70+
};
71+
72+
export default ContextSelector;
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { useId } from 'react';
2+
import type { SVGProps } from 'react';
3+
4+
type FusionLogoProps = Omit<SVGProps<SVGSVGElement>, 'viewBox'> & {
5+
readonly scale?: number;
6+
};
7+
8+
export const FusionLogo = ({ scale = 1, style }: FusionLogoProps) => {
9+
const paint0Id = useId();
10+
const paint1Id = useId();
11+
12+
return (
13+
<svg viewBox="0 0 50 35" style={{ height: '1em', ...style, transform: `scale(${scale})` }}>
14+
<title>Fusion Logo</title>
15+
<path
16+
d="M0 2V23.1776L7.05405 16.1235V7.05405H16.1235L23.1776 0H2C0.895431 0 0 0.89543 0 2Z"
17+
transform="translate(50 17.5) scale(0.92727 1.06779) rotate(135)"
18+
fill={`url(#${paint0Id})`}
19+
/>
20+
<path
21+
d="M0 2V23.1776L7.05405 16.1235V7.05405H16.1235L23.1776 0H2C0.895431 0 0 0.89543 0 2Z"
22+
transform="translate(0 17.5) scale(0.92727 1.06779) rotate(-45)"
23+
fill={`url(#${paint1Id})`}
24+
/>
25+
<path
26+
d="M9.61965 36.6972L2.60087 29.6784L1.96135 22.3809L8.42623 22.9069L9.61965 36.6972Z"
27+
transform="translate(33.8887 34.9863) scale(0.92727 -1.06779) rotate(45)"
28+
fill="#990025"
29+
/>
30+
<path
31+
d="M7.05434 7.05434L0 0L1.21096 13.8183L7.68846 14.3818L7.05434 7.05434Z"
32+
transform="translate(33.8887 34.9863) scale(0.92727 -1.06779) rotate(45)"
33+
fill="#990025"
34+
/>
35+
<path
36+
d="M0 0L2.49398 29.5715L9.61965 36.6972L7.01878 7.01878L0 0Z"
37+
transform="translate(33.8887 0.015625) scale(0.92727 1.06779) rotate(45)"
38+
fill="#FF1243"
39+
/>
40+
<defs>
41+
<linearGradient
42+
id={paint0Id}
43+
x2="1"
44+
gradientUnits="userSpaceOnUse"
45+
gradientTransform="matrix(-13.5478 9.01983 -12.9578 -13.5478 18.0677 6.77391)"
46+
>
47+
<stop offset="0.508287" stopColor="#DC002E" />
48+
<stop offset="0.508387" stopColor="#FF1243" />
49+
</linearGradient>
50+
<linearGradient
51+
id={paint1Id}
52+
x2="1"
53+
gradientUnits="userSpaceOnUse"
54+
gradientTransform="matrix(-13.5478 9.01983 -12.9578 -13.5478 18.0677 6.77391)"
55+
>
56+
<stop offset="0.508287" stopColor="#DC002E" />
57+
<stop offset="0.508387" stopColor="#FF1243" />
58+
</linearGradient>
59+
</defs>
60+
</svg>
61+
);
62+
};
63+
64+
export default FusionLogo;
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { useId } from 'react';
2+
import { ContextSelector } from './ContextSelector';
3+
import { FusionLogo } from './FusionLogo';
4+
5+
import styled from 'styled-components';
6+
import { TopBar } from '@equinor/eds-core-react';
7+
8+
const Styled = {
9+
Title: styled.div`
10+
display: flex;
11+
align-items: center;
12+
gap: 0.75rem;
13+
font-size: 1rem;
14+
font-weight: 500;
15+
`,
16+
};
17+
18+
export const Header = () => {
19+
const topBarId = useId();
20+
21+
return (
22+
<TopBar id={topBarId} sticky={false} style={{ padding: '0 1em', height: 48 }}>
23+
<TopBar.Header>
24+
<Styled.Title>
25+
<FusionLogo />
26+
<span>Fusion Framework CLI</span>
27+
</Styled.Title>
28+
</TopBar.Header>
29+
<TopBar.CustomContent>
30+
<ContextSelector />
31+
</TopBar.CustomContent>
32+
{/* since buttons are 40px but have 48px click bounds */}
33+
</TopBar>
34+
);
35+
};

0 commit comments

Comments
 (0)